feat: add uptime runner agent
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.nogit/
|
||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
deno.lock
|
||||||
@@ -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
@@ -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"
|
||||||
@@ -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.
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
@@ -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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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