feat: add uptime runner agent
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user