Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/install-roundtrip.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# SPDX-License-Identifier: MPL-2.0
#
# install-roundtrip.yml — actual install→activate→stop→uninstall cycle
# for the cross-platform service install machinery, on a real systemd /
# launchd / Windows SCM. Complements install-tests.yml (lint-only).
#
# Each job:
# 1. Stubs `mix` and `deno` so the spawned unit doesn't need the full
# Elixir/Deno toolchain (which would make every job slow + flaky).
# 2. Runs the platform's roundtrip-*.sh / .ps1 driver.
# 3. Drivers always clean up after themselves (trap EXIT / try/finally).

name: Install round-trip

on:
push:
branches: [main, master, 'claude/**']
paths:
- 'setup.sh'
- 'setup.ps1'
- 'scripts/install-service.sh'
- 'scripts/wsl-bolt-udp-forward.ps1'
- 'assets/services/**'
- 'tests/install/**'
- '.github/workflows/install-roundtrip.yml'
pull_request:
branches: [main, master]
paths:
- 'setup.sh'
- 'setup.ps1'
- 'scripts/install-service.sh'
- 'scripts/wsl-bolt-udp-forward.ps1'
- 'assets/services/**'
- 'tests/install/**'
- '.github/workflows/install-roundtrip.yml'

permissions:
contents: read

jobs:
linux-user-roundtrip:
name: Linux --user systemd round-trip
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

- name: Enable user-systemd lingering for runner
# GH Actions runs jobs without a real login session, so the
# per-user systemd instance isn't auto-started. `enable-linger`
# makes systemd-logind start it for the runner user even with
# no session.
run: |
sudo loginctl enable-linger "$USER"
# Wait for the user instance to come up.
for i in 1 2 3 4 5 6 7 8 9 10; do
if systemctl --user list-units >/dev/null 2>&1; then
echo "user-systemd ready (attempt $i)"; exit 0
fi
sleep 1
done
echo "user-systemd never became reachable"; exit 1

- name: Run round-trip
run: tests/install/roundtrip-linux.sh

macos-roundtrip:
name: macOS launchd LaunchAgent round-trip
runs-on: macos-14
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Run round-trip
run: tests/install/roundtrip-macos.sh

