feat: add uptime runner agent

This commit is contained in:
2026-04-29 19:48:14 +00:00
commit d8d1adca14
18 changed files with 1234 additions and 0 deletions
+68
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
.nogit/
dist/
node_modules/
deno.lock
+35
View File
@@ -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
}
}
Executable
+96
View File
@@ -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 <token>"
echo "Install service with: sudo uptimerunner service install && sudo uptimerunner service start"
+21
View File
@@ -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.
Executable
+24
View File
@@ -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<void> {
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);
}
}
+40
View File
@@ -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"
}
+76
View File
@@ -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 <runner-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=<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
```
+35
View File
@@ -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}`);
}
}
+109
View File
@@ -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<Deno.NetAddr>((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}`,
};
}
+97
View File
@@ -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<TCheckJob[]> {
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<ICheckPollResponse | TCheckJob[]>(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<void> {
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<void> {
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<T = unknown>(urlArg: URL, initArg: RequestInit): Promise<T> {
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;
}
}
+151
View File
@@ -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<IUptimeCheckResult> {
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<Omit<IUptimeCheckResult, 'checkId' | 'runnerId' | 'type' | 'timing' | 'metadata'>> {
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<Omit<IUptimeCheckResult, 'checkId' | 'runnerId' | 'type' | 'timing' | 'metadata'>> {
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<Omit<IUptimeCheckResult, 'checkId' | 'runnerId' | 'type' | 'timing' | 'metadata'>> {
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<never>((_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<IUptimeCheckResult, 'checkId' | 'runnerId' | 'type' | 'timing' | 'metadata'> {
return {
status: checkArg.assumedStatus,
message: checkArg.message ?? `Assumed status: ${checkArg.assumedStatus}`,
};
}
private getTimeout(checkArg: TCheckJob): number {
return checkArg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
}
}
+209
View File
@@ -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<void> {
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<void> {
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<void> {
const url = argsArg.find((arg) => !arg.startsWith('--'));
if (!url) {
throw new Error('Usage: uptimerunner check <url>');
}
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<void> {
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<void> {
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<string, string>,
): Promise<IUptimeRunnerConfig> {
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<IUptimeRunnerConfig>;
}
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 <url>
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<string, string> {
const flags: Record<string, string> = {};
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<string, string>, 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)}`;
}
+77
View File
@@ -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<IUptimeRunnerConfig> {
const fileConfig = await readConfigFile(configPathArg);
const config = applyEnvironmentOverrides(fileConfig);
validateConfig(config);
return config;
}
export async function writeConfig(
configArg: IUptimeRunnerConfig,
configPathArg = getConfigPath(),
): Promise<void> {
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<IUptimeRunnerConfig>) {
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<IUptimeRunnerConfig>;
}
export function validateConfig(
configArg: Partial<IUptimeRunnerConfig>,
): 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<Partial<IUptimeRunnerConfig>> {
try {
const configText = await Deno.readTextFile(configPathArg);
return JSON.parse(configText) as Partial<IUptimeRunnerConfig>;
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
return {};
}
throw error;
}
}
+6
View File
@@ -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';
+17
View File
@@ -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';
+92
View File
@@ -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<void> {
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<IRunOnceResult> {
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<void> {
await this.apiClient.heartbeat({
runnerId: this.config.runnerId,
labels: this.config.labels,
version: denoConfig.version,
});
}
private async executeChecks(checksArg: TCheckJob[]): Promise<IUptimeCheckResult[]> {
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<void> {
await new Promise((resolve) => setTimeout(resolve, millisecondsArg));
}
+77
View File
@@ -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<void> {
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<void> {
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<void> {
await run('systemctl', ['start', 'uptimerunner.service']);
}
public async stop(): Promise<void> {
await run('systemctl', ['stop', 'uptimerunner.service']);
}
public async restart(): Promise<void> {
await run('systemctl', ['restart', 'uptimerunner.service']);
}
public async status(): Promise<void> {
await run('systemctl', ['status', 'uptimerunner.service', '--no-pager']);
}
public async logs(): Promise<void> {
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<void> {
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}`);
}
}