From d8d1adca14204f76dc59d69e0484e154b5bceb07 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Wed, 29 Apr 2026 19:48:14 +0000 Subject: [PATCH] feat: add uptime runner agent --- .gitea/workflows/release.yml | 68 ++++++++++++ .gitignore | 4 + deno.json | 35 ++++++ install.sh | 96 ++++++++++++++++ license | 21 ++++ mod.ts | 24 ++++ package.json | 40 +++++++ readme.md | 76 +++++++++++++ scripts/build-binaries.ts | 35 ++++++ test/test.ts | 109 ++++++++++++++++++ ts/api-client.ts | 97 ++++++++++++++++ ts/check-executor.ts | 151 +++++++++++++++++++++++++ ts/cli.ts | 209 +++++++++++++++++++++++++++++++++++ ts/config.ts | 77 +++++++++++++ ts/index.ts | 6 + ts/interfaces.ts | 17 +++ ts/runner.ts | 92 +++++++++++++++ ts/systemd.ts | 77 +++++++++++++ 18 files changed, 1234 insertions(+) create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 deno.json create mode 100755 install.sh create mode 100644 license create mode 100755 mod.ts create mode 100644 package.json create mode 100644 readme.md create mode 100644 scripts/build-binaries.ts create mode 100644 test/test.ts create mode 100644 ts/api-client.ts create mode 100644 ts/check-executor.ts create mode 100644 ts/cli.ts create mode 100644 ts/config.ts create mode 100644 ts/index.ts create mode 100644 ts/interfaces.ts create mode 100644 ts/runner.ts create mode 100644 ts/systemd.ts 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}`); + } +}