diff --git a/launchd/install-newsyslog.sh b/launchd/install-newsyslog.sh new file mode 100755 index 0000000..9a4b4d6 --- /dev/null +++ b/launchd/install-newsyslog.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SRC="$SCRIPT_DIR/newsyslog.d/brainlayer.conf" +DST="/etc/newsyslog.d/brainlayer.conf" +OWNER="${BRAINLAYER_LOG_OWNER:-${SUDO_USER:-$(id -un)}}" +GROUP="${BRAINLAYER_LOG_GROUP:-staff}" +RENDERED_CONFIG="$(mktemp "${TMPDIR:-/tmp}/brainlayer-newsyslog.XXXXXX")" +trap 'rm -f "$RENDERED_CONFIG"' EXIT + +escape_sed_replacement() { + printf '%s' "$1" | sed 's/[\/#&\\]/\\&/g' +} + +if ! id -u "$OWNER" >/dev/null 2>&1; then + echo "ERROR: log owner does not exist: $OWNER" >&2 + exit 1 +fi + +if ! OWNER_HOME="$(dscl . -read "/Users/$OWNER" NFSHomeDirectory 2>/dev/null | sed 's/^NFSHomeDirectory:[[:space:]]*//')"; then + echo "ERROR: could not resolve home directory for $OWNER" >&2 + exit 1 +fi +if [ -z "$OWNER_HOME" ]; then + echo "ERROR: could not resolve home directory for $OWNER" >&2 + exit 1 +fi +LOG_DIR="${BRAINLAYER_LOG_DIR:-$OWNER_HOME/Library/Logs/brainlayer}" +if [[ "$LOG_DIR" =~ [[:space:]] ]]; then + echo "ERROR: newsyslog log paths cannot contain whitespace: $LOG_DIR" >&2 + exit 1 +fi + +if [ ! -f "$SRC" ]; then + echo "ERROR: $SRC not found" >&2 + exit 1 +fi + +LOG_DIR_ESCAPED="$(escape_sed_replacement "$LOG_DIR")" +OWNER_GROUP_ESCAPED="$(escape_sed_replacement "$OWNER:$GROUP")" + +sed \ + -e "s#/Users/etanheyman/Library/Logs/brainlayer#$LOG_DIR_ESCAPED#g" \ + -e "s#etanheyman:staff#$OWNER_GROUP_ESCAPED#g" \ + "$SRC" >"$RENDERED_CONFIG" + +sudo mkdir -p "$LOG_DIR" +# Ensure already-created logs are writable by user LaunchAgents before the first rotation. +sudo chown "$OWNER:$GROUP" "$LOG_DIR" +for log in "$LOG_DIR"/*.log; do + [ -e "$log" ] || continue + if [ -L "$log" ] || [ ! -f "$log" ]; then + echo "Skipping non-regular log path: $log" >&2 + continue + fi + sudo chown "$OWNER:$GROUP" "$log" + sudo chmod 0644 "$log" +done + +sudo newsyslog -nv -f "$RENDERED_CONFIG" +sudo mkdir -p /etc/newsyslog.d +sudo install -o root -g wheel -m 0644 "$RENDERED_CONFIG" "$DST" +sudo newsyslog -nv -f "$DST" +echo "Installed $DST" diff --git a/launchd/newsyslog.d/README.md b/launchd/newsyslog.d/README.md new file mode 100644 index 0000000..8017de8 --- /dev/null +++ b/launchd/newsyslog.d/README.md @@ -0,0 +1,31 @@ +# BrainLayer newsyslog + +BrainLayer LaunchAgents write logs as the user, not as root. macOS `newsyslog` +creates rotated replacement files as `root:admin` unless the config line +specifies an owner and group. A root-owned replacement silently breaks later +appends from user-level daemons. + +This drop-in only rotates finite scheduled LaunchAgent jobs. Long-running jobs +such as BrainBar, watch, and enrichment keep their `StandardOutPath` and +`StandardErrorPath` descriptors open; macOS `newsyslog` has no post-rotate hook +or copy-truncate mode, so those logs need a coupled launchd restart or pid-file +signal path before they can be safely added. Drain is also excluded because it +can be spawned while a rotation pass is running. + +Install `brainlayer.conf` into `/etc/newsyslog.d/` with: + +```sh +launchd/install-newsyslog.sh +sudo newsyslog -nv -f /etc/newsyslog.d/brainlayer.conf +``` + +The checked-in config targets Etan's LaunchAgent account as `etanheyman:staff`. +The installer renders that config for `BRAINLAYER_LOG_OWNER`, +`BRAINLAYER_LOG_GROUP`, and `BRAINLAYER_LOG_DIR` before installing it into +`/etc/newsyslog.d/`. The rendered config is validated with `newsyslog -nv` +before replacing the live drop-in. Because `newsyslog.conf` is +whitespace-delimited, the installer rejects log directories containing +whitespace. + +Every installed entry uses mode `644`, `J` compression, and `N` so rotation does +not signal user LaunchAgents. Launchd owns job lifecycle. diff --git a/launchd/newsyslog.d/brainlayer.conf b/launchd/newsyslog.d/brainlayer.conf new file mode 100644 index 0000000..4f96de1 --- /dev/null +++ b/launchd/newsyslog.d/brainlayer.conf @@ -0,0 +1,16 @@ +# BrainLayer finite user LaunchAgent logs. +# owner:group is explicit so rotated files remain writable by user-level daemons. +# Long-running StandardOutPath/StandardErrorPath jobs must not be added without +# a coupled launchd restart or pid-file signal path; newsyslog has no +# post-rotate hook or copy-truncate mode on macOS. +# logfilename owner:group mode count size when flags +/Users/etanheyman/Library/Logs/brainlayer/backup-daily.out.log etanheyman:staff 644 4 512 * JN +/Users/etanheyman/Library/Logs/brainlayer/backup-daily.err.log etanheyman:staff 644 4 512 * JN +/Users/etanheyman/Library/Logs/brainlayer/decay.out.log etanheyman:staff 644 7 1024 * JN +/Users/etanheyman/Library/Logs/brainlayer/decay.err.log etanheyman:staff 644 7 1024 * JN +/Users/etanheyman/Library/Logs/brainlayer/wal-checkpoint.out.log etanheyman:staff 644 7 1024 * JN +/Users/etanheyman/Library/Logs/brainlayer/wal-checkpoint.err.log etanheyman:staff 644 7 1024 * JN +/Users/etanheyman/Library/Logs/brainlayer/index.out.log etanheyman:staff 644 7 1024 * JN +/Users/etanheyman/Library/Logs/brainlayer/index.err.log etanheyman:staff 644 7 1024 * JN +/Users/etanheyman/Library/Logs/brainlayer/repair-fts.out.log etanheyman:staff 644 7 1024 * JN +/Users/etanheyman/Library/Logs/brainlayer/repair-fts.err.log etanheyman:staff 644 7 1024 * JN diff --git a/tests/test_newsyslog_config.py b/tests/test_newsyslog_config.py new file mode 100644 index 0000000..481ae4d --- /dev/null +++ b/tests/test_newsyslog_config.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +CONFIG = REPO_ROOT / "launchd" / "newsyslog.d" / "brainlayer.conf" +INSTALLER = REPO_ROOT / "launchd" / "install-newsyslog.sh" + + +def _entries() -> list[list[str]]: + return [ + line.split() for line in CONFIG.read_text().splitlines() if line.strip() and not line.lstrip().startswith("#") + ] + + +def test_newsyslog_config_uses_user_writable_rotated_logs(): + entries = _entries() + assert entries + for fields in entries: + assert fields[0].startswith("/Users/etanheyman/Library/Logs/brainlayer/") + assert fields[0].endswith(".log") + assert fields[1] == "etanheyman:staff" + assert fields[2] == "644" + assert fields[6] == "JN" + + +def test_newsyslog_config_covers_launchd_log_pairs(): + names = {Path(fields[0]).name for fields in _entries()} + for daemon in { + "backup-daily", + "decay", + "wal-checkpoint", + "index", + "repair-fts", + }: + assert f"{daemon}.out.log" in names + assert f"{daemon}.err.log" in names + + +def test_newsyslog_config_excludes_held_open_long_running_launchd_logs(): + names = {Path(fields[0]).name for fields in _entries()} + for daemon in {"brainbar", "enrichment", "watch", "drain"}: + assert f"{daemon}.out.log" not in names + assert f"{daemon}.err.log" not in names + + config = CONFIG.read_text() + assert "post-rotate hook or copy-truncate mode" in config + + +def test_newsyslog_installer_documents_root_owned_replacement_footgun(): + readme = (REPO_ROOT / "launchd" / "newsyslog.d" / "README.md").read_text() + assert "root:admin" in readme + assert "etanheyman:staff" in readme + assert "Long-running jobs" in readme + assert "post-rotate hook" in readme + assert "sudo newsyslog -nv -f /etc/newsyslog.d/brainlayer.conf" in readme + + +def test_newsyslog_installer_repairs_root_owned_logs_for_invoking_user(): + script = INSTALLER.read_text() + assert 'OWNER="${BRAINLAYER_LOG_OWNER:-${SUDO_USER:-$(id -un)}}"' in script + assert 'sudo mkdir -p "$LOG_DIR"' in script + assert '[ -L "$log" ] || [ ! -f "$log" ]' in script + assert "Skipping non-regular log path: $log" in script + assert 'sudo chown "$OWNER:$GROUP" "$log"' in script + assert 'sudo chmod 0644 "$log"' in script + + +def test_newsyslog_installer_renders_runtime_owner_and_log_dir(): + script = INSTALLER.read_text() + assert 'mktemp "${TMPDIR:-/tmp}/brainlayer-newsyslog.XXXXXX"' in script + assert "trap 'rm -f \"$RENDERED_CONFIG\"' EXIT" in script + assert 'LOG_DIR_ESCAPED="$(escape_sed_replacement "$LOG_DIR")"' in script + assert 'OWNER_GROUP_ESCAPED="$(escape_sed_replacement "$OWNER:$GROUP")"' in script + assert "s#/Users/etanheyman/Library/Logs/brainlayer#$LOG_DIR_ESCAPED#g" in script + assert "s#etanheyman:staff#$OWNER_GROUP_ESCAPED#g" in script + assert 'sudo newsyslog -nv -f "$RENDERED_CONFIG"' in script + assert 'sudo install -o root -g wheel -m 0644 "$RENDERED_CONFIG" "$DST"' in script + + +def test_newsyslog_installer_handles_home_paths_with_spaces_explicitly(): + script = INSTALLER.read_text() + assert "awk '{print $2}'" not in script + assert 'if ! OWNER_HOME="$(dscl . -read "/Users/$OWNER" NFSHomeDirectory' in script + assert "sed 's/^NFSHomeDirectory:[[:space:]]*//'" in script + assert '[[ "$LOG_DIR" =~ [[:space:]] ]]' in script + assert "newsyslog log paths cannot contain whitespace" in script