From fc29bc9f7e428b44636de7ca89f68177759494bd Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 30 Apr 2026 07:13:20 +0000 Subject: [PATCH] test: add remote runner vagrant scenario --- .gitignore | 1 + Vagrantfile | 62 +++++++++++ package.json | 6 +- readme.md | 22 ++++ scenarios/uptimerunner-basic/readme.md | 3 + scenarios/uptimerunner-vagrant/controller.ts | 111 +++++++++++++++++++ scenarios/uptimerunner-vagrant/readme.md | 38 +++++++ scripts/provision-common.sh | 15 +++ scripts/provision-runner.sh | 75 +++++++++++++ scripts/run-vagrant-scenario.sh | 55 +++++++++ 10 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 Vagrantfile create mode 100644 scenarios/uptimerunner-vagrant/controller.ts create mode 100644 scenarios/uptimerunner-vagrant/readme.md create mode 100755 scripts/provision-common.sh create mode 100755 scripts/provision-runner.sh create mode 100755 scripts/run-vagrant-scenario.sh diff --git a/.gitignore b/.gitignore index 708510a..ac12a4b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .nogit/ +.vagrant/ node_modules/ pnpm-lock.yaml diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000..bda963a --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +uptimelink_root = ENV.fetch('UPTIMELINK_ROOT', File.expand_path('..', __dir__)) +controller_ip = ENV.fetch('UPTIMELINK_VAGRANT_CONTROLLER_IP', '192.168.56.10') +runner_ip = ENV.fetch('UPTIMELINK_VAGRANT_RUNNER_IP', '192.168.56.11') +controller_port = ENV.fetch('UPTIMELINK_VAGRANT_CONTROLLER_PORT', '8080') +runner_id = ENV.fetch('UPTIMELINK_VAGRANT_RUNNER_ID', 'vagrant-runner-1') +runner_token = ENV.fetch('UPTIMELINK_VAGRANT_RUNNER_TOKEN', 'vagrant-token') +target_port = ENV.fetch('UPTIMELINK_VAGRANT_TARGET_PORT', '18081') + +Vagrant.configure('2') do |config| + config.vm.box = ENV.fetch('UPTIMELINK_VAGRANT_BOX', 'bento/ubuntu-24.04') + + config.vm.synced_folder '.', '/vagrant', disabled: true + config.vm.synced_folder uptimelink_root, '/uptime.link', + type: 'rsync', + owner: 'vagrant', + group: 'vagrant', + rsync__auto: false, + rsync__exclude: [ + '.git/', + '.nogit/', + '.vagrant/', + 'node_modules/', + '**/.git/', + '**/.nogit/', + '**/.vagrant/', + '**/node_modules/', + '**/.cache/', + '**/.pnpm-store/', + '**/dist/', + '**/dist_*/', + ] + + config.vm.provider 'virtualbox' do |vb| + vb.cpus = ENV.fetch('UPTIMELINK_VAGRANT_CPUS', '2').to_i + vb.memory = ENV.fetch('UPTIMELINK_VAGRANT_MEMORY', '2048').to_i + end + + config.vm.provider 'libvirt' do |lv| + lv.cpus = ENV.fetch('UPTIMELINK_VAGRANT_CPUS', '2').to_i + lv.memory = ENV.fetch('UPTIMELINK_VAGRANT_MEMORY', '2048').to_i + end + + config.vm.define 'controller', primary: true do |controller| + controller.vm.hostname = 'uptime-controller' + controller.vm.network 'private_network', ip: controller_ip + controller.vm.provision 'shell', path: 'scripts/provision-common.sh' + end + + config.vm.define 'runner' do |runner| + runner.vm.hostname = 'uptime-runner' + runner.vm.network 'private_network', ip: runner_ip + runner.vm.provision 'shell', path: 'scripts/provision-common.sh' + runner.vm.provision 'shell', path: 'scripts/provision-runner.sh', env: { + 'UPTIMELINK_CONTROLLER_URL' => "http://#{controller_ip}:#{controller_port}", + 'UPTIMELINK_RUNNER_ID' => runner_id, + 'UPTIMELINK_RUNNER_TOKEN' => runner_token, + 'UPTIMELINK_TARGET_PORT' => target_port, + } + end +end diff --git a/package.json b/package.json index 6e388ed..75907ee 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,11 @@ "scripts": { "bootstrap:components": "deno cache --config ../uptimerunner/deno.json ../uptimerunner/mod.ts && pnpm install", "test": "pnpm scenario:uptimerunner-basic", - "scenario:uptimerunner-basic": "deno run --allow-all --sloppy-imports --config ../uptimerunner/deno.json scenarios/uptimerunner-basic/scenario.ts" + "test:vagrant": "pnpm scenario:uptimerunner-vagrant", + "scenario:uptimerunner-basic": "deno run --allow-all --sloppy-imports --config ../uptimerunner/deno.json scenarios/uptimerunner-basic/scenario.ts", + "scenario:uptimerunner-vagrant": "bash scripts/run-vagrant-scenario.sh", + "vagrant:halt": "vagrant halt", + "vagrant:destroy": "vagrant destroy -f" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/readme.md b/readme.md index c120e68..41ac368 100644 --- a/readme.md +++ b/readme.md @@ -12,6 +12,10 @@ flows. target HTTP service, runs `uptimerunner` once, verifies the runner fetches assigned checks, executes them from the local machine, and reports results back. +- `uptimerunner-vagrant`: starts controller and runner VMs with Vagrant, + installs `uptimerunner` as a systemd service in the runner VM, and verifies + that the remote runner executes a runner-local HTTP check and submits the + result to the controller VM. ## Run @@ -19,3 +23,21 @@ flows. pnpm bootstrap:components pnpm test ``` + +Run the remote-runner VM scenario with: + +```bash +pnpm scenario:uptimerunner-vagrant +``` + +The VM scenario defaults to the `libvirt` Vagrant provider. Override it with +`UPTIMELINK_VAGRANT_PROVIDER=virtualbox` if needed. + +If the current shell has not picked up newly added `libvirt` group membership, +run it without restarting the machine using: + +```bash +sg libvirt -c 'pnpm scenario:uptimerunner-vagrant' +``` + +Stop or remove the VMs with `pnpm vagrant:halt` or `pnpm vagrant:destroy`. diff --git a/scenarios/uptimerunner-basic/readme.md b/scenarios/uptimerunner-basic/readme.md index fe88918..820947b 100644 --- a/scenarios/uptimerunner-basic/readme.md +++ b/scenarios/uptimerunner-basic/readme.md @@ -7,3 +7,6 @@ This scenario verifies the first concrete uptime.link runner flow: 3. The runner polls `/api/runner/v1/checks`. 4. The runner executes the assigned HTTP check locally. 5. The runner posts the result to `/api/runner/v1/results`. +6. The scenario verifies scheduling, status derivation, snapshot restore, + unauthorized access rejection, token rotation, disabled runner rejection, and + label updates. diff --git a/scenarios/uptimerunner-vagrant/controller.ts b/scenarios/uptimerunner-vagrant/controller.ts new file mode 100644 index 0000000..a70e75c --- /dev/null +++ b/scenarios/uptimerunner-vagrant/controller.ts @@ -0,0 +1,111 @@ +import { assert, assertEquals } from "jsr:@std/assert@^1.0.0"; + +import { RunnerAdmin } from "../../../uptime.link/ts_api/classes/runner-admin.ts"; +import { RunnerCoordinator } from "../../../uptime.link/ts_api/classes/runner-coordinator.ts"; +import { createRunnerRequestHandler } from "../../../uptime.link/ts_api/classes/runner-request-handler.ts"; + +const scenarioName = "uptimerunner-vagrant"; +const controllerHost = Deno.env.get("UPTIMELINK_CONTROLLER_HOST") ?? "0.0.0.0"; +const controllerPort = Number( + Deno.env.get("UPTIMELINK_CONTROLLER_PORT") ?? "8080", +); +const runnerId = Deno.env.get("UPTIMELINK_RUNNER_ID") ?? "vagrant-runner-1"; +const runnerToken = Deno.env.get("UPTIMELINK_RUNNER_TOKEN") ?? "vagrant-token"; +const targetPort = Number(Deno.env.get("UPTIMELINK_TARGET_PORT") ?? "18081"); +const timeoutMs = Number( + Deno.env.get("UPTIMELINK_VAGRANT_TIMEOUT_MS") ?? "120000", +); +const targetUrl = Deno.env.get("UPTIMELINK_RUNNER_TARGET_URL") ?? + `http://127.0.0.1:${targetPort}/health`; + +const main = async () => { + const coordinator = new RunnerCoordinator(); + const admin = new RunnerAdmin(coordinator); + admin.registerRunner({ + runnerId, + token: runnerToken, + labels: ["scenario:vagrant", "role:internal"], + }); + + coordinator.enqueueCheck({ + id: "vagrant-runner-local-http", + type: "http", + url: targetUrl, + expectedStatusCodes: [200], + expectedBodyIncludes: "runner-local healthy", + timeoutMs: 5000, + metadata: { + monitorId: "vagrant-runner-local-http", + }, + }, { runnerId }); + + const runnerRequestHandler = createRunnerRequestHandler(coordinator); + const server = Deno.serve( + { hostname: controllerHost, port: controllerPort }, + (request) => { + return runnerRequestHandler(request); + }, + ); + + try { + console.log( + `[${scenarioName}] Controller listening on ${controllerHost}:${controllerPort}`, + ); + console.log(`[${scenarioName}] Waiting for runner ${runnerId}`); + + await waitFor( + () => coordinator.getRunner(runnerId)?.lastHeartbeat, + timeoutMs, + ); + const result = await waitFor( + () => + coordinator.listResults().find((candidate) => { + return candidate.checkId === "vagrant-runner-local-http"; + }), + timeoutMs, + ); + + assert(result); + if (result.status !== "ok") { + console.error(JSON.stringify(result, null, 2)); + } + assertEquals(result.runnerId, runnerId); + assertEquals(result.type, "http"); + assertEquals(result.status, "ok"); + assertEquals(result.statusCode, 200); + assert(result.responseTime !== undefined); + assertEquals(coordinator.getQueueLength(), 0); + + console.log(`[${scenarioName}] Passed`); + } finally { + await server.shutdown(); + } +}; + +async function waitFor( + readArg: () => T | undefined, + timeoutMsArg: number, +): Promise { + const started = Date.now(); + while (Date.now() - started < timeoutMsArg) { + const value = readArg(); + if (value) { + return value; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`Timed out after ${timeoutMsArg}ms.`); +} + +if (import.meta.main) { + try { + await main(); + } catch (error) { + console.error( + `[${scenarioName}] Failed: ${ + error instanceof Error ? error.stack : String(error) + }`, + ); + Deno.exit(1); + } +} diff --git a/scenarios/uptimerunner-vagrant/readme.md b/scenarios/uptimerunner-vagrant/readme.md new file mode 100644 index 0000000..c1e080d --- /dev/null +++ b/scenarios/uptimerunner-vagrant/readme.md @@ -0,0 +1,38 @@ +# uptimerunner-vagrant + +This scenario verifies a real remote runner shape with two Vagrant machines: + +1. `controller` runs the uptime.link runner protocol handler. +2. `runner` compiles and installs `uptimerunner` as a systemd service. +3. `runner` also runs a local HTTP target on `127.0.0.1`. +4. The controller assigns a check for that runner-local target. +5. The remote runner heartbeats, executes the check from its own VM, and submits + the result back to the controller. + +Run it with: + +```bash +pnpm scenario:uptimerunner-vagrant +``` + +Override the default private-network addresses if they collide locally: + +```bash +UPTIMELINK_VAGRANT_CONTROLLER_IP=192.168.60.10 \ +UPTIMELINK_VAGRANT_RUNNER_IP=192.168.60.11 \ +pnpm scenario:uptimerunner-vagrant +``` + +The scenario defaults to `UPTIMELINK_VAGRANT_PROVIDER=libvirt`. If this host +uses VirtualBox instead, run: + +```bash +UPTIMELINK_VAGRANT_PROVIDER=virtualbox pnpm scenario:uptimerunner-vagrant +``` + +If the current shell has not picked up newly added `libvirt` group membership, +run: + +```bash +sg libvirt -c 'pnpm scenario:uptimerunner-vagrant' +``` diff --git a/scripts/provision-common.sh b/scripts/provision-common.sh new file mode 100755 index 0000000..588f222 --- /dev/null +++ b/scripts/provision-common.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +export DEBIAN_FRONTEND=noninteractive + +apt-get update +apt-get install -y ca-certificates curl git unzip + +if ! command -v deno >/dev/null 2>&1; then + curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh +fi + +if [ -d /uptime.link ]; then + chown -R vagrant:vagrant /uptime.link +fi diff --git a/scripts/provision-runner.sh b/scripts/provision-runner.sh new file mode 100755 index 0000000..585670d --- /dev/null +++ b/scripts/provision-runner.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${UPTIMELINK_CONTROLLER_URL:?UPTIMELINK_CONTROLLER_URL is required}" +: "${UPTIMELINK_RUNNER_ID:?UPTIMELINK_RUNNER_ID is required}" +: "${UPTIMELINK_RUNNER_TOKEN:?UPTIMELINK_RUNNER_TOKEN is required}" + +target_port="${UPTIMELINK_TARGET_PORT:-18081}" + +install -d -m 0755 /opt/uptimerunner /etc/uptimerunner + +cd /uptime.link/uptimerunner +deno compile \ + --allow-net \ + --allow-read \ + --allow-write \ + --allow-run \ + --allow-env \ + --allow-sys \ + --output /opt/uptimerunner/uptimerunner \ + mod.ts + +chmod +x /opt/uptimerunner/uptimerunner +ln -sf /opt/uptimerunner/uptimerunner /usr/local/bin/uptimerunner + +cat >/opt/uptimerunner/target-server.ts <<'DENO' +const port = Number(Deno.env.get('UPTIMELINK_TARGET_PORT') ?? '18081'); + +Deno.serve({ hostname: '127.0.0.1', port }, () => { + return new Response('runner-local healthy\n', { + headers: { 'content-type': 'text/plain' }, + }); +}); +DENO + +cat >/etc/systemd/system/uptimerunner-target.service </dev/null 2>&1; then + break + fi + sleep 1 +done + +curl -fsS "http://127.0.0.1:${target_port}/health" >/dev/null +systemctl restart uptimerunner.service diff --git a/scripts/run-vagrant-scenario.sh b/scripts/run-vagrant-scenario.sh new file mode 100755 index 0000000..fe0fa46 --- /dev/null +++ b/scripts/run-vagrant-scenario.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +controller_ip="${UPTIMELINK_VAGRANT_CONTROLLER_IP:-192.168.56.10}" +controller_port="${UPTIMELINK_VAGRANT_CONTROLLER_PORT:-8080}" +provider="${UPTIMELINK_VAGRANT_PROVIDER:-libvirt}" +runner_id="${UPTIMELINK_VAGRANT_RUNNER_ID:-vagrant-runner-1}" +runner_token="${UPTIMELINK_VAGRANT_RUNNER_TOKEN:-vagrant-token}" +target_port="${UPTIMELINK_VAGRANT_TARGET_PORT:-18081}" + +if ! command -v vagrant >/dev/null 2>&1; then + echo 'Vagrant is required for scenario:uptimerunner-vagrant.' >&2 + exit 1 +fi + +case "${provider}" in + libvirt) + if [[ "$(vagrant plugin list)" != *'vagrant-libvirt'* ]]; then + echo 'The vagrant-libvirt plugin is required for UPTIMELINK_VAGRANT_PROVIDER=libvirt.' >&2 + exit 1 + fi + if [ -S /var/run/libvirt/libvirt-sock ] && [ ! -w /var/run/libvirt/libvirt-sock ]; then + echo 'Cannot access /var/run/libvirt/libvirt-sock.' >&2 + if getent group libvirt | grep -q "$(id -un)"; then + echo 'The current user is listed in libvirt, but this shell session has not picked up that group.' >&2 + echo "Retry without restarting using: sg libvirt -c 'pnpm scenario:uptimerunner-vagrant'" >&2 + else + echo 'Add the current user to the libvirt group and start a new login session, or use another provider.' >&2 + echo 'Example: sudo usermod -aG libvirt "$USER"' >&2 + fi + echo 'Alternative: UPTIMELINK_VAGRANT_PROVIDER=virtualbox pnpm scenario:uptimerunner-vagrant' >&2 + exit 1 + fi + ;; + virtualbox) + if ! command -v VBoxManage >/dev/null 2>&1; then + echo 'VirtualBox is required for UPTIMELINK_VAGRANT_PROVIDER=virtualbox.' >&2 + exit 1 + fi + ;; +esac + +cleanup() { + vagrant ssh runner -c 'sudo systemctl stop uptimerunner.service >/dev/null 2>&1 || true' >/dev/null 2>&1 || true +} +trap cleanup EXIT + +vagrant up --provider="${provider}" --no-provision controller runner +vagrant rsync +vagrant provision controller +vagrant provision runner + +vagrant ssh controller -c "cd /uptime.link/testing && UPTIMELINK_CONTROLLER_HOST=0.0.0.0 UPTIMELINK_CONTROLLER_PORT=${controller_port} UPTIMELINK_RUNNER_ID=${runner_id} UPTIMELINK_RUNNER_TOKEN=${runner_token} UPTIMELINK_TARGET_PORT=${target_port} UPTIMELINK_CONTROLLER_URL=http://${controller_ip}:${controller_port} deno run --allow-all --sloppy-imports --config /uptime.link/uptimerunner/deno.json scenarios/uptimerunner-vagrant/controller.ts"