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
+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}`);
}
}