service status on atproto

feat: put new service with info from systemd if service name is provided

ptr.pet 7ed262bd 72246e0d

verified
+6
proxy/bun.lock
···
"@atcute/lexicons": "^1.1.0",
"@atcute/tid": "^1.0.2",
"barometer-lexicon": "file:../lib",
+
"parsimmon": "^1.18.1",
},
"devDependencies": {
"@types/bun": "latest",
+
"@types/parsimmon": "^1.10.9",
"concurrently": "^9.2.0",
},
"peerDependencies": {
···
"@types/node": ["@types/node@24.0.13", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ=="],
+
"@types/parsimmon": ["@types/parsimmon@1.10.9", "", {}, "sha512-O2M2x1w+m7gWLen8i5DOy6tWRnbRcsW6Pke3j3HAsJUrPb4g0MgjksIUm2aqUtCYxy7Qjr3CzjjwQBzhiGn46A=="],
+
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
···
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
+
+
"parsimmon": ["parsimmon@1.18.1", "", {}, "sha512-u7p959wLfGAhJpSDJVYXoyMCXWYwHia78HhRBWqk7AIbxdmlrfdp5wX0l3xv/iTSH5HvhN9K7o26hwwpgS5Nmw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+4 -1
proxy/gen-routes.ts
···
const indexContent = `// Auto-generated route index
${routeImports.join("\n")}
-
export const routes = {
+
export const routes: Record<
+
string,
+
Record<string, Bun.RouterTypes.RouteHandler<string>>
+
> = {
${routeMap.join(",\n")}
};
+3 -1
proxy/package.json
···
},
"devDependencies": {
"@types/bun": "latest",
+
"@types/parsimmon": "^1.10.9",
"concurrently": "^9.2.0"
},
"peerDependencies": {
···
"@atcute/identity-resolver": "^1.1.3",
"@atcute/lexicons": "^1.1.0",
"@atcute/tid": "^1.0.2",
-
"barometer-lexicon": "file:../lib"
+
"barometer-lexicon": "file:../lib",
+
"parsimmon": "^1.18.1"
}
}
+21 -7
proxy/src/index.ts
···
import os from "os";
-
import { Client, CredentialManager } from "@atcute/client";
import { getPdsEndpoint } from "@atcute/identity";
import {
···
} from "@atcute/identity-resolver";
import { config } from "./config";
import type {} from "@atcute/atproto";
-
import { getRecord, ok, putRecord, type Result } from "./utils";
+
import {
+
applyMiddleware,
+
applyMiddlewareAll,
+
getRecord,
+
log,
+
ok,
+
putRecord,
+
type Middleware,
+
type Result,
+
} from "./utils";
import store from "./store";
import routes from "./routes";
···
// fetch host record for this host
const maybeRecord = await getRecord(
"systems.gaze.barometer.host",
-
os.hostname(),
+
store.hostname,
);
if (maybeRecord.ok) {
store.host = maybeRecord.value;
···
// if it doesnt exist we make a new one
if (store.host === null) {
-
const hostname = os.hostname();
await putRecord(
{
$type: "systems.gaze.barometer.host",
-
name: config.hostName ?? hostname,
+
name: config.hostName ?? store.hostname,
description: config.hostDescription,
os: os.platform(),
},
-
hostname,
+
store.hostname,
);
}
-
const server = Bun.serve({ routes });
+
const traceRequest: Middleware = async (req) => {
+
const url = new URL(req.url);
+
log.info(`${req.method} ${url.pathname}`);
+
return req;
+
};
+
const server = Bun.serve({
+
routes: applyMiddlewareAll([traceRequest], routes),
+
});
console.log(`server running on http://localhost:${server.port}`);
+4 -1
proxy/src/routes/index.ts
···
import * as _healthRoute from "./_health";
import * as pushRoute from "./push";
-
export const routes = {
+
export const routes: Record<
+
string,
+
Record<string, Bun.RouterTypes.RouteHandler<string>>
+
> = {
"/_health": _healthRoute,
"/push": pushRoute
};
+77 -18
proxy/src/routes/push.ts
···
-
import { SystemsGazeBarometerState } from "barometer-lexicon";
import { err, expect, getRecord, ok, putRecord, type Result } from "../utils";
import { parseCanonicalResourceUri, safeParse } from "@atcute/lexicons";
-
import store from "../store";
+
import store, { type Service } from "../store";
+
import { systemctlShow } from "../systemd";
+
import { config } from "../config";
+
import { now as generateTid } from "@atcute/tid";
+
import * as v from "@atcute/lexicons/validations";
+
import type { SystemsGazeBarometerService } from "barometer-lexicon";
+
+
// this is hacky but we want to make forService be optional so its okay
+
const StateSchemaSubset = v.record(
+
v.tidString(),
+
v.object({
+
$type: v.literal("systems.gaze.barometer.state"),
+
changedAt: v.datetimeString(),
+
forService: v.optional(v.resourceUriString()),
+
generatedBy: v.optional(v.resourceUriString()),
+
reason: v.optional(v.string()),
+
from: v.literalEnum([
+
"systems.gaze.barometer.status.degraded",
+
"systems.gaze.barometer.status.healthy",
+
"systems.gaze.barometer.status.unknown",
+
]),
+
to: v.literalEnum([
+
"systems.gaze.barometer.status.degraded",
+
"systems.gaze.barometer.status.healthy",
+
]),
+
}),
+
);
interface PushRequest {
serviceName?: string; // service manager service name
-
state: SystemsGazeBarometerState.Main;
+
state: v.InferOutput<typeof StateSchemaSubset>;
}
const parsePushRequest = (json: unknown): Result<PushRequest, string> => {
···
return err("serviceName is not a string");
}
if ("state" in json) {
-
const parsed = safeParse(SystemsGazeBarometerState.mainSchema, json.state);
+
const parsed = safeParse(StateSchemaSubset, json.state);
if (!parsed.ok) {
return err(`state is invalid: ${parsed.message}`);
}
···
}
const data = maybeData.value;
-
const serviceAtUri = expect(parseCanonicalResourceUri(data.state.forService));
-
let service = store.services.get(serviceAtUri.rkey);
-
if (!service) {
-
const serviceRecord = await getRecord(
-
"systems.gaze.barometer.service",
-
serviceAtUri.rkey,
+
let service: Service | undefined = undefined;
+
if (data.state.forService) {
+
const serviceAtUri = expect(
+
parseCanonicalResourceUri(data.state.forService),
);
-
if (!serviceRecord.ok) {
+
service = store.services.get(serviceAtUri.rkey);
+
if (!service) {
+
let serviceRecord = await getRecord(
+
"systems.gaze.barometer.service",
+
serviceAtUri.rkey,
+
);
+
if (!serviceRecord.ok) {
+
return badRequest({
+
msg: `service was not found or is invalid: ${serviceRecord.error}`,
+
});
+
}
+
service = {
+
record: serviceRecord.value,
+
checks: new Map(),
+
};
+
store.services.set(serviceAtUri.rkey, service);
+
}
+
} else if (data.serviceName) {
+
const serviceInfo = await systemctlShow(data.serviceName);
+
if (serviceInfo.ok) {
+
const record: SystemsGazeBarometerService.Main = {
+
$type: "systems.gaze.barometer.service",
+
name: data.serviceName,
+
description: serviceInfo.value.description,
+
hostedBy: `at://${config.repoDid}/systems.gaze.barometer.host/${store.hostname}`,
+
};
+
const rkey = generateTid();
+
const putAt = await putRecord(record, rkey);
+
data.state.forService = putAt.uri;
+
service = {
+
record,
+
checks: new Map(),
+
};
+
store.services.set(rkey, service);
+
} else {
return badRequest({
-
msg: `service was not found or is invalid: ${serviceRecord.error}`,
+
msg: `could not fetch service from systemd: ${serviceInfo.error}`,
});
}
-
service = {
-
record: serviceRecord.value,
-
checks: new Map(),
-
};
-
store.services.set(serviceAtUri.rkey, service);
+
} else {
+
return badRequest({
+
msg: `either 'state.forService' or 'serviceName' must be provided`,
+
});
}
if (data.state.generatedBy) {
···
}
}
-
const result = await putRecord(data.state);
+
const result = await putRecord(
+
{ ...data.state, forService: data.state.forService! },
+
generateTid(),
+
);
return new Response(JSON.stringify({ cid: result.cid, uri: result.uri }));
};
+5 -2
proxy/src/store.ts
···
+
import os from "os";
import type { RecordKey } from "@atcute/lexicons";
import type {
SystemsGazeBarometerCheck,
···
SystemsGazeBarometerService,
} from "barometer-lexicon";
-
interface Check {
+
export interface Check {
record: SystemsGazeBarometerCheck.Main;
}
-
interface Service {
+
export interface Service {
checks: Map<RecordKey, Check>;
record: SystemsGazeBarometerService.Main;
}
···
class Store {
services;
host: SystemsGazeBarometerHost.Main | null;
+
hostname: string;
constructor() {
this.services = new Map<RecordKey, Service>();
this.host = null;
+
this.hostname = os.hostname();
}
}
+92
proxy/src/systemd.ts
···
+
import { spawn } from "bun";
+
import P from "parsimmon";
+
import { err, ok, type Result } from "./utils";
+
+
interface SystemctlShowOutput {
+
[key: string]: string;
+
}
+
+
// Parsimmon parsers for systemctl output
+
const newline = P.string("\n");
+
const equals = P.string("=");
+
+
// Key: anything except = and newline
+
const key = P.regexp(/[^=\n]+/).map((s) => s.trim());
+
+
// Single line value: everything until newline (or end of input)
+
const singleLineValue = P.regexp(/[^\n]*/);
+
+
// Continuation line: newline followed by whitespace and content
+
const continuationLine = P.seq(
+
newline,
+
P.regexp(/[ \t]*/), // optional whitespace
+
P.regexp(/[^\n]*/), // content
+
).map(([, , content]) => "\n" + content);
+
+
// Multi-line value: first line + any continuation lines
+
const multiLineValue = P.seq(singleLineValue, continuationLine.many()).map(
+
([first, continuations]) => (first + continuations.join("")).trim(),
+
);
+
+
// Key-value pair: key = value
+
const keyValuePair = P.seq(key, equals, multiLineValue).map(([k, , v]) => ({
+
key: k,
+
value: v,
+
}));
+
+
// Empty line (just whitespace)
+
const emptyLine = P.regexp(/[ \t]*/).result(null);
+
+
// A line is either a key-value pair or empty line
+
const line = P.alt(keyValuePair, emptyLine);
+
+
// Complete systemctl output: lines separated by newlines, ending with optional newline
+
const systemctlOutput = P.seq(line.sepBy(newline), P.alt(newline, P.eof)).map(
+
([lines]) =>
+
lines.filter((l): l is { key: string; value: string } => l !== null),
+
);
+
+
const parseSystemctlOutput = (
+
output: string,
+
): Result<SystemctlShowOutput, string> => {
+
const result = systemctlOutput.parse(output);
+
+
if (!result.status) {
+
return err(
+
`Parse error at position ${result.index.offset}: ${result.expected.join(", ")}`,
+
);
+
}
+
+
const kvMap: SystemctlShowOutput = {};
+
+
for (const { key, value } of result.value) {
+
if (value.length > 0) {
+
kvMap[key.toLowerCase()] = value;
+
}
+
}
+
+
return ok(kvMap);
+
};
+
+
export const systemctlShow = async (
+
serviceName: string,
+
): Promise<Result<SystemctlShowOutput, string>> => {
+
try {
+
const proc = spawn(["systemctl", "show", `${serviceName}.service`], {
+
stdout: "pipe",
+
stderr: "pipe",
+
});
+
+
const output = await new Response(proc.stdout).text();
+
const exitCode = await proc.exited;
+
+
if (exitCode !== 0) {
+
const error = await new Response(proc.stderr).text();
+
return err(`systemctl show failed with exit code ${exitCode}: ${error}`);
+
}
+
+
return parseSystemctlOutput(output);
+
} catch (error) {
+
return err(`failed to execute systemctl show: ${error}`);
+
}
+
};
+51 -3
proxy/src/utils.ts
···
import { schemas as BarometerSchemas } from "barometer-lexicon";
import { config } from "./config";
import { ok as clientOk } from "@atcute/client";
-
import { now as generateTid } from "@atcute/tid";
import { atpClient } from ".";
export type Result<T, E> =
···
Collection extends keyof typeof BarometerSchemas,
>(
record: InferOutput<(typeof BarometerSchemas)[Collection]>,
-
rkey?: RecordKey,
+
rkey: RecordKey,
) => {
return await clientOk(
atpClient.post("com.atproto.repo.putRecord", {
···
collection: record["$type"],
repo: config.repoDid,
record,
-
rkey: rkey ?? generateTid(),
+
rkey,
},
}),
);
};
+
+
export const log = {
+
info: console.log,
+
warn: console.warn,
+
error: console.error,
+
};
+
+
export type Middleware = (
+
req: Bun.BunRequest,
+
) => Promise<Bun.BunRequest | Response>;
+
+
export const applyMiddleware =
+
<T extends string>(
+
fns: Middleware[],
+
route: Bun.RouterTypes.RouteHandler<T>,
+
): Bun.RouterTypes.RouteHandler<T> =>
+
async (req, srv) => {
+
for (const fn of fns) {
+
const result = await fn(req);
+
if (result instanceof Response) {
+
return result;
+
} else {
+
req = result;
+
}
+
}
+
return route(req, srv);
+
};
+
+
type Routes = Record<
+
string,
+
Record<string, Bun.RouterTypes.RouteHandler<string>>
+
>;
+
export const applyMiddlewareAll = (
+
fns: Middleware[],
+
routes: Routes,
+
): Routes => {
+
return Object.fromEntries(
+
Object.entries(routes).map(([path, route]) => {
+
return [
+
path,
+
Object.fromEntries(
+
Object.entries(route).map(([method, handler]) => {
+
return [method, applyMiddleware(fns, handler)];
+
}),
+
),
+
];
+
}),
+
);
+
};