feat(sentinèlla): p2p topology with DNS peer discovery
- Replace central sentinel with watcher: each node polls peers discovered via a single DNS name with multiple A records (e.g. peers.sentinella.com) - Auto-detect own IPs via hostname -I; SELF env var available as optional override for NAT/floating-IP setups - Fix Basic Auth bug in router.sh: compare tok against AUTH_TOKENS instead of unset $USER/$PASS - Rename sentinel binary to watcher; drop unused shellplot dep - Add inetutils to watcher runtime deps for hostname -I - Update NixOS module: replace sentinel options with watcher p2p options (peersDns, self, peersPort, peersScheme, pollingIntervalSec) - Add sentinèlla test suite: probe-status-empty, probe-disk, watcher-state-file
This commit is contained in:
44
test/package/sentinèlla/default.nix
Normal file
44
test/package/sentinèlla/default.nix
Normal file
@@ -0,0 +1,44 @@
|
||||
{ inputs, self, pkgs, system, ... }: let
|
||||
lib = inputs.nixpkgs.lib;
|
||||
|
||||
mkTestDrv = name: type:
|
||||
if type == "directory" then
|
||||
pkgs.runCommand "test-${name}" {} ''
|
||||
if ! [ -f ${./test + "/${name}" + /run.sh} ]; then
|
||||
echo "no run.sh in test/${name}"
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$out"
|
||||
cp -r ${./test + "/${name}"}/* "$out/"
|
||||
chmod +x "$out/run.sh"
|
||||
''
|
||||
else if lib.hasSuffix ".sh" name then
|
||||
pkgs.runCommand "test-${lib.removeSuffix ".sh" name}" {} ''
|
||||
mkdir -p "$out"
|
||||
install -Dm755 ${./test + "/${name}"} "$out/run.sh"
|
||||
''
|
||||
else
|
||||
null;
|
||||
|
||||
testDir = builtins.readDir ./test;
|
||||
testDrvs =
|
||||
lib.mapAttrs' (n: v:
|
||||
lib.nameValuePair (lib.removeSuffix ".sh" n) v
|
||||
) (lib.filterAttrs (_: v: v != null)
|
||||
(lib.mapAttrs (n: t: mkTestDrv n t) testDir));
|
||||
|
||||
sentinella = self.packages.${system}."sentinèlla";
|
||||
|
||||
mkTest = testName: testDrv: pkgs.runCommand "sentinella-test-${testName}"
|
||||
{
|
||||
nativeBuildInputs = [ pkgs.coreutils pkgs.gnugrep pkgs.gnused ];
|
||||
buildInputs = [ sentinella pkgs.curl pkgs.jq pkgs.socat ];
|
||||
} ''
|
||||
${builtins.readFile self.legacyPackages.${system}.helpers.posix-shell.log}
|
||||
export HECTIC_LOG=trace
|
||||
test=${testDrv}
|
||||
${builtins.readFile ./launch.sh}
|
||||
|
||||
mkdir -p "$out"
|
||||
'';
|
||||
in lib.mapAttrs (name: drv: mkTest name drv) testDrvs
|
||||
45
test/package/sentinèlla/launch.sh
Normal file
45
test/package/sentinèlla/launch.sh
Normal file
@@ -0,0 +1,45 @@
|
||||
#!/bin/dash
|
||||
# launch.sh — sets up helpers and runs the test pointed to by $test
|
||||
|
||||
# assert_eq(label, got, expected)
|
||||
assert_eq() {
|
||||
label=${1:?}
|
||||
got=${2:?}
|
||||
expected=${3:?}
|
||||
if [ "$got" != "$expected" ]; then
|
||||
log error "FAIL: $label"
|
||||
log error " expected: $WHITE$expected"
|
||||
log error " got: $WHITE$got"
|
||||
exit 1
|
||||
fi
|
||||
log info "PASS: $label"
|
||||
}
|
||||
|
||||
# assert_file_contains(label, file, pattern)
|
||||
assert_file_contains() {
|
||||
label=${1:?}
|
||||
file=${2:?}
|
||||
pattern=${3:?}
|
||||
if ! grep -q "$pattern" "$file" 2>/dev/null; then
|
||||
log error "FAIL: $label — pattern '$pattern' not found in $file"
|
||||
exit 1
|
||||
fi
|
||||
log info "PASS: $label"
|
||||
}
|
||||
|
||||
# wait_for_file(file, timeout_sec)
|
||||
wait_for_file() {
|
||||
file=${1:?}
|
||||
timeout=${2:-10}
|
||||
i=0
|
||||
while [ $i -lt "$timeout" ]; do
|
||||
[ -f "$file" ] && return 0
|
||||
sleep 1
|
||||
i=$((i+1))
|
||||
done
|
||||
log error "timeout waiting for file: $file"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# run the actual test
|
||||
. "$test/run.sh"
|
||||
35
test/package/sentinèlla/test/probe-disk.sh
Normal file
35
test/package/sentinèlla/test/probe-disk.sh
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/bin/dash
|
||||
# Test: probe GET /disk returns JSON with at least one volume entry
|
||||
|
||||
log notice "test case: ${WHITE}probe GET /disk returns volume data"
|
||||
|
||||
PORT=15989
|
||||
export PORT URLS="" VOLUMES="/"
|
||||
|
||||
probe &
|
||||
probe_pid=$!
|
||||
trap 'kill $probe_pid 2>/dev/null; exit' EXIT INT HUP
|
||||
|
||||
sleep 2
|
||||
|
||||
response=$(curl -sS --max-time 5 "http://127.0.0.1:${PORT}/disk")
|
||||
log info "response: $WHITE$response"
|
||||
|
||||
count=$(printf '%s' "$response" | jq -r '.volumes | length')
|
||||
log info "volume count: $WHITE$count"
|
||||
|
||||
if [ "$count" -lt 1 ]; then
|
||||
log error "expected at least 1 volume, got $count"
|
||||
exit 1
|
||||
fi
|
||||
log info "PASS: at least one volume returned"
|
||||
|
||||
# each entry must have a mount field
|
||||
mount=$(printf '%s' "$response" | jq -r '.volumes[0].mount')
|
||||
if [ -z "$mount" ] || [ "$mount" = "null" ]; then
|
||||
log error "volumes[0].mount is missing or null"
|
||||
exit 1
|
||||
fi
|
||||
log info "PASS: volumes[0].mount = $mount"
|
||||
|
||||
log notice "test passed"
|
||||
27
test/package/sentinèlla/test/probe-status-empty.sh
Normal file
27
test/package/sentinèlla/test/probe-status-empty.sh
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/bin/dash
|
||||
# Test: probe responds on GET /status with valid JSON when URLS is empty
|
||||
|
||||
log notice "test case: ${WHITE}probe GET /status returns JSON with empty checks"
|
||||
|
||||
# start probe on a free port
|
||||
PORT=15988
|
||||
export PORT URLS="" VOLUMES="/"
|
||||
|
||||
probe &
|
||||
probe_pid=$!
|
||||
trap 'kill $probe_pid 2>/dev/null; exit' EXIT INT HUP
|
||||
|
||||
# wait for probe to be ready
|
||||
sleep 2
|
||||
|
||||
response=$(curl -sS --max-time 5 "http://127.0.0.1:${PORT}/status")
|
||||
log info "response: $WHITE$response"
|
||||
|
||||
# must be valid JSON with summary.total == 0
|
||||
total=$(printf '%s' "$response" | jq -r '.summary.total')
|
||||
assert_eq "summary.total is 0 when URLS empty" "$total" "0"
|
||||
|
||||
ok=$(printf '%s' "$response" | jq -r '.summary.ok')
|
||||
assert_eq "summary.ok is 0 when URLS empty" "$ok" "0"
|
||||
|
||||
log notice "test passed"
|
||||
75
test/package/sentinèlla/test/watcher-state-file.sh
Normal file
75
test/package/sentinèlla/test/watcher-state-file.sh
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/bin/dash
|
||||
# Test: watcher writes a state file after polling a peer
|
||||
#
|
||||
# Setup:
|
||||
# - Start a probe on 127.0.0.1:15990
|
||||
# - Stub getent to resolve peers.test -> 127.0.0.1 (the probe) and 10.0.0.1 (fake peer)
|
||||
# - Stub hostname to return 10.0.0.1 as the local IP so 10.0.0.1 is excluded
|
||||
# and 127.0.0.1 (the real probe) is kept as a peer
|
||||
# - Assert a state file appears in STATE_DIR within 15s
|
||||
|
||||
log notice "test case: ${WHITE}watcher writes state file after first successful poll"
|
||||
|
||||
PORT=15990
|
||||
export PORT URLS="" VOLUMES="/"
|
||||
|
||||
probe &
|
||||
probe_pid=$!
|
||||
trap 'kill "$probe_pid" 2>/dev/null; kill "$watcher_pid" 2>/dev/null; rm -rf "$stub_dir" "$state_dir"' EXIT INT HUP
|
||||
|
||||
sleep 2
|
||||
|
||||
# Create stubs directory
|
||||
stub_dir=$(mktemp -d)
|
||||
|
||||
# Stub getent: returns two IPs for peers.test
|
||||
cat >"${stub_dir}/getent" <<'EOF'
|
||||
#!/bin/sh
|
||||
if [ "$1" = "hosts" ] && [ "$2" = "peers.test" ]; then
|
||||
printf '127.0.0.1 peers.test\n'
|
||||
printf '10.0.0.1 peers.test\n'
|
||||
else
|
||||
/usr/bin/getent "$@"
|
||||
fi
|
||||
EOF
|
||||
chmod +x "${stub_dir}/getent"
|
||||
|
||||
# Stub hostname: -I returns 10.0.0.1 so watcher excludes it and keeps 127.0.0.1
|
||||
cat >"${stub_dir}/hostname" <<'EOF'
|
||||
#!/bin/sh
|
||||
case "$1" in
|
||||
-I) printf '10.0.0.1\n' ;;
|
||||
*) /bin/hostname "$@" ;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "${stub_dir}/hostname"
|
||||
|
||||
state_dir=$(mktemp -d)
|
||||
|
||||
export PEERS_DNS="peers.test"
|
||||
export PEERS_PORT="$PORT"
|
||||
export PEERS_SCHEME="http"
|
||||
export TG_TOKEN="test-token"
|
||||
export TG_CHAT_ID="test-chat"
|
||||
export STATE_DIR="$state_dir"
|
||||
export POLLING_INTERVAL_SEC="1"
|
||||
export SPAM="0"
|
||||
unset SELF # ensure auto-detection is used
|
||||
|
||||
PATH="${stub_dir}:${PATH}" watcher &
|
||||
watcher_pid=$!
|
||||
|
||||
log info "waiting for state file in $state_dir ..."
|
||||
peer_url="http://127.0.0.1:${PORT}"
|
||||
state_file="${state_dir}/$(printf '%s' "$peer_url" | cksum | awk '{print $1}').state"
|
||||
wait_for_file "$state_file" 15
|
||||
|
||||
state=$(cat "$state_file")
|
||||
log info "state file content: $WHITE$state"
|
||||
|
||||
case "$state" in
|
||||
up:*|down:*) log info "PASS: state file has expected format" ;;
|
||||
*) log error "unexpected state file content: $state"; exit 1 ;;
|
||||
esac
|
||||
|
||||
log notice "test passed"
|
||||
Reference in New Issue
Block a user