Compare commits

..
Author SHA1 Message Date
Richie d986713a3e testing x86_v3
build_systems / build-brain (pull_request) Failing after 2h7m30s
build_systems / build-jeeves (pull_request) Failing after 2h51m28s
build_systems / build-bob (pull_request) Failing after 3h0m2s
build_systems / build-rhapsody-in-green (pull_request) Failing after 3h0m2s
treefmt / nix fmt (pull_request) Successful in 6s
pytest / pytest (pull_request) Successful in 30s
test ebook search / test-ebook-search (pull_request) Successful in 36s
2026-09-01 21:18:02 -04:00
Richie 2882960a8e feat(gitea): replace GITEA_TOKEN with JEEVES_BOT_TOKEN in workflows and script
treefmt / nix fmt (push) Successful in 5s
pytest / pytest (push) Successful in 32s
build_systems / build-brain (push) Successful in 36s
build_systems / build-rhapsody-in-green (push) Successful in 47s
build_systems / build-jeeves (push) Successful in 1m59s
test ebook search / test-ebook-search (push) Successful in 31s
build_systems / build-bob (push) Successful in 36s
test ebook search / test-ebook-search (pull_request) Successful in 35s
treefmt / nix fmt (pull_request) Successful in 6s
pytest / pytest (pull_request) Successful in 30s
build_systems / build-brain (pull_request) Successful in 47s
build_systems / build-bob (pull_request) Successful in 49s
build_systems / build-rhapsody-in-green (pull_request) Successful in 1m0s
build_systems / build-jeeves (pull_request) Successful in 2m15s
2026-08-29 09:38:36 -04:00
Richie bdf67e96f0 feat(monitoring): add Portal-1 and Vultr dashboards
treefmt / nix fmt (pull_request) Successful in 5s
pytest / pytest (pull_request) Successful in 30s
test ebook search / test-ebook-search (pull_request) Successful in 34s
build_systems / build-brain (pull_request) Successful in 49s
build_systems / build-jeeves (pull_request) Successful in 2m14s
build_systems / build-bob (pull_request) Successful in 50s
build_systems / build-rhapsody-in-green (pull_request) Successful in 1m2s
build_systems / build-brain (push) Successful in 43s
treefmt / nix fmt (push) Successful in 5s
pytest / pytest (push) Successful in 36s
test ebook search / test-ebook-search (push) Successful in 37s
build_systems / build-bob (push) Successful in 43s
build_systems / build-rhapsody-in-green (push) Successful in 55s
build_systems / build-jeeves (push) Successful in 2m7s
- scrape Portal-1 node metrics over Tailscale
- add a hardened Vultr billing and bandwidth exporter
- provision host health and infrastructure cost dashboards
2026-08-27 17:49:56 -04:00
Richie d95b8b99ce feat(jeeves): centralize firewall configuration
test ebook search / test-ebook-search (pull_request) Successful in 34s
build_systems / build-brain (pull_request) Successful in 48s
build_systems / build-bob (pull_request) Successful in 49s
build_systems / build-jeeves (pull_request) Successful in 2m11s
treefmt / nix fmt (pull_request) Successful in 5s
pytest / pytest (pull_request) Successful in 29s
build_systems / build-rhapsody-in-green (pull_request) Successful in 1m1s
build_systems / build-bob (push) Successful in 9s
pytest / pytest (push) Successful in 28s
test ebook search / test-ebook-search (push) Successful in 34s
build_systems / build-jeeves (push) Successful in 1m59s
treefmt / nix fmt (push) Successful in 6s
build_systems / build-brain (push) Successful in 33s
build_systems / build-rhapsody-in-green (push) Successful in 46s
- split networking into interface and firewall modules
- define named service ports in one location
- remove scattered service-level firewall rules
2026-08-27 16:59:44 -04:00
Richie 7e6908ef3d feat(networking): migrate host services from ZeroTier to Tailscale
test ebook search / test-ebook-search (pull_request) Successful in 35s
build_systems / build-bob (pull_request) Successful in 52s
build_systems / build-brain (pull_request) Successful in 52s
treefmt / nix fmt (pull_request) Successful in 5s
pytest / pytest (pull_request) Successful in 30s
build_systems / build-rhapsody-in-green (pull_request) Successful in 1m3s
build_systems / build-brain (push) Successful in 35s
build_systems / build-bob (push) Successful in 36s
build_systems / build-jeeves (pull_request) Successful in 2m20s
treefmt / nix fmt (push) Successful in 5s
pytest / pytest (push) Successful in 29s
test ebook search / test-ebook-search (push) Successful in 34s
build_systems / build-rhapsody-in-green (push) Successful in 49s
build_systems / build-jeeves (push) Successful in 2m2s
- replace legacy IP addresses with Tailscale MagicDNS names
- route Prometheus scraping and exporter firewall access through Tailscale
- wait for Tailscale before starting Prometheus
- restrict service UI ports to the Tailscale interface
- update PostgreSQL and Transmission access rules
- remove Jeeves ZeroTier configuration and Docker host overrides
2026-08-27 12:25:27 -04:00
Richie dd51e89abf fix(haproxy): use IPv6 wildcard for dual-stack listeners
pytest / pytest (pull_request) Successful in 29s
test ebook search / test-ebook-search (pull_request) Successful in 34s
treefmt / nix fmt (pull_request) Successful in 5s
build_systems / build-rhapsody-in-green (pull_request) Successful in 59s
build_systems / build-bob (pull_request) Successful in 49s
build_systems / build-brain (pull_request) Successful in 49s
build_systems / build-jeeves (pull_request) Successful in 2m17s
treefmt / nix fmt (push) Successful in 5s
pytest / pytest (push) Successful in 30s
build_systems / build-brain (push) Successful in 35s
test ebook search / test-ebook-search (push) Successful in 34s
build_systems / build-bob (push) Successful in 35s
build_systems / build-rhapsody-in-green (push) Successful in 47s
build_systems / build-jeeves (push) Successful in 2m1s
Bind HTTP, HTTPS, and Gitea SSH to [::] with v4v6 support.
2026-08-27 09:08:18 -04:00
Richie 677364fe74 removed zerotier.nix for common and removed zerotier from all systems except jeeves
treefmt / nix fmt (pull_request) Successful in 6s
pytest / pytest (pull_request) Successful in 29s
test ebook search / test-ebook-search (pull_request) Successful in 35s
build_systems / build-bob (pull_request) Successful in 49s
build_systems / build-brain (pull_request) Successful in 51s
build_systems / build-rhapsody-in-green (pull_request) Successful in 1m1s
build_systems / build-jeeves (pull_request) Successful in 2m15s
treefmt / nix fmt (push) Successful in 5s
pytest / pytest (push) Successful in 29s
build_systems / build-bob (push) Successful in 37s
test ebook search / test-ebook-search (push) Successful in 33s
build_systems / build-brain (push) Successful in 35s
build_systems / build-rhapsody-in-green (push) Successful in 48s
build_systems / build-jeeves (push) Successful in 1m58s
2026-08-26 13:11:34 -04:00
Richie f5a0038ab7 feat(portal): proxy Gitea SSH through HAProxy
treefmt / nix fmt (pull_request) Successful in 6s
pytest / pytest (pull_request) Successful in 31s
test ebook search / test-ebook-search (pull_request) Successful in 35s
build_systems / build-brain (pull_request) Successful in 50s
build_systems / build-bob (pull_request) Successful in 55s
build_systems / build-rhapsody-in-green (pull_request) Successful in 1m3s
build_systems / build-jeeves (pull_request) Successful in 2m20s
treefmt / nix fmt (push) Successful in 5s
pytest / pytest (push) Successful in 32s
test ebook search / test-ebook-search (push) Successful in 33s
build_systems / build-bob (push) Successful in 37s
build_systems / build-brain (push) Successful in 36s
build_systems / build-rhapsody-in-green (push) Successful in 47s
build_systems / build-jeeves (push) Successful in 2m1s
- expose Gitea SSH through Portal on port 2223
- advertise the correct Gitea SSH user and domain
- add the Gitea host to Richie’s SSH config
- correct the local ACME challenge backend port
2026-08-26 10:43:21 -04:00
Richie b406b92622 fix(networking): restore Gitea HTTP port in allowedTCPPorts
pytest / pytest (pull_request) Successful in 29s
test ebook search / test-ebook-search (pull_request) Successful in 33s
build_systems / build-brain (pull_request) Successful in 48s
build_systems / build-bob (pull_request) Successful in 51s
build_systems / build-rhapsody-in-green (pull_request) Successful in 1m0s
treefmt / nix fmt (pull_request) Successful in 6s
build_systems / build-jeeves (pull_request) Successful in 2m16s
2026-08-26 08:37:22 -04:00
Richie e0c14973b0 refactor(haproxy): consolidate ACME configuration
build_systems / build-rhapsody-in-green (pull_request) Failing after 7m11s
build_systems / build-jeeves (pull_request) Failing after 7m19s
build_systems / build-brain (pull_request) Failing after 7m19s
test ebook search / test-ebook-search (pull_request) Failing after 7m18s
build_systems / build-bob (pull_request) Failing after 7m23s
pytest / pytest (pull_request) Failing after 7m26s
treefmt / nix fmt (pull_request) Failing after 7m21s
Merge the ACME module into the HAProxy configuration and start HAProxy only after certificates and Tailscale connectivity are available.
2026-08-26 08:28:41 -04:00
Richie 515cbae3f8 fix(audiobookshelf): updated host
test ebook search / test-ebook-search (pull_request) Failing after 7m8s
build_systems / build-jeeves (pull_request) Failing after 7m13s
pytest / pytest (pull_request) Failing after 7m17s
build_systems / build-bob (pull_request) Failing after 7m27s
build_systems / build-rhapsody-in-green (pull_request) Failing after 7m13s
treefmt / nix fmt (pull_request) Failing after 7m12s
build_systems / build-brain (pull_request) Failing after 7m18s
2026-08-26 07:39:19 -04:00
Richie 219aea9b5f fix(acme) used the correct ip for acme 2026-08-26 07:33:31 -04:00
Richie df8908b708 feat(services): remove haproxy from validate_system.toml 2026-08-26 07:33:31 -04:00
Richie cb83fc6ea5 fix 2 imports 2026-08-26 07:33:31 -04:00
Richie 7f08a6f788 feat(portal): move edge proxy services from Jeeves
- move HAProxy and ACME certificate management to Portal
- route application backends to Jeeves over Tailscale
- restrict Jeeves backend ports to the Tailscale interface
- expose Gems for remote proxy access
2026-08-26 07:33:31 -04:00
Richie 8245d0f82e feat(tailscale): enable mesh networking across NixOS hosts
- enable Tailscale on Bob, Brain, Jeeves, and Rhapsody
- configure the Jeeves binary cache through Tailscale MagicDNS
- trust the existing workshop cache signing key
2026-08-26 07:33:31 -04:00
Richie 1c4e0c0828 renamed portal_1 to portal-1 2026-08-26 07:33:31 -04:00
Richie 5bc9ee5de9 feat(home): expand Richie’s minimal profile for Portal
- move essential CLI tools into the minimal package set
- remove duplicated packages from the full programs profile
- add the portal-1 SSH host on port 278
2026-08-26 07:33:31 -04:00
Richie 6fa93e1b96 feat(portal): add Vultr NixOS host configuration
- use the shared global base and Tailscale module
- configure Portal SSH and VM memory settings
- add Richie's minimal Home Manager profile
- document SOPS and nixos-anywhere deployment
2026-08-26 07:33:31 -04:00
Richie b8b0605279 refactor(nixos): make ZFS configuration optional
- move ZFS and snapshot manager settings out of the global base
- group snapshot files under common/optional/zfs
- enable the ZFS module on existing ZFS hosts
- make the global tmpfs setting overridable
2026-08-26 07:33:31 -04:00
Richie 03537310cb feat(tailscale): add Tailscale service configuration and update secrets 2026-08-26 07:33:31 -04:00
Richie a23c91c4ac feat(global): refactor configuration by moving settings to minimal.nix 2026-08-26 07:33:31 -04:00
Richie f24a14920e feat(flake): add disko to flake.nix for nixos-anywhere 2026-08-26 07:33:31 -04:00
Richie 48806c84fe fix(default.nix): remove permitted insecure packages
pytest / pytest (pull_request) Successful in 29s
test ebook search / test-ebook-search (pull_request) Successful in 35s
build_systems / build-rhapsody-in-green (pull_request) Successful in 58s
treefmt / nix fmt (pull_request) Successful in 5s
build_systems / build-brain (pull_request) Successful in 47s
build_systems / build-bob (pull_request) Successful in 51s
build_systems / build-jeeves (pull_request) Successful in 2m15s
treefmt / nix fmt (push) Successful in 5s
build_systems / build-brain (push) Successful in 36s
build_systems / build-rhapsody-in-green (push) Successful in 47s
build_systems / build-bob (push) Successful in 9s
pytest / pytest (push) Successful in 31s
test ebook search / test-ebook-search (push) Successful in 32s
build_systems / build-jeeves (push) Successful in 2m1s
2026-08-25 16:13:38 -04:00
Richie 03c3b36f9c feat(home_assistant): add energy meter sensors filter
build_systems / build-rhapsody-in-green (pull_request) Successful in 1m2s
treefmt / nix fmt (pull_request) Successful in 6s
test ebook search / test-ebook-search (pull_request) Successful in 39s
pytest / pytest (pull_request) Successful in 40s
build_systems / build-bob (pull_request) Successful in 49s
build_systems / build-brain (pull_request) Successful in 50s
build_systems / build-jeeves (pull_request) Successful in 2m14s
test ebook search / test-ebook-search (push) Successful in 33s
build_systems / build-rhapsody-in-green (push) Successful in 47s
build_systems / build-jeeves (push) Successful in 1m59s
treefmt / nix fmt (push) Successful in 5s
pytest / pytest (push) Successful in 29s
build_systems / build-brain (push) Successful in 35s
build_systems / build-bob (push) Successful in 37s
2026-08-25 12:11:06 -04:00
75 changed files with 2880 additions and 1914 deletions
@@ -19,5 +19,5 @@ jobs:
python -m python.gitea_flake_lock merge
--repo "${{ github.repository }}"
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
JEEVES_BOT_TOKEN: ${{ secrets.JEEVES_BOT_TOKEN }}
GITEA_URL: https://gitea.tmmworkshop.com
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
run: nix flake update
- name: Create or update flake.lock PR
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
JEEVES_BOT_TOKEN: ${{ secrets.JEEVES_BOT_TOKEN }}
GITEA_URL: https://gitea.tmmworkshop.com
run: >-
nix develop .#devShells.x86_64-linux.default -c
-51
View File
@@ -1,51 +0,0 @@
name: zfs integration
on:
workflow_dispatch:
pull_request:
branches: [main]
paths:
- ".github/workflows/zfs-integration.yml"
- "flake.nix"
- "flake.lock"
- "overlays/default.nix"
- "common/global/snapshot_manager.nix"
- "common/optional/zfs_manager.nix"
- "python/signal_alert.py"
- "python/tools/snapshot_manager.py"
- "python/tools/zfs_manager.py"
- "python/zfs/**"
- "systems/jeeves/datasets.nix"
- "systems/jeeves/scripts/zfs.sh"
- "systems/jeeves/zfs.nix"
- "tests/zfs_integration.py"
- "tests/zfs-integration.nix"
push:
branches: [main]
paths:
- ".github/workflows/zfs-integration.yml"
- "flake.nix"
- "flake.lock"
- "overlays/default.nix"
- "common/global/snapshot_manager.nix"
- "common/optional/zfs_manager.nix"
- "python/signal_alert.py"
- "python/tools/snapshot_manager.py"
- "python/tools/zfs_manager.py"
- "python/zfs/**"
- "systems/jeeves/datasets.nix"
- "systems/jeeves/scripts/zfs.sh"
- "systems/jeeves/zfs.nix"
- "tests/zfs_integration.py"
- "tests/zfs-integration.nix"
jobs:
zfs-integration:
runs-on: self-hosted
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Build and run ZFS integration VM
run: >-
nix build --accept-flake-config --print-build-logs
.#packages.x86_64-linux.zfs-integration
+5
View File
@@ -1,6 +1,9 @@
# Generate AGE keys from SSH keys with:
# ssh-keygen -A
# nix-shell -p ssh-to-age --run 'cat /etc/ssh/ssh_host_ed25519_key.pub | ssh-to-age'
# update keys after addin/removing a key
# nix-shell -p sops --run "sops updatekeys users/secrets.yaml" users/secrets.yaml
keys:
- &admin_richie age1u8zj599elqqvcmhxn8zuwrufsz8w8w366d3ayrljjejljt2q45kq8mxw9c # cspell:disable-line
@@ -8,6 +11,7 @@ keys:
- &system_brain age1jhf7vm0005j60mjq63696frrmjhpy8kpc2d66mw044lqap5mjv4snmwvwm # cspell:disable-line
- &system_jeeves age13lmqgc3jvkyah5e3vcwmj4s5wsc2akctcga0lpc0x8v8du3fxprqp4ldkv # cspell:disable-line
- &system_rhapsody age1ufnewppysaq2wwcl4ugngjz8pfzc5a35yg7luq0qmuqvctajcycs5lf6k4 # cspell:disable-line
- &system_portal_1 age1vyav6kxtvt3z4vtnkkjj38eu8hlts5m7ygyckhskvalg2gpjk52su53d0a # cspell:disable-line
creation_rules:
- path_regex: users/secrets\.yaml$
@@ -18,3 +22,4 @@ creation_rules:
- *system_brain
- *system_jeeves
- *system_rhapsody
- *system_portal_1
+1 -19
View File
@@ -17,16 +17,11 @@
./nix.nix
./programs.nix
./ssh.nix
./snapshot_manager.nix
];
boot = {
tmp.useTmpfs = true;
tmp.useTmpfs = lib.mkDefault true;
kernelPackages = lib.mkDefault pkgs.linuxPackages_6_12;
zfs = {
package = lib.mkDefault pkgs.zfs_2_4;
forceImportRoot = lib.mkDefault false;
};
};
hardware.enableRedistributableFirmware = true;
@@ -42,9 +37,6 @@
overlays = builtins.attrValues outputs.overlays;
config = {
allowUnfree = true;
permittedInsecurePackages = [
"openssl-1.1.1w" # This is for discord-canary
];
};
};
@@ -53,16 +45,6 @@
# firmware update
fwupd.enable = true;
snapshot_manager = {
enable = lib.mkDefault true;
PYTHONPATH = "${inputs.self}/";
};
zfs = {
trim.enable = lib.mkDefault true;
autoScrub.enable = lib.mkDefault true;
};
};
powerManagement.powertop.enable = lib.mkDefault true;
+1 -1
View File
@@ -5,7 +5,7 @@
...
}:
let
monitoringInterface = "ztwfunumly";
monitoringInterface = "tailscale0";
nodeTextfileDir = "/var/lib/prometheus-node-exporter-textfile";
mkProcessNameTemplate =
+37
View File
@@ -0,0 +1,37 @@
{
config,
inputs,
...
}:
{
nix.settings = {
trusted-substituters = [ "http://jeeves:5000" ];
substituters = [ "http://jeeves:5000/?priority=1&want-mass-query=true" ];
trusted-public-keys = [ "cache.tmmworkshop.com:jHffkpgbmEdstQPoihJPYW9TQe6jnQbWR2LqkNGV3iA=" ];
};
services.tailscale = {
enable = true;
openFirewall = true;
authKeyFile = config.sops.secrets.tailscale_auth_key.path;
# OAuth client secrets create ephemeral nodes by default. NixOS machines
# are persistent and should enroll without interactive device approval.
authKeyParameters = {
ephemeral = false;
preauthorized = true;
};
extraUpFlags = [ "--advertise-tags=tag:nixos" ];
};
sops = {
age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
secrets.tailscale_auth_key = {
sopsFile = "${inputs.self}/users/secrets.yaml";
owner = "root";
mode = "0400";
};
};
}
+9
View File
@@ -0,0 +1,9 @@
{
nixpkgs.hostPlatform = {
system = "x86_64-linux";
gcc = {
arch = "x86-64-v3";
tune = "generic";
};
};
}
-11
View File
@@ -1,11 +0,0 @@
{
services.zerotierone = {
enable = true;
joinNetworks = [ "e4da7455b2ae64ca" ];
};
nix.settings = {
trusted-substituters = [ "http://192.168.90.40:5000" ];
substituters = [ "http://192.168.90.40:5000/?priority=1&want-mass-query=true" ];
trusted-public-keys = [ "cache.tmmworkshop.com:jHffkpgbmEdstQPoihJPYW9TQe6jnQbWR2LqkNGV3iA=" ];
};
}
+26
View File
@@ -0,0 +1,26 @@
{
inputs,
lib,
pkgs,
...
}:
{
imports = [ ./snapshot.nix ];
boot.zfs = {
package = lib.mkDefault pkgs.zfs_2_4;
forceImportRoot = lib.mkDefault false;
};
services = {
snapshot_manager = {
enable = lib.mkDefault true;
PYTHONPATH = "${inputs.self}/";
};
zfs = {
trim.enable = lib.mkDefault true;
autoScrub.enable = lib.mkDefault true;
};
};
}
@@ -22,11 +22,6 @@ in
the PYTHONPATH to use for the snapshot_manager service.
'';
};
package = lib.mkOption {
type = lib.types.package;
default = pkgs.my_python;
description = "Python environment used to run snapshot_manager.";
};
EnvironmentFile = lib.mkOption {
type = lib.types.nullOr (lib.types.coercedTo lib.types.path toString lib.types.str);
default = null;
@@ -50,7 +45,7 @@ in
};
serviceConfig = {
Type = "oneshot";
ExecStart = "${cfg.package}/bin/python -m python.tools.snapshot_manager ${lib.escapeShellArg cfg.path}";
ExecStart = "${pkgs.my_python}/bin/python -m python.tools.snapshot_manager ${lib.escapeShellArg cfg.path}";
}
// lib.optionalAttrs (cfg.EnvironmentFile != null) {
EnvironmentFile = cfg.EnvironmentFile;
-191
View File
@@ -1,191 +0,0 @@
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.zfs_manager;
snapshotOptions = {
options = {
"15_min" = lib.mkOption {
type = lib.types.ints.unsigned;
default = 0;
description = "How many 15 minute snapshots to keep.";
};
hourly = lib.mkOption {
type = lib.types.ints.unsigned;
default = 0;
description = "How many hourly snapshots to keep.";
};
daily = lib.mkOption {
type = lib.types.ints.unsigned;
default = 0;
description = "How many daily snapshots to keep.";
};
monthly = lib.mkOption {
type = lib.types.ints.unsigned;
default = 0;
description = "How many monthly snapshots to keep.";
};
};
};
datasetOptions = {
options = {
manageProperties = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether zfs_manager owns this dataset's properties. When false the
dataset only contributes its snapshot retention, which is how
root_pool datasets are declared.
'';
};
createIfMissing = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether zfs_manager may create this dataset when it is absent.
Set it false for a dataset that has to be provisioned by hand, such
as an encryption root: encryption is fixed at creation time and
cannot be expressed here, so creating it automatically would silently
produce an unencrypted dataset where an encrypted one was intended.
The dataset is still property checked, and its absence is reported as
a failure rather than quietly fixed.
'';
};
properties = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
description = ''
The zfs properties this dataset should have. Values are compared
against the live dataset and corrected when they differ.
'';
};
snapshots = lib.mkOption {
type = lib.types.submodule snapshotOptions;
default = cfg.defaultSnapshots;
description = ''
Snapshot retention for this dataset. Defaults to defaultSnapshots.
'';
};
};
};
# snapshot_manager.py only ever walks datasets below a pool root, so pool
# roots are left out of the retention table. It also indexes the table
# directly, which is why every entry carries all four keys.
snapshotTable = lib.mapAttrs (_: dataset: dataset.snapshots) (
lib.filterAttrs (name: _: lib.hasInfix "/" name) cfg.datasets
);
snapshotConfig = (pkgs.formats.toml { }).generate "snapshot_config.toml" (
snapshotTable // { default = cfg.defaultSnapshots; }
);
# Every declared dataset is emitted, including the ones whose properties are
# not managed, so the tool can tell "deliberately hands off" apart from
# "nobody has written this down yet".
datasetConfig = (pkgs.formats.json { }).generate "zfs_datasets.json" {
datasets = lib.mapAttrs (_: dataset: {
inherit (dataset) manageProperties createIfMissing properties;
}) cfg.datasets;
};
in
{
options = {
services.zfs_manager = {
enable = lib.mkEnableOption "declarative ZFS dataset management";
datasets = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule datasetOptions);
default = { };
example = lib.literalExpression ''
{
"media/temp".properties = {
sync = "disabled";
redundant_metadata = "none";
};
}
'';
description = ''
The datasets to manage, keyed by full dataset name. Missing datasets
are created and drifted properties are corrected. Nothing is ever
destroyed, and datasets that are not declared are left alone.
A name without a "/" is a pool root filesystem. Its properties are
managed but it is never created, pool creation stays manual.
'';
};
defaultSnapshots = lib.mkOption {
type = lib.types.submodule snapshotOptions;
default = { };
description = ''
Retention for undeclared datasets and for declared datasets that do
not override their snapshots. Emitted as the "default" table of the
snapshot config.
'';
};
dryRun = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Log every change that would be made without touching zfs. Use this to
validate a new or heavily edited declaration before applying it.
'';
};
PYTHONPATH = lib.mkOption {
type = lib.types.str;
description = ''
the PYTHONPATH to use for the zfs_manager service.
'';
};
package = lib.mkOption {
type = lib.types.package;
default = pkgs.my_python;
description = "Python environment used to run zfs_manager.";
};
EnvironmentFile = lib.mkOption {
type = lib.types.nullOr (lib.types.coercedTo lib.types.path toString lib.types.str);
default = null;
description = ''
Single environment file for the service (e.g. /etc/zfs-manager/env).
Use a leading "-" to ignore if missing (systemd feature).
'';
};
};
};
config = lib.mkIf cfg.enable {
services.snapshot_manager.path = snapshotConfig;
systemd = {
services.zfs_manager = {
description = "ZFS Dataset Manager";
requires = [ "zfs-import.target" ];
after = [
"zfs-import.target"
"zfs-mount.service"
];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.zfs ];
# Re-run on nixos-rebuild switch whenever the declaration changes.
restartTriggers = [ datasetConfig ];
environment = {
PYTHONPATH = cfg.PYTHONPATH;
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${cfg.package}/bin/python -m python.tools.zfs_manager ${lib.escapeShellArg datasetConfig}${lib.optionalString cfg.dryRun " --dry-run"}";
}
// lib.optionalAttrs (cfg.EnvironmentFile != null) {
EnvironmentFile = cfg.EnvironmentFile;
};
};
};
};
}
Generated
+21
View File
@@ -1,5 +1,25 @@
{
"nodes": {
"disko": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1781152676,
"narHash": "sha256-RxWs5ND31KzTG7wvMM+PMfUjyNpmIEr999lqNARaM5o=",
"owner": "nix-community",
"repo": "disko",
"rev": "ff8702b4de27f72b4c78573dfb89ec74e36abdf1",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "disko",
"type": "github"
}
},
"firefox-addons": {
"inputs": {
"nixpkgs": [
@@ -124,6 +144,7 @@
},
"root": {
"inputs": {
"disko": "disko",
"firefox-addons": "firefox-addons",
"home-manager": "home-manager",
"nixos-hardware": "nixos-hardware",
+5 -1
View File
@@ -35,6 +35,11 @@
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
disko = {
url = "github:nix-community/disko";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
@@ -77,7 +82,6 @@
}
// lib.optionalAttrs (pkgs.stdenv.hostPlatform.system == "x86_64-linux") {
iso = self.nixosConfigurations.iso.config.system.build.isoImage;
zfs-integration = pkgs.testers.runNixOSTest (import ./tests/zfs-integration.nix { inherit self; });
}
);
apps = forEachSystem (
+2 -2
View File
@@ -37,7 +37,7 @@ class RerankConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="EBOOK_SEARCH_RERANK_", frozen=True, protected_namespaces=())
enabled: bool = True
base_url: str = "http://192.168.90.25:8001"
base_url: str = "http://bob:8001"
model: str = "qwen3-reranker-06b"
candidates: int = 24
timeout_seconds: float = 30.0
@@ -67,7 +67,7 @@ class EbookSearchConfig(BaseSettings):
)
chat_model: str = "deepseek-v4-flash"
answer_enabled: bool = True
embedding_base_url: str = "http://192.168.90.25:8000/v1"
embedding_base_url: str = "http://bob:8000/v1"
embedding_api_key: str = "not-needed"
embedding_model: str = "qwen3-embedding-0.6b"
embedding_batch_size: int = 32
@@ -9,8 +9,6 @@ services:
restart: unless-stopped
ports:
- "${EBOOK_SEARCH_PORT:-8070}:8070"
extra_hosts:
- "jeeves:192.168.90.40"
env_file:
- ../../../.env
environment:
+5 -5
View File
@@ -84,13 +84,13 @@ def push_branch(*, branch: str) -> None:
run_cmd(["git", "push", "origin", f"HEAD:{branch}", "--force"])
def _required_gitea_token() -> str:
def _required_jeeves_bot_token() -> str:
"""Read the required Gitea token from the environment."""
token = getenv("GITEA_TOKEN")
token = getenv("JEEVES_BOT_TOKEN")
if token:
return token
msg = "GITEA_TOKEN environment variable is required"
msg = "JEEVES_BOT_TOKEN environment variable is required"
raise RuntimeError(msg)
@@ -111,7 +111,7 @@ def update(
owner, repo_name = split_repo_name(repo)
with GiteaClient(
base_url=getenv("GITEA_URL", DEFAULT_GITEA_URL),
token=_required_gitea_token(),
token=_required_jeeves_bot_token(),
) as client:
pull_request = ensure_flake_lock_pull_request(
client,
@@ -134,7 +134,7 @@ def merge(
owner, repo_name = split_repo_name(repo)
with GiteaClient(
base_url=getenv("GITEA_URL", DEFAULT_GITEA_URL),
token=_required_gitea_token(),
token=_required_jeeves_bot_token(),
) as client:
pull_request = find_flake_lock_pull_request(client, owner=owner, repo=repo_name)
if not pull_request:
+5 -26
View File
@@ -23,7 +23,6 @@ def main(config_file: Path) -> None:
"""Main."""
configure_logger(level="DEBUG")
logger.info("Starting snapshot_manager")
failures: list[str] = []
try:
time_stamp = get_time_stamp()
@@ -35,23 +34,16 @@ def main(config_file: Path) -> None:
msg = f"{dataset.name} failed to create snapshot {time_stamp}"
logger.error(msg)
signal_alert(msg)
failures.append(msg)
continue
count_lookup = get_count_lookup(config_file, dataset.name)
logger.info(f"using {count_lookup} for {dataset.name}")
failures.extend(get_snapshots_to_delete(dataset, count_lookup))
get_snapshots_to_delete(dataset, count_lookup)
except Exception:
logger.exception("snapshot_manager failed")
signal_alert("snapshot_manager failed")
sys.exit(1)
if failures:
logger.error(f"snapshot_manager completed with {len(failures)} errors")
for failure in failures:
logger.error(f" {failure}")
sys.exit(1)
logger.info("snapshot_manager completed")
else:
logger.info("snapshot_manager completed")
def get_count_lookup(config_file: Path, dataset_name: str) -> dict[str, int]:
@@ -100,29 +92,19 @@ def load_config_data(config_file: Path) -> dict[str, dict[str, int]]:
def get_snapshots_to_delete(
dataset: Dataset,
count_lookup: dict[str, int],
) -> list[str]:
) -> None:
"""Get snapshots to delete.
Args:
dataset (Dataset): the dataset
count_lookup (dict[str, int]): the count lookup
Returns:
list[str]: Snapshot deletion failures encountered while pruning.
"""
for retention_class in ("15_min", "hourly", "daily", "monthly"):
count = count_lookup.get(retention_class)
if not isinstance(count, int) or isinstance(count, bool) or count < 0:
error = f"{retention_class} retention must be a non-negative integer, got {count!r}"
raise ValueError(error)
failures: list[str] = []
snapshots = dataset.get_snapshots()
logger.info(f"calculating snapshots for {dataset.name} to be deleted")
if not snapshots:
logger.info(f"{dataset.name} has no snapshots")
return failures
return
filters = (
("15_min", re_compile(r"auto_\d{10}(?:15|30|45)")),
@@ -147,9 +129,6 @@ def get_snapshots_to_delete(
error_message = f"{dataset.name}@{snapshot} failed to delete: {error}"
signal_alert(error_message)
logger.error(error_message)
failures.append(error_message)
return failures
def get_time_stamp() -> str:
-340
View File
@@ -1,340 +0,0 @@
"""zfs_manager.
Reconciles the live zfs datasets against a declaration generated by
common/optional/zfs_manager.nix. Datasets are created and properties are
corrected, but nothing is ever destroyed or renamed.
"""
from __future__ import annotations
import json
import logging
import sys
from pathlib import Path # noqa: TC003 This is required for the typer CLI
import typer
from python.common import configure_logger
from python.signal_alert import signal_alert
from python.zfs import create_dataset, get_properties, list_dataset_names, set_property
logger = logging.getLogger(__name__)
# Properties that can only be chosen at creation time. Attempting to zfs set
# these fails on every run, so a mismatch is reported instead of retried.
CREATE_ONLY_PROPERTIES = frozenset(
{
"casesensitivity",
"encryption",
"keyformat",
"normalization",
"utf8only",
"volblocksize",
},
)
# Properties whose values zfs reports in bytes but which are conventionally
# declared with a size suffix, so "16k" and "16384" mean the same thing.
SIZE_PROPERTIES = frozenset(
{
"quota",
"recordsize",
"refquota",
"refreservation",
"reservation",
"special_small_blocks",
"volblocksize",
"volsize",
},
)
SIZE_SUFFIXES = {"b": 1, "k": 1024, "m": 1024**2, "g": 1024**3, "t": 1024**4, "p": 1024**5}
# Sources that mean the value was deliberately put on this dataset rather than
# inherited from a parent or left at the zfs default.
LOCAL_SOURCES = ("local", "received")
class ReconciliationError(RuntimeError):
"""One or more datasets could not be brought in line with the declaration."""
def __init__(self, failures: list[str]) -> None:
"""Record the individual failures behind this run's exit code."""
self.failures = failures
super().__init__(f"ZFS reconciliation failed with {len(failures)} errors")
def main(config_file: Path, *, dry_run: bool = False) -> None:
"""Main.
Args:
config_file (Path): The path to the generated dataset declaration.
dry_run (bool): Log the changes that would be made without making them.
"""
configure_logger(level="DEBUG")
logger.info(f"Starting zfs_manager {dry_run=}")
try:
reconcile(config_file, dry_run=dry_run)
except ReconciliationError as error:
summary = error
except Exception:
logger.exception("zfs_manager failed")
signal_alert("zfs_manager failed")
sys.exit(1)
else:
logger.info("zfs_manager completed")
return
# Each failure was logged and alerted as it happened. Repeating them
# together puts the whole picture at the end of the journal, which is what
# systemctl status shows. No traceback: this is an expected outcome, not a
# crash, and a stack trace would only bury the list.
logger.error(str(summary))
for failure in summary.failures:
logger.error(f" {failure}")
sys.exit(1)
def reconcile(config_file: Path, *, dry_run: bool) -> None:
"""Bring every declared dataset in line, collecting problems as it goes.
One bad dataset must not hide the state of the others, so everything is
checked before anything is raised.
Args:
config_file (Path): The path to the generated dataset declaration.
dry_run (bool): Log the changes without making them.
Raises:
ReconciliationError: If anything could not be reconciled.
"""
declared = json.loads(config_file.read_text())["datasets"]
existing = set(list_dataset_names())
unusable: set[str] = set()
failures: list[str] = []
# Parents before children so a newly created parent exists by the time its
# children are reconciled.
for name in sorted(declared, key=lambda name: (name.count("/"), name)):
entry = declared[name]
# Declared purely to record retention, its properties belong to
# whoever set them.
if not entry.get("manageProperties", True):
logger.debug(f"{name} is declared but its properties are not managed")
continue
if has_unusable_parent(name, unusable):
failures.append(fail(f"cannot reconcile {name}, its parent is missing"))
continue
if name in existing:
failures.extend(reconcile_dataset(name, entry["properties"], dry_run=dry_run))
continue
created, failure = handle_missing_dataset(name, entry, dry_run=dry_run)
if failure is not None:
failures.append(failure)
if created:
existing.add(name)
elif not dry_run:
unusable.add(name)
report_undeclared_datasets(existing, declared)
if failures:
raise ReconciliationError(failures)
def fail(message: str) -> str:
"""Log and alert a problem, and hand it back for the failure tally.
Args:
message (str): What went wrong.
Returns:
str: The same message, so the caller can collect it.
"""
logger.error(message)
signal_alert(message)
return message
def has_unusable_parent(name: str, unusable: set[str]) -> bool:
"""Check whether an ancestor of a dataset is missing.
Args:
name (str): The name of the dataset.
unusable (set[str]): The datasets that do not exist and were not created.
Returns:
bool: True if any ancestor is unusable.
"""
parts = name.split("/")
return any("/".join(parts[:depth]) in unusable for depth in range(1, len(parts)))
def handle_missing_dataset(name: str, entry: dict, *, dry_run: bool) -> tuple[bool, str | None]:
"""Deal with a declared dataset that is not on the system.
Pool roots are never created, and neither is anything the declaration marks
as provisioned outside of nix, such as an encryption root whose key
settings cannot be reproduced from the declaration.
Args:
name (str): The name of the dataset.
entry (dict): The declaration for this dataset.
dry_run (bool): Log the change without making it.
Returns:
tuple[bool, str | None]: Whether the dataset now exists, and a failure
message if there was one.
"""
properties = entry["properties"]
if "/" not in name:
return False, fail(f"pool {name} is declared but does not exist, zfs_manager does not create pools")
if not entry.get("createIfMissing", True):
return False, fail(
f"{name} is declared but does not exist, and is marked as created outside of nix. "
"It has to be made by hand, see systems/jeeves/scripts/zfs.sh.",
)
if dry_run:
logger.info(f"would create {name} with {properties}")
return False, None
logger.info(f"creating {name} with {properties}")
if error := create_dataset(name, properties):
return False, fail(error)
return True, None
def reconcile_dataset(name: str, properties: dict[str, str], *, dry_run: bool) -> list[str]:
"""Bring an existing dataset in line with its declared properties.
Args:
name (str): The name of the dataset.
properties (dict[str, str]): The declared properties.
dry_run (bool): Log the changes without making them.
Returns:
list[str]: Anything that could not be put right.
"""
failures: list[str] = []
current = get_properties(name)
for key, wanted in sorted(properties.items()):
current_value, _ = current.get(key, ("-", "-"))
if values_match(key, wanted, current_value):
continue
if key in CREATE_ONLY_PROPERTIES:
# Nothing can put this right while the dataset exists, so it is a
# hard failure rather than a warning that repeats unnoticed.
failures.append(
fail(
f"{name} {key} is {current_value} but {wanted} is declared, "
f"{key} can only be set when the dataset is created",
),
)
continue
if dry_run:
logger.info(f"would set {key}={wanted} on {name}, currently {current_value}")
continue
logger.info(f"setting {key}={wanted} on {name}, was {current_value}")
if error := set_property(name, key, wanted):
failures.append(fail(error))
report_undeclared_properties(name, properties, current)
return failures
def report_undeclared_properties(name: str, properties: dict[str, str], current: dict[str, tuple[str, str]]) -> None:
"""Warn about properties set on the dataset but absent from the declaration.
Inherited and default values are silent, they are not drift. A locally set
value that nix does not know about was changed outside of this tool and
will be lost the next time the dataset is recreated, so it is worth saying.
Args:
name (str): The name of the dataset.
properties (dict[str, str]): The declared properties.
current (dict[str, tuple[str, str]]): The live properties keyed to (value, source).
"""
for key, (value, source) in sorted(current.items()):
# User properties such as nixos:shutdown-time are written by other
# tools and are not something a dataset declaration should own.
if key in properties or ":" in key or source not in LOCAL_SOURCES:
continue
logger.warning(f"{name} has {key}={value} set outside of nix")
signal_alert(f"{name} has {key}={value} set outside of nix")
def report_undeclared_datasets(existing: set[str], declared: dict[str, dict]) -> None:
"""Warn about datasets that exist but are not declared.
These are left completely alone. They still get snapshots through the
default retention table.
Args:
existing (set[str]): The names of every live dataset.
declared (dict[str, dict]): The declaration.
"""
for name in sorted(existing - set(declared)):
logger.warning(f"{name} exists but is not declared in nix")
def values_match(key: str, wanted: str, current: str) -> bool:
"""Compare a declared property value against the live one.
Args:
key (str): The property name.
wanted (str): The declared value.
current (str): The live value.
Returns:
bool: True if the two values mean the same thing.
"""
if key in SIZE_PROPERTIES:
wanted_size = parse_size(wanted)
current_size = parse_size(current)
if wanted_size is not None and current_size is not None:
return wanted_size == current_size
return wanted == current
def parse_size(value: str) -> int | None:
"""Convert a zfs size such as 16k or 1M into bytes.
Args:
value (str): The size to convert.
Returns:
int | None: The size in bytes, or None if it is not a size.
"""
value = value.strip()
if value.isdigit():
return int(value)
number, suffix = value[:-1], value[-1:].lower()
if suffix in SIZE_SUFFIXES and number.isdigit():
return int(number) * SIZE_SUFFIXES[suffix]
return None
def cli() -> None:
"""CLI."""
typer.run(main)
if __name__ == "__main__":
cli()
+5 -4
View File
@@ -6,15 +6,16 @@
"${inputs.self}/users/steve"
"${inputs.self}/common/global"
"${inputs.self}/common/optional/docker.nix"
"${inputs.self}/common/optional/scanner.nix"
"${inputs.self}/common/optional/monitoring-agent.nix"
"${inputs.self}/common/optional/nvidia.nix"
"${inputs.self}/common/optional/scanner.nix"
"${inputs.self}/common/optional/steam.nix"
"${inputs.self}/common/optional/syncthing_base.nix"
"${inputs.self}/common/optional/systemd-boot.nix"
"${inputs.self}/common/optional/tailscale.nix"
"${inputs.self}/common/optional/update.nix"
"${inputs.self}/common/optional/yubikey.nix"
"${inputs.self}/common/optional/zerotier.nix"
"${inputs.self}/common/optional/nvidia.nix"
"${inputs.self}/common/optional/x86-64-v3.nix"
"${inputs.self}/common/optional/zfs"
./hardware.nix
./syncthing.nix
./llms.nix
+3 -1
View File
@@ -7,8 +7,10 @@
"${inputs.self}/common/optional/ssh_decrypt.nix"
"${inputs.self}/common/optional/syncthing_base.nix"
"${inputs.self}/common/optional/systemd-boot.nix"
"${inputs.self}/common/optional/tailscale.nix"
"${inputs.self}/common/optional/update.nix"
"${inputs.self}/common/optional/zerotier.nix"
"${inputs.self}/common/optional/x86-64-v3.nix"
"${inputs.self}/common/optional/zfs"
./docker
./hardware.nix
./programs.nix
+2 -1
View File
@@ -21,6 +21,7 @@
packages = {
victron_modbuss = "!include ${./home_assistant/victron_modbuss.yaml}";
battery_sensors = "!include ${./home_assistant/battery_sensors.yaml}";
energy_meters = "!include ${./home_assistant/energy_meters.yaml}";
gps_location = "!include ${./home_assistant/gps_location.yaml}";
heater = "!include ${./home_assistant/heater.yaml}";
van_weather = "!include ${./home_assistant/van_weather_template.yaml}";
@@ -85,7 +86,7 @@
esphome = {
enable = true;
openFirewall = true;
address = "192.168.90.35";
address = "0.0.0.0";
};
};
}
@@ -0,0 +1,71 @@
template:
- sensor:
- name: "JK0 Energy In Clean"
unique_id: jk0_energy_in_clean
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
availability: >-
{{ is_number(states('sensor.jk0_energy_in')) }}
state: >-
{{ states('sensor.jk0_energy_in') | float }}
- name: "JK0 Energy Out Clean"
unique_id: jk0_energy_out_clean
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
availability: >-
{{ is_number(states('sensor.jk0_energy_out')) }}
state: >-
{{ states('sensor.jk0_energy_out') | float }}
- name: "JK1 Energy In Clean"
unique_id: jk1_energy_in_clean
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
availability: >-
{{ is_number(states('sensor.jk1_energy_in')) }}
state: >-
{{ states('sensor.jk1_energy_in') | float }}
- name: "JK1 Energy Out Clean"
unique_id: jk1_energy_out_clean
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
availability: >-
{{ is_number(states('sensor.jk1_energy_out')) }}
state: >-
{{ states('sensor.jk1_energy_out') | float }}
- name: "Solar Yield Clean"
unique_id: solar_yield_clean
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
availability: >-
{{ is_number(states('sensor.solar_yield_daily')) }}
state: >-
{{ states('sensor.solar_yield_daily') | float }}
- name: "DC Load Energy Clean"
unique_id: dc_load_energy_clean
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
availability: >-
{{ is_number(states('sensor.dc_load_energy_daily')) }}
state: >-
{{ states('sensor.dc_load_energy_daily') | float }}
- name: "DC Charger Energy Clean"
unique_id: dc_charger_energy_clean
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
availability: >-
{{ is_number(states('sensor.dc_charger_energy')) }}
state: >-
{{ states('sensor.dc_charger_energy') | float }}
+2 -2
View File
@@ -19,7 +19,7 @@
local all richie trust
host all richie 127.0.0.1/32 trust
host all richie ::1/128 trust
host all richie 192.168.90.1/24 trust
host all richie 100.64.0.0/10 trust
host all richie 192.168.99.1/24 trust
local vaninventory vaninventory trust
@@ -28,7 +28,7 @@
local hass hass trust
# ipv4
host hass hass 192.168.90.1/24 trust
host hass hass 100.64.0.0/10 trust
host hass hass 127.0.0.1/32 trust
# ipv6
+1 -1
View File
@@ -3,7 +3,7 @@
services.syncthing = {
overrideFolders = false;
guiAddress = "192.168.90.35:8384";
guiAddress = "0.0.0.0:8384";
settings = {
"dotfiles" = {
path = "/home/richie/dotfiles";
-362
View File
@@ -1,362 +0,0 @@
# Dataset declarations for jeeves, kept as plain data rather than inside
# zfs.nix so the dataset tree stays separate from the NixOS service wiring.
# Datasets are nested the way zfs nests them: a pool holds datasets, which can
# hold datasets of their own. The tree is flattened into "pool/parent/child"
# names below, which is what zfs and services.zfs_manager work in.
#
# Consumed by ./zfs.nix, which feeds it to services.zfs_manager.
let
# Every pool on jeeves was created with the same -O options.
poolDefaults = mountpoint: {
inherit mountpoint;
acltype = "posix"; # zfs reports posixacl back as posix
atime = "off";
compression = "zstd";
dnodesize = "auto";
xattr = "sa";
};
zfsKey = "file:///root/zfs.key";
# What a dataset gets when it is not called out below, kept identical to the
# "default" table so the datasets that used to fall through are unchanged.
standard = {
"15_min" = 8;
hourly = 24;
};
disabledSnapshots = {
"15_min" = 0;
hourly = 0;
daily = 0;
monthly = 0;
};
pools = {
# root_pool: retention only, its properties are not managed yet.
root_pool = {
manageProperties = false;
datasets = {
home = {
manageProperties = false;
snapshots = {
"15_min" = 8;
hourly = 24;
daily = 14;
};
};
root = {
manageProperties = false;
snapshots = standard;
};
nix = {
manageProperties = false;
snapshots."15_min" = 4;
};
var = {
manageProperties = false;
snapshots = {
"15_min" = 8;
hourly = 24;
daily = 30;
monthly = 6;
};
};
};
};
media = {
properties = poolDefaults "/zfs/media";
datasets = {
temp = {
properties = {
redundant_metadata = "none";
sync = "disabled";
};
snapshots."15_min" = 2;
};
secure = {
# An encryption root provisioned by scripts/zfs.sh. Its create-only
# properties are declared for verification, but zfs_manager must
# never create it automatically.
createIfMissing = true;
properties = {
encryption = "aes-256-gcm";
keyformat = "hex";
keylocation = zfsKey;
};
snapshots = disabledSnapshots;
datasets = {
docker = {
properties = {
mountpoint = "/zfs/media/docker";
compression = "zstd-9";
};
snapshots = {
"15_min" = 3;
hourly = 12;
daily = 14;
monthly = 2;
};
};
"github-runners" = {
properties = {
mountpoint = "/zfs/media/github-runners";
compression = "zstd-9";
sync = "disabled";
};
snapshots = {
"15_min" = 6;
hourly = 2;
daily = 1;
};
};
home_assistant = {
properties = {
mountpoint = "/zfs/media/home_assistant";
compression = "zstd-19";
};
snapshots = standard;
};
important = {
properties = {
compression = "zstd-9";
copies = "2";
};
snapshots = standard;
};
notes = {
properties = {
mountpoint = "/zfs/media/notes";
copies = "2";
};
snapshots = {
"15_min" = 8;
hourly = 24;
daily = 30;
monthly = 12;
};
};
postgres = {
properties = {
mountpoint = "/zfs/media/database/postgres";
primarycache = "metadata";
recordsize = "16K";
};
snapshots = {
"15_min" = 8;
hourly = 24;
daily = 7;
};
};
"postgres-wal" = {
properties = {
compression = "lz4";
logbias = "latency";
mountpoint = "/zfs/media/database/postgres-wal";
primarycache = "metadata";
recordsize = "32K";
secondarycache = "none";
special_small_blocks = "32K";
};
snapshots = {
"15_min" = 4;
hourly = 2;
};
};
prometheus = {
properties = {
mountpoint = "/zfs/media/database/prometheus";
compression = "lz4";
};
snapshots = standard;
};
services = {
properties = {
mountpoint = "/zfs/media/services";
compression = "zstd-9";
};
snapshots = standard;
};
share = {
properties = {
mountpoint = "/zfs/media/share";
exec = "off";
};
snapshots."15_min" = 4;
};
};
};
};
};
storage = {
properties = poolDefaults "/zfs/storage";
datasets = {
nomad = {
properties = {
mountpoint = "/zfs/storage/nomad";
compression = "zstd-9";
};
snapshots = standard;
};
ollama = {
properties = {
compression = "zstd-19";
recordsize = "1M";
sync = "disabled";
};
snapshots."15_min" = 2;
};
secure = {
# An encryption root provisioned by scripts/zfs.sh. Its create-only
# properties are declared for verification, but zfs_manager must
# never create it automatically.
createIfMissing = false;
properties = {
encryption = "aes-256-gcm";
keyformat = "hex";
keylocation = zfsKey;
};
snapshots = disabledSnapshots;
datasets = {
archive = {
properties = {
compression = "zstd-19";
mountpoint = "/zfs/storage/archive";
recordsize = "1M";
};
snapshots = standard;
};
important = {
properties = {
compression = "zstd-19";
copies = "2";
mountpoint = "/zfs/storage/important";
};
snapshots = standard;
};
library = {
properties = {
compression = "zstd-19";
mountpoint = "/zfs/storage/library";
recordsize = "1M";
};
snapshots = standard;
};
main = {
properties = {
compression = "zstd-19";
mountpoint = "/zfs/storage/main";
};
snapshots = standard;
};
photos = {
properties = {
compression = "zstd-19";
copies = "2";
mountpoint = "/zfs/storage/photos";
recordsize = "16K";
};
snapshots = standard;
};
plex = {
properties = {
compression = "zstd-19";
mountpoint = "/zfs/storage/plex";
recordsize = "1M";
};
snapshots = {
"15_min" = 6;
hourly = 2;
daily = 1;
};
};
secrets = {
properties = {
compression = "zstd-19";
copies = "3";
mountpoint = "/zfs/storage/secrets";
};
snapshots = {
"15_min" = 8;
hourly = 24;
daily = 30;
monthly = 12;
};
};
syncthing = {
properties = {
compression = "zstd-19";
mountpoint = "/zfs/storage/syncthing";
};
snapshots = standard;
};
transmission = {
properties = {
compression = "zstd-9";
exec = "off";
mountpoint = "/zfs/storage/transmission";
recordsize = "1M";
sync = "disabled";
};
snapshots."15_min" = 4;
};
};
};
};
};
scratch = {
properties = poolDefaults "/zfs/scratch" // {
encryption = "aes-256-gcm";
keyformat = "hex";
keylocation = zfsKey;
};
datasets = {
kafka = {
properties = {
mountpoint = "/zfs/scratch/kafka";
recordsize = "1M";
};
snapshots = standard;
};
kestra = {
properties = {
mountpoint = "/zfs/scratch/kestra";
sync = "disabled";
};
snapshots = standard;
};
transmission = {
properties = {
mountpoint = "/zfs/scratch/transmission";
recordsize = "16K";
sync = "disabled";
};
snapshots."15_min" = 2;
};
uv_cache = {
properties.mountpoint = "/zfs/scratch/uv_cache";
snapshots."15_min" = 2;
};
};
};
};
# Collapse the tree into the flat "pool/parent/child" names zfs uses. Each
# node keeps everything except its children.
flatten =
name: node:
builtins.foldl' (result: child: result // flatten "${name}/${child}" node.datasets.${child}) {
${name} = builtins.removeAttrs node [ "datasets" ];
} (builtins.attrNames (node.datasets or { }));
datasets = builtins.foldl' (result: pool: result // flatten pool pools.${pool}) { } (
builtins.attrNames pools
);
in
{
inherit datasets;
defaultSnapshots = standard;
}
+19 -6
View File
@@ -1,4 +1,7 @@
{ inputs, ... }:
let
vars = import ./vars.nix;
in
{
imports = [
"${inputs.self}/users/dov"
@@ -10,19 +13,18 @@
"${inputs.self}/common/optional/monitoring-agent.nix"
"${inputs.self}/common/optional/ssh_decrypt.nix"
"${inputs.self}/common/optional/syncthing_base.nix"
"${inputs.self}/common/optional/tailscale.nix"
"${inputs.self}/common/optional/update.nix"
"${inputs.self}/common/optional/zerotier.nix"
"${inputs.self}/common/optional/zfs_manager.nix"
"${inputs.self}/common/optional/x86-64-v3.nix"
"${inputs.self}/common/optional/zfs"
./monitoring
./docker
./services
./web_services
./hardware.nix
./networking.nix
./networking
./programs.nix
./runners
./syncthing.nix
./zfs.nix
];
services = {
@@ -30,7 +32,18 @@
smartd.enable = true;
zerotierone.joinNetworks = [ "a09acf02330d37b9" ];
snapshot_manager = {
path = ./snapshot_config.toml;
EnvironmentFile = "${vars.secrets}/services/snapshot_manager";
};
zerotierone = {
enable = true;
joinNetworks = [
"e4da7455b2ae64ca"
"a09acf02330d37b9"
];
};
};
users.groups = {
@@ -2,9 +2,6 @@ let
vars = import ../vars.nix;
in
{
networking.firewall.allowedTCPPorts = [
8989
];
virtualisation.oci-containers.containers.signal_cli_rest_api = {
image = "bbernhard/signal-cli-rest-api:0.199-dev";
ports = [
+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.
@@ -0,0 +1,798 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": false,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "green",
"value": 1
}
]
},
"mappings": [
{
"options": {
"0": {
"color": "red",
"index": 1,
"text": "Offline"
},
"1": {
"color": "green",
"index": 0,
"text": "Online"
}
},
"type": "value"
}
]
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 4,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"editorMode": "code",
"expr": "up{job=\"node\",instance=\"portal-1\"}",
"instant": true,
"legendFormat": "",
"range": false,
"refId": "A"
}
],
"title": "Node Exporter",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "percent",
"min": 0,
"max": 100,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 70
},
{
"color": "red",
"value": 90
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 5,
"x": 4,
"y": 0
},
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"editorMode": "code",
"expr": "100 * (1 - avg(rate(node_cpu_seconds_total{job=\"node\",instance=\"portal-1\",mode=\"idle\"}[5m])))",
"instant": true,
"legendFormat": "",
"range": false,
"refId": "A"
}
],
"title": "CPU Used",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "percent",
"min": 0,
"max": 100,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 70
},
{
"color": "red",
"value": 90
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 5,
"x": 9,
"y": 0
},
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"editorMode": "code",
"expr": "100 * (1 - (node_memory_MemAvailable_bytes{job=\"node\",instance=\"portal-1\"} / node_memory_MemTotal_bytes{job=\"node\",instance=\"portal-1\"}))",
"instant": true,
"legendFormat": "",
"range": false,
"refId": "A"
}
],
"title": "RAM Used",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "percent",
"min": 0,
"max": 100,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 70
},
{
"color": "red",
"value": 90
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 5,
"x": 14,
"y": 0
},
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"editorMode": "code",
"expr": "100 * (1 - (node_filesystem_avail_bytes{job=\"node\",instance=\"portal-1\",mountpoint=\"/\",fstype!=\"\"} / node_filesystem_size_bytes{job=\"node\",instance=\"portal-1\",mountpoint=\"/\",fstype!=\"\"}))",
"instant": true,
"legendFormat": "",
"range": false,
"refId": "A"
}
],
"title": "Root Filesystem Used",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 5,
"x": 19,
"y": 0
},
"id": 5,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"editorMode": "code",
"expr": "node_load1{job=\"node\",instance=\"portal-1\"}",
"instant": true,
"legendFormat": "",
"range": false,
"refId": "A"
}
],
"title": "Load (1m)",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "percent",
"min": 0,
"max": 100,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 70
},
{
"color": "red",
"value": 90
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 5
},
"id": 6,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"editorMode": "code",
"expr": "100 * (1 - avg(rate(node_cpu_seconds_total{job=\"node\",instance=\"portal-1\",mode=\"idle\"}[5m])))",
"instant": false,
"legendFormat": "CPU used",
"range": true,
"refId": "A"
}
],
"title": "CPU Usage",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "percent",
"min": 0,
"max": 100,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 70
},
{
"color": "red",
"value": 90
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 5
},
"id": 7,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"editorMode": "code",
"expr": "100 * (1 - (node_memory_MemAvailable_bytes{job=\"node\",instance=\"portal-1\"} / node_memory_MemTotal_bytes{job=\"node\",instance=\"portal-1\"}))",
"instant": false,
"legendFormat": "RAM used",
"range": true,
"refId": "A"
}
],
"title": "RAM Usage",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "percent",
"min": 0,
"max": 100,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 70
},
{
"color": "red",
"value": 90
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 13
},
"id": 8,
"options": {
"cellHeight": "sm",
"showHeader": true,
"sortBy": [
{
"desc": true,
"displayName": "Value"
}
]
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"editorMode": "code",
"expr": "100 * (1 - (node_filesystem_avail_bytes{job=\"node\",instance=\"portal-1\",fstype!~\"tmpfs|devtmpfs|overlay|squashfs\",mountpoint!~\"/run.*\"} / node_filesystem_size_bytes{job=\"node\",instance=\"portal-1\",fstype!~\"tmpfs|devtmpfs|overlay|squashfs\",mountpoint!~\"/run.*\"}))",
"format": "table",
"instant": true,
"legendFormat": "{{mountpoint}}",
"range": false,
"refId": "A"
}
],
"title": "Filesystem Usage",
"type": "table"
},
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "Bps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 13
},
"id": 9,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"editorMode": "code",
"expr": "sum(rate(node_disk_read_bytes_total{job=\"node\",instance=\"portal-1\",device!~\"loop.*|ram.*|fd.*\"}[5m]))",
"instant": false,
"legendFormat": "read",
"range": true,
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"editorMode": "code",
"expr": "sum(rate(node_disk_written_bytes_total{job=\"node\",instance=\"portal-1\",device!~\"loop.*|ram.*|fd.*\"}[5m]))",
"instant": false,
"legendFormat": "write",
"range": true,
"refId": "B"
}
],
"title": "Disk Throughput",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "iops"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 21
},
"id": 10,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"editorMode": "code",
"expr": "sum(rate(node_disk_reads_completed_total{job=\"node\",instance=\"portal-1\",device!~\"loop.*|ram.*|fd.*\"}[5m]))",
"instant": false,
"legendFormat": "reads",
"range": true,
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"editorMode": "code",
"expr": "sum(rate(node_disk_writes_completed_total{job=\"node\",instance=\"portal-1\",device!~\"loop.*|ram.*|fd.*\"}[5m]))",
"instant": false,
"legendFormat": "writes",
"range": true,
"refId": "B"
}
],
"title": "Disk Operations",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "Bps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 21
},
"id": 11,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"editorMode": "code",
"expr": "sum(rate(node_network_receive_bytes_total{job=\"node\",instance=\"portal-1\",device!=\"lo\"}[5m]))",
"instant": false,
"legendFormat": "received",
"range": true,
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "prom-main"
},
"editorMode": "code",
"expr": "sum(rate(node_network_transmit_bytes_total{job=\"node\",instance=\"portal-1\",device!=\"lo\"}[5m]))",
"instant": false,
"legendFormat": "sent",
"range": true,
"refId": "B"
}
],
"title": "Network Traffic",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": [
"monitoring",
"portal-1",
"vultr"
],
"templating": {
"list": []
},
"time": {
"from": "now-24h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Portal-1 Host",
"uid": "portal-1-host",
"version": 1,
"weekStart": ""
}
File diff suppressed because it is too large Load Diff
+21 -10
View File
@@ -41,29 +41,36 @@ let
{
job_name = "node";
static_configs = [
(mkTarget "jeeves" "192.168.90.40:9100")
(mkTarget "bob" "192.168.90.25:9100")
(mkTarget "jeeves" "jeeves.taile39c31.ts.net:9100")
(mkTarget "bob" "bob.taile39c31.ts.net:9100")
(mkTarget "portal-1" "portal-1.taile39c31.ts.net:9100")
];
}
{
job_name = "vultr";
scrape_interval = "5m";
scrape_timeout = "45s";
static_configs = [ (mkTarget "vultr" "127.0.0.1:9188") ];
}
{
job_name = "process_grouped";
static_configs = [
(mkTarget "jeeves" "192.168.90.40:9256")
(mkTarget "bob" "192.168.90.25:9256")
(mkTarget "jeeves" "jeeves.taile39c31.ts.net:9256")
(mkTarget "bob" "bob.taile39c31.ts.net:9256")
];
}
{
job_name = "smartctl";
static_configs = [
(mkTarget "jeeves" "192.168.90.40:9633")
(mkTarget "bob" "192.168.90.25:9633")
(mkTarget "jeeves" "jeeves.taile39c31.ts.net:9633")
(mkTarget "bob" "bob.taile39c31.ts.net:9633")
];
}
{
job_name = "zfs";
static_configs = [
(mkTarget "jeeves" "192.168.90.40:9134")
(mkTarget "bob" "192.168.90.25:9134")
(mkTarget "jeeves" "jeeves.taile39c31.ts.net:9134")
(mkTarget "bob" "bob.taile39c31.ts.net:9134")
];
}
];
@@ -79,8 +86,8 @@ let
{
job_name = "process_pid";
static_configs = [
(mkTarget "jeeves" "192.168.90.40:9257")
(mkTarget "bob" "192.168.90.25:9257")
(mkTarget "jeeves" "jeeves.taile39c31.ts.net:9257")
(mkTarget "bob" "bob.taile39c31.ts.net:9257")
];
}
];
@@ -97,7 +104,9 @@ let
after = [
"zfs-media-database-prometheus.mount"
"network.target"
"tailscaled-autoconnect.service"
];
wants = [ "tailscaled-autoconnect.service" ];
requires = [ "zfs-media-database-prometheus.mount" ];
wantedBy = [ "multi-user.target" ];
unitConfig.RequiresMountsFor = [ dataDir ];
@@ -151,6 +160,8 @@ let
};
in
{
imports = [ ./vultr-exporter.nix ];
users = {
groups.prometheus = { };
users.prometheus = {
@@ -0,0 +1,96 @@
{
lib,
pkgs,
...
}:
let
vars = import ../vars.nix;
vultrExporter = pkgs.buildGoModule rec {
pname = "vultr-exporter";
version = "0-unstable-2025-10-14";
src = pkgs.fetchFromGitHub {
owner = "DazWilkin";
repo = "vultr-exporter";
rev = "d8b5d7107c2eda31566e6e895c8a67db32fdaf68";
hash = "sha256-3LpXh+mFMBRJfiY2ftELpM3AIE0LiZ9c6NFok0OeH5I=";
};
vendorHash = "sha256-qCHWmg6GxLIjqlf5UqRcuDRd1xqRFI3sl4ZduLajx+Y=";
subPackages = [ "cmd/server" ];
postPatch = ''
substituteInPlace cmd/server/main.go \
--replace-fail 'registry.MustRegister(collector.NewBlockStorageCollector(s, client, log))' "" \
--replace-fail 'registry.MustRegister(collector.NewKubernetesCollector(s, client, log))' "" \
--replace-fail 'registry.MustRegister(collector.NewLoadBalancerCollector(s, client, log))' "" \
--replace-fail 'registry.MustRegister(collector.NewReservedIPsCollector(s, client, log))' ""
'';
ldflags = [
"-s"
"-w"
"-X main.GitCommit=${src.rev}"
"-X main.OSVersion=NixOS"
];
postInstall = ''
mv $out/bin/server $out/bin/vultr-exporter
'';
meta = {
description = "Prometheus exporter for Vultr account, billing, and bandwidth metrics";
homepage = "https://github.com/DazWilkin/vultr-exporter";
license = lib.licenses.asl20;
mainProgram = "vultr-exporter";
};
};
in
{
systemd.services.vultr-exporter = {
description = "Vultr Prometheus exporter";
documentation = [ "https://github.com/DazWilkin/vultr-exporter" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${lib.getExe vultrExporter} --endpoint=127.0.0.1:9188 --path=/metrics";
EnvironmentFile = "${vars.secrets}/services/vultr-exporter";
Restart = "on-failure";
RestartSec = "30s";
CapabilityBoundingSet = [ "" ];
DeviceAllow = [ "" ];
DynamicUser = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
];
UMask = "0077";
};
};
}
+16
View File
@@ -0,0 +1,16 @@
{
imports = [
./firewall.nix
./interfaces.nix
];
networking = {
hostName = "jeeves";
hostId = "0e15ce35";
useNetworkd = true;
# Without this, unconfigured NICs get a catch-all DHCP network and
# join the LAN uninvited (how the dead atlantic card ended up
# ARP-poisoning 192.168.99.14). Every interface must be configured
# explicitly below.
useDHCP = false;
};
}
+47
View File
@@ -0,0 +1,47 @@
let
ports = {
audiobookshelf = 8000;
ebook_searcher = 8070;
gems = 8002;
gitea_http = 6443;
gitea_ssh = 2223;
grafana = 3000;
jellyfin_http = 8096;
nix_binary_cache = 5000;
nornsight = 8001;
ollama = 11434;
open_webui = 8080;
postgresql = 5432;
share = 8091;
signal_cli = 8989;
syncthing = 8384;
};
in
{
networking.firewall = {
enable = true;
interfaces.br-nix-builder = {
allowedTCPPorts = [ ];
allowedUDPPorts = [ ];
};
interfaces.tailscale0.allowedTCPPorts = [
ports.audiobookshelf
ports.gems
ports.jellyfin_http
ports.nix_binary_cache
ports.nornsight
ports.share
];
allowedTCPPorts = [
ports.ebook_searcher
ports.gitea_http
ports.gitea_ssh
ports.grafana
ports.ollama
ports.open_webui
ports.postgresql
ports.signal_cli
ports.syncthing
];
};
}
@@ -8,27 +8,6 @@
"net.bridge.bridge-nf-call-iptables" = 0;
};
networking = {
hostName = "jeeves";
hostId = "0e15ce35";
firewall = {
enable = true;
interfaces.br-nix-builder = {
allowedTCPPorts = [ ];
allowedUDPPorts = [ ];
};
allowedTCPPorts = [
8070
];
};
useNetworkd = true;
# Without this, unconfigured NICs get a catch-all DHCP network and
# join the LAN uninvited (how the dead atlantic card ended up
# ARP-poisoning 192.168.99.14). Every interface must be configured
# explicitly below.
useDHCP = false;
};
systemd.network = {
enable = true;
wait-online = {
+28 -9
View File
@@ -1,10 +1,5 @@
#!/bin/bash
# Pool and vdev creation only. This is run by hand once per pool.
#
# Datasets and their properties are declared in systems/jeeves/zfs.nix and
# reconciled by the zfs_manager service. Do not add zfs create lines here.
# zpools
# media
@@ -17,10 +12,34 @@ sudo zpool add storage -o ashift=12 special mirror
sudo zpool add storage -o ashift=12 logs mirror
# scratch
sudo zpool create scratch -o ashift=12 -O acltype=posixacl -O atime=off -O dnodesize=auto -O xattr=sa -O compression=zstd -O encryption=aes-256-gcm -O keyformat=hex -O keylocation=file:///root/zfs.key -m /zfs/scratch
sudo zpool create scratch -o ashift=12 -O acltype=posixacl -O atime=off -O dnodesize=auto -O xattr=sa -O compression=zstd -O encryption=aes-256-gcm -O keyformat=hex -O keylocation=file:///key -m /zfs/scratch
# The two encrypted parent datasets have to exist before zfs_manager can create
# anything under them, since encryption cannot be set after creation.
# These will be removed if/when the media and storage pools are encrypted in the future.
# media datasets
sudo zfs create media/temp -o sync=disabled -o redundant_metadata=none
sudo zfs create media/secure -o encryption=aes-256-gcm -o keyformat=hex -o keylocation=file:///root/zfs.key
sudo zfs create media/secure/docker -o compression=zstd-9
sudo zfs create media/secure/github-runners -o compression=zstd-9 -o sync=disabled
sudo zfs create media/secure/notes -o copies=2
sudo zfs create media/secure/postgres -o mountpoint=/zfs/media/database/postgres -o recordsize=16k -o primarycache=metadata
sudo zfs create media/secure/postgres-wal -o mountpoint=/zfs/media/database/postgres-wal -o recordsize=32k -o primarycache=metadata -o special_small_blocks=32K -o compression=lz4 -o secondarycache=none -o logbias=latency
sudo zfs create media/secure/prometheus -o mountpoint=/zfs/media/database/prometheus -o compression=lz4
sudo zfs create media/secure/services -o compression=zstd-9
sudo zfs create media/secure/share -o mountpoint=/zfs/media/share -o exec=off
# scratch datasets
sudo zfs create scratch/kafka -o mountpoint=/zfs/scratch/kafka -o recordsize=1M
sudo zfs create scratch/transmission -o mountpoint=/zfs/scratch/transmission -o recordsize=16k -o sync=disabled -o redundant_metadata=none
sudo zfs create scratch/uv_cache -o mountpoint=/zfs/scratch/uv_cache
# storage datasets
sudo zfs create storage/ollama -o recordsize=1M -o compression=zstd-19 -o sync=disabled
sudo zfs create storage/secure -o encryption=aes-256-gcm -o keyformat=hex -o keylocation=file:///root/zfs.key
sudo zfs create storage/secure/archive -o recordsize=1M -o compression=zstd-19
sudo zfs create storage/secure/library -o recordsize=1M -o compression=zstd-19
sudo zfs create storage/secure/main -o compression=zstd-19
sudo zfs create storage/secure/photos -o recordsize=16K -o compression=zstd-19 -o copies=2
sudo zfs create storage/secure/plex -o recordsize=1M -o compression=zstd-19
sudo zfs create storage/secure/secrets -o compression=zstd-19 -o copies=3
sudo zfs create storage/secure/syncthing -o compression=zstd-19
sudo zfs create storage/secure/transmission -o recordsize=1M -o compression=zstd-9 -o exec=off -o sync=disabled
sudo zfs create storage/secure/important -o compression=zstd-19 -o copies=2 -o mountpoint=/zfs/storage/important
@@ -6,6 +6,7 @@ in
services.audiobookshelf = {
enable = true;
port = 8000;
host = "0.0.0.0";
};
systemd.services.audiobookshelf.serviceConfig.WorkingDirectory =
lib.mkForce "${vars.docker_configs}/audiobookshelf";
+1 -1
View File
@@ -36,7 +36,7 @@ in
Type = "simple";
User = "gems";
Group = "gems";
ExecStart = "${pkgs.my_python}/bin/python -m python.gems.main --host 127.0.0.1 --port 8002";
ExecStart = "${pkgs.my_python}/bin/python -m python.gems.main --host 0.0.0.0 --port 8002";
Restart = "on-failure";
RestartSec = "5s";
StandardOutput = "journal";
+5 -6
View File
@@ -2,11 +2,6 @@ let
vars = import ../vars.nix;
in
{
networking.firewall.allowedTCPPorts = [
6443
2223
];
services.gitea = {
enable = true;
appName = "TMM Workshop";
@@ -27,10 +22,14 @@ in
};
service.DISABLE_REGISTRATION = true;
server = {
DOMAIN = "tmmworkshop.com";
DOMAIN = "gitea.tmmworkshop.com";
ROOT_URL = "https://gitea.tmmworkshop.com/";
HTTP_PORT = 6443;
BUILTIN_SSH_SERVER_USER = "gitea";
SSH_USER = "gitea";
SSH_DOMAIN = "gitea.tmmworkshop.com";
SSH_PORT = 2223;
SSH_LISTEN_HOST = "0.0.0.0";
SSH_LISTEN_PORT = 2223;
START_SSH_SERVER = true;
PUBLIC_URL_DETECTION = "auto";
+2 -4
View File
@@ -6,8 +6,6 @@ let
grafanaDataDir = "${vars.services}/grafana";
in
{
networking.firewall.allowedTCPPorts = [ 3000 ];
services.grafana = {
enable = true;
dataDir = grafanaDataDir;
@@ -19,9 +17,9 @@ in
secret_key = "$__file{${vars.secrets}/services/grafana/secret_key}";
};
server = {
http_addr = "192.168.90.40";
http_addr = "0.0.0.0";
http_port = 3000;
root_url = "http://192.168.90.40:3000/";
root_url = "http://jeeves:3000/";
};
};
provision = {
-1
View File
@@ -4,7 +4,6 @@ in
{
services.jellyfin = {
enable = true;
openFirewall = true;
dataDir = "${vars.services}/jellyfin";
cacheDir = "${vars.services}/jellyfin/cache";
};
-1
View File
@@ -38,7 +38,6 @@ in
"qwen3.5:35b"
];
modelsDir = vars.ollama;
openFirewall = true;
};
systemd.services = {
ollama.serviceConfig = {
-1
View File
@@ -7,6 +7,5 @@ in
package = pkgs.nix-serve-ng;
enable = true;
secretKeyFile = "${vars.secrets}/services/nix-cache/cache-priv-key.pem";
openFirewall = true;
};
}
-1
View File
@@ -2,7 +2,6 @@
services.open-webui = {
enable = true;
host = "0.0.0.0";
openFirewall = true;
environment = {
ANONYMIZED_TELEMETRY = "False";
DO_NOT_TRACK = "True";
+3 -5
View File
@@ -3,8 +3,6 @@ let
vars = import ../vars.nix;
in
{
networking.firewall.allowedTCPPorts = [ 5432 ];
# Symlink pg_wal to a ZFS dataset on the special (metadata) vdev for fast WAL writes
# this is required for systemd sandboxing
systemd.services.postgresql.serviceConfig.ReadWritePaths = [ "/zfs/media/database/postgres-wal" ];
@@ -27,7 +25,7 @@ in
local all richie trust
host all richie 127.0.0.1/32 trust
host all richie ::1/128 trust
host all richie 192.168.90.1/24 trust
host all richie 100.64.0.0/10 trust
host all richie 192.168.99.1/24 trust
host all richie 172.16.0.0/12 trust
@@ -42,12 +40,12 @@ in
local postgres math trust
host postgres math 127.0.0.1/32 trust
host postgres math ::1/128 trust
host postgres math 192.168.90.1/24 trust
host postgres math 100.64.0.0/10 trust
local data_science_dev math trust
host data_science_dev math 127.0.0.1/32 trust
host data_science_dev math ::1/128 trust
host data_science_dev math 192.168.90.1/24 trust
host data_science_dev math 100.64.0.0/10 trust
'';
identMap = ''
+2 -2
View File
@@ -21,11 +21,11 @@ in
message-level = 3;
peer-port = 51413;
rpc-bind-address = "0.0.0.0";
rpc-host-whitelist = "127.0.0.1,192.168.90.40";
rpc-host-whitelist = "127.0.0.1,jeeves,jeeves.taile39c31.ts.net";
rpc-host-whitelist-enabled = true;
rpc-port = 9091;
rpc-whitelist-enabled = true;
rpc-whitelist = "127.0.0.1,192.168.90.49";
rpc-whitelist = "127.0.0.1,100.*.*.*";
seed-queue-enabled = false;
};
};
@@ -1,7 +1,6 @@
zpool = ["root_pool", "storage", "media"]
services = [
"audiobookshelf",
"haproxy",
"docker",
"jellyfin",
]
+129
View File
@@ -0,0 +1,129 @@
["default"]
15_min = 8
hourly = 24
daily = 0
monthly = 0
# root_pool
["root_pool/home"]
15_min = 8
hourly = 24
daily = 14
monthly = 0
["root_pool/root"]
15_min = 8
hourly = 24
daily = 0
monthly = 0
["root_pool/nix"]
15_min = 4
hourly = 0
daily = 0
monthly = 0
["root_pool/var"]
15_min = 8
hourly = 24
daily = 30
monthly = 6
# storage
["storage/ollama"]
15_min = 2
hourly = 0
daily = 0
monthly = 0
["storage/secure"]
15_min = 0
hourly = 0
daily = 0
monthly = 0
["storage/secure/plex"]
15_min = 6
hourly = 2
daily = 1
monthly = 0
["storage/secure/transmission"]
15_min = 4
hourly = 0
daily = 0
monthly = 0
["storage/secure/secrets"]
15_min = 8
hourly = 24
daily = 30
monthly = 12
# media
["media/temp"]
15_min = 2
hourly = 0
daily = 0
monthly = 0
["media/secure"]
15_min = 0
hourly = 0
daily = 0
monthly = 0
["media/secure/plex"]
15_min = 6
hourly = 2
daily = 1
monthly = 0
["media/secure/postgres-wal"]
15_min = 4
hourly = 2
daily = 0
monthly = 0
["media/secure/postgres"]
15_min = 8
hourly = 24
daily = 7
monthly = 0
["media/secure/share"]
15_min = 4
hourly = 0
daily = 0
monthly = 0
["media/secure/github-runners"]
15_min = 6
hourly = 2
daily = 1
monthly = 0
["media/secure/notes"]
15_min = 8
hourly = 24
daily = 30
monthly = 12
["media/secure/docker"]
15_min = 3
hourly = 12
daily = 14
monthly = 2
# scratch
["scratch/transmission"]
15_min = 2
hourly = 0
daily = 0
monthly = 0
["scratch/uv_cache"]
15_min = 2
hourly = 0
daily = 0
monthly = 0
+1 -3
View File
@@ -2,11 +2,9 @@ let
vars = import ./vars.nix;
in
{
networking.firewall.allowedTCPPorts = [ 8384 ];
services.syncthing = {
overrideFolders = false;
guiAddress = "192.168.90.40:8384";
guiAddress = "0.0.0.0:8384";
settings = {
devices.davids-server.id = "7GXTDGR-AOXFW2O-K6J7NM3-XYZNRRW-AKHAFWM-GBOWUPQ-OA6JIWD-ER7RDQL"; # cspell:disable-line
folders = {
-75
View File
@@ -1,75 +0,0 @@
let
domains = [
"audiobookshelf"
"cache"
"gitea"
"gems"
"jellyfin"
"share"
];
extraDomains = [ "www.norn-sight.com" ];
makeCert = name: {
name = "${name}.tmmworkshop.com";
value = {
webroot = "/var/lib/acme/.challenges";
group = "acme";
reloadServices = [ "haproxy.service" ];
};
};
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
{
users.users.haproxy.extraGroups = [ "acme" ];
security.acme = {
acceptTerms = true;
defaults.email = "Richie@tmmworkshop.com";
certs = builtins.listToAttrs ((map makeCert domains) ++ (map makeExtraCert extraDomains));
};
# Minimal nginx to serve ACME HTTP-01 challenge files for HAProxy
services.nginx = {
enable = true;
virtualHosts."acme-challenge" = {
listen = [
{
addr = "127.0.0.1";
port = 8402;
}
];
locations."/.well-known/acme-challenge/" = {
root = "/var/lib/acme/.challenges";
};
};
};
# Ensure the challenge directory exists with correct permissions
systemd.tmpfiles.rules = [
"d /var/lib/acme/.challenges 0750 acme acme - -"
"d /var/lib/acme/.challenges/.well-known 0750 acme acme - -"
"d /var/lib/acme/.challenges/.well-known/acme-challenge 0750 acme acme - -"
];
users.users.nginx.extraGroups = [ "acme" ];
# HAProxy needs certs to exist before it can bind :443.
# NixOS's acme module generates self-signed placeholders on first boot
# via acme-<domain>.service — just make HAProxy wait for them.
systemd.services.haproxy = {
after = acmeServices;
wants = acmeServices;
};
}
-9
View File
@@ -1,9 +0,0 @@
{ lib, ... }:
{
imports =
let
files = builtins.attrNames (builtins.readDir ./.);
nixFiles = builtins.filter (name: lib.hasSuffix ".nix" name && name != "default.nix") files;
in
map (file: ./. + "/${file}") nixFiles;
}
-14
View File
@@ -1,14 +0,0 @@
{
networking.firewall.allowedTCPPorts = [
80
443
];
# Global robots.txt served by HAProxy for every vhost (see haproxy.cfg).
environment.etc."haproxy/robots.txt".source = ./robots.txt;
services.haproxy = {
enable = true;
config = builtins.readFile ./haproxy.cfg;
};
}
-20
View File
@@ -1,20 +0,0 @@
{ inputs, ... }:
let
vars = import ./vars.nix;
jeeves_zfs = import ./datasets.nix;
in
{
services = {
zfs_manager = {
enable = true;
PYTHONPATH = "${inputs.self}/";
EnvironmentFile = "${vars.secrets}/services/snapshot_manager";
inherit (jeeves_zfs) datasets defaultSnapshots;
};
# Its retention config is generated from ./datasets.nix by
# common/optional/zfs_manager.nix, so only the credentials are set here.
snapshot_manager.EnvironmentFile = "${vars.secrets}/services/snapshot_manager";
};
}
+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.
+58
View File
@@ -0,0 +1,58 @@
{
inputs,
lib,
pkgs,
...
}:
{
imports = [
inputs.disko.nixosModules.disko
"${inputs.self}/users/richie"
"${inputs.self}/common/global"
"${inputs.self}/common/optional/tailscale.nix"
"${inputs.self}/common/optional/x86-64-v3.nix"
./disk-config.nix
./haproxy
./monitoring.nix
];
nixpkgs.hostPlatform = "x86_64-linux";
boot = {
# Avoid consuming the VM's limited memory for /tmp.
tmp.useTmpfs = false;
# The Vultr system disk and NIC are exposed as virtio devices.
initrd.availableKernelModules = [
"virtio_pci"
"virtio_blk"
"virtio_scsi"
"sd_mod"
];
};
networking = {
hostName = "portal-1";
useDHCP = lib.mkDefault true;
firewall = {
enable = true;
allowedTCPPorts = [ 278 ];
};
};
services.openssh.ports = [ 278 ];
environment.systemPackages = with pkgs; [
curl
htop
vim
];
# The VM uses its regular swap instead of compressed RAM swap.
zramSwap.enable = false;
time.timeZone = "Etc/UTC";
system.stateVersion = "24.05";
}
+59
View File
@@ -0,0 +1,59 @@
{ ... }:
{
# Vultr's first virtio disk is normally /dev/vda. Change this before
# deployment if the selected image exposes its system disk differently.
disko.devices.disk.main = {
type = "disk";
device = "/dev/vda";
content = {
type = "gpt";
partitions = {
bios = {
size = "1M";
type = "EF02";
};
ESP = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [
"fmask=0077"
"dmask=0077"
];
};
};
swap = {
size = "8G";
content = {
type = "swap";
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
# Install GRUB for both legacy BIOS and EFI-capable Vultr plans.
boot.loader = {
grub = {
enable = true;
devices = [ "/dev/vda" ];
efiSupport = true;
efiInstallAsRemovable = true;
};
efi.canTouchEfiVariables = false;
};
}
+89
View File
@@ -0,0 +1,89 @@
let
domains = [
"audiobookshelf"
"cache"
"gitea"
"gems"
"jellyfin"
"share"
];
extraDomains = [ "www.norn-sight.com" ];
makeCert = name: {
name = "${name}.tmmworkshop.com";
value = {
webroot = "/var/lib/acme/.challenges";
group = "acme";
reloadServices = [ "haproxy.service" ];
};
};
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 = [
80
443
2223
];
# Global robots.txt served by HAProxy for every vhost (see haproxy.cfg).
environment.etc."haproxy/robots.txt".source = ./robots.txt;
services = {
haproxy = {
enable = true;
config = builtins.readFile ./haproxy.cfg;
};
nginx = {
enable = true;
virtualHosts."acme-challenge" = {
listen = [
{
addr = "127.0.0.1";
port = 8402;
}
];
locations."/.well-known/acme-challenge/" = {
root = "/var/lib/acme/.challenges";
};
};
};
};
security.acme = {
acceptTerms = true;
defaults.email = "Richie@tmmworkshop.com";
certs = builtins.listToAttrs ((map makeCert domains) ++ (map makeExtraCert extraDomains));
};
systemd = {
services.haproxy = {
# HAProxy needs the initial certificates and Tailscale connectivity before
# it can serve requests to its backends.
after = acmeServices ++ [ "tailscaled-autoconnect.service" ];
wants = acmeServices ++ [ "tailscaled-autoconnect.service" ];
};
tmpfiles.rules = [
"d /var/lib/acme/.challenges 0750 acme acme - -"
"d /var/lib/acme/.challenges/.well-known 0750 acme acme - -"
"d /var/lib/acme/.challenges/.well-known/acme-challenge 0750 acme acme - -"
];
};
users.users = {
haproxy.extraGroups = [ "acme" ];
nginx.extraGroups = [ "acme" ];
};
}
@@ -22,8 +22,8 @@ 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 /var/lib/acme/www.norn-sight.com/full.pem
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 /var/lib/acme/www.norn-sight.com/full.pem
mode http
# ACME challenge routing (must be first)
@@ -110,6 +110,14 @@ frontend ContentSwitching
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.
frontend GiteaSSH
bind [::]:2223 v4v6
mode tcp
option tcplog
default_backend gitea_ssh
# Stick-table only (no servers): tracks per-IP request rate to Gitea's compare
# endpoint over a 5-minute window so the frontend can cap it at 1 per 5 min.
backend st_compare
@@ -124,31 +132,35 @@ backend acme_challenge
backend audiobookshelf_nodes
mode http
server server 127.0.0.1:8000
server server jeeves:8000
backend cache_nodes
mode http
server server 127.0.0.1:5000
server server jeeves:5000
backend jellyfin
mode http
option forwardfor
server jellyfin 127.0.0.1:8096
server jellyfin jeeves:8096
backend share_nodes
mode http
server server 127.0.0.1:8091
server server jeeves:8091
backend gitea
mode http
server server 127.0.0.1:6443
server server jeeves:6443
backend gitea_ssh
mode tcp
server gitea jeeves:2223
backend gems
mode http
option forwardfor
timeout server 1h
server gems 127.0.0.1:8002
server gems jeeves:8002
backend norn_sight
mode http
server server 127.0.0.1:8001
server server jeeves:8001
+15
View File
@@ -0,0 +1,15 @@
{ config, ... }:
{
networking.firewall.interfaces.tailscale0.allowedTCPPorts = [
config.services.prometheus.exporters.node.port
];
services.prometheus.exporters.node = {
enable = true;
enabledCollectors = [
"pressure"
"processes"
"systemd"
];
};
}
+3 -1
View File
@@ -8,8 +8,10 @@
"${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/x86-64-v3.nix"
"${inputs.self}/common/optional/yubikey.nix"
"${inputs.self}/common/optional/zerotier.nix"
"${inputs.self}/common/optional/zfs"
./hardware.nix
./open_webui.nix
./programs.nix
+1 -1
View File
@@ -51,7 +51,7 @@ def test_config_defaults_enable_reranking(mocker: MockerFixture) -> None:
config = load_rerank_config()
assert config.enabled is True
assert config.base_url == "http://192.168.90.25:8001"
assert config.base_url == "http://bob:8001"
assert config.model == "qwen3-reranker-06b"
assert config.candidates == 24
assert config.timeout_seconds == 30
+3 -45
View File
@@ -42,7 +42,7 @@ def test_main(mocker: MockerFixture, fs: FakeFilesystem) -> None:
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", return_value=[])
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)
@@ -76,39 +76,13 @@ def test_main_create_snapshot_failure(mocker: MockerFixture, fs: FakeFilesystem)
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 exit_info:
main(Path("/mock_snapshot_config.toml"))
main(Path("/mock_snapshot_config.toml"))
assert exit_info.value.code == 1
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_delete_snapshot_failure(mocker: MockerFixture, fs: FakeFilesystem) -> None:
"""Deletion failures make the service fail after processing the dataset."""
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"
mocker.patch(f"{SNAPSHOT_MANAGER}.get_datasets", return_value=(mock_dataset,))
mocker.patch(
f"{SNAPSHOT_MANAGER}.get_snapshots_to_delete",
return_value=["test_dataset@auto_202301010000 failed to delete: busy"],
)
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 exit_info:
main(Path("/mock_snapshot_config.toml"))
assert exit_info.value.code == 1
def test_main_exception(mocker: MockerFixture, fs: FakeFilesystem) -> None:
"""Test main."""
load_config_data.cache_clear()
@@ -167,18 +141,6 @@ def test_get_snapshots_to_delete_no_snapshot(mocker: MockerFixture) -> None:
mock_dataset.delete_snapshot.assert_not_called()
@pytest.mark.parametrize("invalid_count", [-1, "1", None, True])
def test_invalid_retention_is_rejected_before_reading_snapshots(mocker: MockerFixture, invalid_count: object) -> None:
"""Invalid standalone TOML values must never reach deletion logic."""
mock_dataset = mocker.MagicMock(spec=Dataset)
count_lookup = {"15_min": invalid_count, "hourly": 0, "daily": 0, "monthly": 0}
with pytest.raises(ValueError, match="15_min retention must be a non-negative integer"):
get_snapshots_to_delete(mock_dataset, count_lookup) # type: ignore[arg-type]
mock_dataset.get_snapshots.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")
@@ -191,12 +153,8 @@ def test_get_snapshots_to_delete_errored(mocker: MockerFixture) -> None:
mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert")
failures = get_snapshots_to_delete(
mock_dataset,
{"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0},
)
get_snapshots_to_delete(mock_dataset, {"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0})
assert failures == ["test_dataset@auto_202509150415 failed to delete: snapshot has dependent clones"]
mock_signal_alert.assert_called_once_with(
"test_dataset@auto_202509150415 failed to delete: snapshot has dependent clones"
)
-382
View File
@@ -1,382 +0,0 @@
"""test_zfs_manager."""
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from python.tools.zfs_manager import main, parse_size, values_match
if TYPE_CHECKING:
from pyfakefs.fake_filesystem import FakeFilesystem
from pytest_mock import MockerFixture
ZFS_MANAGER = "python.tools.zfs_manager"
CONFIG_PATH = "/mock_zfs_datasets.json"
def write_config(
fs: FakeFilesystem,
datasets: dict[str, dict[str, str]],
unmanaged: list[str] | None = None,
never_create: list[str] | None = None,
) -> Path:
"""Write a dataset declaration to the fake filesystem."""
contents = {
"datasets": {
name: {
"manageProperties": True,
"createIfMissing": name not in (never_create or []),
"properties": props,
}
for name, props in datasets.items()
}
| {name: {"manageProperties": False, "createIfMissing": True, "properties": {}} for name in unmanaged or []},
}
fs.create_file(CONFIG_PATH, contents=json.dumps(contents))
return Path(CONFIG_PATH)
def patch_zfs(
mocker: MockerFixture,
existing: list[str],
properties: dict[str, dict[str, tuple[str, str]]] | None = None,
) -> dict[str, object]:
"""Patch every zfs call zfs_manager makes."""
return {
"list": mocker.patch(f"{ZFS_MANAGER}.list_dataset_names", return_value=existing),
"get": mocker.patch(f"{ZFS_MANAGER}.get_properties", side_effect=lambda name: (properties or {}).get(name, {})),
"create": mocker.patch(f"{ZFS_MANAGER}.create_dataset", return_value=None),
"set": mocker.patch(f"{ZFS_MANAGER}.set_property", return_value=None),
"alert": mocker.patch(f"{ZFS_MANAGER}.signal_alert"),
}
def test_creates_missing_dataset(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(mocker, existing=["media", "media/secure"])
config = write_config(fs, {"media/secure/new": {"compression": "zstd-9"}})
main(config)
zfs["create"].assert_called_once_with("media/secure/new", {"compression": "zstd-9"})
zfs["set"].assert_not_called()
zfs["alert"].assert_not_called()
def test_sets_drifted_property(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(
mocker,
existing=["media", "media/temp"],
properties={"media/temp": {"compression": ("zstd", "inherited from media")}},
)
config = write_config(fs, {"media/temp": {"compression": "zstd-9"}})
main(config)
zfs["set"].assert_called_once_with("media/temp", "compression", "zstd-9")
zfs["create"].assert_not_called()
def test_no_op_when_in_sync(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(
mocker,
existing=["media", "media/temp"],
properties={"media/temp": {"sync": ("disabled", "local")}},
)
config = write_config(fs, {"media/temp": {"sync": "disabled"}})
main(config)
zfs["set"].assert_not_called()
zfs["create"].assert_not_called()
zfs["alert"].assert_not_called()
def test_size_property_does_not_churn(mocker: MockerFixture, fs: FakeFilesystem) -> None:
"""zfs get -p reports recordsize in bytes, the declaration uses a suffix."""
zfs = patch_zfs(
mocker,
existing=["media", "media/db"],
properties={"media/db": {"recordsize": ("16384", "local"), "special_small_blocks": ("32768", "local")}},
)
config = write_config(fs, {"media/db": {"recordsize": "16k", "special_small_blocks": "32K"}})
main(config)
zfs["set"].assert_not_called()
def test_create_only_property_alerts_instead_of_setting(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(
mocker,
existing=["media", "media/secure"],
properties={"media/secure": {"encryption": ("aes-256-gcm", "local")}},
)
config = write_config(fs, {"media/secure": {"encryption": "off"}})
# Nothing can fix a create-only mismatch at runtime, so it fails the run.
with pytest.raises(SystemExit) as exit_info:
main(config)
assert exit_info.value.code == 1
zfs["set"].assert_not_called()
assert zfs["alert"].call_count == 1
assert "can only be set when the dataset is created" in zfs["alert"].call_args.args[0]
def test_undeclared_local_property_warns(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(
mocker,
existing=["media", "media/temp"],
properties={"media/temp": {"exec": ("off", "local")}},
)
config = write_config(fs, {"media/temp": {}})
main(config)
zfs["alert"].assert_called_once_with("media/temp has exec=off set outside of nix")
def test_undeclared_inherited_property_is_silent(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(
mocker,
existing=["media", "media/temp"],
properties={
"media/temp": {
"compression": ("zstd", "inherited from media"),
"exec": ("on", "default"),
"nixos:shutdown-time": ("whenever", "local"),
},
},
)
config = write_config(fs, {"media/temp": {}})
main(config)
zfs["alert"].assert_not_called()
def test_dry_run_makes_no_changes(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(
mocker,
existing=["media", "media/temp"],
properties={"media/temp": {"compression": ("zstd", "local")}},
)
config = write_config(fs, {"media/temp": {"compression": "zstd-9"}, "media/new": {}})
main(config, dry_run=True)
zfs["set"].assert_not_called()
zfs["create"].assert_not_called()
def test_pool_root_is_never_created(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(mocker, existing=[])
config = write_config(fs, {"media": {"atime": "off"}})
with pytest.raises(SystemExit) as exit_info:
main(config)
assert exit_info.value.code == 1
zfs["create"].assert_not_called()
assert "does not create pools" in zfs["alert"].call_args.args[0]
def test_children_skipped_when_parent_creation_fails(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(mocker, existing=["media"])
zfs["create"].return_value = "Failed to create media/secure: key not loaded"
config = write_config(fs, {"media/secure": {}, "media/secure/child": {}})
with pytest.raises(SystemExit) as exit_info:
main(config)
assert exit_info.value.code == 1
zfs["create"].assert_called_once_with("media/secure", {})
def test_unmanaged_dataset_properties_are_untouched(mocker: MockerFixture, fs: FakeFilesystem) -> None:
"""A snapshots-only dataset is neither reconciled nor reported as unknown."""
zfs = patch_zfs(
mocker,
existing=["root_pool", "root_pool/var"],
properties={"root_pool/var": {"compression": ("lz4", "local")}},
)
config = write_config(fs, {}, unmanaged=["root_pool", "root_pool/var"])
main(config)
zfs["get"].assert_not_called()
zfs["set"].assert_not_called()
zfs["alert"].assert_not_called()
def test_undeclared_dataset_is_left_alone(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(mocker, existing=["media", "media/undeclared"])
config = write_config(fs, {})
main(config)
zfs["create"].assert_not_called()
zfs["set"].assert_not_called()
zfs["alert"].assert_not_called()
def test_main_exception(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(mocker, existing=[])
zfs["list"].side_effect = Exception("test")
config = write_config(fs, {})
with pytest.raises(SystemExit) as pytest_wrapped_e:
main(config)
assert pytest_wrapped_e.value.code == 1
zfs["alert"].assert_called_once_with("zfs_manager failed")
@pytest.mark.parametrize(
("value", "expected"),
[
("16384", 16384),
("16k", 16384),
("16K", 16384),
("1M", 1048576),
("none", None),
("", None),
],
)
def test_parse_size(value, expected) -> None:
assert parse_size(value) == expected
def test_values_match_falls_back_to_string_for_unparsable_sizes() -> None:
assert not values_match("recordsize", "none", "16384")
assert values_match("recordsize", "none", "none")
assert not values_match("compression", "zstd", "zstd-9")
# -- failure handling: every one of these must exit non-zero -------------------
def test_dataset_listing_failure_exits_nonzero(mocker: MockerFixture, fs: FakeFilesystem) -> None:
"""A failed zfs list must abort, never be read as an empty system.
bash_wrapper hands back stderr as though it were output, so without the
return code check the reconciler would treat the error text as the dataset
list and conclude every declared dataset was missing.
"""
zfs = patch_zfs(mocker, existing=[])
zfs["list"].side_effect = RuntimeError("Failed to list ZFS datasets: pool is busy")
config = write_config(fs, {"media/temp": {}})
with pytest.raises(SystemExit) as exit_info:
main(config)
assert exit_info.value.code == 1
zfs["create"].assert_not_called()
zfs["alert"].assert_called_once_with("zfs_manager failed")
def test_create_failure_exits_nonzero(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(mocker, existing=["media"])
zfs["create"].return_value = "Failed to create media/temp: out of space"
config = write_config(fs, {"media/temp": {}})
with pytest.raises(SystemExit) as exit_info:
main(config)
assert exit_info.value.code == 1
assert "out of space" in zfs["alert"].call_args.args[0]
def test_set_failure_exits_nonzero(mocker: MockerFixture, fs: FakeFilesystem) -> None:
zfs = patch_zfs(
mocker,
existing=["media", "media/temp"],
properties={"media/temp": {"compression": ("zstd", "local")}},
)
zfs["set"].return_value = "Failed to set compression=zstd-9 on media/temp: permission denied"
config = write_config(fs, {"media/temp": {"compression": "zstd-9"}})
with pytest.raises(SystemExit) as exit_info:
main(config)
assert exit_info.value.code == 1
assert "permission denied" in zfs["alert"].call_args.args[0]
def test_every_dataset_is_checked_before_failing(mocker: MockerFixture, fs: FakeFilesystem) -> None:
"""One broken dataset must not hide the state of the others."""
zfs = patch_zfs(
mocker,
existing=["media", "media/one", "media/two", "media/three"],
properties={
"media/one": {"compression": ("zstd", "local")},
"media/two": {"compression": ("zstd", "local")},
"media/three": {"compression": ("zstd", "local")},
},
)
zfs["set"].return_value = "Failed to set compression: permission denied"
config = write_config(
fs,
{name: {"compression": "zstd-9"} for name in ("media/one", "media/two", "media/three")},
)
with pytest.raises(SystemExit) as exit_info:
main(config)
assert exit_info.value.code == 1
# All three were attempted and all three were reported, not just the first.
assert zfs["set"].call_count == 3
assert zfs["alert"].call_count == 3
def test_dataset_marked_as_externally_created_is_never_created(mocker: MockerFixture, fs: FakeFilesystem) -> None:
"""An encryption root must be reported as missing, not silently recreated.
Recreating it from this declaration would produce an unencrypted dataset,
since encryption is fixed at creation and is not declared here.
"""
zfs = patch_zfs(mocker, existing=["media"])
config = write_config(fs, {"media/secure": {}}, never_create=["media/secure"])
with pytest.raises(SystemExit) as exit_info:
main(config)
assert exit_info.value.code == 1
zfs["create"].assert_not_called()
assert "created outside of nix" in zfs["alert"].call_args.args[0]
def test_externally_created_dataset_is_still_property_checked(mocker: MockerFixture, fs: FakeFilesystem) -> None:
"""When it does exist, it is reconciled like anything else."""
zfs = patch_zfs(
mocker,
existing=["media", "media/secure"],
properties={"media/secure": {"keylocation": ("prompt", "local")}},
)
config = write_config(
fs,
{"media/secure": {"keylocation": "file:///root/zfs.key"}},
never_create=["media/secure"],
)
main(config)
zfs["set"].assert_called_once_with("media/secure", "keylocation", "file:///root/zfs.key")
def test_success_exits_cleanly(mocker: MockerFixture, fs: FakeFilesystem) -> None:
"""The happy path must not raise SystemExit at all."""
zfs = patch_zfs(
mocker,
existing=["media", "media/temp"],
properties={"media/temp": {"sync": ("disabled", "local")}},
)
config = write_config(fs, {"media/temp": {"sync": "disabled"}})
main(config)
zfs["alert"].assert_not_called()
-135
View File
@@ -1,135 +0,0 @@
{ self }:
{
name = "zfs-integration";
nodes.machine =
{ pkgs, ... }:
let
testPython = pkgs.python314.withPackages (pythonPackages: [
pythonPackages.apprise
pythonPackages.typer
]);
in
{
imports = [
../common/global/snapshot_manager.nix
../common/optional/zfs_manager.nix
];
boot.supportedFilesystems = [ "zfs" ];
networking.hostId = "deadbeef";
virtualisation = {
emptyDiskImages = [ 2048 ];
memorySize = 2048;
};
environment.systemPackages = [
testPython
pkgs.zfs
];
services = {
snapshot_manager = {
enable = true;
package = testPython;
PYTHONPATH = "${self}/";
};
zfs_manager = {
enable = true;
package = testPython;
PYTHONPATH = "${self}/";
defaultSnapshots = {
"15_min" = 2;
hourly = 2;
daily = 2;
monthly = 2;
};
datasets = {
testpool = {
properties = {
atime = "off";
compression = "lz4";
mountpoint = "/testpool";
};
};
"testpool/parent" = {
properties = {
compression = "zstd";
mountpoint = "/testpool/parent";
};
};
"testpool/parent/child" = {
properties = {
recordsize = "16K";
sync = "disabled";
};
snapshots = {
"15_min" = 1;
hourly = 1;
daily = 1;
monthly = 1;
};
};
"testpool/secure" = {
createIfMissing = false;
properties = {
encryption = "aes-256-gcm";
keyformat = "hex";
keylocation = "file:///root/zfs.key";
};
snapshots = {
"15_min" = 0;
hourly = 0;
daily = 0;
monthly = 0;
};
};
"testpool/secure/child" = {
properties.compression = "zstd-9";
};
};
};
};
systemd.services = {
prepare-zfs-integration = {
description = "Prepare the ZFS integration-test pool";
requiredBy = [ "zfs_manager.service" ];
before = [ "zfs_manager.service" ];
path = [ pkgs.zfs ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
printf '%064d\n' 0 > /root/zfs.key
chmod 0400 /root/zfs.key
zpool create -f -m /testpool testpool /dev/vdb
zfs create \
-o encryption=aes-256-gcm \
-o keyformat=hex \
-o keylocation=file:///root/zfs.key \
testpool/secure
'';
};
zfs_manager = {
requires = [ "prepare-zfs-integration.service" ];
after = [ "prepare-zfs-integration.service" ];
};
};
};
testScript =
{ nodes, ... }:
builtins.replaceStrings
[ "@snapshot_config@" ]
[ (toString nodes.machine.services.snapshot_manager.path) ]
(builtins.readFile ./zfs_integration.py);
}
-56
View File
@@ -1,56 +0,0 @@
# The NixOS test driver provides these globals at runtime.
# ruff: noqa: F821
import tomllib
from pathlib import Path
snapshot_config_path = Path("@snapshot_config@")
machine.start()
machine.wait_for_unit("multi-user.target")
machine.wait_for_unit("zfs_manager.service")
with subtest("zfs_manager creates parents before children"):
machine.succeed("zfs list testpool/parent")
machine.succeed("zfs list testpool/parent/child")
machine.succeed("zfs list testpool/secure/child")
with subtest("declared properties are reconciled"):
machine.succeed('test "$(zfs get -H -o value atime testpool)" = off')
machine.succeed('test "$(zfs get -H -o value compression testpool)" = lz4')
machine.succeed('test "$(zfs get -H -o value recordsize testpool/parent/child)" = 16K')
machine.succeed('test "$(zfs get -H -o value sync testpool/parent/child)" = disabled')
machine.succeed("zfs set sync=standard testpool/parent/child")
machine.succeed("systemctl restart zfs_manager.service")
machine.succeed('test "$(zfs get -H -o value sync testpool/parent/child)" = disabled')
with subtest("externally created encryption roots are verified"):
machine.succeed('test "$(zfs get -H -o value encryption testpool/secure)" = aes-256-gcm')
machine.succeed('test "$(zfs get -H -o value keyformat testpool/secure)" = hex')
machine.succeed('test "$(zfs get -H -o value encryption testpool/secure/child)" = aes-256-gcm')
with subtest("declared datasets inherit default snapshot retention"):
with snapshot_config_path.open("rb") as config_file:
snapshot_config = tomllib.load(config_file)
expected_default = {"15_min": 2, "hourly": 2, "daily": 2, "monthly": 2}
assert snapshot_config["default"] == expected_default
assert snapshot_config["testpool/parent"] == expected_default
assert snapshot_config["testpool/secure/child"] == expected_default
assert snapshot_config["testpool/secure"] == {
"15_min": 0,
"hourly": 0,
"daily": 0,
"monthly": 0,
}
with subtest("snapshot deletion failures fail the systemd service"):
machine.succeed("zfs snapshot testpool/parent/child@auto_200001010015")
machine.succeed("zfs clone testpool/parent/child@auto_200001010015 testpool/dependent-clone")
machine.succeed("zfs snapshot testpool/parent/child@auto_200001010030")
machine.fail("systemctl start snapshot_manager.service")
machine.succeed("systemctl is-failed --quiet snapshot_manager.service")
machine.succeed("journalctl -u snapshot_manager.service --no-pager | grep -q 'snapshot has dependent clones'")
machine.fail("zfs list -H -t snapshot -o name | grep -q '^testpool/secure@auto_'")
+1 -16
View File
@@ -1,22 +1,7 @@
{ config, ... }:
{
imports = [
./cli
./minimal.nix
./programs.nix
./ssh_config.nix
];
programs = {
home-manager.enable = true;
git.enable = true;
};
home = {
username = "richie";
homeDirectory = "/home/${config.home.username}";
stateVersion = "24.05";
sessionVariables = {
FLAKE = "$HOME/dotfiles";
};
};
}
+1 -1
View File
@@ -35,7 +35,7 @@
"sqltools.connections": [
{
"previewLimit": 50,
"server": "192.168.90.40",
"server": "jeeves.taile39c31.ts.net",
"port": 5432,
"askForPassword": true,
"driver": "PostgreSQL",
+27
View File
@@ -0,0 +1,27 @@
{ config, pkgs, ... }:
{
imports = [
./cli
];
programs = {
home-manager.enable = true;
};
home = {
username = "richie";
homeDirectory = "/home/${config.home.username}";
stateVersion = "24.05";
sessionVariables.FLAKE = "$HOME/dotfiles";
packages = with pkgs; [
# cli
btop
eza
ripgrep
starship
tmux
# networking
wget
];
};
}
-6
View File
@@ -3,8 +3,6 @@
home.packages = with pkgs; [
# cli
bat
btop
eza
fd
ffmpegthumbnailer
fzf
@@ -18,9 +16,6 @@
p7zip
poppler
rar
ripgrep
starship
tmux
unzip
yazi
zoxide
@@ -37,7 +32,6 @@
# networking
iperf3
nmap
wget
# python
ruff
uv
+16 -4
View File
@@ -4,7 +4,7 @@
enableDefaultConfig = false;
settings = {
jeeves = {
HostName = "192.168.90.40";
HostName = "jeeves";
User = "richie";
IdentityFile = "~/.ssh/id_ed25519";
Port = 629;
@@ -18,7 +18,7 @@
Port = 2222;
};
brain = {
HostName = "192.168.90.35";
HostName = "brain";
User = "richie";
IdentityFile = "~/.ssh/id_ed25519";
Port = 129;
@@ -31,18 +31,30 @@
Port = 2222;
};
bob = {
HostName = "192.168.90.25";
HostName = "bob";
User = "richie";
IdentityFile = "~/.ssh/id_ed25519";
Port = 262;
DynamicForward = [ { port = 9050; } ];
};
rhapsody-in-green = {
HostName = "192.168.90.221";
HostName = "rhapsody-in-green";
User = "richie";
IdentityFile = "~/.ssh/id_ed25519";
Port = 922;
};
portal-1 = {
HostName = "portal-1";
User = "richie";
IdentityFile = "~/.ssh/id_ed25519";
Port = 278;
};
"gitea.tmmworkshop.com" = {
HostName = "gitea.tmmworkshop.com";
User = "gitea";
IdentityFile = "~/.ssh/id_ed25519";
Port = 2223;
};
};
};
}
+5
View File
@@ -0,0 +1,5 @@
{
imports = [
../home/minimal.nix
];
}
+39 -37
View File
@@ -1,62 +1,64 @@
richie_password: ENC[AES256_GCM,data:DMi3M8aqrQ60APIofr8wJMh+VZ14hLRxz6jWZgzswr0pV/QVSX53ShBFr90ruO3mucOLYv0l+bI31covfqMAhXWBJp9wUgtC2Q==,iv:qgtn30hZfIL4dBnQSLkjbo7zPJA4m9TR0f52sTFc0v4=,tag:ydLbcGyXjv0fE+4b5ECX5w==,type:str]
math_password: ENC[AES256_GCM,data:ykiSr3iBHrShJarEQSJ/zuXbCPcbW2oUpaAjblu1V15ufFKVSMZM94LlpMiCYtN9cYBLs98hcMeajJbvgbwT5emPHthy9+TJDw==,iv:1TJEUo0ishqFAZiUE1473yR3RT6Gbtqt4zM+C1a1KEk=,tag:pR6jyIj+bu3XaSx5yIHSmA==,type:str]
tailscale_auth_key: ENC[AES256_GCM,data:SZEfpVnqA84AQLA/geGsPNZfsWBktlM3Zrm7OpuSS/hCWWEKtSIbRYyovH68h8tQJBQFpv18n4oXJe9eAr6Mog==,iv:x1m6XPS/h818qNRcO+uqYjgTfd/FkxHHaSHHJrLyrp4=,tag:eOpcJIhzUWFzXTuRdS9S6w==,type:str]
tailscale_oauth_client_id: ENC[AES256_GCM,data:+cWxKlLIcJtsLhqmZUNLVDs=,iv:qrTjhLfu82pzbZnU6BrK1n+Ncbm2Cl3MMCSTKCJ6H8U=,tag:s/N5XpLwNH1gZ+LzbqRr2g==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBObHhkaFlnaG4zaTZtbkIw
TkxSQnMxbDNwUVo4R1VYRDNKVFRDUE9kb0ZjCnpWWElJUVNuNFBsMzZod1ZQY0Fa
VXgxNjZ0TUJ3cEt2OTFWL2dGY0txVHMKLS0tIHNQU3Rsanh4dkc3ZElsai9YMFdH
cXUzVmFxTUVIOWZVR2Fpa2crdWsrdlkKwdGLfbKWc25qfBKyd/cawiUWv9iepKHN
EOp/LdH2GbCfnQSVbxi28ukLHxWqOLdqMm8xSni/Of2PXvMnpdyCyQ==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBEMjQvSzZSY08vRlI0dzBP
YytpVlpPSGFHaTI5b2xzUkFFdEY0bVhQdGgwClM3UmM1OElpcEZTcTB0NHpSZ2d6
aVBpcjVIaDk3Tko1cm4xYjdkYVQ1L1EKLS0tIE1aQW95U0JIVGc4MGloSXRXc3ZS
a3EvU1dJdmxOeVBIS28yT3dHd2ZEYWcKv5TIqRRQs3AXER1WHEx8wotyXr2Ktxsu
dwldwEHyDNqwf7UgGOZu7JueeZ7y2KC8KZ7ixIdrhVaoxuLEvCg7VA==
-----END AGE ENCRYPTED FILE-----
recipient: age1u8zj599elqqvcmhxn8zuwrufsz8w8w366d3ayrljjejljt2q45kq8mxw9c
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6R0djMTArVmFySE5DMnFr
WFdBUERFbE1HRVVFd0oyaXJ2eU5HUStBUFN3CnR3ckZ2bkpGZFFScHQwTlBZYTMv
cTBXbGVoTXlWczhId05QVjZDRzFqRjQKLS0tIDNIcVBydHYwTGRnVU1mWWk4WTlR
eVlwQWgxSG5SdmFrWTlOcFo5eXZONWMKgx4huoSnbkRq0wQbsYgsWUKDTxDGNvYR
anVMQg+c7PwDlk1V4JQZ4WrYLx63Ep5qDjGlN/Ssf2Vo6rAuuKetcA==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByaE5jb3p3VzZ1dE05Wmlx
c1ZoWGhTcTRHRE9oMWtkaCtBY1l1a2QyWWlvCk9MM2FmcEZubXR1REpOcHZ6aHVE
UEkzbllZdkxPYzlTMHNod3VWNnhoaTgKLS0tIGVCdEpqUzhFM0swRjVjb0Z6ckFN
UzlYb1U4UGJqWE9oczdlNVh1S1lqL3cKSKxaJFhun2ee45I1jC55YKNdfYB8MsS+
dgFOFCvn/4uEcUyKZbUf0STjbIZ5eMHl8NVRTupI4tftGiE559M5Wg==
-----END AGE ENCRYPTED FILE-----
recipient: age1q47vup0tjhulkg7d6xwmdsgrw64h4ax3la3evzqpxyy4adsmk9fs56qz3y
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2QjZYejFYbCswQjdmaDA0
L2ZqUUhtYU12YlpISmxueHQzRG5YL0tQNXh3CndGamMwRzYvUzkvaE9DVnMwTkNC
cFMxczZuOUorS3dnVUpjaVdCUjhLZ2MKLS0tIGlDeGRGa3dRbWgzT3NVYjBaYUJ4
VW5yeFlvWUZ5MVpNZHA5M1VXR1hxU1kKqii08/MB2aabgP4RQs1ry8AxmFqB8Mn+
m7B0u64aziKXLSl0u471wqgD+YGRwNcajXT2pHCy8QWLznzvIMSrxA==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2U01jTVZQT1RRRU1nQmVv
Z0lnOGJLN1c0UkNLbnJjV29na3Rub0xzbXhNCjY0aFNuUnFDUnZheDRHT3R5L3Fv
ZkJ3QkF2RENKRVdUYjRzRXMrNklvN2cKLS0tIFJzK2o1MmVTWHZMWmVDWlgzeGY5
VDg4OGl0NGFYY2VNZGY5UkR1cVdWY0UK1qa3swdfgUw1tnnThRf1sl9f2zTP/d/d
iwcenOEryXatVMJjZVWrLC5C7MwtKz+pIBXa98yaSpCDLqjAMwOCPg==
-----END AGE ENCRYPTED FILE-----
recipient: age1jhf7vm0005j60mjq63696frrmjhpy8kpc2d66mw044lqap5mjv4snmwvwm
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrSUc3VnllVDFZTm1jRnlP
ZDBqelhkeHliZ1VlcjVnblQyeFlWclZTWkZjCjhJQk5EWkVoQjdoMHg4Zko4OU1C
NWFnVTIwV3RYbTZSeWttaVRPbjNrMG8KLS0tIDZWUHJSbVVlR0lKUDRtc3VqOTc1
cGJ3NDBLem9FNUpnbStYRTlqQStHV2sKwxPe4nTULsU0mVeUh8mhr2KX9U0iT5dL
zvHldoQG6mZHgtHK6XI5AQJYf+zUW66OKqNSxAnn+BM20QkAQVZNVw==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTREI2UEZwS0Y0SmEvK1F4
Y1hZZjRVWWxMT29mc3ZXcHFDclRUYi96LzA4CkIwZ2h4T2JQRW1OL2hmRTFHWkFY
RHZqQUFqU3hvNlRBZHRiSThPVUE3TG8KLS0tIHh0Z2tlNXREczJsa1g2ekZyZjBt
dW41aVA3VEVqa1NDMHM0T3FKZFNTN0kKGQPRUMSRtibFVFRZR/igsjgxgDo4ogga
dUHBRU65r41er+Wk3lKdtbXJX1g83K8ay7540hNXheOhxFi57trrzA==
-----END AGE ENCRYPTED FILE-----
recipient: age13lmqgc3jvkyah5e3vcwmj4s5wsc2akctcga0lpc0x8v8du3fxprqp4ldkv
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0NU9ac2FuRHI1dkQycmc0
YlhGK29UeTdiZEZXcWtPUW4rMis4Z2NWYWpJCkVldEdMc3ZTaDFidHpaZk5mM283
bTd0RlN5SHVabGhjeXFnSThydnVoem8KLS0tIElWclYvTmtkb0wyNDlKT3ZEckwx
L2NObzZadlJ5d3MyeGRqKy95L3BOMFEKtoswi6r2TmCZzngUkiGQV5TTsuzisMFS
5QI0aQZwhexqUMvbPuajYKvcPj+D6a2xaxbL3TBRLjOrFmcp5J7/YA==
-----END AGE ENCRYPTED FILE-----
recipient: age1l272y8udvg60z7edgje42fu49uwt4x2gxn5zvywssnv9h2krms8s094m4k
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByUlVjM2xpc281bHZzOGVo
L3VEclhJZDd5SS9mazFiTk9DcmxMaWxPT213ClNPWERKQU03OWk0OEVIY05ib2VG
WXhiZDhuMDZ5ZmFPWHB5RTFKYmpkVzAKLS0tIGppUndCb25wb2dyV01YbENrWjdU
TmMvWVpobnl0eXBIOGQwMW5BSlhJTUkKzua1artJWbZlKfzv27xfZJeBpntBYwUf
c8i1gNlvRwkhFAlrWcKR65vgyxsO3rbkLJRkcwG/q4hHj9zBeC/K2A==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrV1Z2MGNPK28wTStRSUZW
dmFMaUt5MENHeUpVcDVxQnZhd1o5RmJwOWwwCittaGNrcW1hV25VaGJaTEhZRFBJ
Y2toWEpFWFZYT0plb2lkTlJCS2Mwb3MKLS0tIHlkVjVESWFsbWlVQ0xVQ0llclF2
L1FMbC91YjJsR3BGRjJ0QVQ1MDdBbEUKNnOI9+H1LJcvVcwl9uQMmFp35y5RB+Zl
Mo3z3unweQtS+jiUn7RyVs5daG1E2tfihu2CQDWaktuCuunMpt3Wew==
-----END AGE ENCRYPTED FILE-----
recipient: age1ufnewppysaq2wwcl4ugngjz8pfzc5a35yg7luq0qmuqvctajcycs5lf6k4
lastmodified: "2026-06-29T20:19:44Z"
mac: ENC[AES256_GCM,data:GIvQxWt4tZGn0fyiXVtxGFQQoNcFUgilF+/PSz50exVrmzsS0XQUk/TIDFHaQR9jlJI50jqlyc1rBHgjnqC2oPHhPWaaVhgF18vQI55rGKdymNFjsHnaCkblFVdR1RJm0FSB2Ri6y5k8tfN3ywiwromJRz4NYzr1hbmr36azfg4=,iv:V3jspeYt/d2wy13gUrQmPGARm0hxwvSL/mocJAUofdw=,tag:vARoUzBWTJKkONDGoQdzNQ==,type:str]
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqR0hUT3hxTzRwTVhuTm9E
TUFtNFFCYWNUNUNVbHNaNXQ0aEVBa04vSEU0CkRPVzhuYUsrM0wxZjRDdkxvTVA2
UW9CSjlIQW5yenhaUi9mbEdLYlhWYk0KLS0tIGRXMm9Wa3l4UWxqcGl6ZlBZV3E0
Z3YrbUYrSmV0NHNTeTdhMkJEdytwSmMKGLpe5+p4FGJNHy6SVgnsDbEsiP0J8lW/
0+3X3LQoL0f1S2L5DjexF/H0NGvWjAPaGzDNB6V50VxGDabEIVqjvQ==
-----END AGE ENCRYPTED FILE-----
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
version: 3.13.1
version: 3.13.3