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
··· 11 11 "@atcute/lexicons": "^1.1.0", 12 12 "@atcute/tid": "^1.0.2", 13 13 "barometer-lexicon": "file:../lib", 14 + "parsimmon": "^1.18.1", 14 15 }, 15 16 "devDependencies": { 16 17 "@types/bun": "latest", 18 + "@types/parsimmon": "^1.10.9", 17 19 "concurrently": "^9.2.0", 18 20 }, 19 21 "peerDependencies": { ··· 48 50 49 51 "@types/node": ["@types/node@24.0.13", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ=="], 50 52 53 + "@types/parsimmon": ["@types/parsimmon@1.10.9", "", {}, "sha512-O2M2x1w+m7gWLen8i5DOy6tWRnbRcsW6Pke3j3HAsJUrPb4g0MgjksIUm2aqUtCYxy7Qjr3CzjjwQBzhiGn46A=="], 54 + 51 55 "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], 52 56 53 57 "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], ··· 83 87 "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], 84 88 85 89 "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], 90 + 91 + "parsimmon": ["parsimmon@1.18.1", "", {}, "sha512-u7p959wLfGAhJpSDJVYXoyMCXWYwHia78HhRBWqk7AIbxdmlrfdp5wX0l3xv/iTSH5HvhN9K7o26hwwpgS5Nmw=="], 86 92 87 93 "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 88 94
+4 -1
proxy/gen-routes.ts
··· 28 28 const indexContent = `// Auto-generated route index 29 29 ${routeImports.join("\n")} 30 30 31 - export const routes = { 31 + export const routes: Record< 32 + string, 33 + Record<string, Bun.RouterTypes.RouteHandler<string>> 34 + > = { 32 35 ${routeMap.join(",\n")} 33 36 }; 34 37
+3 -1
proxy/package.json
··· 9 9 }, 10 10 "devDependencies": { 11 11 "@types/bun": "latest", 12 + "@types/parsimmon": "^1.10.9", 12 13 "concurrently": "^9.2.0" 13 14 }, 14 15 "peerDependencies": { ··· 21 22 "@atcute/identity-resolver": "^1.1.3", 22 23 "@atcute/lexicons": "^1.1.0", 23 24 "@atcute/tid": "^1.0.2", 24 - "barometer-lexicon": "file:../lib" 25 + "barometer-lexicon": "file:../lib", 26 + "parsimmon": "^1.18.1" 25 27 } 26 28 }
+21 -7
proxy/src/index.ts
··· 1 1 import os from "os"; 2 - 3 2 import { Client, CredentialManager } from "@atcute/client"; 4 3 import { getPdsEndpoint } from "@atcute/identity"; 5 4 import { ··· 9 8 } from "@atcute/identity-resolver"; 10 9 import { config } from "./config"; 11 10 import type {} from "@atcute/atproto"; 12 - import { getRecord, ok, putRecord, type Result } from "./utils"; 11 + import { 12 + applyMiddleware, 13 + applyMiddlewareAll, 14 + getRecord, 15 + log, 16 + ok, 17 + putRecord, 18 + type Middleware, 19 + type Result, 20 + } from "./utils"; 13 21 import store from "./store"; 14 22 import routes from "./routes"; 15 23 ··· 36 44 // fetch host record for this host 37 45 const maybeRecord = await getRecord( 38 46 "systems.gaze.barometer.host", 39 - os.hostname(), 47 + store.hostname, 40 48 ); 41 49 if (maybeRecord.ok) { 42 50 store.host = maybeRecord.value; ··· 44 52 45 53 // if it doesnt exist we make a new one 46 54 if (store.host === null) { 47 - const hostname = os.hostname(); 48 55 await putRecord( 49 56 { 50 57 $type: "systems.gaze.barometer.host", 51 - name: config.hostName ?? hostname, 58 + name: config.hostName ?? store.hostname, 52 59 description: config.hostDescription, 53 60 os: os.platform(), 54 61 }, 55 - hostname, 62 + store.hostname, 56 63 ); 57 64 } 58 65 59 - const server = Bun.serve({ routes }); 66 + const traceRequest: Middleware = async (req) => { 67 + const url = new URL(req.url); 68 + log.info(`${req.method} ${url.pathname}`); 69 + return req; 70 + }; 71 + const server = Bun.serve({ 72 + routes: applyMiddlewareAll([traceRequest], routes), 73 + }); 60 74 61 75 console.log(`server running on http://localhost:${server.port}`);
+4 -1
proxy/src/routes/index.ts
··· 2 2 import * as _healthRoute from "./_health"; 3 3 import * as pushRoute from "./push"; 4 4 5 - export const routes = { 5 + export const routes: Record< 6 + string, 7 + Record<string, Bun.RouterTypes.RouteHandler<string>> 8 + > = { 6 9 "/_health": _healthRoute, 7 10 "/push": pushRoute 8 11 };
+77 -18
proxy/src/routes/push.ts
··· 1 - import { SystemsGazeBarometerState } from "barometer-lexicon"; 2 1 import { err, expect, getRecord, ok, putRecord, type Result } from "../utils"; 3 2 import { parseCanonicalResourceUri, safeParse } from "@atcute/lexicons"; 4 - import store from "../store"; 3 + import store, { type Service } from "../store"; 4 + import { systemctlShow } from "../systemd"; 5 + import { config } from "../config"; 6 + import { now as generateTid } from "@atcute/tid"; 7 + import * as v from "@atcute/lexicons/validations"; 8 + import type { SystemsGazeBarometerService } from "barometer-lexicon"; 9 + 10 + // this is hacky but we want to make forService be optional so its okay 11 + const StateSchemaSubset = v.record( 12 + v.tidString(), 13 + v.object({ 14 + $type: v.literal("systems.gaze.barometer.state"), 15 + changedAt: v.datetimeString(), 16 + forService: v.optional(v.resourceUriString()), 17 + generatedBy: v.optional(v.resourceUriString()), 18 + reason: v.optional(v.string()), 19 + from: v.literalEnum([ 20 + "systems.gaze.barometer.status.degraded", 21 + "systems.gaze.barometer.status.healthy", 22 + "systems.gaze.barometer.status.unknown", 23 + ]), 24 + to: v.literalEnum([ 25 + "systems.gaze.barometer.status.degraded", 26 + "systems.gaze.barometer.status.healthy", 27 + ]), 28 + }), 29 + ); 5 30 6 31 interface PushRequest { 7 32 serviceName?: string; // service manager service name 8 - state: SystemsGazeBarometerState.Main; 33 + state: v.InferOutput<typeof StateSchemaSubset>; 9 34 } 10 35 11 36 const parsePushRequest = (json: unknown): Result<PushRequest, string> => { ··· 16 41 return err("serviceName is not a string"); 17 42 } 18 43 if ("state" in json) { 19 - const parsed = safeParse(SystemsGazeBarometerState.mainSchema, json.state); 44 + const parsed = safeParse(StateSchemaSubset, json.state); 20 45 if (!parsed.ok) { 21 46 return err(`state is invalid: ${parsed.message}`); 22 47 } ··· 38 63 } 39 64 const data = maybeData.value; 40 65 41 - const serviceAtUri = expect(parseCanonicalResourceUri(data.state.forService)); 42 - let service = store.services.get(serviceAtUri.rkey); 43 - if (!service) { 44 - const serviceRecord = await getRecord( 45 - "systems.gaze.barometer.service", 46 - serviceAtUri.rkey, 66 + let service: Service | undefined = undefined; 67 + if (data.state.forService) { 68 + const serviceAtUri = expect( 69 + parseCanonicalResourceUri(data.state.forService), 47 70 ); 48 - if (!serviceRecord.ok) { 71 + service = store.services.get(serviceAtUri.rkey); 72 + if (!service) { 73 + let serviceRecord = await getRecord( 74 + "systems.gaze.barometer.service", 75 + serviceAtUri.rkey, 76 + ); 77 + if (!serviceRecord.ok) { 78 + return badRequest({ 79 + msg: `service was not found or is invalid: ${serviceRecord.error}`, 80 + }); 81 + } 82 + service = { 83 + record: serviceRecord.value, 84 + checks: new Map(), 85 + }; 86 + store.services.set(serviceAtUri.rkey, service); 87 + } 88 + } else if (data.serviceName) { 89 + const serviceInfo = await systemctlShow(data.serviceName); 90 + if (serviceInfo.ok) { 91 + const record: SystemsGazeBarometerService.Main = { 92 + $type: "systems.gaze.barometer.service", 93 + name: data.serviceName, 94 + description: serviceInfo.value.description, 95 + hostedBy: `at://${config.repoDid}/systems.gaze.barometer.host/${store.hostname}`, 96 + }; 97 + const rkey = generateTid(); 98 + const putAt = await putRecord(record, rkey); 99 + data.state.forService = putAt.uri; 100 + service = { 101 + record, 102 + checks: new Map(), 103 + }; 104 + store.services.set(rkey, service); 105 + } else { 49 106 return badRequest({ 50 - msg: `service was not found or is invalid: ${serviceRecord.error}`, 107 + msg: `could not fetch service from systemd: ${serviceInfo.error}`, 51 108 }); 52 109 } 53 - service = { 54 - record: serviceRecord.value, 55 - checks: new Map(), 56 - }; 57 - store.services.set(serviceAtUri.rkey, service); 110 + } else { 111 + return badRequest({ 112 + msg: `either 'state.forService' or 'serviceName' must be provided`, 113 + }); 58 114 } 59 115 60 116 if (data.state.generatedBy) { ··· 79 135 } 80 136 } 81 137 82 - const result = await putRecord(data.state); 138 + const result = await putRecord( 139 + { ...data.state, forService: data.state.forService! }, 140 + generateTid(), 141 + ); 83 142 return new Response(JSON.stringify({ cid: result.cid, uri: result.uri })); 84 143 };
+5 -2
proxy/src/store.ts
··· 1 + import os from "os"; 1 2 import type { RecordKey } from "@atcute/lexicons"; 2 3 import type { 3 4 SystemsGazeBarometerCheck, ··· 5 6 SystemsGazeBarometerService, 6 7 } from "barometer-lexicon"; 7 8 8 - interface Check { 9 + export interface Check { 9 10 record: SystemsGazeBarometerCheck.Main; 10 11 } 11 - interface Service { 12 + export interface Service { 12 13 checks: Map<RecordKey, Check>; 13 14 record: SystemsGazeBarometerService.Main; 14 15 } ··· 16 17 class Store { 17 18 services; 18 19 host: SystemsGazeBarometerHost.Main | null; 20 + hostname: string; 19 21 20 22 constructor() { 21 23 this.services = new Map<RecordKey, Service>(); 22 24 this.host = null; 25 + this.hostname = os.hostname(); 23 26 } 24 27 } 25 28
+92
proxy/src/systemd.ts
··· 1 + import { spawn } from "bun"; 2 + import P from "parsimmon"; 3 + import { err, ok, type Result } from "./utils"; 4 + 5 + interface SystemctlShowOutput { 6 + [key: string]: string; 7 + } 8 + 9 + // Parsimmon parsers for systemctl output 10 + const newline = P.string("\n"); 11 + const equals = P.string("="); 12 + 13 + // Key: anything except = and newline 14 + const key = P.regexp(/[^=\n]+/).map((s) => s.trim()); 15 + 16 + // Single line value: everything until newline (or end of input) 17 + const singleLineValue = P.regexp(/[^\n]*/); 18 + 19 + // Continuation line: newline followed by whitespace and content 20 + const continuationLine = P.seq( 21 + newline, 22 + P.regexp(/[ \t]*/), // optional whitespace 23 + P.regexp(/[^\n]*/), // content 24 + ).map(([, , content]) => "\n" + content); 25 + 26 + // Multi-line value: first line + any continuation lines 27 + const multiLineValue = P.seq(singleLineValue, continuationLine.many()).map( 28 + ([first, continuations]) => (first + continuations.join("")).trim(), 29 + ); 30 + 31 + // Key-value pair: key = value 32 + const keyValuePair = P.seq(key, equals, multiLineValue).map(([k, , v]) => ({ 33 + key: k, 34 + value: v, 35 + })); 36 + 37 + // Empty line (just whitespace) 38 + const emptyLine = P.regexp(/[ \t]*/).result(null); 39 + 40 + // A line is either a key-value pair or empty line 41 + const line = P.alt(keyValuePair, emptyLine); 42 + 43 + // Complete systemctl output: lines separated by newlines, ending with optional newline 44 + const systemctlOutput = P.seq(line.sepBy(newline), P.alt(newline, P.eof)).map( 45 + ([lines]) => 46 + lines.filter((l): l is { key: string; value: string } => l !== null), 47 + ); 48 + 49 + const parseSystemctlOutput = ( 50 + output: string, 51 + ): Result<SystemctlShowOutput, string> => { 52 + const result = systemctlOutput.parse(output); 53 + 54 + if (!result.status) { 55 + return err( 56 + `Parse error at position ${result.index.offset}: ${result.expected.join(", ")}`, 57 + ); 58 + } 59 + 60 + const kvMap: SystemctlShowOutput = {}; 61 + 62 + for (const { key, value } of result.value) { 63 + if (value.length > 0) { 64 + kvMap[key.toLowerCase()] = value; 65 + } 66 + } 67 + 68 + return ok(kvMap); 69 + }; 70 + 71 + export const systemctlShow = async ( 72 + serviceName: string, 73 + ): Promise<Result<SystemctlShowOutput, string>> => { 74 + try { 75 + const proc = spawn(["systemctl", "show", `${serviceName}.service`], { 76 + stdout: "pipe", 77 + stderr: "pipe", 78 + }); 79 + 80 + const output = await new Response(proc.stdout).text(); 81 + const exitCode = await proc.exited; 82 + 83 + if (exitCode !== 0) { 84 + const error = await new Response(proc.stderr).text(); 85 + return err(`systemctl show failed with exit code ${exitCode}: ${error}`); 86 + } 87 + 88 + return parseSystemctlOutput(output); 89 + } catch (error) { 90 + return err(`failed to execute systemctl show: ${error}`); 91 + } 92 + };
+51 -3
proxy/src/utils.ts
··· 2 2 import { schemas as BarometerSchemas } from "barometer-lexicon"; 3 3 import { config } from "./config"; 4 4 import { ok as clientOk } from "@atcute/client"; 5 - import { now as generateTid } from "@atcute/tid"; 6 5 import { atpClient } from "."; 7 6 8 7 export type Result<T, E> = ··· 64 63 Collection extends keyof typeof BarometerSchemas, 65 64 >( 66 65 record: InferOutput<(typeof BarometerSchemas)[Collection]>, 67 - rkey?: RecordKey, 66 + rkey: RecordKey, 68 67 ) => { 69 68 return await clientOk( 70 69 atpClient.post("com.atproto.repo.putRecord", { ··· 72 71 collection: record["$type"], 73 72 repo: config.repoDid, 74 73 record, 75 - rkey: rkey ?? generateTid(), 74 + rkey, 76 75 }, 77 76 }), 78 77 ); 79 78 }; 79 + 80 + export const log = { 81 + info: console.log, 82 + warn: console.warn, 83 + error: console.error, 84 + }; 85 + 86 + export type Middleware = ( 87 + req: Bun.BunRequest, 88 + ) => Promise<Bun.BunRequest | Response>; 89 + 90 + export const applyMiddleware = 91 + <T extends string>( 92 + fns: Middleware[], 93 + route: Bun.RouterTypes.RouteHandler<T>, 94 + ): Bun.RouterTypes.RouteHandler<T> => 95 + async (req, srv) => { 96 + for (const fn of fns) { 97 + const result = await fn(req); 98 + if (result instanceof Response) { 99 + return result; 100 + } else { 101 + req = result; 102 + } 103 + } 104 + return route(req, srv); 105 + }; 106 + 107 + type Routes = Record< 108 + string, 109 + Record<string, Bun.RouterTypes.RouteHandler<string>> 110 + >; 111 + export const applyMiddlewareAll = ( 112 + fns: Middleware[], 113 + routes: Routes, 114 + ): Routes => { 115 + return Object.fromEntries( 116 + Object.entries(routes).map(([path, route]) => { 117 + return [ 118 + path, 119 + Object.fromEntries( 120 + Object.entries(route).map(([method, handler]) => { 121 + return [method, applyMiddleware(fns, handler)]; 122 + }), 123 + ), 124 + ]; 125 + }), 126 + ); 127 + };