windows-roundtrip:
name: Windows Service round-trip
runs-on: windows-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Run round-trip
shell: pwsh
run: tests/install/roundtrip-windows.ps1
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- Real install→activate→stop→uninstall round-trip tests for all three
service managers (complements the lint-only tests):
- `tests/install/roundtrip-linux.sh` — `systemctl --user` round-trip.
Also kills the unit's main PID and asserts `Restart=on-failure`
respawns it within `RestartSec=5`.
- `tests/install/roundtrip-macos.sh` — `launchctl bootstrap gui/$UID`
/ `bootout` round-trip with stub `PATH=` patched into the plist
(launchd ignores the user shell's env).
- `tests/install/roundtrip-windows.ps1` — creates a throwaway local
user (`burble-ci-test`) with a random password, installs the
Windows Service non-interactively via a new `-Credential`
parameter on `wsl-bolt-udp-forward.ps1` (skips the
`Get-Credential` prompt), asserts SCM state, uninstalls, removes
the user in a `finally` block.
- `tests/install/stubs/{mix,deno}` — sleep-forever stand-ins so the
spawned units satisfy systemd/launchd's "Active" check without
needing the full Elixir/Deno toolchain in CI.
- `.github/workflows/install-roundtrip.yml` — CI matrix:
`ubuntu-latest` (with `loginctl enable-linger` for user-systemd),
`macos-14`, `windows-latest`. Path-filtered to install machinery.
- `tests/install/run.sh` — cross-platform validation for the install
machinery, safe to run anywhere. Renders systemd units in both
system and user modes and checks invariants (no unsubstituted
Expand Down
31 changes: 22 additions & 9 deletions scripts/wsl-bolt-udp-forward.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ param(
[Parameter(ParameterSetName = 'Status')] [switch]$Status,
[string]$Distro,
[int[]]$Ports = @(7373, 9),
[switch]$Firewall
[switch]$Firewall,
# Optional: pre-built PSCredential for non-interactive install (CI).
# When omitted, -Install prompts via Get-Credential as usual.
[System.Management.Automation.PSCredential]$Credential
)

$ErrorActionPreference = 'Stop'
Expand Down Expand Up @@ -301,7 +304,12 @@ function Compile-ServiceHost {
}

function Install-Service {
param([string]$Distro, [int[]]$Ports, [switch]$Firewall)
param(
[string]$Distro,
[int[]]$Ports,
[switch]$Firewall,
[System.Management.Automation.PSCredential]$Credential
)
Assert-Elevated -Action '-Install (creates a Windows Service)'
$self = $PSCommandPath
if (-not $self) { $self = $MyInvocation.MyCommand.Path }
Expand All @@ -328,12 +336,17 @@ function Install-Service {

Compile-ServiceHost

Write-Host "[bolt-fwd] WSL distros are per-user. The service needs to run under YOUR account"
Write-Host " so it can launch wsl.exe and see your distro. New-Service stores the"
Write-Host " password securely via LSA Secrets — you only enter it once."
$cred = Get-Credential -UserName "$env:USERDOMAIN\$env:USERNAME" `
-Message "Password for $env:USERDOMAIN\$env:USERNAME (so the service can launch wsl.exe as you)"
if (-not $cred) { Write-Error "Cancelled — no credential supplied."; exit 1 }
if ($Credential) {
$cred = $Credential
Write-Host "[bolt-fwd] Using pre-supplied credential for $($cred.UserName) (non-interactive install)."
} else {
Write-Host "[bolt-fwd] WSL distros are per-user. The service needs to run under YOUR account"
Write-Host " so it can launch wsl.exe and see your distro. New-Service stores the"
Write-Host " password securely via LSA Secrets — you only enter it once."
$cred = Get-Credential -UserName "$env:USERDOMAIN\$env:USERNAME" `
-Message "Password for $env:USERDOMAIN\$env:USERNAME (so the service can launch wsl.exe as you)"
if (-not $cred) { Write-Error "Cancelled — no credential supplied."; exit 1 }
}

# Grant the service user Modify on the install dir — it lives under
# %ProgramData% which is admin-only by default, but the service runs
Expand Down Expand Up @@ -395,7 +408,7 @@ function Uninstall-Service {
}

switch ($PSCmdlet.ParameterSetName) {
'Install' { Install-Service -Distro $Distro -Ports $Ports -Firewall:$Firewall }
'Install' { Install-Service -Distro $Distro -Ports $Ports -Firewall:$Firewall -Credential $Credential }
'Uninstall' { Uninstall-Service }
'Status' {
$ip = Resolve-WslIp -Distro $Distro
Expand Down
48 changes: 31 additions & 17 deletions tests/install/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,34 @@ not a failure. Install whichever are missing to widen coverage:

Triggered on any change to the install machinery.

## What's NOT tested

Real install/start/stop round-trip on a clean host:

- **Linux systemd round-trip** — needs `loginctl enable-linger` + a real
user session bus in CI for `systemctl --user`, or root for system
units. Tractable but not wired up yet.
- **Windows `New-Service`** — needs a throwaway local user with `Log on
as a service` for the `-Credential` argument. Tractable but not wired
up yet.
- **macOS `launchctl bootstrap`** — would work in CI but requires the
Mix/Deno toolchains to be present for the spawned process to do
anything meaningful.

Until those land, the lint suite catches everything we've actually hit
in practice (unsubstituted tokens, `AmbientCapabilities=` in user mode,
malformed PowerShell, missing csc.exe path).
## Round-trip tests (actually mutate the host)

In addition to the lint suite, there are three platform-specific
drivers that do a real install → activate → stop → uninstall cycle
against the platform's service manager. They use stub `mix` / `deno`
binaries (`tests/install/stubs/`) so the spawned units survive long
enough to be Active without needing the full Elixir/Deno toolchain.

| Driver | Platform | What it round-trips |
|---|---|---|
| `roundtrip-linux.sh` | Linux | `systemctl --user enable --now` round-trip (also kills the main PID and asserts `Restart=on-failure` respawns it) |
| `roundtrip-macos.sh` | macOS | `launchctl bootstrap gui/$UID` / `bootout` |
| `roundtrip-windows.ps1` | Windows | creates throwaway local user, installs Windows Service non-interactively via the new `-Credential` parameter on `wsl-bolt-udp-forward.ps1`, asserts SCM state, uninstalls, removes user |

All three are idempotent and clean up after themselves on failure
(`trap EXIT` / `try { … } finally { … }`). They will mutate your host
for the duration of the test — safe locally if you're OK with a brief
service install.

CI workflow `install-roundtrip.yml` runs them on:
- `ubuntu-latest` (with `loginctl enable-linger` to bring up user-systemd)
- `macos-14` (Apple Silicon)
- `windows-latest`

## What's still NOT tested

- The actual UDP forwarding (the Windows round-trip exercises install
+ SCM state, not whether packets actually relay — that requires a
real WSL distro target).
- The Elixir/Deno app itself starting up correctly under systemd /
launchd — covered by `elixir-ci.yml`.
132 changes: 132 additions & 0 deletions tests/install/roundtrip-linux.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MPL-2.0
#
# tests/install/roundtrip-linux.sh — full install→activate→stop→uninstall
# cycle for the Linux --user systemd path.
#
# Strategy:
# * stub `mix` and `deno` with a script that sleeps forever, so the
# unit's ExecStart succeeds without needing the Elixir/Deno toolchain
# * inject the stub dir into the systemd --user instance's PATH via
# `systemctl --user import-environment`
# * install via scripts/install-service.sh install --user
# * assert both units reach `active`
# * uninstall, assert both are gone
#
# Safe-ish locally: it WILL mutate ~/.config/systemd/user/ and start
# burble.service in your user-systemd session for the duration of the
# test. The uninstall step undoes both even on failure (trap EXIT).

set -uo pipefail

REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
STUBS="$REPO_DIR/tests/install/stubs"
TMPLOG="$(mktemp -t burble-roundtrip-linux.XXXXXX.log)"
# shellcheck disable=SC2154 # ec is assigned inside the trap body
trap 'ec=$?; cleanup; exit $ec' EXIT

PASS=0; FAIL=0
pass() { printf ' \033[0;32mPASS\033[0m %s\n' "$1"; PASS=$((PASS+1)); }
fail() { printf ' \033[0;31mFAIL\033[0m %s\n' "$1"; FAIL=$((FAIL+1)); }
hdr() { printf '\n\033[1;36m── %s ──\033[0m\n' "$1"; }

cleanup() {
echo
hdr "Cleanup"
"$REPO_DIR/scripts/install-service.sh" uninstall >>"$TMPLOG" 2>&1 || true
rm -f "$TMPLOG"
}

# ─── Preflight ────────────────────────────────────────────────────────────
hdr "Preflight"
command -v systemctl >/dev/null || { echo "systemctl required"; exit 2; }
[ -x "$STUBS/mix" ] && [ -x "$STUBS/deno" ] || { echo "missing stubs/"; exit 2; }

# Need a user systemd session. `systemctl --user` requires either an
# interactive login (which CI lacks) or `loginctl enable-linger`. If we
# don't have a working bus, abort with a clear message.
CUR_USER="${USER:-$(id -un)}"
if ! systemctl --user is-system-running >/dev/null 2>&1 && \
! systemctl --user list-units >/dev/null 2>&1; then
echo "No working systemd --user instance for $CUR_USER."
echo "On CI: sudo loginctl enable-linger $CUR_USER && sleep 2"
echo "On a dev box, log in interactively or run 'systemctl --user start default.target'"
exit 2
fi
pass "systemd --user instance reachable"

# ─── Inject stub PATH into the user-systemd environment ───────────────────
hdr "Stub PATH"
export PATH="$STUBS:$PATH"
systemctl --user import-environment PATH
pass "PATH=$STUBS:... imported into user-systemd env"

# ─── Install ──────────────────────────────────────────────────────────────
hdr "Install (--user)"
if "$REPO_DIR/scripts/install-service.sh" install --user >>"$TMPLOG" 2>&1; then
pass "install --user exit 0"
else
fail "install --user exit $?"
tail -20 "$TMPLOG" | sed 's/^/ /'
fi

# Give systemd a moment to actually start the services.
sleep 2

# ─── Assert active ────────────────────────────────────────────────────────
hdr "Assert active"
for unit in burble.service burble-ai-bridge.service; do
state=$(systemctl --user is-active "$unit" 2>/dev/null || echo unknown)
if [ "$state" = "active" ]; then
pass "$unit is active"
else
fail "$unit state=$state (expected active)"
systemctl --user status "$unit" --no-pager 2>&1 | sed 's/^/ /' | head -15
fi
done

# ─── Assert restart-on-failure works ──────────────────────────────────────
hdr "Restart-on-failure"
# Kill the burble.service main process; systemd should respawn it
# within RestartSec=5 because of Restart=on-failure.
mainpid=$(systemctl --user show -p MainPID --value burble.service 2>/dev/null)
if [ -n "$mainpid" ] && [ "$mainpid" != "0" ]; then
kill -9 "$mainpid" 2>/dev/null || true
sleep 8
newpid=$(systemctl --user show -p MainPID --value burble.service 2>/dev/null)
if [ -n "$newpid" ] && [ "$newpid" != "0" ] && [ "$newpid" != "$mainpid" ]; then
pass "burble.service respawned after kill (pid $mainpid -> $newpid)"
else
fail "burble.service did not respawn (pid still $newpid)"
fi
else
fail "burble.service has no MainPID — cannot test restart"
fi

# ─── Uninstall ────────────────────────────────────────────────────────────
hdr "Uninstall"
if "$REPO_DIR/scripts/install-service.sh" uninstall >>"$TMPLOG" 2>&1; then
pass "uninstall exit 0"
else
fail "uninstall exit $?"
tail -20 "$TMPLOG" | sed 's/^/ /'
fi

sleep 1

# ─── Assert gone ──────────────────────────────────────────────────────────
hdr "Assert removed"
for unit in burble.service burble-ai-bridge.service; do
if [ -f "$HOME/.config/systemd/user/$unit" ]; then
fail "$unit file still present after uninstall"
else
pass "$unit file removed"
fi
done

# ─── Summary ──────────────────────────────────────────────────────────────
echo
echo "──────────────────────────────────────────"
printf 'Results: \033[0;32m%d pass\033[0m, \033[0;31m%d fail\033[0m\n' "$PASS" "$FAIL"
echo "──────────────────────────────────────────"
[ "$FAIL" -eq 0 ] || exit 1
Loading
Loading