deploy-guard: block activation while users are online
Some checks failed
Build and Deploy / mreow (push) Successful in 51s
Build and Deploy / yarn (push) Successful in 47s
Build and Deploy / muffin (push) Failing after 1m9s

- modules/server-deploy-guard.nix: extendable aggregator registered via
  services.deployGuard.checks.<name>.{description,command}. Installs
  deploy-guard-check with per-check timeout, pass/block reporting, JSON
  output, DEPLOY_GUARD_BYPASS / /run/deploy-guard-bypass (single-shot).
- services/jellyfin/jellyfin-deploy-guard.nix: curl+jq on /Sessions,
  blocks when any session carries NowPlayingItem; soft-fails when unreachable.
- services/minecraft-deploy-guard.nix: mcstatus SLP query on 25565, blocks
  when players.online > 0; soft-fails when unreachable.
- flake.nix: wrap deploy.nodes.muffin activation with activate.custom so
  deploy-guard-check runs before switch-to-configuration. Auto-rollback
  catches the failure. dryActivate/boot branches preserved.
- deploy.sh: SSH preflight for ./deploy.sh muffin with --force /
  DEPLOY_GUARD_FORCE=1 (touches remote bypass marker). Connectivity
  failure is soft; activation still enforces.
- tests/deploy-guard.nix: aggregator contract, bypass mechanics, timeout,
  JSON output.
This commit is contained in:
2026-04-22 00:36:21 -04:00
parent ddac5e3f04
commit aef99e7365
11 changed files with 603 additions and 7 deletions

View File

@@ -2,5 +2,6 @@
imports = [
./jellyfin.nix
./jellyfin-qbittorrent-monitor.nix
./jellyfin-deploy-guard.nix
];
}

View File

@@ -0,0 +1,78 @@
# Deploy guard check for Jellyfin.
#
# Contract (deploy-guard-check plug-in):
# - exit 0: Jellyfin has no active playback sessions (or is unreachable, which
# also means no users can be watching).
# - exit 1: at least one session is actively playing back media; stdout lists
# user / title / client so the operator sees who they'd disrupt.
#
# A paused session counts as "active" — the user is at the keyboard and will
# notice a restart.
{
config,
lib,
pkgs,
service_configs,
...
}:
let
apiKeyPath = config.age.secrets.jellyfin-api-key.path;
jellyfinPort = service_configs.ports.private.jellyfin.port;
check = pkgs.writeShellApplication {
name = "deploy-guard-check-jellyfin";
runtimeInputs = with pkgs; [
curl
jq
coreutils
];
text = ''
api_key_path=${lib.escapeShellArg apiKeyPath}
if [[ ! -r "$api_key_path" ]]; then
echo "jellyfin: api key not readable at $api_key_path; skipping" >&2
exit 0
fi
key=$(cat "$api_key_path")
if ! resp=$(curl -sf --max-time 5 \
-H "Authorization: MediaBrowser Token=$key" \
"http://127.0.0.1:${toString jellyfinPort}/Sessions" 2>/dev/null); then
echo "jellyfin: unreachable; assuming safe to deploy" >&2
exit 0
fi
# Parse defensively if Jellyfin returns something we can't understand
# we prefer allowing the deploy over blocking it (the worst case is we
# restart jellyfin while nobody is watching).
if ! active=$(printf '%s' "$resp" | jq '[.[] | select(.NowPlayingItem)] | length' 2>/dev/null); then
echo "jellyfin: /Sessions response not parsable; assuming safe" >&2
exit 0
fi
if [[ "$active" -eq 0 ]]; then
exit 0
fi
echo "Jellyfin: $active active playback session(s):"
printf '%s' "$resp" | jq -r '
.[]
| select(.NowPlayingItem)
| " - \(.UserName // "?") \(if (.PlayState.IsPaused // false) then "paused" else "playing" end) \(.NowPlayingItem.Type // "item") \"\(.NowPlayingItem.Name // "?")\" on \(.Client // "?") / \(.DeviceName // "?")"
'
exit 1
'';
};
in
{
imports = [
../../modules/server-deploy-guard.nix
];
config = lib.mkIf config.services.jellyfin.enable {
services.deployGuard.checks.jellyfin = {
description = "Active Jellyfin playback sessions";
command = check;
};
};
}

View File

@@ -0,0 +1,67 @@
# Deploy guard check for the Minecraft server.
#
# Queries the standard Server List Ping (SLP) handshake on the game port —
# no RCON, no query, no extra config. SLP is always enabled and returns the
# live player count plus (usually) a short name sample.
#
# Contract (deploy-guard-check plug-in):
# - exit 0: no players online, or the server isn't reachable at all (down ⇒
# no users to disrupt).
# - exit 1: at least one player is connected; stdout lists the names that
# made it into the SLP sample.
{
config,
lib,
pkgs,
service_configs,
...
}:
let
minecraftPort = service_configs.ports.public.minecraft.port;
check =
pkgs.writers.writePython3Bin "deploy-guard-check-minecraft"
{
libraries = [ pkgs.python3Packages.mcstatus ];
flakeIgnore = [
"E501"
"E402"
];
}
''
import sys
try:
from mcstatus import JavaServer
except ImportError as e:
print(f"minecraft: mcstatus unavailable ({e}); assuming safe", file=sys.stderr)
sys.exit(0)
try:
status = JavaServer.lookup("127.0.0.1:${toString minecraftPort}", timeout=5).status()
except Exception as e:
print(f"minecraft: unreachable ({e}); assuming safe to deploy", file=sys.stderr)
sys.exit(0)
online = status.players.online
if online <= 0:
sys.exit(0)
sample = getattr(status.players, "sample", None) or []
names = ", ".join(p.name for p in sample) or "<names not reported>"
print(f"Minecraft: {online} player(s) online: {names}")
sys.exit(1)
'';
in
{
imports = [
../modules/server-deploy-guard.nix
];
config = lib.mkIf config.services.minecraft-servers.enable {
services.deployGuard.checks.minecraft = {
description = "Players connected to the Minecraft server";
command = check;
};
};
}

View File

@@ -22,6 +22,7 @@
"z ${service_configs.minecraft.parent_dir}/${service_configs.minecraft.server_name} 710 ${config.services.minecraft-servers.user} ${config.services.minecraft-servers.group}"
"z ${service_configs.minecraft.parent_dir}/${service_configs.minecraft.server_name}/squaremap 710 ${config.services.minecraft-servers.user} ${config.services.minecraft-servers.group}"
])
./minecraft-deploy-guard.nix
];
boot.kernel.sysctl = {