Compare commits

..
1 Commits
Author SHA1 Message Date
Richie 932e6c9ab1 refactor(signal): use native signal-cli service 2026-09-29 20:03:12 -04:00
79 changed files with 1411 additions and 2231 deletions

No files matched your search

+1 -31
View File
@@ -11,7 +11,7 @@ keys:
- &system_brain age1jhf7vm0005j60mjq63696frrmjhpy8kpc2d66mw044lqap5mjv4snmwvwm # cspell:disable-line
- &system_jeeves age13lmqgc3jvkyah5e3vcwmj4s5wsc2akctcga0lpc0x8v8du3fxprqp4ldkv # cspell:disable-line
- &system_rhapsody age1ufnewppysaq2wwcl4ugngjz8pfzc5a35yg7luq0qmuqvctajcycs5lf6k4 # cspell:disable-line
- &system_portal_1 age1cm7ujz4t9cyc3nyy0w30l2addggk0gy8h3shd9yq47jg845tndnsa3utfl # cspell:disable-line
- &system_portal_1 age1vyav6kxtvt3z4vtnkkjj38eu8hlts5m7ygyckhskvalg2gpjk52su53d0a # cspell:disable-line
creation_rules:
- path_regex: users/secrets\.yaml$
@@ -23,33 +23,3 @@ creation_rules:
- *system_jeeves
- *system_rhapsody
- *system_portal_1
- path_regex: systems/bob/secrets\.yaml$
key_groups:
- age:
- *admin_richie
- *system_bob
- path_regex: systems/brain/secrets\.yaml$
key_groups:
- age:
- *admin_richie
- *system_brain
- path_regex: systems/jeeves/secrets\.yaml$
key_groups:
- age:
- *admin_richie
- *system_jeeves
- path_regex: systems/rhapsody-in-green/secrets\.yaml$
key_groups:
- age:
- *admin_richie
- *system_rhapsody
- path_regex: systems/portal-1/secrets\.yaml$
key_groups:
- age:
- *admin_richie
- *system_portal_1
Generated
-163
View File
@@ -290,28 +290,6 @@ dependencies = [
"log",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
@@ -403,12 +381,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
@@ -641,16 +613,6 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "ipnet"
version = "2.12.1"
@@ -722,12 +684,6 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.3"
@@ -1015,19 +971,6 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.43"
@@ -1118,15 +1061,6 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -1145,15 +1079,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal"
version = "0.1.0"
dependencies = [
"anyhow",
"libc",
"serde_json",
]
[[package]]
name = "slab"
version = "0.4.12"
@@ -1166,22 +1091,6 @@ version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "snapshot-manager"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"env_logger",
"log",
"serde",
"signal",
"tempfile",
"toml",
"zfs",
]
[[package]]
name = "socket2"
version = "0.6.5"
@@ -1252,19 +1161,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "2.0.20"
@@ -1334,45 +1230,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow 1.0.4",
]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tower"
version = "0.5.3"
@@ -1488,7 +1345,6 @@ dependencies = [
"clap",
"env_logger",
"log",
"num-traits",
"reqwest",
"serde",
"serde_json",
@@ -1734,18 +1590,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
[[package]]
name = "writeable"
version = "0.6.4"
@@ -1835,13 +1679,6 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "zfs"
version = "0.1.0"
dependencies = [
"anyhow",
]
[[package]]
name = "zmij"
version = "1.0.23"
-30
View File
@@ -2,33 +2,3 @@
resolver = "2"
members = ["rust/*"]
[workspace.dependencies]
anyhow = "1.0"
chrono = "0.4"
clap = "4.5"
env_logger = "0.11"
log = "0.4"
serde = "1.0"
serde_json = "1.0"
signal = { path = "rust/signal" }
zfs = { path = "rust/zfs" }
[workspace.lints.clippy]
all = { level = "deny", priority = -1 }
pedantic = { level = "deny", priority = -1 } # UM, ACTUALLY
nursery = { level = "deny", priority = -1 } # BETA LINTS
# DENY PANICS
unwrap_used = "deny"
expect_used = "deny"
indexing_slicing = "deny"
arithmetic_side_effects = "deny"
unreachable = "deny"
unimplemented = "deny"
unchecked_time_subtraction = "deny"
todo = "deny"
string_slice = "deny"
panic_in_result_fn = "deny"
panic = "deny"
exit = "deny"
as_conversions = "deny"
-4
View File
@@ -1,4 +0,0 @@
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true
+1 -1
View File
@@ -21,7 +21,7 @@
boot = {
tmp.useTmpfs = lib.mkDefault true;
kernelPackages = lib.mkDefault pkgs.linuxPackages_6_18;
kernelPackages = lib.mkDefault pkgs.linuxPackages_6_12;
};
hardware.enableRedistributableFirmware = true;
+42
View File
@@ -0,0 +1,42 @@
{ pkgs, ... }:
{
boot = {
kernelPackages = pkgs.linuxPackages_6_18;
zfs.package = pkgs.zfs_2_4;
};
hardware.bluetooth = {
enable = true;
powerOnBoot = true;
};
# rtkit is optional but recommended for pipewire
security.rtkit.enable = true;
services = {
displayManager.sddm = {
enable = true;
wayland.enable = true;
};
desktopManager.plasma6.enable = true;
xserver = {
enable = true;
xkb = {
layout = "us";
variant = "";
};
};
pulseaudio.enable = false;
pipewire = {
enable = true;
alsa.enable = true;
alsa.support32Bit = true;
pulse.enable = true;
wireplumber.enable = true;
};
};
}
-17
View File
@@ -1,17 +0,0 @@
{ pkgs, ... }:
{
imports = [
./kde.nix
./pipewire.nix
];
boot = {
kernelPackages = pkgs.linuxPackages_6_18;
zfs.package = pkgs.zfs_2_4;
};
hardware.bluetooth = {
enable = true;
powerOnBoot = true;
};
}
-45
View File
@@ -1,45 +0,0 @@
{ pkgs, ... }:
{
environment.plasma6.excludePackages = with pkgs.kdePackages; [
aurorae # theme
elisa # music player
kate # text editor
kconfig # config editor
khelpcenter # help center
kinfocenter # system info
konsole # terminal
krdp # remote desktop
ktexteditor # thing for kate
okular # pdf reader
plasma-keyboard # used by plasma-keyboard KCM
plasma-sdk # plasma development tools
plasma-workspace-wallpapers # wallpapers
qrca # qr code scanner
qtvirtualkeyboard # virtual keyboard
union # theme
];
# disable KDE PIM (Personal Information Management) applications
programs.kde-pim.enable = false;
services = {
# removes screen reader and speech dispatcher from the system
orca.enable = false;
speechd.enable = false;
displayManager.sddm = {
enable = true;
wayland.enable = true;
};
desktopManager.plasma6.enable = true;
xserver = {
enable = true;
xkb = {
layout = "us";
variant = "";
};
};
};
}
-16
View File
@@ -1,16 +0,0 @@
{
# rtkit is optional but recommended for pipewire
security.rtkit.enable = true;
services = {
pulseaudio.enable = false;
pipewire = {
enable = true;
alsa.enable = true;
alsa.support32Bit = true;
pulse.enable = true;
wireplumber.enable = true;
};
};
}
+1
View File
@@ -1,6 +1,7 @@
{ pkgs, ... }:
{
environment.systemPackages = with pkgs; [
mangohud
steam-run
];
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; };
prometheus = import ./prometheus { inherit (prev) prometheus; };
zopfli = import ./zopfli { inherit (prev) zopfli; };
pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [
(_pythonFinal: pythonPrev: {
afdko = import ./afdko { inherit (pythonPrev) afdko; };
psutil = import ./psutil { inherit (pythonPrev) psutil; };
pytest-xdist = import ./pytest-xdist { inherit (pythonPrev) pytest-xdist; };
scipy = import ./scipy { inherit (pythonPrev) scipy; };
})
@@ -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;
}
+2
View File
@@ -1,4 +1,5 @@
{
inputs,
lib,
pkgs,
...
@@ -14,6 +15,7 @@
services = {
snapshot_manager = {
enable = lib.mkDefault true;
PYTHONPATH = "${inputs.self}/";
};
zfs = {
+46 -4
View File
@@ -1,4 +1,5 @@
{
inputs,
pkgs,
lib,
config,
@@ -6,18 +7,23 @@
}:
let
cfg = config.services.snapshot_manager;
snapshotManager = pkgs.callPackage ../../../rust/snapshot_manager/package.nix { };
snapshotManagerPackages = ps: with ps; [ typer ];
in
{
options = {
services.snapshot_manager = {
enable = lib.mkEnableOption "ZFS snapshot manager";
signalAlerts = lib.mkEnableOption "Signal failure alerts for the snapshot manager";
path = lib.mkOption {
type = lib.types.path;
default = ./snapshot_config.toml;
description = "Path to the snapshot_manager TOML config.";
};
PYTHONPATH = lib.mkOption {
type = lib.types.str;
description = ''
the PYTHONPATH to use for the snapshot_manager service.
'';
};
EnvironmentFile = lib.mkOption {
type = lib.types.nullOr (lib.types.coercedTo lib.types.path toString lib.types.str);
default = null;
@@ -30,7 +36,40 @@ in
};
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 = {
services.snapshot_manager = {
@@ -38,9 +77,12 @@ in
requires = [ "zfs-import.target" ];
after = [ "zfs-import.target" ];
path = [ pkgs.zfs ];
environment = {
PYTHONPATH = cfg.PYTHONPATH;
};
serviceConfig = {
Type = "oneshot";
ExecStart = "${snapshotManager}/bin/snapshot-manager ${lib.escapeShellArg cfg.path} --signal-alerts ${lib.boolToString cfg.signalAlerts}";
ExecStart = "${pkgs.snapshot_manager_python}/bin/python -m python.tools.snapshot_manager ${lib.escapeShellArg cfg.path}";
}
// lib.optionalAttrs (cfg.EnvironmentFile != null) {
EnvironmentFile = cfg.EnvironmentFile;
Generated
+23 -100
View File
@@ -7,11 +7,11 @@
]
},
"locked": {
"lastModified": 1789770686,
"narHash": "sha256-uZkBR7yHdIKUFB5SZdfgh1qkGfI3XmYmI/lTiquxbck=",
"lastModified": 1781152676,
"narHash": "sha256-RxWs5ND31KzTG7wvMM+PMfUjyNpmIEr999lqNARaM5o=",
"owner": "nix-community",
"repo": "disko",
"rev": "725ea35e410ad83be4931d1bff7e090eacaf3563",
"rev": "ff8702b4de27f72b4c78573dfb89ec74e36abdf1",
"type": "github"
},
"original": {
@@ -20,24 +20,6 @@
"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": {
"inputs": {
"nixpkgs": [
@@ -45,11 +27,11 @@
]
},
"locked": {
"lastModified": 1791293500,
"narHash": "sha256-3hwJd2/vG/VYV1P/g7oDgdbNsWODhN5F//Od+hPMm2k=",
"lastModified": 1788651960,
"narHash": "sha256-v9wJd32eZ2bvhBzVOd7TIjLQd011P7nwOhjKtWlci5I=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "0e11b7bcba1d74ef8ea10bfdfb1e1a5811a21087",
"rev": "2c0350c759688177331b8f5242311fae8877bdb3",
"type": "github"
},
"original": {
@@ -58,38 +40,16 @@
"type": "github"
}
},
"jellyswarrm": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": [
"nixpkgs"
],
"rust-overlay": "rust-overlay"
},
"locked": {
"lastModified": 1791127101,
"narHash": "sha256-SmrBBhcv9iNHA2vlafi7Zpe8bkcsMjVNSmCCVf5/jrQ=",
"owner": "LLukas22",
"repo": "Jellyswarrm",
"rev": "0b4301c6c74efff9c3634296913032579c98faf6",
"type": "github"
},
"original": {
"owner": "LLukas22",
"repo": "Jellyswarrm",
"type": "github"
}
},
"nixos-hardware": {
"inputs": {
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1791128201,
"narHash": "sha256-rlsYY/Dg/LhbIj51Aa6TLvRpZeb3KZgUTd7llQ5XcJE=",
"lastModified": 1788860136,
"narHash": "sha256-MhPMOFV4pVkygWEbQ8t1De/uQ9cWF1u++tRe2L5tG48=",
"owner": "nixos",
"repo": "nixos-hardware",
"rev": "31cc5f4d9b9ba601071e8b8504601b9b176e2756",
"rev": "62173785b9a18c78b4a15aca2623d02bceb9d077",
"type": "github"
},
"original": {
@@ -101,11 +61,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1789546076,
"narHash": "sha256-vWkSk5bbfTqdtMoSgD9FshACO8JCvXTFi+3cqEp0mH0=",
"rev": "b1b875982b17dabde9b4a37f3e229e74913e6db3",
"lastModified": 1767892417,
"narHash": "sha256-8bW3q88CEg2u4hSP66Vf4lpbLonHz7hqDNBMcCY7E9U=",
"rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba",
"type": "tarball",
"url": "https://releases.nixos.org/nixos/unstable/nixos-26.11pre1074753.b1b875982b17/nixexprs.tar.xz"
"url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre924538.3497aa5c9457/nixexprs.tar.xz"
},
"original": {
"type": "tarball",
@@ -114,11 +74,11 @@
},
"nixpkgs-master": {
"locked": {
"lastModified": 1791296764,
"narHash": "sha256-N2b6Uxdr1MFhscEPqDyntTTtGZASgq3XYE0ujRzfeNI=",
"lastModified": 1788892992,
"narHash": "sha256-cIMFh9gyU4/aLeB3JCcsWM3tTAvD9pAq9Smr1Wa8aIU=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "a15afac8691bd24f746255ffbd76306693e4cf7a",
"rev": "dff6994123e257ec9901c271bc2b52e64d7c8f05",
"type": "github"
},
"original": {
@@ -146,11 +106,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1791260994,
"narHash": "sha256-Miqqk/ammqnTUxaoCyvtwoPeLbI1ksFyLxi/CazZpWY=",
"lastModified": 1788752844,
"narHash": "sha256-VaWGJ6+cIYN2erfSecbRV+4ljI185Ty2wUrXyvQbgOw=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "151fa4e8ddfdd8dd25d945ad94ed54a13de9f6e4",
"rev": "dc5d91f840324650bac8c379428c7037a416959a",
"type": "github"
},
"original": {
@@ -164,34 +124,12 @@
"inputs": {
"disko": "disko",
"home-manager": "home-manager",
"jellyswarrm": "jellyswarrm",
"nixos-hardware": "nixos-hardware",
"nixpkgs": "nixpkgs_2",
"nixpkgs-master": "nixpkgs-master",
"nixpkgs-stable": "nixpkgs-stable",
"sops-nix": "sops-nix",
"systems": "systems_2"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"jellyswarrm",
"nixpkgs"
]
},
"locked": {
"lastModified": 1779419951,
"narHash": "sha256-dMX0PUslUHPajP6o8FEoRdFv9afq/dec4POR0vVfjK4=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "5b5c521d6cae9ef4aa32f888eb2c0ce595c9be52",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
"systems": "systems"
}
},
"sops-nix": {
@@ -201,11 +139,11 @@
]
},
"locked": {
"lastModified": 1791103873,
"narHash": "sha256-nFxM+pKoZ8LJAEnUXARyCaOAloWgaW9kZQOSjWzKcTE=",
"lastModified": 1788337237,
"narHash": "sha256-gkSH8VUtCo6hnysNmb9DbTuDepH2t5pv+QWjP75xKAk=",
"owner": "Mic92",
"repo": "sops-nix",
"rev": "dcd241ba97088c22569d1573286e1b9daad340c0",
"rev": "fbf759290e0cb0a98dfc813a4eb7d53ad1dacb57",
"type": "github"
},
"original": {
@@ -215,21 +153,6 @@
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1689347949,
"narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=",
-5
View File
@@ -21,11 +21,6 @@
nixos-hardware.url = "github:nixos/nixos-hardware/master";
jellyswarrm = {
url = "github:LLukas22/Jellyswarrm";
inputs.nixpkgs.follows = "nixpkgs";
};
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
+77
View File
@@ -0,0 +1,77 @@
# Ebook Search Docker
Run the EPUB search app against the existing Postgres database on `jeeves`:
```sh
python -m python.ebook_search.docker.containers start --library-path /path/to/epubs --build
```
All ebook-search Docker files live in this directory:
- `Dockerfile` — multi-stage: `test` (runs pytest) and `runtime` (default target, the app image)
- `docker-compose.yml`
- `containers.py` — Typer lifecycle CLI
- `pyproject.toml` / `uv.lock` — the container's uv-locked dependencies
The app listens on `http://localhost:8070`.
Useful lifecycle commands:
```sh
python -m python.ebook_search.docker.containers build
python -m python.ebook_search.docker.containers start --library-path /path/to/epubs
python -m python.ebook_search.docker.containers test
python -m python.ebook_search.docker.containers logs
python -m python.ebook_search.docker.containers ps
python -m python.ebook_search.docker.containers stop
```
Direct compose usage from the repo root:
```sh
docker compose -f python/ebook_search/docker/docker-compose.yml ps
```
## Dependencies
The image builds its environment with uv from `pyproject.toml` + `uv.lock` in this
directory — this is the source of truth for the container's dependencies. To add or
update a dependency, edit `pyproject.toml` here and regenerate the lock (uv is
available in the `ebook-search` dev shell):
```sh
nix develop .#ebook-search -c uv lock --project python/ebook_search/docker
```
## Tests
The main pytest suite excludes `tests/ebook_search` (its dependencies are no longer
in the nix dev shell). The `test ebook search` CI workflow runs them in a uv env
built from the lockfile in this directory — same commands work locally from the
repo root (the `--override-ini` drops the main suite's ignore):
```sh
uv sync --locked --project python/ebook_search/docker
uv run --project python/ebook_search/docker --no-sync pytest tests/ebook_search --override-ini addopts="-n auto -ra"
```
They can also run inside the Docker `test` image, which validates the image itself:
```sh
python -m python.ebook_search.docker.containers test
```
or the raw docker equivalent:
```sh
docker build --file python/ebook_search/docker/Dockerfile --target test --tag ebook-search:test .
docker run --rm ebook-search:test
```
## Configuration
The compose service loads the repo root `.env` into the container via `env_file`.
Mount your EPUB directory by setting `EBOOK_LIBRARY_HOST_PATH` in an env file or on the command line. The container sees it as `/library`, and `EBOOK_SEARCH_LIBRARY_PATHS` is set to `/library` inside the container.
Database connection settings are controlled by `RICHIE_DB`, `RICHIE_HOST`, `RICHIE_PORT`, `RICHIE_USER`, and `RICHIE_PASSWORD`. The default host is `jeeves`.
+54
View File
@@ -0,0 +1,54 @@
# Gems
Gems is a server-rendered, turn-based resource-engine game for one to four human or AI players. It uses FastAPI,
Jinja, HTMX, server-sent events, and SQLite.
The application deliberately contains no playable card deck, patron/governor set, objective set, official artwork,
or copied rulebook text. A room host must upload a content pack they are entitled to use before starting a game.
## Run locally
```shell
uv run gems --host 127.0.0.1 --port 8082
```
The default database and installation key are created under `.gems/`. The following environment variables override
runtime behavior:
- `GEMS_DATABASE_PATH`
- `GEMS_KEY_PATH`
- `GEMS_PUBLIC_ORIGIN`
- `GEMS_SECURE_COOKIES`
- `GEMS_HOST`
- `GEMS_PORT`
## Content packs
The current schema is available from a running server at `/schemas/content-pack-v1.json`. A pack defines exactly
five normal resources, one wild resource, cards, and optional patrons, objectives, and outposts. `patrons` is the
canonical field name; `governors` is accepted as an input alias.
Cards may use only the built-in, bounded effect vocabulary:
- `none`
- `virtual_wild`
- `copy_bonus`
- `copy_and_claim`
- `multi_bonus`
- `claim_free`
- an optional discard-cards alternate cost
Unknown fields, resource references, executable expressions, HTML, artwork URLs, and files larger than 512 KiB are
rejected. The normalized pack is private to its room and becomes immutable when play starts.
## Neutral module mapping
Gems calls the four optional mechanics Objectives, Outposts, Eastern Decks, and Fortifications. Lobby presets combine
these mechanics into the familiar base, objective-race, objective-plus-outpost, eastern-plus-fortification, and
all-module configurations. Component identities and values always come from the uploaded pack.
## Jeeves
The NixOS module runs one Uvicorn worker on `127.0.0.1:8002`, stores state in
`/zfs/media/services/gems`, and publishes it through HAProxy at `https://gems.tmmworkshop.com`. The DNS record must
point to Jeeves before ACME can issue the certificate.
+19 -27
View File
@@ -5,28 +5,23 @@ from __future__ import annotations
import json
import logging
import socket
from os import getenv
logger = logging.getLogger(__name__)
class SignalRPCError(RuntimeError):
"""signal-cli returned an error or closed without responding."""
def signal_alert(body: str, from_phone: str, to_phone: str, *, timeout: float = 4.0) -> None:
"""Send a Signal alert through the local signal-cli daemon.
def signal_alert(body: str) -> None:
"""Send a signal alert.
Args:
body: The body of the alert.
from_phone: The Signal account sending the alert.
to_phone: The Signal account receiving the alert.
timeout: Seconds to wait on each socket operation.
Raises:
SignalRPCError: If signal-cli returns an error or closes the
connection before responding.
OSError: If the socket is unreachable or an operation times out.
body (str): The body of the alert.
"""
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"
request = {
"jsonrpc": "2.0",
@@ -41,7 +36,7 @@ def signal_alert(body: str, from_phone: str, to_phone: str, *, timeout: float =
try:
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.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:
line, buffer = buffer.split(b"\n", maxsplit=1)
response = json.loads(line)
if response.get("id") != signal_rpc_id:
continue
if rpc_error := response.get("error"):
error = f"Signal RPC returned an error: {rpc_error}"
raise SignalRPCError(error)
return
error = "Signal RPC socket closed before returning a response"
raise SignalRPCError(error)
except (OSError, json.JSONDecodeError) as exc:
error = f"Signal RPC failed: {exc!r}"
raise SignalRPCError(error) from exc
if response.get("id") == signal_rpc_id:
if error := response.get("error"):
logger.error("Signal RPC returned an error: %s", error)
return
logger.error("Signal RPC socket closed before returning a response")
except OSError, json.JSONDecodeError:
logger.exception("Unable to communicate with signal-cli")
+147
View File
@@ -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()
-10
View File
@@ -1,10 +0,0 @@
{ lib }:
lib.fileset.toSource {
root = ../.;
fileset = lib.fileset.unions [
../Cargo.toml
../Cargo.lock
../clippy.toml
./.
];
}
-17
View File
@@ -1,17 +0,0 @@
[package]
name = "signal"
version = "0.1.0"
edition = "2024"
description = "Send Signal alerts through the local signal-cli JSON-RPC daemon"
license = "MIT"
publish = false
[dependencies]
anyhow.workspace = true
serde_json.workspace = true
[target.'cfg(target_os = "linux")'.dev-dependencies]
libc = "0.2"
[lints]
workspace = true
-78
View File
@@ -1,78 +0,0 @@
//! Send alerts through the local signal-cli daemon on Unix systems.
//!
//! ```no_run
//! use signal::Signal;
//!
//! let client = Signal {
//! from_phone: "+15555550100".into(),
//! };
//! client.send("+15555550101", "Service failed")?;
//! client.send("+15555550102", "Service recovered")?;
//! # Ok::<(), anyhow::Error>(())
//! ```
use std::{
io::{BufRead, BufReader, Write},
os::unix::net::UnixStream,
time::Duration,
};
use anyhow::{Context, Result, bail};
use serde_json::{Value, json};
/// A sending account for alerts through `/run/signal-cli/socket`.
pub struct Signal {
/// The phone number of an account registered with the local daemon.
pub from_phone: String,
}
impl Signal {
/// Send a message to `to_phone` and wait for the corresponding JSON-RPC response.
///
/// Socket reads and writes use a four-second timeout.
///
/// # Errors
///
/// Returns an error if connecting, writing, or reading fails, the daemon
/// returns invalid JSON or an RPC error, or it closes before responding.
pub fn send(&self, to_phone: &str, body: &str) -> Result<()> {
let mut stream = UnixStream::connect("/run/signal-cli/socket")
.context("Failed to connect to Signal RPC socket")?;
let timeout = Duration::from_secs(4);
stream.set_read_timeout(Some(timeout))?;
stream.set_write_timeout(Some(timeout))?;
let request = json!({
"jsonrpc": "2.0",
"method": "send",
"params": {
"account": self.from_phone,
"recipient": [to_phone],
"message": body,
},
"id": "signal-alert",
});
writeln!(stream, "{request}").context("Failed to send Signal RPC request")?;
let mut reader = BufReader::new(stream);
let mut line = String::new();
loop {
line.clear();
if reader
.read_line(&mut line)
.context("Failed to read Signal RPC response")?
== 0
|| !line.ends_with('\n')
{
bail!("Signal RPC socket closed before returning a response");
}
let response: Value =
serde_json::from_str(&line).context("Invalid Signal RPC response")?;
if response.get("id").and_then(Value::as_str) != Some("signal-alert") {
continue;
}
if let Some(error) = response.get("error").filter(|value| !value.is_null()) {
bail!("Signal RPC returned an error: {error}");
}
return Ok(());
}
}
}
-147
View File
@@ -1,147 +0,0 @@
#![cfg(test)]
#![cfg(target_os = "linux")]
use std::{
cell::RefCell,
io::{self, BufRead, BufReader, Write},
os::{fd::AsRawFd, unix::net::UnixStream},
thread,
time::{Duration, Instant},
};
use serde_json::{Value, json};
use signal::Signal;
thread_local! {
static CONNECTION: RefCell<Option<UnixStream>> = const { RefCell::new(None) };
}
// Patch libc's connect only in this integration-test executable. Each test
// supplies its own socket pair; no call can reach the real Signal daemon.
// SAFETY: This symbol uses libc's connect ABI and preserves its return/errno
// convention. It never delegates to a real connection attempt.
#[unsafe(no_mangle)]
unsafe extern "C" fn connect(
socket: libc::c_int,
address: *const libc::sockaddr,
length: libc::socklen_t,
) -> libc::c_int {
let path_offset = std::mem::offset_of!(libc::sockaddr_un, sun_path);
let expected = b"/run/signal-cli/socket\0";
if !address.is_null()
&& let Some(path_end) = path_offset.checked_add(expected.len())
&& usize::try_from(length).is_ok_and(|length| length >= path_end)
{
// SAFETY: connect's caller supplies an address valid for length bytes.
// The bounds check above covers the entire pathname slice.
let path = unsafe {
std::slice::from_raw_parts(address.cast::<u8>().add(path_offset), expected.len())
};
if path == expected
&& let Some(stream) = CONNECTION.with_borrow_mut(Option::take)
{
// SAFETY: Both descriptors are live and owned by this thread.
// dup2 replaces the unconnected socket with the test endpoint.
return if unsafe { libc::dup2(stream.as_raw_fd(), socket) } < 0 {
-1
} else {
0
};
}
}
// SAFETY: __errno_location returns this thread's valid errno pointer.
unsafe { *libc::__errno_location() = libc::ECONNREFUSED };
-1
}
fn test_connection() -> UnixStream {
let (client, server) = UnixStream::pair().unwrap();
CONNECTION.with_borrow_mut(|connection| assert!(connection.replace(client).is_none()));
server
}
fn signal() -> Signal {
Signal {
from_phone: "1234567890".into(),
}
}
#[test]
fn reuses_sender_for_different_recipients_and_ignores_unrelated_responses() {
let client = signal();
for (to_phone, body) in [
("0987654321", "failure\nwith details"),
("+15555550102", "another failure"),
] {
let server = test_connection();
let task = thread::spawn(move || {
let mut reader = BufReader::new(server);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let request: Value = serde_json::from_str(&line).unwrap();
assert_eq!(
request,
json!({
"jsonrpc": "2.0", "method": "send", "id": "signal-alert",
"params": { "account": "1234567890", "recipient": [to_phone], "message": body },
})
);
reader
.get_mut()
.write_all(b"{\"method\":\"receive\"}\n{\"id\":\"other\"}\n{\"id\":\"signal-")
.unwrap();
reader
.get_mut()
.write_all(b"alert\",\"result\":{}}\n")
.unwrap();
});
client.send(to_phone, body).unwrap();
task.join().unwrap();
}
}
#[test]
fn rejects_rpc_errors_invalid_json_and_premature_close() {
for (reply, expected) in [
(
"{\"id\":\"signal-alert\",\"error\":{\"code\":-1}}\n",
"Signal RPC returned an error",
),
("invalid\n", "Invalid Signal RPC response"),
("", "socket closed"),
("{\"id\":\"signal-alert\"}", "socket closed"),
] {
let server = test_connection();
let task = thread::spawn(move || {
let mut reader = BufReader::new(server);
reader.read_line(&mut String::new()).unwrap();
reader.get_mut().write_all(reply.as_bytes()).unwrap();
});
let error = signal().send("0987654321", "failure").unwrap_err();
assert!(format!("{error:#}").contains(expected), "{error:#}");
task.join().unwrap();
}
}
#[test]
fn reports_connection_failure() {
let error = signal().send("0987654321", "failure").unwrap_err();
assert_eq!(error.to_string(), "Failed to connect to Signal RPC socket");
assert_eq!(
error.downcast_ref::<io::Error>().unwrap().kind(),
io::ErrorKind::ConnectionRefused
);
}
#[test]
fn unresponsive_server_times_out() {
let _server = test_connection();
let start = Instant::now();
let error = signal().send("0987654321", "failure").unwrap_err();
assert_eq!(error.to_string(), "Failed to read Signal RPC response");
assert!(matches!(
error.downcast_ref::<io::Error>().unwrap().kind(),
io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
));
assert!(start.elapsed() >= Duration::from_secs(4));
}
-23
View File
@@ -1,23 +0,0 @@
[package]
name = "snapshot-manager"
version = "0.1.0"
edition = "2024"
description = "Create and rotate ZFS snapshots with Signal failure alerts"
license = "MIT"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap = { workspace = true, features = ["derive", "env"] }
env_logger.workspace = true
log.workspace = true
serde = { workspace = true, features = ["derive"] }
signal.workspace = true
toml = "0.9"
zfs.workspace = true
[dev-dependencies]
tempfile = "3"
[lints]
workspace = true
-25
View File
@@ -1,25 +0,0 @@
{ lib, rustPlatform }:
rustPlatform.buildRustPackage {
pname = "snapshot-manager";
version = "0.1.0";
src = import ../rust-source.nix { inherit lib; };
cargoLock.lockFile = ../../Cargo.lock;
cargoBuildFlags = [
"-p"
"snapshot-manager"
];
cargoTestFlags = [
"-p"
"snapshot-manager"
"-p"
"signal"
"-p"
"zfs"
];
meta = {
description = "ZFS snapshot manager with Signal failure alerts";
mainProgram = "snapshot-manager";
};
}
-383
View File
@@ -1,383 +0,0 @@
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
process::ExitCode,
};
use anyhow::{Context, Result};
use chrono::{DateTime, Datelike, NaiveDate, NaiveTime, Timelike, Utc};
use clap::Parser;
use log::{error, info};
use serde::Deserialize;
use signal::Signal;
#[derive(Parser)]
#[command(version, about)]
struct Args {
/// TOML file containing default and per-dataset snapshot retention counts.
config_file: PathBuf,
/// Send Signal failure alerts (requires both phone numbers).
#[arg(long, env = "SIGNAL_ALERTS", default_value_t = false, action = clap::ArgAction::Set)]
signal_alerts: bool,
/// Signal account sending failure alerts.
#[arg(
long,
env = "SIGNAL_ALERT_FROM_PHONE",
hide_env_values = true,
required_if_eq("signal_alerts", "true"),
value_parser = clap::builder::NonEmptyStringValueParser::new()
)]
signal_alert_from_phone: Option<String>,
/// Signal recipient for failure alerts.
#[arg(
long,
env = "SIGNAL_ALERT_TO_PHONE",
hide_env_values = true,
required_if_eq("signal_alerts", "true"),
value_parser = clap::builder::NonEmptyStringValueParser::new()
)]
signal_alert_to_phone: Option<String>,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct Retention {
#[serde(rename = "15_min")]
quarter_hourly: usize,
hourly: usize,
daily: usize,
monthly: usize,
}
impl Default for Retention {
fn default() -> Self {
Self {
quarter_hourly: 4,
hourly: 12,
daily: 0,
monthly: 0,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(transparent)]
struct Config(HashMap<String, Retention>);
impl Config {
fn load(path: &Path) -> Result<Self> {
let contents = fs::read_to_string(path)
.with_context(|| format!("Failed to read configuration {}", path.display()))?;
toml::from_str(&contents)
.with_context(|| format!("Invalid configuration {}", path.display()))
}
fn retention_for(&self, dataset: &str) -> Retention {
self.0
.get(dataset)
.or_else(|| self.0.get("default"))
.copied()
.unwrap_or_default()
}
}
fn main() -> ExitCode {
let args = Args::parse();
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
info!("Starting snapshot_manager");
let signal = if args.signal_alerts {
args.signal_alert_from_phone
.map(|from_phone| Signal { from_phone })
} else {
None
};
let alerts = signal.as_ref().zip(args.signal_alert_to_phone.as_deref());
match run(&args.config_file, alerts) {
Ok(()) => {
info!("snapshot_manager completed");
ExitCode::SUCCESS
}
Err(error) => {
error!("{error:#}");
ExitCode::FAILURE
}
}
}
fn run(config_file: &Path, alerts: Option<(&Signal, &str)>) -> Result<()> {
let result = (|| {
// Validate all retention settings before making any changes to ZFS.
let config = Config::load(config_file)?;
let snapshot_time = timestamp(Utc::now());
for dataset_name in zfs::list_datasets()? {
if let Err(error) = zfs::create_snapshot(&dataset_name, &snapshot_time) {
let message = format!("{dataset_name} failed to create snapshot {snapshot_time}");
error!("{message}: {error:#}");
send_alert(alerts, &message)?;
continue;
}
remove_old_snapshots(&config, alerts, &dataset_name)
.context("Failed to remove old snapshots")?;
}
Ok(())
})();
if let Err(error) = &result {
error!("snapshot_manager failed: {error:#}");
if let Err(alert_error) = send_alert(alerts, "snapshot_manager failed") {
error!("Failed to send failure alert: {alert_error:#}");
}
}
result
}
fn timestamp(now: DateTime<Utc>) -> String {
let minute = now.minute().saturating_sub(now.minute() % 15);
format!("auto_{}{minute:02}", now.format("%Y%m%d%H"))
}
fn remove_old_snapshots(
config: &Config,
alerts: Option<(&Signal, &str)>,
dataset_name: &str,
) -> Result<()> {
let retention = config.retention_for(dataset_name);
info!("Using {retention:?} for {dataset_name}");
let snapshots = zfs::list_snapshots(dataset_name)?;
for snapshot in snapshots_to_delete(&snapshots, retention) {
info!("Deleting {dataset_name}@{snapshot}");
if let Some(error) = zfs::delete_snapshot(dataset_name, snapshot)? {
let message = format!("{dataset_name}@{snapshot} failed to delete: {error}");
error!("{message}");
send_alert(alerts, &message)?;
}
}
Ok(())
}
fn snapshots_to_delete(snapshots: &[String], retention: Retention) -> Vec<&str> {
let mut tiers: [Vec<&str>; 4] = Default::default();
for snapshot in snapshots {
if let Some(index) = bucket(snapshot)
&& let Some(tier) = tiers.get_mut(index)
{
tier.push(snapshot);
}
}
let counts = [
retention.quarter_hourly,
retention.hourly,
retention.daily,
retention.monthly,
];
let mut deletions = Vec::new();
for (mut tier, count) in tiers.into_iter().zip(counts) {
tier.sort_unstable();
let delete_count = tier.len().saturating_sub(count);
deletions.extend(tier.into_iter().take(delete_count));
}
deletions
}
fn bucket(name: &str) -> Option<usize> {
let digits = name.strip_prefix("auto_")?;
if digits.len() != 12 || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
let date = NaiveDate::from_ymd_opt(
digits.get(..4)?.parse().ok()?,
digits.get(4..6)?.parse().ok()?,
digits.get(6..8)?.parse().ok()?,
)?;
let time = NaiveTime::from_hms_opt(
digits.get(8..10)?.parse().ok()?,
digits.get(10..)?.parse().ok()?,
0,
)?;
// The tiers are disjoint: midnight belongs to daily, except on the first
// of the month, when it belongs to monthly.
match (time.minute(), time.hour(), date.day()) {
(15 | 30 | 45, _, _) => Some(0),
(0, 1..=23, _) => Some(1),
(0, 0, 2..=31) => Some(2),
(0, 0, 1) => Some(3),
_ => None,
}
}
fn send_alert(alerts: Option<(&Signal, &str)>, body: &str) -> Result<()> {
if let Some((signal, to_phone)) = alerts {
signal.send(to_phone, body)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use chrono::TimeZone;
use super::*;
#[test]
fn dataset_overrides_default_and_other_datasets_fall_back() {
let config: Config = toml::from_str(
r#"
[default]
15_min = 8
hourly = 24
daily = 0
monthly = 0
["pool/home"]
15_min = 2
hourly = 3
daily = 4
monthly = 5
"#,
)
.unwrap();
assert_eq!(
config.retention_for("pool/home"),
Retention {
quarter_hourly: 2,
hourly: 3,
daily: 4,
monthly: 5,
}
);
assert_eq!(config.retention_for("pool/other").hourly, 24);
assert_eq!(config.retention_for("pool/home/child").quarter_hourly, 8);
}
#[test]
fn absent_default_uses_original_builtin_counts() {
let config: Config = toml::from_str("").unwrap();
assert_eq!(config.retention_for("pool/home"), Retention::default());
}
#[test]
fn rejects_incomplete_negative_and_unknown_retention_settings() {
for settings in [
"15_min = 1\nhourly = 2\ndaily = 3",
"15_min = -1\nhourly = 2\ndaily = 3\nmonthly = 4",
"15_min = 1\nhourly = 2\ndaily = 3\nmonthly = 4\nweekly = 5",
"15_min = 1.5\nhourly = 2\ndaily = 3\nmonthly = 4",
] {
assert!(toml::from_str::<Config>(&format!("[default]\n{settings}")).is_err());
}
}
#[test]
fn timestamps_round_down_at_quarter_hour_boundaries() {
for (minute, expected) in [
(0, "00"),
(14, "00"),
(15, "15"),
(29, "15"),
(30, "30"),
(44, "30"),
(45, "45"),
(59, "45"),
] {
let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, minute, 59).unwrap();
assert_eq!(timestamp(now), format!("auto_2026010100{expected}"));
}
}
#[test]
fn tiers_are_disjoint_at_day_and_month_boundaries() {
for (name, expected) in [
("auto_202602010015", 0),
("auto_202602010030", 0),
("auto_202602010045", 0),
("auto_202602010100", 1),
("auto_202602012300", 1),
("auto_202602020000", 2),
("auto_202402290000", 2),
("auto_202602010000", 3),
("auto_202601010000", 3),
] {
assert_eq!(bucket(name), Some(expected), "{name}");
}
}
#[test]
fn only_exact_valid_manager_names_are_eligible() {
for name in [
"manual",
"manual_auto_202602010015",
"auto_202602010015_backup",
"auto_20260201001",
"auto_202602300000",
"auto_202602012400",
"auto_202602010060",
"auto_202602010005",
"auto_202602010015",
] {
assert_eq!(bucket(name), None, "{name}");
}
}
#[test]
fn keeps_newest_in_each_tier_regardless_of_input_order() {
let snapshots = [
"auto_202601010000",
"auto_202602020000",
"auto_202602020115",
"auto_202602020215",
"auto_202602020200",
"auto_202602020100",
"auto_202602030000",
"auto_202602010000",
"manual",
]
.map(String::from);
let retention = Retention {
quarter_hourly: 1,
hourly: 1,
daily: 1,
monthly: 1,
};
assert_eq!(
snapshots_to_delete(&snapshots, retention),
[
"auto_202602020115",
"auto_202602020100",
"auto_202602020000",
"auto_202601010000",
]
);
}
#[test]
fn zero_counts_delete_all_managed_snapshots_and_large_counts_keep_them() {
let snapshots = [
"auto_202601010015",
"auto_202601010100",
"auto_202601020000",
"auto_202601010000",
"manual",
]
.map(String::from);
let none = Retention {
quarter_hourly: 0,
hourly: 0,
daily: 0,
monthly: 0,
};
assert_eq!(snapshots_to_delete(&snapshots, none).len(), 4);
let all = Retention {
quarter_hourly: 100,
hourly: 100,
daily: 100,
monthly: 100,
};
assert!(snapshots_to_delete(&snapshots, all).is_empty());
assert!(snapshots_to_delete(&[], none).is_empty());
}
}
-202
View File
@@ -1,202 +0,0 @@
#![cfg(test)]
use std::{fs, os::unix::fs::symlink, path::Path, process::Command};
use tempfile::tempdir;
fn command() -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_snapshot-manager"));
command
.env_remove("SIGNAL_ALERTS")
.env_remove("SIGNAL_ALERT_FROM_PHONE")
.env_remove("SIGNAL_ALERT_TO_PHONE")
.env_remove("SNAPSHOT_TEST_SCRIPT")
.env_remove("RUST_LOG");
command
}
#[test]
fn help_works_without_phone_numbers() {
let output = command().arg("--help").output().unwrap();
assert!(output.status.success());
assert!(
String::from_utf8(output.stdout)
.unwrap()
.contains("<CONFIG_FILE>")
);
}
#[test]
fn help_documents_environment_variables_without_exposing_phone_numbers() {
let output = command()
.env("SIGNAL_ALERT_FROM_PHONE", "+15555550101")
.env("SIGNAL_ALERT_TO_PHONE", "+15555550102")
.arg("--help")
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
for name in [
"SIGNAL_ALERTS",
"SIGNAL_ALERT_FROM_PHONE",
"SIGNAL_ALERT_TO_PHONE",
] {
assert!(stdout.contains(name), "{stdout}");
}
assert!(!stdout.contains("+15555550101"), "{stdout}");
assert!(!stdout.contains("+15555550102"), "{stdout}");
}
#[test]
fn config_argument_is_required() {
let output = command().output().unwrap();
assert_eq!(output.status.code(), Some(2));
}
#[test]
fn enabled_alerts_require_phone_numbers_before_running() {
for use_env in [false, true] {
for (from, to, expected) in [
(None, None, "--signal-alert-from-phone"),
(Some(""), None, "--signal-alert-from-phone"),
(Some("+15555550101"), None, "--signal-alert-to-phone"),
(Some("+15555550101"), Some(""), "--signal-alert-to-phone"),
(None, Some("+15555550102"), "--signal-alert-from-phone"),
] {
let mut command = command();
if use_env {
command.env("SIGNAL_ALERTS", "true");
} else {
command.args(["--signal-alerts", "true"]);
}
if let Some(from) = from {
command.env("SIGNAL_ALERT_FROM_PHONE", from);
}
if let Some(to) = to {
command.env("SIGNAL_ALERT_TO_PHONE", to);
}
let output = command.arg("unused.toml").output().unwrap();
let stderr = String::from_utf8(output.stderr).unwrap();
assert_eq!(output.status.code(), Some(2), "{stderr}");
assert!(stderr.contains(expected), "{stderr}");
assert!(!stderr.contains("Starting snapshot_manager"), "{stderr}");
}
}
}
#[test]
fn enabled_alerts_accept_environment_variables_and_cli_overrides() {
let dir = tempdir().unwrap();
let config = dir.path().join("config.toml");
let scenario = dir.path().join("scenario");
fs::write(&config, "").unwrap();
// An empty dataset list completes successfully without sending an alert.
fs::write(&scenario, "").unwrap();
symlink(
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/zfs"),
dir.path().join("zfs"),
)
.unwrap();
for use_cli in [false, true] {
let mut command = command();
if use_cli {
command
.env("SIGNAL_ALERTS", "invalid")
.env("SIGNAL_ALERT_FROM_PHONE", "")
.env("SIGNAL_ALERT_TO_PHONE", "")
.args([
"--signal-alerts",
"true",
"--signal-alert-from-phone",
"+15555550101",
"--signal-alert-to-phone",
"+15555550102",
]);
} else {
command
.env("SIGNAL_ALERTS", "true")
.env("SIGNAL_ALERT_FROM_PHONE", "+15555550101")
.env("SIGNAL_ALERT_TO_PHONE", "+15555550102");
}
let output = command
.current_dir(dir.path())
.env("PATH", dir.path())
.env("SNAPSHOT_TEST_SCRIPT", &scenario)
.arg(&config)
.output()
.unwrap();
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(output.status.success(), "{stderr}");
assert!(stderr.contains("snapshot_manager completed"), "{stderr}");
}
}
#[test]
fn disabled_alerts_allow_snapshot_failures_without_phone_numbers() {
let dir = tempdir().unwrap();
let config = dir.path().join("config.toml");
fs::write(&config, "").unwrap();
symlink(
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/zfs"),
dir.path().join("zfs"),
)
.unwrap();
for (env_value, args) in [
(None, vec![]),
(Some("false"), vec![]),
(None, vec!["--signal-alerts", "false"]),
(Some("true"), vec!["--signal-alerts", "false"]),
] {
let mut command = command();
if let Some(value) = env_value {
command.env("SIGNAL_ALERTS", value);
}
let output = command
.env("PATH", dir.path())
.args(args)
.arg(&config)
.output()
.unwrap();
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(output.status.success(), "{stderr}");
assert!(
stderr.contains("pool/home failed to create snapshot"),
"{stderr}"
);
assert!(stderr.contains("snapshot_manager completed"), "{stderr}");
assert!(!stderr.contains("Signal"), "{stderr}");
}
}
#[test]
fn disabled_alerts_preserve_fatal_errors_without_phone_numbers() {
let dir = tempdir().unwrap();
for args in [vec![], vec!["--signal-alerts", "false"]] {
let output = command()
.args(args)
.arg(dir.path().join("missing.toml"))
.output()
.unwrap();
let stderr = String::from_utf8(output.stderr).unwrap();
assert_eq!(output.status.code(), Some(1), "{stderr}");
assert!(stderr.contains("Failed to read configuration"), "{stderr}");
assert!(!stderr.contains("SIGNAL_ALERT_"), "{stderr}");
assert!(!stderr.contains("Failed to send failure alert"), "{stderr}");
}
}
#[test]
fn signal_alerts_requires_a_boolean_value() {
for use_env in [false, true] {
let mut command = command();
if use_env {
command.env("SIGNAL_ALERTS", "invalid");
} else {
command.args(["--signal-alerts", "invalid"]);
}
let output = command.arg("unused.toml").output().unwrap();
assert_eq!(output.status.code(), Some(2));
}
}
-12
View File
@@ -1,12 +0,0 @@
#!/bin/sh
if [ -n "${SNAPSHOT_TEST_SCRIPT:-}" ]; then
printf '%s\n' "$*" >> calls
. "$SNAPSHOT_TEST_SCRIPT"
exit
fi
case "$*" in
'list -Hp -t filesystem -o name') printf '%s\n' pool/home ;;
'snapshot pool/home@'*) printf '%s\n' 'permission denied' >&2; exit 1 ;;
*) exit 99 ;;
esac
-290
View File
@@ -1,290 +0,0 @@
#![cfg(test)]
use std::{
fs,
os::unix::fs::symlink,
path::{Path, PathBuf},
process::{Command, Output},
};
use tempfile::{TempDir, tempdir};
struct Fixture {
dir: TempDir,
config: PathBuf,
}
impl Fixture {
fn new(script: &str) -> Self {
let dir = tempdir().unwrap();
// Only the child process sees this PATH. The real zfs cannot be found.
symlink(
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/zfs"),
dir.path().join("zfs"),
)
.unwrap();
// The executable is a checked-in fixture; scenario files are read by
// its shell, so parallel tests never execute a file being written.
fs::write(dir.path().join("scenario"), script).unwrap();
let config = dir.path().join("config.toml");
fs::write(
&config,
"[default]\n15_min = 1\nhourly = 0\ndaily = 0\nmonthly = 0\n",
)
.unwrap();
Self { dir, config }
}
fn calls(&self) -> Vec<String> {
fs::read_to_string(self.dir.path().join("calls"))
.unwrap_or_default()
.lines()
.map(String::from)
.collect()
}
fn normalized_calls(&self) -> Vec<String> {
self.calls()
.into_iter()
.map(|call| {
if call.starts_with("snapshot ") {
let (dataset, name) = call.split_once('@').unwrap();
assert!(name.starts_with("auto_"), "{call}");
format!("{dataset}@<timestamp>")
} else {
call
}
})
.collect()
}
fn run(&self) -> Output {
Command::new(env!("CARGO_BIN_EXE_snapshot-manager"))
.current_dir(self.dir.path())
.env("PATH", self.dir.path())
.env("SNAPSHOT_TEST_SCRIPT", self.dir.path().join("scenario"))
.env_remove("SIGNAL_ALERTS")
.env_remove("SIGNAL_ALERT_FROM_PHONE")
.env_remove("SIGNAL_ALERT_TO_PHONE")
.env_remove("RUST_LOG")
.arg(&self.config)
.output()
.unwrap()
}
}
fn stderr(output: &Output) -> &str {
std::str::from_utf8(&output.stderr).unwrap()
}
#[test]
fn creates_then_prunes_only_this_datasets_managed_snapshots() {
let fixture = Fixture::new(
r#"
case "$*" in
'list -Hp -t filesystem -o name')
printf '%s\n' pool pool/home
printf '%s\n' 'warning on stderr' >&2 ;;
'snapshot pool/home@auto_'*) ;;
'list -Hp -t snapshot -o name -d 1 pool/home')
printf '%s\n' pool/home@auto_200002020145 pool/home@auto_200002020115 pool/home@manual pool/home@manual_auto_200001010015 pool/home/child@auto_200001010015 pool/other@auto_200001010015 ;;
'destroy pool/home@auto_200002020115') ;;
*) exit 99 ;;
esac
"#,
);
let output = fixture.run();
assert!(output.status.success(), "{}", stderr(&output));
assert_eq!(
fixture.normalized_calls(),
[
"list -Hp -t filesystem -o name",
"snapshot pool/home@<timestamp>",
"list -Hp -t snapshot -o name -d 1 pool/home",
"destroy pool/home@auto_200002020115",
]
);
}
#[test]
fn failed_or_duplicate_creation_skips_pruning_but_continues() {
for error in ["snapshot already exists", "permission denied"] {
let fixture = Fixture::new(&format!(
r#"
case "$*" in
'list -Hp -t filesystem -o name') printf '%s\n' pool/home pool/other ;;
'snapshot pool/home@auto_'*) printf '%s\n' '{error}' >&2; exit 1 ;;
'snapshot pool/other@auto_'*) ;;
'list -Hp -t snapshot -o name -d 1 pool/other') ;;
*) exit 99 ;;
esac
"#
));
let output = fixture.run();
assert!(output.status.success(), "{}", stderr(&output));
assert!(stderr(&output).contains("pool/home failed to create snapshot auto_"));
assert!(stderr(&output).contains(error));
assert_eq!(
fixture.normalized_calls(),
[
"list -Hp -t filesystem -o name",
"snapshot pool/home@<timestamp>",
"snapshot pool/other@<timestamp>",
"list -Hp -t snapshot -o name -d 1 pool/other",
]
);
let calls = fixture.calls();
assert_eq!(
calls[1].split_once('@').unwrap().1,
calls[2].split_once('@').unwrap().1
);
}
}
#[test]
fn dependent_clone_failure_is_reported_and_remaining_deletions_continue() {
let fixture = Fixture::new(
r#"
case "$*" in
'list -Hp -t filesystem -o name') printf '%s\n' pool/home ;;
'snapshot pool/home@auto_'*) ;;
'list -Hp -t snapshot -o name -d 1 pool/home')
printf '%s\n' pool/home@auto_200002020115 pool/home@auto_200002020130 pool/home@auto_200002020145 ;;
'destroy pool/home@auto_200002020115')
printf '%s\n' "cannot destroy 'pool/home@auto_200002020115': snapshot has dependent clones" >&2; exit 1 ;;
'destroy pool/home@auto_200002020130') ;;
*) exit 99 ;;
esac
"#,
);
let output = fixture.run();
assert!(output.status.success(), "{}", stderr(&output));
assert!(
stderr(&output).contains(
"pool/home@auto_200002020115 failed to delete: snapshot has dependent clones"
)
);
assert_eq!(
fixture.calls().last().unwrap(),
"destroy pool/home@auto_200002020130"
);
}
#[test]
fn unexpected_destroy_failure_aborts_and_reports_the_error() {
let fixture = Fixture::new(
r#"
case "$1" in
list)
if [ "$4" = filesystem ]; then
printf '%s\n' pool/home
else
printf '%s\n' pool/home@auto_200002020115 pool/home@auto_200002020130 pool/home@auto_200002020145
fi ;;
snapshot) ;;
destroy) printf '%s\n' 'permission denied' >&2; exit 1 ;;
esac
"#,
);
let output = fixture.run();
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(stderr(&output).contains("permission denied"));
assert!(stderr(&output).contains("snapshot_manager failed"));
assert_eq!(
fixture.calls().last().unwrap(),
"destroy pool/home@auto_200002020115"
);
}
#[test]
fn failed_list_never_uses_partial_output() {
for stream in ["", " >&2"] {
let fixture = Fixture::new(&format!(
"printf '%s\\n' pool/home\nprintf '%s\\n' 'cannot list datasets'{stream}\nexit 1"
));
let output = fixture.run();
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(stderr(&output).contains("cannot list datasets"));
assert!(stderr(&output).contains("snapshot_manager failed"));
assert_eq!(fixture.calls(), ["list -Hp -t filesystem -o name"]);
}
}
#[test]
fn failed_snapshot_listing_aborts_without_destroying_anything() {
let fixture = Fixture::new(
r#"
case "$*" in
'list -Hp -t filesystem -o name') printf '%s\n' pool/home ;;
'snapshot pool/home@auto_'*) ;;
*) printf '%s\n' pool/home@auto_200002020115; exit 1 ;;
esac
"#,
);
let output = fixture.run();
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(stderr(&output).contains("snapshot_manager failed"));
assert_eq!(fixture.calls().len(), 3);
}
#[test]
fn invalid_config_stops_before_any_zfs_commands() {
let fixture = Fixture::new("exit 99");
fs::write(
&fixture.config,
"[default]\n15_min = -1\nhourly = 1\ndaily = 0\nmonthly = 0",
)
.unwrap();
let output = fixture.run();
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(stderr(&output).contains("Invalid configuration"));
assert!(fixture.calls().is_empty());
}
#[test]
fn missing_zfs_executable_fails_and_reports_the_error() {
let fixture = Fixture::new("exit 99");
fs::remove_file(fixture.dir.path().join("zfs")).unwrap();
let output = fixture.run();
assert_eq!(output.status.code(), Some(1), "{}", stderr(&output));
assert!(stderr(&output).contains("Failed to execute zfs"));
assert!(stderr(&output).contains("snapshot_manager failed"));
assert!(fixture.calls().is_empty());
}
#[test]
fn dataset_names_are_passed_as_one_argument_without_a_shell() {
let fixture = Fixture::new(
r#"
case "$1" in
list)
[ "$*" = 'list -Hp -t filesystem -o name' ] || exit 98
printf '%s\n' 'pool/two words;touch injected' ;;
snapshot)
[ "$#" -eq 2 ] || exit 97
case "$2" in
'pool/two words;touch injected@auto_'*) printf '%s\n' 'argument preserved' >&2; exit 1 ;;
*) exit 96 ;;
esac ;;
*) exit 99 ;;
esac
"#,
);
let output = fixture.run();
assert!(output.status.success(), "{}", stderr(&output));
assert!(stderr(&output).contains("argument preserved"));
assert_eq!(
fixture.normalized_calls(),
[
"list -Hp -t filesystem -o name",
"snapshot pool/two words;touch injected@<timestamp>"
]
);
assert!(fs::read_dir(fixture.dir.path()).unwrap().all(|entry| {
!entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with("injected")
}));
}
+11 -10
View File
@@ -6,15 +6,16 @@ description = "Fetch privacy-masked weather for a van and publish it to Home Ass
license = "MIT"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
clap = { workspace = true, features = ["derive", "env"] }
env_logger.workspace = true
log.workspace = true
num-traits = "0.2"
anyhow = "1.0"
chrono = "0.4"
clap = { version = "4.5", features = ["derive", "env"] }
env_logger = "0.11"
log = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[lints.clippy]
all = "deny"
pedantic = "deny"
[lints]
workspace = true
+3 -11
View File
@@ -1,18 +1,10 @@
{ lib, rustPlatform }:
{ rustPlatform }:
rustPlatform.buildRustPackage {
pname = "van-weather";
version = "0.1.0";
src = import ../rust-source.nix { inherit lib; };
src = ./.;
cargoLock.lockFile = ../../Cargo.lock;
cargoBuildFlags = [
"-p"
"van-weather"
];
cargoTestFlags = [
"-p"
"van-weather"
];
cargoLock.lockFile = ./Cargo.lock;
meta = {
description = "Privacy-masked van weather publisher for Home Assistant";
+9 -34
View File
@@ -4,7 +4,6 @@ use anyhow::{Context, Result, bail};
use chrono::{DateTime, Utc};
use clap::Parser;
use log::{error, info};
use num_traits::ToPrimitive;
use reqwest::{
StatusCode, Url,
blocking::{Client, ClientBuilder},
@@ -26,7 +25,7 @@ const fn decimal_factor(decimals: u32) -> f64 {
let mut remaining = decimals;
while remaining > 0 {
factor *= 10.0;
remaining = remaining.saturating_sub(1);
remaining -= 1;
}
factor
}
@@ -469,25 +468,18 @@ fn weather_updates(weather: &Weather) -> Vec<(String, Value)> {
}
fn sensor(entity_id: &str, state: Option<Value>, attributes: Value) -> Option<(String, Value)> {
let state = state?;
let mut payload = serde_json::Map::new();
payload.insert("state".to_owned(), state);
payload.insert("attributes".to_owned(), attributes);
Some((entity_id.to_owned(), Value::Object(payload)))
state.map(|state| {
let mut payload = serde_json::Map::new();
payload.insert("state".to_owned(), state);
payload.insert("attributes".to_owned(), attributes);
(entity_id.to_owned(), Value::Object(payload))
})
}
#[allow(clippy::cast_possible_truncation)]
fn percent(value: Option<f64>) -> i64 {
// Preserve Python's int(probability * 100) behavior for Home Assistant.
let value = value.unwrap_or_default() * 100.0;
value.to_i64().unwrap_or_else(|| {
if value.is_nan() {
0
} else if value.is_sign_negative() {
i64::MIN
} else {
i64::MAX
}
})
(value.unwrap_or_default() * 100.0) as i64
}
#[cfg(test)]
@@ -570,21 +562,4 @@ mod tests {
assert!((mask_coordinate(37.7749) - 37.8).abs() < f64::EPSILON);
assert!((mask_coordinate(-122.4194) - (-122.4)).abs() < f64::EPSILON);
}
#[test]
fn percentages_truncate_and_preserve_saturation_for_extreme_values() {
for (value, expected) in [
(None, 0),
(Some(0.129), 12),
(Some(-0.129), -12),
(Some(1.0), 100),
(Some(f64::NAN), 0),
(Some(f64::MAX), i64::MAX),
(Some(f64::MIN), i64::MIN),
(Some(f64::INFINITY), i64::MAX),
(Some(f64::NEG_INFINITY), i64::MIN),
] {
assert_eq!(percent(value), expected);
}
}
}
-13
View File
@@ -1,13 +0,0 @@
[package]
name = "zfs"
version = "0.1.0"
edition = "2024"
description = "List ZFS datasets and manage snapshots through the zfs executable"
license = "MIT"
publish = false
[dependencies]
anyhow.workspace = true
[lints]
workspace = true
-100
View File
@@ -1,100 +0,0 @@
//! Manage ZFS datasets and snapshots using the `zfs` executable on `PATH`.
//!
//! ```no_run
//! for snapshot in zfs::list_snapshots("pool/home")? {
//! println!("{snapshot}");
//! }
//! # Ok::<(), anyhow::Error>(())
//! ```
use std::process::Command;
use anyhow::{Context, Result, bail};
const ZFS_EXECUTABLE: &str = "zfs";
fn zfs_command(args: &[&str]) -> Result<String> {
let output = Command::new(ZFS_EXECUTABLE)
.args(args)
.env("LC_ALL", "C")
.output()
.context("Failed to execute zfs")?;
if !output.status.success() {
let message = if output.stderr.is_empty() {
&output.stdout
} else {
&output.stderr
};
bail!(
"zfs {} failed ({}): {}",
args.join(" "),
output.status,
String::from_utf8_lossy(message).trim()
);
}
String::from_utf8(output.stdout).context("zfs returned invalid UTF-8")
}
/// List filesystem datasets, excluding pool roots.
///
/// # Errors
///
/// Returns an error if executing ZFS or decoding its output fails.
pub fn list_datasets() -> Result<Vec<String>> {
let output = zfs_command(&["list", "-Hp", "-t", "filesystem", "-o", "name"])?;
Ok(output
.lines()
.filter(|name| name.contains('/'))
.map(String::from)
.collect())
}
/// List snapshot names belonging directly to `dataset`, excluding child datasets.
///
/// Returned names omit the `dataset@` prefix.
///
/// # Errors
///
/// Returns an error if executing ZFS or decoding its output fails.
pub fn list_snapshots(dataset: &str) -> Result<Vec<String>> {
let output = zfs_command(&[
"list", "-Hp", "-t", "snapshot", "-o", "name", "-d", "1", dataset,
])?;
Ok(output
.lines()
.filter_map(|name| {
let (owner, snapshot) = name.split_once('@')?;
(owner == dataset).then(|| snapshot.to_owned())
})
.collect())
}
/// Create `dataset@snapshot`.
///
/// # Errors
///
/// Returns an error if executing ZFS or decoding its output fails.
pub fn create_snapshot(dataset: &str, snapshot: &str) -> Result<()> {
zfs_command(&["snapshot", &format!("{dataset}@{snapshot}")])?;
Ok(())
}
/// Delete `dataset@snapshot`.
///
/// Returns `None` on success, or `Some("snapshot has dependent clones")` when
/// dependent clones prevent deletion.
///
/// # Errors
///
/// Returns an error for other ZFS failures, execution failures, or invalid UTF-8 output.
pub fn delete_snapshot(dataset: &str, snapshot: &str) -> Result<Option<&'static str>> {
if let Err(error) = zfs_command(&["destroy", &format!("{dataset}@{snapshot}")]) {
if error.to_string().contains(&format!(
"cannot destroy '{dataset}@{snapshot}': snapshot has dependent clones"
)) {
return Ok(Some("snapshot has dependent clones"));
}
return Err(error);
}
Ok(None)
}
-37
View File
@@ -1,37 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
if [[ "$(hostname)" != "jeeves" ]]; then
echo "Signal device authorization must run on jeeves." >&2
exit 1
fi
if [[ "$EUID" -ne 0 ]]; then
echo "Signal device authorization must run as root." >&2
exit 1
fi
if [[ "$#" -ne 1 ]]; then
echo "Usage: signal_authorize.sh DEVICE_URI" >&2
exit 2
fi
if [[ "$1" != sgnl://linkdevice\?* ]]; then
echo "Invalid Signal device link URI." >&2
exit 2
fi
service_name="signal-cli"
data_dir="/var/lib/signal-cli"
device_uri="$1"
restart_service=false
cleanup() {
if [[ "$restart_service" == true ]]; then
systemctl start "$service_name"
fi
}
trap cleanup EXIT
restart_service=true
systemctl stop "$service_name"
runuser -u signal-cli -- \
signal-cli --data-dir "$data_dir" -a +12016554378 addDevice --uri "$device_uri"
-60
View File
@@ -1,60 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
host_name="$(hostname)"
if [[ "$#" -ne 0 ]]; then
echo "Usage: signal_link.sh" >&2
exit 2
fi
service_name="signal-cli"
data_dir="/var/lib/signal-cli"
link_pid=""
temp_dir=""
restart_service=false
cleanup() {
if [[ -n "$link_pid" ]] && kill -0 "$link_pid" 2>/dev/null; then
kill "$link_pid" 2>/dev/null || true
wait "$link_pid" 2>/dev/null || true
fi
if [[ -n "$temp_dir" ]]; then
rm -f -- "$temp_dir/link-uri"
rmdir -- "$temp_dir"
fi
if [[ "$restart_service" == true ]]; then
sudo systemctl start "$service_name"
fi
}
trap cleanup EXIT
restart_service=true
sudo systemctl stop "$service_name"
temp_dir="$(mktemp -d)"
link_fifo="$temp_dir/link-uri"
mkfifo "$link_fifo"
sudo -u signal-cli \
signal-cli --data-dir "$data_dir" link --name "$host_name" >"$link_fifo" &
link_pid="$!"
if ! IFS= read -r device_uri <"$link_fifo"; then
wait "$link_pid"
exit 1
fi
if [[ "$device_uri" != sgnl://linkdevice\?* ]]; then
echo "signal-cli returned an invalid device link URI." >&2
exit 1
fi
echo "Run this command manually on jeeves in another dotfiles dir:"
printf "sudo ./scripts/signal/authorize.sh %q\n" "$device_uri"
echo "Waiting for jeeves to authorize this device..."
wait "$link_pid"
link_pid=""
echo "$host_name is now linked to the Signal account on jeeves."
+1 -1
View File
@@ -8,7 +8,7 @@
"${inputs.self}/common/optional/monitoring-agent.nix"
"${inputs.self}/common/optional/nvidia.nix"
"${inputs.self}/common/optional/scanner.nix"
"${inputs.self}/common/optional/signal-cli.nix"
"${inputs.self}/common/optional/steam.nix"
"${inputs.self}/common/optional/syncthing_base.nix"
"${inputs.self}/common/optional/systemd-boot.nix"
"${inputs.self}/common/optional/tailscale.nix"
-25
View File
@@ -1,25 +0,0 @@
system_token: ENC[AES256_GCM,data:zpyosU2oJTljDBbjeQs79gpzPtfL6Y41nZ3X1d+IgdI1fczx/F6MUOaBN6xVkVGYNHhaaCKaPmS/X7J8TDJHQGWPxM0ZsPluGGCnxhQjsqyY4oc3jyBkye7IbnnWmj1yhisq2V1ti5W7uewCQBioGqJYCNo4PfEjp6ipcgaI5no=,iv:Ee6nw88SUL8+80RR0YA4vAAGilGf7bY6EPhE81jD1Ic=,tag:NtRwWqCntdQN9BlakxvslQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtdjNXQ2VpUlBYMjZJbmRT
ajk2WEY5VndyU0o0aWdYbXRUSnE0QWxGNlYwCnBkOVd1VkMweTdIZHZBTmp5akMv
TE5SRStHYndXWm0wOG82TDlRa2dHV1kKLS0tIEwveXkxMkpULzhBZWQ5WXJQOGFm
TjVmQUkzQmJHYXgwSjF1T1dqMlNUVUEKCz8bv8tdtNPRLSXCNoyEY9yTdpFBg1uG
EpZ/VgsBV0LktJkqkv6ZSRWp2HiEC61305GEFxrE3u4w7ha6FLJMrQ==
-----END AGE ENCRYPTED FILE-----
recipient: age1u8zj599elqqvcmhxn8zuwrufsz8w8w366d3ayrljjejljt2q45kq8mxw9c
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrLzVQSFZQWTZ2ZnVLdEta
S0tkMElVMzZJQ2xSeElQL3N3dy9hQWxzVld3ClcySjhvT0RyUUk4SlNPd2Jpdzhv
QU5samVSQnFEdk80M3diazVEVUVFT1UKLS0tIEpLdE82dUFuT0t2NGxqYmxSbis1
MFAxN3BtRHVKMWxTeVBpblJDTUcvK0UKZTfYCDTAijn0UPK+Z7UjWOoODPf746Q9
ya085jHs39Z1597tgJzWIgydkHl6x9VYUkDu+1Yq4LOeyHFscELnJQ==
-----END AGE ENCRYPTED FILE-----
recipient: age1q47vup0tjhulkg7d6xwmdsgrw64h4ax3la3evzqpxyy4adsmk9fs56qz3y
lastmodified: "2026-10-14T02:07:23Z"
mac: ENC[AES256_GCM,data:o21tNt4Cs5DpCMcWCFjCQ3DRZZBCftFgm2P80y849sFF8rfFVZy99Rda0fiPpohRs45GsvDxPaDAxcCJl2N31pQ3Zg8VCKNS1LbBoDI3iqhuUtmKeudGfOUY6Gd1+j/E4ML29Cfrzm6r3WiXNPa7q8Jmq/kspwoubpQF2q9xp3o=,iv:6hbw+cRlEAdSaPvfh1sMoORI1uhQkqmpOK0aIhL+f+c=,tag:wTa/39ToJSOmn7b31+m34Q==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3
-1
View File
@@ -4,7 +4,6 @@
"${inputs.self}/users/richie"
"${inputs.self}/common/global"
"${inputs.self}/common/optional/docker.nix"
"${inputs.self}/common/optional/signal-cli.nix"
"${inputs.self}/common/optional/ssh_decrypt.nix"
"${inputs.self}/common/optional/syncthing_base.nix"
"${inputs.self}/common/optional/systemd-boot.nix"
+3
View File
@@ -0,0 +1,3 @@
# docker_networks
docker network create -d bridge web
-25
View File
@@ -1,25 +0,0 @@
system_token: ENC[AES256_GCM,data:qub1LgQ89K1EmKk8y/sE1OcPdEB5Zu/S/iZENBA+5d3NQUxJwhX3f3xTo4syBsYFk3b4X1AVx8X0JK27fyr36f9QKPc8IR3Kt4foNp+96gJs9wzskJ188yGFGn1Tle9BLkF1/l5Pzt8Xwe4VCOGuT9uh74q2sCUgcC52u+aez6E=,iv:AbAunuRTY9lhcnd2MwOyVv4in0MfyxwH+LsNK0DoMbQ=,tag:VAmVTSpPh734cZt5Vy0mTg==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjWGNFR01XRXBJUDZ0cFd6
dzdWVUlHT1NQU2VrV3VRSlF2bThYdWRtTERJClI1cFFTZzRNSlc1QmlnQUNxSHZB
dVhqejBMcDZVVXdIeU0yM3NxelBhMTgKLS0tIFJWRHdqTW5UY3ZOZUZ5dXJKRity
YXhHOWl0Y0dQOS9uN0VCSmJHWkxXd0EKV5LbdNZ5oujX+rey8i/XUxoOKw99JKJJ
N1sIIc4LmXrqeqRRhXAakQYoWEqQa12Vu3OZrPXkvoQ0KQHKcKbPPA==
-----END AGE ENCRYPTED FILE-----
recipient: age1u8zj599elqqvcmhxn8zuwrufsz8w8w366d3ayrljjejljt2q45kq8mxw9c
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBhVkNnUkZEbk8rcm90dFAy
Ty9vYWN4YkIxdVUyTlJCak4yMmx2dkZXUFFRCmZTRkJReGRwTXB1ZThYSjVmUC9D
L1JkVEhHUjRjenpBS0hkQWRiVXY1dEEKLS0tIGM1S2VYbHBjbDU2RG5MUC9HcDNW
VEFCR3ZXVzNOMVY3Q0VEM0Y4dnROZUEKKpULO8QALBkItUgkT7IQgPsC/3gQKfid
1dgv+CpFGLXDtS8Ocm/r0y6WL2ds1E5rwoQ4UphnxWJGLIAo8cs36g==
-----END AGE ENCRYPTED FILE-----
recipient: age1jhf7vm0005j60mjq63696frrmjhpy8kpc2d66mw044lqap5mjv4snmwvwm
lastmodified: "2026-10-14T02:08:28Z"
mac: ENC[AES256_GCM,data:fBhvyzmnzQtLZ7QNELrJvFfnfymLjfzb7Bi61+6dCqNNgrMnYI4gU+9xDSepHskWQ+9ZS3fHZ8ZQCMC+xjhEvI8PWVe4nQYFKHkNOOrfZSBb8osWyzChbQCQXJtK1zalfo/Yk9gvOzeGhqM+XOqMt15Wr56eETSPpPNwVdG2QPY=,iv:TRCrl90dFGJiP0RsYpEIuvknCh8tslIVWTu+Loeklbw=,tag:LkyzzSEnytMkz40IPeBM6g==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3
+1 -5
View File
@@ -13,10 +13,6 @@
services = {
home-assistant = {
enable = true;
# Keep Home Assistant's Python dependencies on baseline x86-64.
package = pkgs.x86-v1.home-assistant.overrideAttrs (_: {
doInstallCheck = false;
});
config = {
homeassistant = {
time_zone = "America/New_York";
@@ -83,7 +79,7 @@
"isal"
"modbus" # for victron modbus integration
];
customComponents = with pkgs.x86-v1.home-assistant-custom-components; [
customComponents = with pkgs.home-assistant-custom-components; [
garmin_connect
];
+6 -1
View File
@@ -9,8 +9,8 @@ in
"${inputs.self}/common/global"
"${inputs.self}/common/optional/docker.nix"
"${inputs.self}/common/optional/monitoring-agent.nix"
"${inputs.self}/common/optional/signal-cli.nix"
"${inputs.self}/common/optional/ssh_decrypt.nix"
"${inputs.self}/common/optional/signal-cli.nix"
"${inputs.self}/common/optional/syncthing_base.nix"
"${inputs.self}/common/optional/tailscale.nix"
"${inputs.self}/common/optional/update.nix"
@@ -38,5 +38,10 @@ in
};
};
users.groups = {
nornsight = { };
nornsight-admin = { };
};
system.stateVersion = "24.05";
}
+33
View File
@@ -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.
+2 -2
View File
@@ -7,8 +7,8 @@ let
gitea_ssh = 2223;
grafana = 3000;
jellyfin_http = 8096;
jellyswarrm = 3100;
nix_binary_cache = 5000;
nornsight = 8001;
ollama = 11434;
open_webui = 8080;
postgresql = 5432;
@@ -27,8 +27,8 @@ in
ports.audiobookshelf
ports.gems
ports.jellyfin_http
ports.jellyswarrm
ports.nix_binary_cache
ports.nornsight
ports.share
];
allowedTCPPorts = [
-1
View File
@@ -107,7 +107,6 @@ let
nixos-rebuild
nodejs
ruff
rustfmt
treefmt
wget
];
-25
View File
@@ -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
+1
View File
@@ -23,6 +23,7 @@ in
};
service.DISABLE_REGISTRATION = true;
server = {
DOMAIN = "gitea.tmmworkshop.com";
ROOT_URL = "https://gitea.tmmworkshop.com/";
HTTP_PORT = 6443;
BUILTIN_SSH_SERVER_USER = "gitea";
-16
View File
@@ -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";
};
}
+107
View File
@@ -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
'';
};
}
+83
View File
@@ -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.
-1
View File
@@ -9,7 +9,6 @@
inputs.disko.nixosModules.disko
"${inputs.self}/users/richie"
"${inputs.self}/common/global"
"${inputs.self}/common/optional/signal-cli.nix"
"${inputs.self}/common/optional/tailscale.nix"
"${inputs.self}/common/optional/x86-64-v3"
./disk-config.nix
+15 -2
View File
@@ -7,6 +7,8 @@ let
"jellyfin"
"share"
];
extraDomains = [ "www.norn-sight.com" ];
makeCert = name: {
name = "${name}.tmmworkshop.com";
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
{
networking.firewall.allowedTCPPorts = [
@@ -52,7 +65,7 @@ in
security.acme = {
acceptTerms = true;
defaults.email = "Richie@tmmworkshop.com";
certs = builtins.listToAttrs ((map makeCert domains));
certs = builtins.listToAttrs ((map makeCert domains) ++ (map makeExtraCert extraDomains));
};
systemd = {
+8 -11
View File
@@ -1,21 +1,12 @@
global
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 timeout 30s
defaults
log global
mode http
log-format "%{+json}o ${HAPROXY_JSON_LOG}"
option httplog
retries 3
maxconn 2000
timeout connect 5s
@@ -32,7 +23,7 @@ defaults
#Application Setup
frontend ContentSwitching
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
# ACME challenge routing (must be first)
@@ -45,6 +36,7 @@ frontend ContentSwitching
acl host_share hdr(host) -i share.tmmworkshop.com
acl host_gitea hdr(host) -i gitea.tmmworkshop.com
acl host_gems hdr(host) -i gems.tmmworkshop.com
acl host_norn_sight hdr(host) -i www.norn-sight.com
# --- Request logging ---
# 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 gitea if host_gitea
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
# available to Endlessh on Portal.
@@ -167,3 +160,7 @@ backend gems
option forwardfor
timeout server 1h
server gems jeeves:8002
backend norn_sight
mode http
server server jeeves:8001
-29
View File
@@ -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
```
-25
View File
@@ -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 -1
View File
@@ -3,15 +3,17 @@
imports = [
"${inputs.self}/users/richie"
"${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/steam.nix"
"${inputs.self}/common/optional/syncthing_base.nix"
"${inputs.self}/common/optional/systemd-boot.nix"
"${inputs.self}/common/optional/tailscale.nix"
"${inputs.self}/common/optional/yubikey.nix"
"${inputs.self}/common/optional/zfs"
./hardware.nix
./programs.nix
./qmk.nix
./syncthing.nix
inputs.nixos-hardware.nixosModules.framework-13-7040-amd
];
-25
View File
@@ -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
View File
@@ -4,23 +4,25 @@ from __future__ import annotations
import json
import socket
from os import environ
from typing import TYPE_CHECKING
import pytest
from python.signal_alert import SignalRPCError, signal_alert
from python.signal_alert import signal_alert
if TYPE_CHECKING:
from pytest_mock import MockerFixture
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_socket = mocker.patch("python.signal_alert.socket.socket")
client = mock_socket.return_value.__enter__.return_value
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_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:
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"))
with pytest.raises(SignalRPCError, match="Signal RPC failed") as exc_info:
signal_alert("test", "1234567890", "0987654321")
signal_alert("test")
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:
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")
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']
with pytest.raises(SignalRPCError, match=r"Signal RPC returned an error:.*failed"):
signal_alert("test", "1234567890", "0987654321")
signal_alert("test")
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:
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
mock_socket = mocker.patch("python.signal_alert.socket.socket")
client = mock_socket.return_value.__enter__.return_value
client.recv.side_effect = [
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
+167
View File
@@ -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"
-5
View File
@@ -13,11 +13,6 @@ command = "nixfmt"
# Glob pattern of files to include
includes = ["*.nix"]
[formatter.rustfmt]
command = "rustfmt"
options = ["--edition", "2024"]
includes = ["*.rs"]
[formatter.ruff-format]
command = "ruff"
options = ["format"]
+2
View File
@@ -36,6 +36,8 @@ in
"hass"
"libvirtd"
"networkmanager"
"nornsight"
"nornsight-admin"
"plugdev"
"scanner"
"transmission"
+2 -1
View File
@@ -36,10 +36,11 @@ in
"hass"
"libvirtd"
"networkmanager"
"nornsight"
"nornsight-admin"
"ollama"
"plugdev"
"scanner"
"signal-cli"
"transmission"
"uaccess"
"uucp"
-1
View File
@@ -21,7 +21,6 @@
vlc
# browser
brave
firefox
# dev tools
gparted
jetbrains.datagrip
+2
View File
@@ -1,7 +1,9 @@
{ pkgs, ... }:
{
home.packages = [
pkgs.master.claude-code
pkgs.master.codex
pkgs.master.opencode
pkgs.master.pi-coding-agent
];
}
-3
View File
@@ -42,9 +42,6 @@
rustfmt
trunk
wasm-pack
# protobuf
protobuf
buf
# nix
nix-init
nix-output-monitor
+31 -31
View File
@@ -6,58 +6,58 @@ sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArMVJ6Rm1QQlAvR2FKRmFu
dlBianhsU051dnh4Y3Zqdkt2RHVQTy9KVVZFClVCMXZmR3pSR0hxNzB2RnFuS2Fs
OGZCQjdJTmhTa1NDZ1B4L2tJSnFmdmcKLS0tIEhTQTRNZUdlNy9XcC9EbnVrcmZQ
Tzh5SUt0K3hUVll2dDZNR1ZnbzVPRFUKxTLOmsr5zMKCuH7KDqs1itkPLn+2kzSh
0s5C5XOIsMwUrkA/uIFrOWaP57YBVWPM2Ry+GAp4Bbf+y8C1en5HUw==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBEMjQvSzZSY08vRlI0dzBP
YytpVlpPSGFHaTI5b2xzUkFFdEY0bVhQdGgwClM3UmM1OElpcEZTcTB0NHpSZ2d6
aVBpcjVIaDk3Tko1cm4xYjdkYVQ1L1EKLS0tIE1aQW95U0JIVGc4MGloSXRXc3ZS
a3EvU1dJdmxOeVBIS28yT3dHd2ZEYWcKv5TIqRRQs3AXER1WHEx8wotyXr2Ktxsu
dwldwEHyDNqwf7UgGOZu7JueeZ7y2KC8KZ7ixIdrhVaoxuLEvCg7VA==
-----END AGE ENCRYPTED FILE-----
recipient: age1u8zj599elqqvcmhxn8zuwrufsz8w8w366d3ayrljjejljt2q45kq8mxw9c
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBmaTd1dU0zUGZVSkJJOUQw
OThIMDVSM1FKdVptaDNsMlFOYThpVmFnN1JvCmpqZlBrY2hVQ2grWkJOZTJkay9T
Q0VBVzRVYk5vU1BGMFhOckl3TDJDNjQKLS0tIDVqQTlJaHBuY0tHLzEwKzh6QStZ
a0ZqZmdOTnNEdEp6Nk5sN0NyT05MWmMKgP43hrlXzBpp+hELBaOJdYdsU3AmDvPf
FOh89Fn9Zp+Lm1shQTF6GcACTAr04+7MPfLveyUFt4vbHxr9ISYaOg==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByaE5jb3p3VzZ1dE05Wmlx
c1ZoWGhTcTRHRE9oMWtkaCtBY1l1a2QyWWlvCk9MM2FmcEZubXR1REpOcHZ6aHVE
UEkzbllZdkxPYzlTMHNod3VWNnhoaTgKLS0tIGVCdEpqUzhFM0swRjVjb0Z6ckFN
UzlYb1U4UGJqWE9oczdlNVh1S1lqL3cKSKxaJFhun2ee45I1jC55YKNdfYB8MsS+
dgFOFCvn/4uEcUyKZbUf0STjbIZ5eMHl8NVRTupI4tftGiE559M5Wg==
-----END AGE ENCRYPTED FILE-----
recipient: age1q47vup0tjhulkg7d6xwmdsgrw64h4ax3la3evzqpxyy4adsmk9fs56qz3y
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvWUZRME43WmxLd1BHaHFm
dXBib3NLcnI2K3M0cnl3NHJxSXU4Q253cW1ZCmlva2RReGcyTFN0TURCbkJFcHRW
Skd6aVVhcFlOYXkvTVVBRzZaYUpSUHcKLS0tIHlxMHQ1d21TSDBHMmtraUM3S0F2
VUUwOFJtSkM3V2tESnExR1hUSCt0TjgKOnhAVyikMjsc/lTKJrkbhQCw/EgiKuUg
DmzoBvVYmBfAJR74VVOdex2t0Ieg9SuHtCO7dQ7h82woY9Z8UudWDQ==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2U01jTVZQT1RRRU1nQmVv
Z0lnOGJLN1c0UkNLbnJjV29na3Rub0xzbXhNCjY0aFNuUnFDUnZheDRHT3R5L3Fv
ZkJ3QkF2RENKRVdUYjRzRXMrNklvN2cKLS0tIFJzK2o1MmVTWHZMWmVDWlgzeGY5
VDg4OGl0NGFYY2VNZGY5UkR1cVdWY0UK1qa3swdfgUw1tnnThRf1sl9f2zTP/d/d
iwcenOEryXatVMJjZVWrLC5C7MwtKz+pIBXa98yaSpCDLqjAMwOCPg==
-----END AGE ENCRYPTED FILE-----
recipient: age1jhf7vm0005j60mjq63696frrmjhpy8kpc2d66mw044lqap5mjv4snmwvwm
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMazg0K1o0WkU0ZHRZdWpV
UGxMV0cwaW9ZOG5PWVI4ZXZrSldIc2xuMDNzCmlhYjUwMXF5U1g0ODlMZmVVVXdj
K3hOM1Arc3VNTHNKMkozRXpYSG5RQWcKLS0tIEhrZFF6K1ZpYjNiMEFyYUpGejNM
Q3Q2cFhaVHhoOTZzM3pKeDBFam9PUVEK4XPeeoMYFWCr/LBTA+ZrnMqz+mE3IaKo
OQNVGW84fk7SZ4lWWU8l6jZmmX1Sf9aqyFad1hoI9N3AEZmhiiyhlA==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTREI2UEZwS0Y0SmEvK1F4
Y1hZZjRVWWxMT29mc3ZXcHFDclRUYi96LzA4CkIwZ2h4T2JQRW1OL2hmRTFHWkFY
RHZqQUFqU3hvNlRBZHRiSThPVUE3TG8KLS0tIHh0Z2tlNXREczJsa1g2ekZyZjBt
dW41aVA3VEVqa1NDMHM0T3FKZFNTN0kKGQPRUMSRtibFVFRZR/igsjgxgDo4ogga
dUHBRU65r41er+Wk3lKdtbXJX1g83K8ay7540hNXheOhxFi57trrzA==
-----END AGE ENCRYPTED FILE-----
recipient: age13lmqgc3jvkyah5e3vcwmj4s5wsc2akctcga0lpc0x8v8du3fxprqp4ldkv
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBoRHF5R2lWYWF1dUM3a3ZJ
RWZDVFBjRVhRaE5PRGtlbmROZm5IOWdNQkYwCldSWisvaVZDLzdwRDFCRkprM1Vo
NnFXUU1FWWIwZ3Nja3Mya2l3U1lROGcKLS0tIGRlZmhFQlBCZEJxYnRTUkcrSlVq
RC9jY0NINCtKTHRmMGRmd0N1WUVHWWcKrBjys6OKDuAELbpFpLOvV5GOXuVW9STX
ndHNw1rI14QJ7lZ54gAfdAtNFl1NzHDsaUuv/HIW9i2jhkv+APKtuA==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrV1Z2MGNPK28wTStRSUZW
dmFMaUt5MENHeUpVcDVxQnZhd1o5RmJwOWwwCittaGNrcW1hV25VaGJaTEhZRFBJ
Y2toWEpFWFZYT0plb2lkTlJCS2Mwb3MKLS0tIHlkVjVESWFsbWlVQ0xVQ0llclF2
L1FMbC91YjJsR3BGRjJ0QVQ1MDdBbEUKNnOI9+H1LJcvVcwl9uQMmFp35y5RB+Zl
Mo3z3unweQtS+jiUn7RyVs5daG1E2tfihu2CQDWaktuCuunMpt3Wew==
-----END AGE ENCRYPTED FILE-----
recipient: age1ufnewppysaq2wwcl4ugngjz8pfzc5a35yg7luq0qmuqvctajcycs5lf6k4
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA5Ti9laG9aTlpSSFR5Vmk0
MEVFY2pJdDJaK0RZQ1dUd2dRMDBSOVNNWEhBCmdsSDl3a1RPTE9xSlRSZTBhNFVB
bzFoVlZzQjQ3QWJoZkwrUWFucTlRN1kKLS0tIEozRUwwTnlpMHg4eGdLa2h5U3hF
ekEwYTBKWmRiK29JMXVqbU1DWVVWNGcKY6kkh7poDQHDdZdFdqzEI12P9UMq2saQ
gQXljjqcamqZ96KwFmuZm9RB/6OJJAPY8MBSqlrPTSjcnAqikXWpEA==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqR0hUT3hxTzRwTVhuTm9E
TUFtNFFCYWNUNUNVbHNaNXQ0aEVBa04vSEU0CkRPVzhuYUsrM0wxZjRDdkxvTVA2
UW9CSjlIQW5yenhaUi9mbEdLYlhWYk0KLS0tIGRXMm9Wa3l4UWxqcGl6ZlBZV3E0
Z3YrbUYrSmV0NHNTeTdhMkJEdytwSmMKGLpe5+p4FGJNHy6SVgnsDbEsiP0J8lW/
0+3X3LQoL0f1S2L5DjexF/H0NGvWjAPaGzDNB6V50VxGDabEIVqjvQ==
-----END AGE ENCRYPTED FILE-----
recipient: age1cm7ujz4t9cyc3nyy0w30l2addggk0gy8h3shd9yq47jg845tndnsa3utfl
recipient: age1vyav6kxtvt3z4vtnkkjj38eu8hlts5m7ygyckhskvalg2gpjk52su53d0a
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]
unencrypted_suffix: _unencrypted