Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d986713a3e | ||
|
|
2882960a8e | ||
|
|
bdf67e96f0 | ||
|
|
d95b8b99ce | ||
|
|
7e6908ef3d | ||
|
|
dd51e89abf | ||
|
|
677364fe74 | ||
|
|
f5a0038ab7 | ||
|
|
b406b92622 | ||
|
|
e0c14973b0 | ||
|
|
515cbae3f8 | ||
|
|
219aea9b5f | ||
|
|
df8908b708 | ||
|
|
cb83fc6ea5 | ||
|
|
7f08a6f788 | ||
|
|
8245d0f82e | ||
|
|
1c4e0c0828 | ||
|
|
5bc9ee5de9 | ||
|
|
6fa93e1b96 | ||
|
|
b8b0605279 | ||
|
|
03537310cb | ||
|
|
a23c91c4ac | ||
|
|
f24a14920e | ||
|
|
48806c84fe | ||
|
|
03c3b36f9c |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
monitoringInterface = "ztwfunumly";
|
||||
monitoringInterface = "tailscale0";
|
||||
nodeTextfileDir = "/var/lib/prometheus-node-exporter-textfile";
|
||||
|
||||
mkProcessNameTemplate =
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
nixpkgs.hostPlatform = {
|
||||
system = "x86_64-linux";
|
||||
gcc = {
|
||||
arch = "x86-64-v3";
|
||||
tune = "generic";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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=" ];
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
@@ -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";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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 = {
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -4,7 +4,6 @@ in
|
||||
{
|
||||
services.jellyfin = {
|
||||
enable = true;
|
||||
openFirewall = true;
|
||||
dataDir = "${vars.services}/jellyfin";
|
||||
cacheDir = "${vars.services}/jellyfin/cache";
|
||||
};
|
||||
|
||||
@@ -38,7 +38,6 @@ in
|
||||
"qwen3.5:35b"
|
||||
];
|
||||
modelsDir = vars.ollama;
|
||||
openFirewall = true;
|
||||
};
|
||||
systemd.services = {
|
||||
ollama.serviceConfig = {
|
||||
|
||||
@@ -7,6 +7,5 @@ in
|
||||
package = pkgs.nix-serve-ng;
|
||||
enable = true;
|
||||
secretKeyFile = "${vars.secrets}/services/nix-cache/cache-priv-key.pem";
|
||||
openFirewall = true;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,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 = ''
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"sqltools.connections": [
|
||||
{
|
||||
"previewLimit": 50,
|
||||
"server": "192.168.90.40",
|
||||
"server": "jeeves.taile39c31.ts.net",
|
||||
"port": 5432,
|
||||
"askForPassword": true,
|
||||
"driver": "PostgreSQL",
|
||||
|
||||
@@ -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
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
imports = [
|
||||
../home/minimal.nix
|
||||
];
|
||||
}
|
||||
+39
-37
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user