feat(discord): systemd user service with a supervised run; brakes exit 3 and are never retried (#1509)
QUEUE row 17, MVP iteration 2. scripts/discord-service.sh renders and installs mosaic-discord@<binding> from packages/discord/systemd/. The unit's main process is `run --supervised`, which applies the new recover policy first: a lock whose owner is gone is cleared and only the STOP written for that is removed; an operator STOP or a held binding refuses with exit 3, which RestartPreventExitStatus never retries. `recover` is also a CLI verb. First cut used ExecStartPre and looped live, since systemd honours the never-retry status only from the main process; replaced and re-verified before any message traffic. Suite 40/40, 95 node tests. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
# `scripts/discord-service.sh render|install|uninstall|status [<binding>]`:
|
||||
# the systemd user unit that supervises `scripts/discord.sh run <binding>`.
|
||||
# The template is packages/discord/systemd/[email protected]; the
|
||||
# rendered unit is [email protected] in the systemd user directory, one
|
||||
# instance per binding (mosaic-discord@<binding>). Nothing here reads the
|
||||
# binding or the token. See packages/discord/README.md, "Service unit".
|
||||
#
|
||||
# render print the rendered unit on stdout
|
||||
# install [--dir DIR] [--no-reload]
|
||||
# write the unit (write to a temp file, then
|
||||
# rename) and run `systemctl --user daemon-reload`
|
||||
# uninstall [--dir DIR] [--no-reload]
|
||||
# remove the unit; refuses while an instance is active
|
||||
# status <binding> unit state, STOP and run.lock for one binding
|
||||
#
|
||||
# Exit codes: 0 ok, 1 operation failed, 4 usage.
|
||||
set -euo pipefail
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TEMPLATE="$REPO/packages/discord/systemd/[email protected]"
|
||||
UNIT_NAME="[email protected]"
|
||||
USAGE="usage: scripts/discord-service.sh render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>"
|
||||
|
||||
die() { echo "discord-service: $*" >&2; exit 1; }
|
||||
usage() { echo "$USAGE" >&2; exit 4; }
|
||||
|
||||
render() {
|
||||
[ -f "$TEMPLATE" ] || die "template missing: $TEMPLATE"
|
||||
local node_dir
|
||||
node_dir="$(dirname "$(command -v node || true)")"
|
||||
[ -n "$node_dir" ] && [ "$node_dir" != "." ] || die "node not found on PATH"
|
||||
local path="/usr/local/bin:/usr/bin:/bin"
|
||||
case ":$path:" in *":$node_dir:"*) ;; *) path="$node_dir:$path" ;; esac
|
||||
case "$REPO" in *@*|*'|'*) die "repository path contains a character the template cannot carry: $REPO" ;; esac
|
||||
sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" "$TEMPLATE"
|
||||
}
|
||||
|
||||
# Parse --dir and --no-reload for install and uninstall.
|
||||
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
RELOAD=1
|
||||
parse_flags() {
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dir) [ $# -ge 2 ] || usage; UNIT_DIR="$2"; shift 2 ;;
|
||||
--no-reload) RELOAD=0; shift ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
install_unit() {
|
||||
parse_flags "$@"
|
||||
mkdir -p "$UNIT_DIR"
|
||||
local target="$UNIT_DIR/$UNIT_NAME" tmp
|
||||
tmp="$(mktemp "$UNIT_DIR/.$UNIT_NAME.XXXXXX")"
|
||||
render > "$tmp"
|
||||
chmod 0644 "$tmp"
|
||||
if [ -f "$target" ] && cmp -s "$tmp" "$target"; then
|
||||
rm -f "$tmp"
|
||||
echo "unchanged: $target"
|
||||
else
|
||||
mv -f "$tmp" "$target"
|
||||
echo "written: $target"
|
||||
fi
|
||||
if [ "$RELOAD" = 1 ]; then
|
||||
systemctl --user daemon-reload || die "daemon-reload failed"
|
||||
echo "daemon-reload done"
|
||||
fi
|
||||
cat <<MSG
|
||||
next, for one binding:
|
||||
systemctl --user enable --now mosaic-discord@<binding> start now and at login
|
||||
systemctl --user status mosaic-discord@<binding>
|
||||
journalctl --user -u mosaic-discord@<binding> -f the connector's log
|
||||
systemctl --user stop mosaic-discord@<binding> SIGTERM; restartable
|
||||
scripts/discord.sh stop <binding> the brake: writes STOP, the unit stays down until STOP is removed
|
||||
survive logout and reboot only with lingering on: loginctl enable-linger $USER
|
||||
MSG
|
||||
}
|
||||
|
||||
uninstall_unit() {
|
||||
parse_flags "$@"
|
||||
local target="$UNIT_DIR/$UNIT_NAME"
|
||||
if [ "$RELOAD" = 1 ]; then
|
||||
local active
|
||||
active="$(systemctl --user list-units --no-legend --plain 'mosaic-discord@*' 2>/dev/null | awk '$3 == "active" || $3 == "activating" || $3 == "deactivating" {print $1}')"
|
||||
[ -z "$active" ] || die "refusing: instance(s) still running: $(echo "$active" | tr '\n' ' ')stop them first"
|
||||
fi
|
||||
if [ -f "$target" ]; then
|
||||
rm -f "$target"
|
||||
echo "removed: $target"
|
||||
else
|
||||
echo "absent: $target"
|
||||
fi
|
||||
if [ "$RELOAD" = 1 ]; then
|
||||
systemctl --user daemon-reload || die "daemon-reload failed"
|
||||
echo "daemon-reload done"
|
||||
fi
|
||||
}
|
||||
|
||||
status_unit() {
|
||||
[ $# -eq 1 ] || usage
|
||||
local binding="$1"
|
||||
case "$binding" in ''|*[!A-Za-z0-9_-]*) die "binding name must be [A-Za-z0-9_-]+" ;; esac
|
||||
local unit="mosaic-discord@$binding.service"
|
||||
echo "unit: $unit"
|
||||
echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)"
|
||||
echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)"
|
||||
systemctl --user show "$unit" -p MainPID -p NRestarts -p ExecMainStartTimestamp -p Result 2>/dev/null | sed 's/^/ /'
|
||||
local config="${XDG_CONFIG_HOME:-$HOME/.config}/mosaic-dev/config.json"
|
||||
local data_root
|
||||
data_root="$(node -e 'const c=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(String(c.dataRoot||""))' "$config" 2>/dev/null || true)"
|
||||
if [ -z "$data_root" ]; then
|
||||
echo " journal: dataRoot not readable from $config"
|
||||
return 0
|
||||
fi
|
||||
data_root="${data_root/#\~/$HOME}"
|
||||
local dir="$data_root/discord/$binding"
|
||||
if [ -f "$dir/STOP" ]; then
|
||||
echo " STOP: present ($(tail -n 1 "$dir/STOP"))"
|
||||
else
|
||||
echo " STOP: absent"
|
||||
fi
|
||||
if [ -d "$dir/run.lock" ]; then
|
||||
echo " run.lock: present$( [ -f "$dir/run.lock/owner.json" ] && printf ' (owner pid %s)' "$(node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).pid))' "$dir/run.lock/owner.json" 2>/dev/null || echo '?')" )"
|
||||
else
|
||||
echo " run.lock: absent"
|
||||
fi
|
||||
}
|
||||
|
||||
[ $# -ge 1 ] || usage
|
||||
cmd="$1"; shift
|
||||
case "$cmd" in
|
||||
render) [ $# -eq 0 ] || usage; render ;;
|
||||
install) install_unit "$@" ;;
|
||||
uninstall) uninstall_unit "$@" ;;
|
||||
status) status_unit "$@" ;;
|
||||
-h|--help) echo "$USAGE" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
+39
-1
@@ -25,7 +25,7 @@ echo "toolchain: node $(node --version)"
|
||||
echo
|
||||
|
||||
# --- syntax ---
|
||||
for f in packages/discord/src/*.mjs packages/discord/tests/*.mjs scripts/discord.sh; do
|
||||
for f in packages/discord/src/*.mjs packages/discord/tests/*.mjs packages/discord/fixtures/*.mjs scripts/discord.sh scripts/discord-service.sh; do
|
||||
case "$f" in
|
||||
*.sh) bash -n "$f" >/dev/null 2>&1 ;;
|
||||
*) node --check "$f" >/dev/null 2>&1 ;;
|
||||
@@ -71,6 +71,44 @@ check "scripts/discord.sh --help exits 0" $?
|
||||
scripts/discord.sh check >/dev/null 2>&1
|
||||
[ $? -eq 4 ]
|
||||
check "scripts/discord.sh check without a binding exits 4" $?
|
||||
scripts/discord.sh recover >/dev/null 2>&1
|
||||
[ $? -eq 4 ]
|
||||
check "scripts/discord.sh recover without a binding exits 4" $?
|
||||
|
||||
# --- the service unit: rendered from the template, never touching systemd here ---
|
||||
UNITS="$SANDBOX/units"
|
||||
scripts/discord-service.sh >/dev/null 2>&1
|
||||
[ $? -eq 4 ]
|
||||
check "scripts/discord-service.sh without a command exits 4" $?
|
||||
REPO_DIR="$(pwd)"
|
||||
scripts/discord-service.sh render >"$SANDBOX/unit.rendered" 2>/dev/null \
|
||||
&& grep -qF "ExecStart=$REPO_DIR/scripts/discord.sh run %i --supervised" "$SANDBOX/unit.rendered" \
|
||||
&& ! grep -q '^ExecStartPre=' "$SANDBOX/unit.rendered" \
|
||||
&& grep -qx 'RestartPreventExitStatus=3' "$SANDBOX/unit.rendered" \
|
||||
&& grep -qx 'Restart=on-failure' "$SANDBOX/unit.rendered" \
|
||||
&& grep -qx 'KillSignal=SIGTERM' "$SANDBOX/unit.rendered" \
|
||||
&& ! grep -q '@REPO@\|@PATH@' "$SANDBOX/unit.rendered"
|
||||
check "service unit renders with the repository path, a supervised run as the main process, and exit 3 never retried" $?
|
||||
scripts/discord-service.sh install --dir "$UNITS" --no-reload >"$SANDBOX/install.1" 2>&1 \
|
||||
&& [ -f "$UNITS/[email protected]" ] \
|
||||
&& grep -q '^written:' "$SANDBOX/install.1" \
|
||||
&& cmp -s "$UNITS/[email protected]" "$SANDBOX/unit.rendered" \
|
||||
&& [ "$(stat -c %a "$UNITS/[email protected]")" = "644" ] \
|
||||
&& [ -z "$(ls -A "$UNITS" | grep -v '^[email protected]$')" ]
|
||||
check "service install writes the rendered unit (0644) and leaves no temp file" $?
|
||||
scripts/discord-service.sh install --dir "$UNITS" --no-reload >"$SANDBOX/install.2" 2>&1 && grep -q '^unchanged:' "$SANDBOX/install.2"
|
||||
check "service install a second time reports unchanged" $?
|
||||
if command -v systemd-analyze >/dev/null 2>&1; then
|
||||
cp "$UNITS/[email protected]" "$UNITS/[email protected]"
|
||||
systemd-analyze --user verify "$UNITS/[email protected]" >"$SANDBOX/verify.log" 2>&1 && ! grep -qi 'warning\|error\|fail' "$SANDBOX/verify.log"
|
||||
check "systemd-analyze verify accepts the rendered unit" $?
|
||||
rm -f "$UNITS/[email protected]"
|
||||
fi
|
||||
scripts/discord-service.sh uninstall --dir "$UNITS" --no-reload >/dev/null 2>&1 && [ ! -e "$UNITS/[email protected]" ]
|
||||
check "service uninstall removes the unit file" $?
|
||||
scripts/discord-service.sh install --dir "$UNITS" --no-reload --bogus >/dev/null 2>&1
|
||||
[ $? -eq 4 ]
|
||||
check "service install with an unknown flag exits 4" $?
|
||||
|
||||
echo
|
||||
echo "discord suite: $PASS passed, $FAIL failed"
|
||||
|
||||
Reference in New Issue
Block a user