commit d8d1adca14204f76dc59d69e0484e154b5bceb07 Author: Juergen Kunz Date: Wed Apr 29 19:48:14 2026 +0000 feat: add uptime runner agent diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..5271677 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,68 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + build-and-release: + runs-on: ubuntu-latest + container: + image: code.foss.global/host.today/ht-docker-node:latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + + - name: Get version from tag + id: version + run: | + VERSION=${GITHUB_REF#refs/tags/} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT + + - name: Verify deno.json version matches tag + run: | + DENO_VERSION=$(grep -o '"version": "[^"]*"' deno.json | cut -d'"' -f4) + if [ "$DENO_VERSION" != "${{ steps.version.outputs.version_number }}" ]; then + echo "deno.json version $DENO_VERSION does not match tag ${{ steps.version.outputs.version_number }}" + exit 1 + fi + + - name: Test + run: deno task test + + - name: Compile binaries + run: deno task compile + + - name: Generate checksums + run: | + cd dist/binaries + sha256sum * > SHA256SUMS.txt + cd ../.. + + - name: Create Gitea Release + run: | + VERSION="${{ steps.version.outputs.version }}" + RELEASE_ID=$(curl -X POST -s \ + -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + -H "Content-Type: application/json" \ + "https://code.foss.global/api/v1/repos/uptime.link/uptimerunner/releases" \ + -d "{\"tag_name\":\"$VERSION\",\"name\":\"uptimerunner $VERSION\",\"body\":\"Pre-compiled uptime.link runner binaries.\",\"draft\":false,\"prerelease\":false}" | jq -r '.id') + + for binary in dist/binaries/*; do + filename=$(basename "$binary") + curl -X POST -s \ + -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$binary" \ + "https://code.foss.global/api/v1/repos/uptime.link/uptimerunner/releases/$RELEASE_ID/assets?name=$filename" + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e07daa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.nogit/ +dist/ +node_modules/ +deno.lock diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..98e91da --- /dev/null +++ b/deno.json @@ -0,0 +1,35 @@ +{ + "name": "@uptime.link/uptimerunner", + "version": "0.1.0", + "exports": "./mod.ts", + "nodeModulesDir": "auto", + "tasks": { + "dev": "deno run --allow-all mod.ts", + "compile": "deno run --allow-run --allow-read --allow-write --allow-env scripts/build-binaries.ts", + "test": "deno test --allow-all test/", + "test:watch": "deno test --allow-all --watch test/", + "check": "deno check mod.ts test/test.ts", + "fmt": "deno fmt", + "lint": "deno lint" + }, + "lint": { + "rules": { + "tags": [ + "recommended" + ] + } + }, + "fmt": { + "useTabs": false, + "lineWidth": 100, + "indentWidth": 2, + "semiColons": true, + "singleQuote": true + }, + "compilerOptions": { + "lib": [ + "deno.window" + ], + "strict": true + } +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..087fd2d --- /dev/null +++ b/install.sh @@ -0,0 +1,96 @@ +#!/bin/bash + +set -e + +SHOW_HELP=0 +SPECIFIED_VERSION="" +INSTALL_DIR="/opt/uptimerunner" +GITEA_BASE_URL="https://code.foss.global" +GITEA_REPO="uptime.link/uptimerunner" + +while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) + SHOW_HELP=1 + shift + ;; + --version) + SPECIFIED_VERSION="$2" + shift 2 + ;; + --install-dir) + INSTALL_DIR="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +if [ $SHOW_HELP -eq 1 ]; then + echo "uptimerunner installer" + echo "" + echo "Usage: curl -sSL https://code.foss.global/uptime.link/uptimerunner/raw/branch/main/install.sh | sudo bash" + echo "Options: --version VERSION --install-dir DIR" + exit 0 +fi + +if [ "$EUID" -ne 0 ]; then + echo "Please run as root." + exit 1 +fi + +detect_platform() { + local os=$(uname -s) + local arch=$(uname -m) + + case "$os" in + Linux) os_name="linux" ;; + Darwin) os_name="macos" ;; + MINGW*|MSYS*|CYGWIN*) os_name="windows" ;; + *) echo "Unsupported operating system: $os"; exit 1 ;; + esac + + case "$arch" in + x86_64|amd64) arch_name="x64" ;; + aarch64|arm64) arch_name="arm64" ;; + *) echo "Unsupported architecture: $arch"; exit 1 ;; + esac + + if [ "$os_name" = "windows" ]; then + echo "uptimerunner-${os_name}-${arch_name}.exe" + else + echo "uptimerunner-${os_name}-${arch_name}" + fi +} + +get_latest_version() { + local response=$(curl -sSL "${GITEA_BASE_URL}/api/v1/repos/${GITEA_REPO}/releases/latest") + local version=$(echo "$response" | grep -o '"tag_name":"[^"]*"' | cut -d'"' -f4) + if [ -z "$version" ]; then + echo "Could not determine latest release version" >&2 + exit 1 + fi + echo "$version" +} + +echo "Installing uptime.link runner..." +BINARY_NAME=$(detect_platform) +VERSION=${SPECIFIED_VERSION:-$(get_latest_version)} +DOWNLOAD_URL="${GITEA_BASE_URL}/${GITEA_REPO}/releases/download/${VERSION}/${BINARY_NAME}" + +if systemctl is-active --quiet uptimerunner 2>/dev/null; then + systemctl stop uptimerunner +fi + +rm -rf "$INSTALL_DIR" +mkdir -p "$INSTALL_DIR" +curl -sSL "$DOWNLOAD_URL" -o "$INSTALL_DIR/uptimerunner" +chmod +x "$INSTALL_DIR/uptimerunner" +ln -sf "$INSTALL_DIR/uptimerunner" /usr/local/bin/uptimerunner + +echo "Installed /usr/local/bin/uptimerunner" +echo "Configure with: sudo uptimerunner config write --url https://uptime.link --runner-id edge-1 --token " +echo "Install service with: sudo uptimerunner service install && sudo uptimerunner service start" diff --git a/license b/license new file mode 100644 index 0000000..5d19587 --- /dev/null +++ b/license @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lossless GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mod.ts b/mod.ts new file mode 100755 index 0000000..a40420f --- /dev/null +++ b/mod.ts @@ -0,0 +1,24 @@ +#!/usr/bin/env -S deno run --allow-all + +/** + * uptime.link runner agent. + * + * The runner connects to an uptime.link instance, fetches assigned check jobs, + * executes them from the local network location, and reports results back. + */ + +import { UptimeRunnerCli } from './ts/cli.ts'; + +async function main(): Promise { + const cli = new UptimeRunnerCli(); + await cli.parseAndExecute(Deno.args); +} + +if (import.meta.main) { + try { + await main(); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + Deno.exit(1); + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b3445a3 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "@uptime.link/uptimerunner", + "version": "0.1.0", + "description": "Deno-powered uptime.link runner agent for executing checks close to the monitored target.", + "keywords": [ + "uptime", + "monitoring", + "runner", + "agent", + "deno", + "statuspage" + ], + "homepage": "https://code.foss.global/uptime.link/uptimerunner", + "bugs": { + "url": "https://code.foss.global/uptime.link/uptimerunner/issues" + }, + "repository": { + "type": "git", + "url": "ssh://git@code.foss.global:29419/uptime.link/uptimerunner.git" + }, + "author": "Lossless GmbH", + "license": "MIT", + "type": "module", + "scripts": { + "test": "deno task test", + "build": "deno task check", + "compile": "deno task compile", + "lint": "deno task lint", + "format": "deno task fmt" + }, + "files": [ + "mod.ts", + "ts/", + "dist/", + "install.sh", + "readme.md", + "license" + ], + "packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34" +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..2242d2e --- /dev/null +++ b/readme.md @@ -0,0 +1,76 @@ +# @uptime.link/uptimerunner + +Deno-powered uptime.link runner agent. It connects to an uptime.link instance, fetches assigned +checks, executes them from the runner's network location, and reports results back. + +## Install + +```bash +curl -sSL https://code.foss.global/uptime.link/uptimerunner/raw/branch/main/install.sh | sudo bash +``` + +## Configure + +```bash +sudo uptimerunner config write \ + --url https://uptime.link \ + --runner-id edge-1 \ + --token +``` + +## Run + +```bash +uptimerunner run +``` + +For systemd: + +```bash +sudo uptimerunner service install +sudo uptimerunner service start +``` + +## Runner Protocol + +The runner polls: + +```text +GET /api/runner/v1/checks?runnerId= +``` + +The uptime.link instance returns: + +```json +{ + "checks": [ + { + "id": "api-health", + "type": "http", + "url": "https://api.example.com/health", + "expectedStatusCodes": [200], + "expectedBodyIncludes": "ok" + } + ] +} +``` + +The runner reports: + +```text +POST /api/runner/v1/results +``` + +Supported check types: + +- `http` +- `tcp` +- `assumption` + +## Development + +```bash +deno task check +deno task test +deno task compile +``` diff --git a/scripts/build-binaries.ts b/scripts/build-binaries.ts new file mode 100644 index 0000000..80e28b8 --- /dev/null +++ b/scripts/build-binaries.ts @@ -0,0 +1,35 @@ +const targets = [ + { target: 'x86_64-unknown-linux-gnu', name: 'uptimerunner-linux-x64' }, + { target: 'aarch64-unknown-linux-gnu', name: 'uptimerunner-linux-arm64' }, + { target: 'x86_64-apple-darwin', name: 'uptimerunner-macos-x64' }, + { target: 'aarch64-apple-darwin', name: 'uptimerunner-macos-arm64' }, + { target: 'x86_64-pc-windows-msvc', name: 'uptimerunner-windows-x64.exe' }, +]; + +await Deno.mkdir('dist/binaries', { recursive: true }); + +for (const target of targets) { + console.log(`Compiling ${target.name}...`); + const command = new Deno.Command('deno', { + args: [ + 'compile', + '--allow-net', + '--allow-read', + '--allow-write', + '--allow-run', + '--allow-env', + '--allow-sys', + '--target', + target.target, + '--output', + `dist/binaries/${target.name}`, + 'mod.ts', + ], + stdout: 'inherit', + stderr: 'inherit', + }); + const output = await command.output(); + if (!output.success) { + throw new Error(`Failed to compile ${target.name}`); + } +} diff --git a/test/test.ts b/test/test.ts new file mode 100644 index 0000000..62e680b --- /dev/null +++ b/test/test.ts @@ -0,0 +1,109 @@ +import { assert, assertEquals } from 'jsr:@std/assert@^1.0.0'; +import { CheckExecutor } from '../ts/check-executor.ts'; +import { UptimeRunner } from '../ts/runner.ts'; +import type { TCheckJob } from '../ts/interfaces.ts'; + +Deno.test('CheckExecutor: executes successful HTTP check', async () => { + const { server, url } = await startServer(() => new Response('ok')); + try { + const executor = new CheckExecutor('test-runner'); + const result = await executor.execute({ + id: 'http-ok', + type: 'http', + url, + expectedStatusCodes: [200], + expectedBodyIncludes: 'ok', + }); + + assertEquals(result.status, 'ok'); + assertEquals(result.statusCode, 200); + assert(result.responseTime !== undefined); + } finally { + await server.shutdown(); + } +}); + +Deno.test('CheckExecutor: reports HTTP expectation mismatch', async () => { + const { server, url } = await startServer(() => new Response('not-ok', { status: 503 })); + try { + const executor = new CheckExecutor('test-runner'); + const result = await executor.execute({ + id: 'http-not-ok', + type: 'http', + url, + expectedStatusCodes: [200], + }); + + assertEquals(result.status, 'not ok'); + assertEquals(result.statusCode, 503); + } finally { + await server.shutdown(); + } +}); + +Deno.test('UptimeRunner: polls checks and submits results', async () => { + const targetServer = await startServer(() => new Response('healthy')); + const postedResults: unknown[] = []; + const checks: TCheckJob[] = [ + { + id: 'target-health', + type: 'http', + url: targetServer.url, + expectedStatusCodes: [200], + expectedBodyIncludes: 'healthy', + }, + ]; + + const coordinatorServer = await startServer(async (request) => { + const url = new URL(request.url); + if (url.pathname === '/api/runner/v1/checks') { + assertEquals(request.headers.get('authorization'), 'Bearer test-token'); + return Response.json({ checks }); + } + if (url.pathname === '/api/runner/v1/results') { + postedResults.push(await request.json()); + return Response.json({ ok: true }); + } + if (url.pathname === '/api/runner/v1/heartbeat') { + return Response.json({ ok: true }); + } + return new Response('not found', { status: 404 }); + }); + + try { + const runner = new UptimeRunner({ + instanceUrl: coordinatorServer.url, + runnerId: 'test-runner', + token: 'test-token', + }); + const result = await runner.runOnce(); + + assertEquals(result.checks.length, 1); + assertEquals(result.results.length, 1); + assertEquals(result.results[0].status, 'ok'); + assertEquals(postedResults.length, 1); + } finally { + await coordinatorServer.server.shutdown(); + await targetServer.server.shutdown(); + } +}); + +async function startServer( + handlerArg: Deno.ServeHandler, +): Promise<{ server: Deno.HttpServer; url: string }> { + let resolveListening: (addr: Deno.NetAddr) => void; + const listening = new Promise((resolve) => { + resolveListening = resolve; + }); + + const server = Deno.serve({ + hostname: '127.0.0.1', + port: 0, + onListen: (addr) => resolveListening(addr), + }, handlerArg); + const addr = await listening; + return { + server, + url: `http://${addr.hostname}:${addr.port}`, + }; +} diff --git a/ts/api-client.ts b/ts/api-client.ts new file mode 100644 index 0000000..8214b41 --- /dev/null +++ b/ts/api-client.ts @@ -0,0 +1,97 @@ +import type { + ICheckPollResponse, + IHeartbeatRequest, + IResultSubmitRequest, + IUptimeCheckResult, + IUptimeRunnerConfig, + TCheckJob, +} from './interfaces.ts'; + +export class UptimeRunnerApiClient { + private readonly instanceUrl: URL; + private readonly token: string; + + constructor(configArg: IUptimeRunnerConfig) { + this.instanceUrl = new URL(configArg.instanceUrl); + this.token = configArg.token; + } + + public async fetchAssignedChecks( + runnerIdArg: string, + labelsArg: string[] = [], + ): Promise { + const url = this.createUrl('/api/runner/v1/checks'); + url.searchParams.set('runnerId', runnerIdArg); + for (const label of labelsArg) { + url.searchParams.append('label', label); + } + + const responseData = await this.requestJson(url, { + method: 'GET', + }); + + if (Array.isArray(responseData)) { + return responseData; + } + + if (!Array.isArray(responseData.checks)) { + throw new Error('Invalid check poll response: expected checks array.'); + } + + return responseData.checks; + } + + public async submitResults(runnerIdArg: string, resultsArg: IUptimeCheckResult[]): Promise { + if (resultsArg.length === 0) { + return; + } + + const body: IResultSubmitRequest = { + runnerId: runnerIdArg, + results: resultsArg, + }; + + await this.requestJson(this.createUrl('/api/runner/v1/results'), { + method: 'POST', + body: JSON.stringify(body), + }); + } + + public async heartbeat(requestArg: IHeartbeatRequest): Promise { + await this.requestJson(this.createUrl('/api/runner/v1/heartbeat'), { + method: 'POST', + body: JSON.stringify(requestArg), + }); + } + + private createUrl(pathArg: string): URL { + return new URL(pathArg, this.instanceUrl); + } + + private async requestJson(urlArg: URL, initArg: RequestInit): Promise { + const response = await fetch(urlArg, { + ...initArg, + headers: { + accept: 'application/json', + authorization: `Bearer ${this.token}`, + 'content-type': 'application/json', + ...(initArg.headers ?? {}), + }, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error( + `uptime.link API ${ + initArg.method ?? 'GET' + } ${urlArg.pathname} failed: ${response.status} ${body}`, + ); + } + + if (response.status === 204) { + return undefined as T; + } + + return await response.json() as T; + } +} diff --git a/ts/check-executor.ts b/ts/check-executor.ts new file mode 100644 index 0000000..7a36509 --- /dev/null +++ b/ts/check-executor.ts @@ -0,0 +1,151 @@ +import type { + IAssumptionCheckJob, + IHttpCheckJob, + ITcpCheckJob, + IUptimeCheckResult, + TCheckJob, + TRunnerCheckResultStatus, +} from './interfaces.ts'; + +const DEFAULT_TIMEOUT_MS = 10000; + +export class CheckExecutor { + constructor(private readonly runnerId: string) {} + + public async execute(checkArg: TCheckJob): Promise { + const timeStarted = Date.now(); + + try { + const partialResult = await this.executeByType(checkArg, timeStarted); + const timeEnded = Date.now(); + return { + checkId: checkArg.id, + runnerId: this.runnerId, + type: checkArg.type, + timing: { + timeStarted, + timeEnded, + duration: timeEnded - timeStarted, + }, + metadata: checkArg.metadata, + ...partialResult, + }; + } catch (error) { + const timeEnded = Date.now(); + const isTimeout = error instanceof Error && /timed out|aborted/i.test(error.message); + return { + checkId: checkArg.id, + runnerId: this.runnerId, + type: checkArg.type, + status: isTimeout ? 'timed out' : 'not ok', + message: error instanceof Error ? error.message : String(error), + timing: { + timeStarted, + timeEnded, + duration: timeEnded - timeStarted, + }, + error: error instanceof Error ? error.message : String(error), + metadata: checkArg.metadata, + }; + } + } + + private async executeByType( + checkArg: TCheckJob, + timeStartedArg: number, + ): Promise> { + switch (checkArg.type) { + case 'http': + return await this.executeHttpCheck(checkArg, timeStartedArg); + case 'tcp': + return await this.executeTcpCheck(checkArg, timeStartedArg); + case 'assumption': + return this.executeAssumptionCheck(checkArg); + default: { + const neverCheck: never = checkArg; + throw new Error(`Unsupported check type: ${JSON.stringify(neverCheck)}`); + } + } + } + + private async executeHttpCheck( + checkArg: IHttpCheckJob, + timeStartedArg: number, + ): Promise> { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort('HTTP check timed out'), + this.getTimeout(checkArg), + ); + const method = checkArg.method ?? (checkArg.expectedBodyIncludes ? 'GET' : 'HEAD'); + + try { + const response = await fetch(checkArg.url, { + method, + headers: checkArg.headers, + body: checkArg.body, + signal: controller.signal, + }); + const expectedStatusCodes = checkArg.expectedStatusCodes ?? [200]; + const statusMatches = expectedStatusCodes.includes(response.status); + const body = checkArg.expectedBodyIncludes ? await response.text() : ''; + const bodyMatches = checkArg.expectedBodyIncludes + ? body.includes(checkArg.expectedBodyIncludes) + : true; + const status: TRunnerCheckResultStatus = statusMatches && bodyMatches ? 'ok' : 'not ok'; + + return { + status, + statusCode: response.status, + responseTime: Date.now() - timeStartedArg, + message: status === 'ok' + ? `HTTP ${response.status} matched expectations` + : `HTTP ${response.status} did not match expected status/body`, + }; + } finally { + clearTimeout(timeout); + } + } + + private async executeTcpCheck( + checkArg: ITcpCheckJob, + timeStartedArg: number, + ): Promise> { + const timeoutMs = this.getTimeout(checkArg); + let timeoutId: number | undefined; + let connection: Deno.TcpConn | undefined; + + try { + connection = await Promise.race([ + Deno.connect({ hostname: checkArg.host, port: checkArg.port }), + new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => reject(new Error('TCP check timed out')), timeoutMs); + }), + ]); + + return { + status: 'ok', + responseTime: Date.now() - timeStartedArg, + message: `TCP connection established to ${checkArg.host}:${checkArg.port}`, + }; + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + connection?.close(); + } + } + + private executeAssumptionCheck( + checkArg: IAssumptionCheckJob, + ): Omit { + return { + status: checkArg.assumedStatus, + message: checkArg.message ?? `Assumed status: ${checkArg.assumedStatus}`, + }; + } + + private getTimeout(checkArg: TCheckJob): number { + return checkArg.timeoutMs ?? DEFAULT_TIMEOUT_MS; + } +} diff --git a/ts/cli.ts b/ts/cli.ts new file mode 100644 index 0000000..c8a79f1 --- /dev/null +++ b/ts/cli.ts @@ -0,0 +1,209 @@ +import { CheckExecutor } from './check-executor.ts'; +import { DEFAULT_CONFIG_PATH, loadConfig, validateConfig, writeConfig } from './config.ts'; +import type { IUptimeRunnerConfig, TCheckJob } from './interfaces.ts'; +import { UptimeRunner } from './runner.ts'; +import { UptimeRunnerSystemd } from './systemd.ts'; +import denoConfig from '../deno.json' with { type: 'json' }; + +export class UptimeRunnerCli { + public async parseAndExecute(argsArg: string[]): Promise { + const command = argsArg[0] ?? 'help'; + const commandArgs = argsArg.slice(1); + + switch (command) { + case 'run': + await this.run(commandArgs, false); + break; + case 'once': + await this.run(commandArgs, true); + break; + case 'check': + await this.check(commandArgs); + break; + case 'config': + await this.config(commandArgs); + break; + case 'service': + await this.service(commandArgs); + break; + case '--version': + case '-v': + case 'version': + console.log(denoConfig.version); + break; + case 'help': + case '--help': + case '-h': + this.showHelp(); + break; + default: + throw new Error(`Unknown command: ${command}`); + } + } + + private async run(argsArg: string[], onceArg: boolean): Promise { + const flags = parseFlags(argsArg); + const config = await this.loadConfigFromFlags(flags); + const runner = new UptimeRunner(config); + + if (onceArg) { + const result = await runner.runOnce(); + console.log(JSON.stringify(result, null, 2)); + return; + } + + await runner.run(); + } + + private async check(argsArg: string[]): Promise { + const url = argsArg.find((arg) => !arg.startsWith('--')); + if (!url) { + throw new Error('Usage: uptimerunner check '); + } + + const check: TCheckJob = { + id: `manual-${Date.now().toString(36)}`, + type: 'http', + url, + expectedStatusCodes: [200], + }; + const executor = new CheckExecutor('manual'); + const result = await executor.execute(check); + console.log(JSON.stringify(result, null, 2)); + if (result.status !== 'ok') { + Deno.exit(2); + } + } + + private async config(argsArg: string[]): Promise { + const subcommand = argsArg[0] ?? 'show'; + const flags = parseFlags(argsArg.slice(1)); + const configPath = flags.config ?? DEFAULT_CONFIG_PATH; + + switch (subcommand) { + case 'show': { + const config = await loadConfig(configPath); + console.log(JSON.stringify({ ...config, token: mask(config.token) }, null, 2)); + break; + } + case 'write': { + const config: IUptimeRunnerConfig = { + instanceUrl: requiredFlag(flags, 'url'), + runnerId: requiredFlag(flags, 'runner-id'), + token: requiredFlag(flags, 'token'), + pollIntervalMs: flags.interval ? Number(flags.interval) : 30000, + labels: flags.labels?.split(',').map((labelArg) => labelArg.trim()).filter(Boolean), + maxConcurrentChecks: flags.concurrency ? Number(flags.concurrency) : 8, + }; + await writeConfig(config, configPath); + console.log(`Config written to ${configPath}`); + break; + } + default: + throw new Error(`Unknown config command: ${subcommand}`); + } + } + + private async service(argsArg: string[]): Promise { + const subcommand = argsArg[0] ?? 'status'; + const systemd = new UptimeRunnerSystemd(); + + switch (subcommand) { + case 'install': + await systemd.install(); + break; + case 'uninstall': + await systemd.uninstall(); + break; + case 'start': + await systemd.start(); + break; + case 'stop': + await systemd.stop(); + break; + case 'restart': + await systemd.restart(); + break; + case 'status': + await systemd.status(); + break; + case 'logs': + await systemd.logs(); + break; + default: + throw new Error(`Unknown service command: ${subcommand}`); + } + } + + private async loadConfigFromFlags( + flagsArg: Record, + ): Promise { + const configPath = flagsArg.config ?? DEFAULT_CONFIG_PATH; + const fileConfig = await loadConfig(configPath).catch((error) => { + if (flagsArg.url && flagsArg.token && flagsArg['runner-id']) { + return {} as Partial; + } + throw error; + }); + + const config = { + ...fileConfig, + instanceUrl: flagsArg.url ?? fileConfig.instanceUrl, + runnerId: flagsArg['runner-id'] ?? fileConfig.runnerId, + token: flagsArg.token ?? fileConfig.token, + pollIntervalMs: flagsArg.interval ? Number(flagsArg.interval) : fileConfig.pollIntervalMs, + labels: flagsArg.labels + ? flagsArg.labels.split(',').map((labelArg) => labelArg.trim()).filter(Boolean) + : fileConfig.labels, + maxConcurrentChecks: flagsArg.concurrency + ? Number(flagsArg.concurrency) + : fileConfig.maxConcurrentChecks, + }; + validateConfig(config); + return config; + } + + private showHelp(): void { + console.log(`uptimerunner ${denoConfig.version} + +Usage: + uptimerunner run [--config path] [--url https://uptime.link] [--token token] [--runner-id id] + uptimerunner once [--config path] + uptimerunner check + uptimerunner config write --url https://uptime.link --runner-id edge-1 --token token + uptimerunner service install|start|stop|restart|status|logs|uninstall + +Environment: + UPTIMERUNNER_CONFIG + UPTIMERUNNER_INSTANCE_URL + UPTIMERUNNER_RUNNER_ID + UPTIMERUNNER_TOKEN + UPTIMERUNNER_LABELS +`); + } +} + +function parseFlags(argsArg: string[]): Record { + const flags: Record = {}; + for (let index = 0; index < argsArg.length; index++) { + const arg = argsArg[index]; + if (!arg.startsWith('--')) { + continue; + } + const [rawName, inlineValue] = arg.slice(2).split('=', 2); + flags[rawName] = inlineValue ?? argsArg[++index]; + } + return flags; +} + +function requiredFlag(flagsArg: Record, nameArg: string): string { + const value = flagsArg[nameArg]; + if (!value) { + throw new Error(`Missing required --${nameArg} flag.`); + } + return value; +} + +function mask(valueArg: string): string { + return valueArg.length <= 8 ? '********' : `${valueArg.slice(0, 4)}...${valueArg.slice(-4)}`; +} diff --git a/ts/config.ts b/ts/config.ts new file mode 100644 index 0000000..64d5cf1 --- /dev/null +++ b/ts/config.ts @@ -0,0 +1,77 @@ +import type { IUptimeRunnerConfig } from './interfaces.ts'; + +export const DEFAULT_CONFIG_PATH = '/etc/uptimerunner/config.json'; + +export async function loadConfig(configPathArg = getConfigPath()): Promise { + const fileConfig = await readConfigFile(configPathArg); + const config = applyEnvironmentOverrides(fileConfig); + validateConfig(config); + return config; +} + +export async function writeConfig( + configArg: IUptimeRunnerConfig, + configPathArg = getConfigPath(), +): Promise { + validateConfig(configArg); + const configDir = configPathArg.slice(0, configPathArg.lastIndexOf('/')) || '.'; + await Deno.mkdir(configDir, { recursive: true }); + await Deno.writeTextFile(`${configPathArg}.tmp`, `${JSON.stringify(configArg, null, 2)}\n`); + await Deno.rename(`${configPathArg}.tmp`, configPathArg); +} + +export function getConfigPath(): string { + return Deno.env.get('UPTIMERUNNER_CONFIG') || DEFAULT_CONFIG_PATH; +} + +export function applyEnvironmentOverrides(configArg: Partial) { + const labels = Deno.env.get('UPTIMERUNNER_LABELS'); + const pollIntervalMs = Deno.env.get('UPTIMERUNNER_POLL_INTERVAL_MS'); + const maxConcurrentChecks = Deno.env.get('UPTIMERUNNER_MAX_CONCURRENT_CHECKS'); + + return { + ...configArg, + instanceUrl: Deno.env.get('UPTIMERUNNER_INSTANCE_URL') || configArg.instanceUrl, + runnerId: Deno.env.get('UPTIMERUNNER_RUNNER_ID') || configArg.runnerId, + token: Deno.env.get('UPTIMERUNNER_TOKEN') || configArg.token, + labels: labels + ? labels.split(',').map((labelArg) => labelArg.trim()).filter(Boolean) + : configArg.labels, + pollIntervalMs: pollIntervalMs ? Number(pollIntervalMs) : configArg.pollIntervalMs, + maxConcurrentChecks: maxConcurrentChecks + ? Number(maxConcurrentChecks) + : configArg.maxConcurrentChecks, + } satisfies Partial; +} + +export function validateConfig( + configArg: Partial, +): asserts configArg is IUptimeRunnerConfig { + if (!configArg.instanceUrl) { + throw new Error('Missing instanceUrl. Set it in config or UPTIMERUNNER_INSTANCE_URL.'); + } + if (!configArg.runnerId) { + throw new Error('Missing runnerId. Set it in config or UPTIMERUNNER_RUNNER_ID.'); + } + if (!configArg.token) { + throw new Error('Missing token. Set it in config or UPTIMERUNNER_TOKEN.'); + } + if (configArg.pollIntervalMs !== undefined && configArg.pollIntervalMs < 1000) { + throw new Error('pollIntervalMs must be at least 1000ms.'); + } + if (configArg.maxConcurrentChecks !== undefined && configArg.maxConcurrentChecks < 1) { + throw new Error('maxConcurrentChecks must be at least 1.'); + } +} + +async function readConfigFile(configPathArg: string): Promise> { + try { + const configText = await Deno.readTextFile(configPathArg); + return JSON.parse(configText) as Partial; + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return {}; + } + throw error; + } +} diff --git a/ts/index.ts b/ts/index.ts new file mode 100644 index 0000000..1080b8d --- /dev/null +++ b/ts/index.ts @@ -0,0 +1,6 @@ +export * from './api-client.ts'; +export * from './check-executor.ts'; +export * from './config.ts'; +export * from './interfaces.ts'; +export * from './runner.ts'; +export * from './systemd.ts'; diff --git a/ts/interfaces.ts b/ts/interfaces.ts new file mode 100644 index 0000000..f40826f --- /dev/null +++ b/ts/interfaces.ts @@ -0,0 +1,17 @@ +export type { + IAssumptionCheckJob, + ICheckJobBase, + ICheckPollResponse, + ICheckTiming, + IHeartbeatRequest, + IHttpCheckJob, + IResultSubmitRequest, + IRunnerHeartbeat, + IRunOnceResult, + ITcpCheckJob, + IUptimeCheckResult, + IUptimeRunnerConfig, + TCheckJob, + TCheckJobType, + TRunnerCheckResultStatus, +} from '../../uptime.link/ts_interfaces/data/runner.ts'; diff --git a/ts/runner.ts b/ts/runner.ts new file mode 100644 index 0000000..5ff1b5d --- /dev/null +++ b/ts/runner.ts @@ -0,0 +1,92 @@ +import { UptimeRunnerApiClient } from './api-client.ts'; +import { CheckExecutor } from './check-executor.ts'; +import type { + IRunOnceResult, + IUptimeCheckResult, + IUptimeRunnerConfig, + TCheckJob, +} from './interfaces.ts'; +import denoConfig from '../deno.json' with { type: 'json' }; + +export class UptimeRunner { + private readonly apiClient: UptimeRunnerApiClient; + private readonly checkExecutor: CheckExecutor; + private running = false; + + constructor(private readonly config: IUptimeRunnerConfig, apiClientArg?: UptimeRunnerApiClient) { + this.apiClient = apiClientArg ?? new UptimeRunnerApiClient(config); + this.checkExecutor = new CheckExecutor(config.runnerId); + } + + public async run(): Promise { + this.running = true; + console.log(`uptimerunner ${this.config.runnerId} connected to ${this.config.instanceUrl}`); + + while (this.running) { + const started = Date.now(); + try { + await this.heartbeat().catch((error) => { + console.warn( + `heartbeat failed: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + const result = await this.runOnce(); + if (result.results.length > 0) { + console.log(`reported ${result.results.length} check result(s)`); + } + } catch (error) { + console.error( + `runner iteration failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + const elapsed = Date.now() - started; + const delayMs = Math.max((this.config.pollIntervalMs ?? 30000) - elapsed, 1000); + await delayFor(delayMs); + } + } + + public stop(): void { + this.running = false; + } + + public async runOnce(): Promise { + const checks = await this.apiClient.fetchAssignedChecks( + this.config.runnerId, + this.config.labels ?? [], + ); + const results = await this.executeChecks(checks); + await this.apiClient.submitResults(this.config.runnerId, results); + return { checks, results }; + } + + private async heartbeat(): Promise { + await this.apiClient.heartbeat({ + runnerId: this.config.runnerId, + labels: this.config.labels, + version: denoConfig.version, + }); + } + + private async executeChecks(checksArg: TCheckJob[]): Promise { + const maxConcurrentChecks = this.config.maxConcurrentChecks ?? 8; + const results: IUptimeCheckResult[] = []; + let nextIndex = 0; + + const worker = async () => { + while (nextIndex < checksArg.length) { + const currentIndex = nextIndex++; + results[currentIndex] = await this.checkExecutor.execute(checksArg[currentIndex]); + } + }; + + await Promise.all( + Array.from({ length: Math.min(maxConcurrentChecks, checksArg.length) }, () => worker()), + ); + return results; + } +} + +async function delayFor(millisecondsArg: number): Promise { + await new Promise((resolve) => setTimeout(resolve, millisecondsArg)); +} diff --git a/ts/systemd.ts b/ts/systemd.ts new file mode 100644 index 0000000..f4320aa --- /dev/null +++ b/ts/systemd.ts @@ -0,0 +1,77 @@ +import { DEFAULT_CONFIG_PATH } from './config.ts'; + +const SERVICE_FILE_PATH = '/etc/systemd/system/uptimerunner.service'; + +export class UptimeRunnerSystemd { + private readonly serviceTemplate = `[Unit] +Description=uptime.link Runner Agent +After=network-online.target +Wants=network-online.target + +[Service] +ExecStart=/usr/local/bin/uptimerunner run --config ${DEFAULT_CONFIG_PATH} +Restart=always +RestartSec=10 +User=root +Group=root +Environment=PATH=/usr/bin:/usr/local/bin +WorkingDirectory=/opt/uptimerunner + +[Install] +WantedBy=multi-user.target +`; + + public async install(): Promise { + this.assertRoot(); + await Deno.writeTextFile(SERVICE_FILE_PATH, this.serviceTemplate); + await run('systemctl', ['daemon-reload']); + await run('systemctl', ['enable', 'uptimerunner.service']); + console.log(`Service installed: ${SERVICE_FILE_PATH}`); + } + + public async uninstall(): Promise { + this.assertRoot(); + await run('systemctl', ['disable', '--now', 'uptimerunner.service']).catch(() => null); + await Deno.remove(SERVICE_FILE_PATH).catch(() => null); + await run('systemctl', ['daemon-reload']); + console.log('Service removed.'); + } + + public async start(): Promise { + await run('systemctl', ['start', 'uptimerunner.service']); + } + + public async stop(): Promise { + await run('systemctl', ['stop', 'uptimerunner.service']); + } + + public async restart(): Promise { + await run('systemctl', ['restart', 'uptimerunner.service']); + } + + public async status(): Promise { + await run('systemctl', ['status', 'uptimerunner.service', '--no-pager']); + } + + public async logs(): Promise { + await run('journalctl', ['-u', 'uptimerunner.service', '-n', '120', '--no-pager']); + } + + private assertRoot(): void { + if (Deno.uid && Deno.uid() !== 0) { + throw new Error('This service command must run as root.'); + } + } +} + +async function run(commandArg: string, argsArg: string[]): Promise { + const command = new Deno.Command(commandArg, { + args: argsArg, + stdout: 'inherit', + stderr: 'inherit', + }); + const output = await command.output(); + if (!output.success) { + throw new Error(`${commandArg} ${argsArg.join(' ')} exited with ${output.code}`); + } +}