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 "@atcute/lexicons": "^1.1.0", 12 "@atcute/tid": "^1.0.2", 13 "barometer-lexicon": "file:../lib", 14 }, 15 "devDependencies": { 16 "@types/bun": "latest", 17 "concurrently": "^9.2.0", 18 }, 19 "peerDependencies": { ··· 48 49 "@types/node": ["@types/node@24.0.13", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ=="], 50 51 "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], 52 53 "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], ··· 83 "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], 84 85 "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], 86 87 "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 88
··· 11 "@atcute/lexicons": "^1.1.0", 12 "@atcute/tid": "^1.0.2", 13 "barometer-lexicon": "file:../lib", 14 + "parsimmon": "^1.18.1", 15 }, 16 "devDependencies": { 17 "@types/bun": "latest", 18 + "@types/parsimmon": "^1.10.9", 19 "concurrently": "^9.2.0", 20 }, 21 "peerDependencies": { ··· 50 51 "@types/node": ["@types/node@24.0.13", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ=="], 52 53 + "@types/parsimmon": ["@types/parsimmon@1.10.9", "", {}, "sha512-O2M2x1w+m7gWLen8i5DOy6tWRnbRcsW6Pke3j3HAsJUrPb4g0MgjksIUm2aqUtCYxy7Qjr3CzjjwQBzhiGn46A=="], 54 + 55 "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], 56 57 "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], ··· 87 "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], 88 89 "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], 90 + 91 + "parsimmon": ["parsimmon@1.18.1", "", {}, "sha512-u7p959wLfGAhJpSDJVYXoyMCXWYwHia78HhRBWqk7AIbxdmlrfdp5wX0l3xv/iTSH5HvhN9K7o26hwwpgS5Nmw=="], 92 93 "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 94
+4 -1
proxy/gen-routes.ts
··· 28 const indexContent = `// Auto-generated route index 29 ${routeImports.join("\n")} 30 31 - export const routes = { 32 ${routeMap.join(",\n")} 33 }; 34
··· 28 const indexContent = `// Auto-generated route index 29 ${routeImports.join("\n")} 30 31 + export const routes: Record< 32 + string, 33 + Record<string, Bun.RouterTypes.RouteHandler<string>> 34 + > = { 35 ${routeMap.join(",\n")} 36 }; 37
+3 -1
proxy/package.json
··· 9 }, 10 "devDependencies": { 11 "@types/bun": "latest", 12 "concurrently": "^9.2.0" 13 }, 14 "peerDependencies": { ··· 21 "@atcute/identity-resolver": "^1.1.3", 22 "@atcute/lexicons": "^1.1.0", 23 "@atcute/tid": "^1.0.2", 24 - "barometer-lexicon": "file:../lib" 25 } 26 }
··· 9 }, 10 "devDependencies": { 11 "@types/bun": "latest", 12 + "@types/parsimmon": "^1.10.9", 13 "concurrently": "^9.2.0" 14 }, 15 "peerDependencies": { ··· 22 "@atcute/identity-resolver": "^1.1.3", 23 "@atcute/lexicons": "^1.1.0", 24 "@atcute/tid": "^1.0.2", 25 + "barometer-lexicon": "file:../lib", 26 + "parsimmon": "^1.18.1" 27 } 28 }
+21 -7
proxy/src/index.ts
··· 1 import os from "os"; 2 - 3 import { Client, CredentialManager } from "@atcute/client"; 4 import { getPdsEndpoint } from "@atcute/identity"; 5 import { ··· 9 } from "@atcute/identity-resolver"; 10 import { config } from "./config"; 11 import type {} from "@atcute/atproto"; 12 - import { getRecord, ok, putRecord, type Result } from "./utils"; 13 import store from "./store"; 14 import routes from "./routes"; 15 ··· 36 // fetch host record for this host 37 const maybeRecord = await getRecord( 38 "systems.gaze.barometer.host", 39 - os.hostname(), 40 ); 41 if (maybeRecord.ok) { 42 store.host = maybeRecord.value; ··· 44 45 // if it doesnt exist we make a new one 46 if (store.host === null) { 47 - const hostname = os.hostname(); 48 await putRecord( 49 { 50 $type: "systems.gaze.barometer.host", 51 - name: config.hostName ?? hostname, 52 description: config.hostDescription, 53 os: os.platform(), 54 }, 55 - hostname, 56 ); 57 } 58 59 - const server = Bun.serve({ routes }); 60 61 console.log(`server running on http://localhost:${server.port}`);
··· 1 import os from "os"; 2 import { Client, CredentialManager } from "@atcute/client"; 3 import { getPdsEndpoint } from "@atcute/identity"; 4 import { ··· 8 } from "@atcute/identity-resolver"; 9 import { config } from "./config"; 10 import type {} from "@atcute/atproto"; 11 + import { 12 + applyMiddleware, 13 + applyMiddlewareAll, 14 + getRecord, 15 + log, 16 + ok, 17 + putRecord, 18 + type Middleware, 19 + type Result, 20 + } from "./utils"; 21 import store from "./store"; 22 import routes from "./routes"; 23 ··· 44 // fetch host record for this host 45 const maybeRecord = await getRecord( 46 "systems.gaze.barometer.host", 47 + store.hostname, 48 ); 49 if (maybeRecord.ok) { 50 store.host = maybeRecord.value; ··· 52 53 // if it doesnt exist we make a new one 54 if (store.host === null) { 55 await putRecord( 56 { 57 $type: "systems.gaze.barometer.host", 58 + name: config.hostName ?? store.hostname, 59 description: config.hostDescription, 60 os: os.platform(), 61 }, 62 + store.hostname, 63 ); 64 } 65 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 + }); 74 75 console.log(`server running on http://localhost:${server.port}`);
+4 -1
proxy/src/routes/index.ts
··· 2 import * as _healthRoute from "./_health"; 3 import * as pushRoute from "./push"; 4 5 - export const routes = { 6 "/_health": _healthRoute, 7 "/push": pushRoute 8 };
··· 2 import * as _healthRoute from "./_health"; 3 import * as pushRoute from "./push"; 4 5 + export const routes: Record< 6 + string, 7 + Record<string, Bun.RouterTypes.RouteHandler<string>> 8 + > = { 9 "/_health": _healthRoute, 10 "/push": pushRoute 11 };
+77 -18
proxy/src/routes/push.ts
··· 1 - import { SystemsGazeBarometerState } from "barometer-lexicon"; 2 import { err, expect, getRecord, ok, putRecord, type Result } from "../utils"; 3 import { parseCanonicalResourceUri, safeParse } from "@atcute/lexicons"; 4 - import store from "../store"; 5 6 interface PushRequest { 7 serviceName?: string; // service manager service name 8 - state: SystemsGazeBarometerState.Main; 9 } 10 11 const parsePushRequest = (json: unknown): Result<PushRequest, string> => { ··· 16 return err("serviceName is not a string"); 17 } 18 if ("state" in json) { 19 - const parsed = safeParse(SystemsGazeBarometerState.mainSchema, json.state); 20 if (!parsed.ok) { 21 return err(`state is invalid: ${parsed.message}`); 22 } ··· 38 } 39 const data = maybeData.value; 40 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, 47 ); 48 - if (!serviceRecord.ok) { 49 return badRequest({ 50 - msg: `service was not found or is invalid: ${serviceRecord.error}`, 51 }); 52 } 53 - service = { 54 - record: serviceRecord.value, 55 - checks: new Map(), 56 - }; 57 - store.services.set(serviceAtUri.rkey, service); 58 } 59 60 if (data.state.generatedBy) { ··· 79 } 80 } 81 82 - const result = await putRecord(data.state); 83 return new Response(JSON.stringify({ cid: result.cid, uri: result.uri })); 84 };
··· 1 import { err, expect, getRecord, ok, putRecord, type Result } from "../utils"; 2 import { parseCanonicalResourceUri, safeParse } from "@atcute/lexicons"; 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 + ); 30 31 interface PushRequest { 32 serviceName?: string; // service manager service name 33 + state: v.InferOutput<typeof StateSchemaSubset>; 34 } 35 36 const parsePushRequest = (json: unknown): Result<PushRequest, string> => { ··· 41 return err("serviceName is not a string"); 42 } 43 if ("state" in json) { 44 + const parsed = safeParse(StateSchemaSubset, json.state); 45 if (!parsed.ok) { 46 return err(`state is invalid: ${parsed.message}`); 47 } ··· 63 } 64 const data = maybeData.value; 65 66 + let service: Service | undefined = undefined; 67 + if (data.state.forService) { 68 + const serviceAtUri = expect( 69 + parseCanonicalResourceUri(data.state.forService), 70 ); 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 { 106 return badRequest({ 107 + msg: `could not fetch service from systemd: ${serviceInfo.error}`, 108 }); 109 } 110 + } else { 111 + return badRequest({ 112 + msg: `either 'state.forService' or 'serviceName' must be provided`, 113 + }); 114 } 115 116 if (data.state.generatedBy) { ··· 135 } 136 } 137 138 + const result = await putRecord( 139 + { ...data.state, forService: data.state.forService! }, 140 + generateTid(), 141 + ); 142 return new Response(JSON.stringify({ cid: result.cid, uri: result.uri })); 143 };
+5 -2
proxy/src/store.ts
··· 1 import type { RecordKey } from "@atcute/lexicons"; 2 import type { 3 SystemsGazeBarometerCheck, ··· 5 SystemsGazeBarometerService, 6 } from "barometer-lexicon"; 7 8 - interface Check { 9 record: SystemsGazeBarometerCheck.Main; 10 } 11 - interface Service { 12 checks: Map<RecordKey, Check>; 13 record: SystemsGazeBarometerService.Main; 14 } ··· 16 class Store { 17 services; 18 host: SystemsGazeBarometerHost.Main | null; 19 20 constructor() { 21 this.services = new Map<RecordKey, Service>(); 22 this.host = null; 23 } 24 } 25
··· 1 + import os from "os"; 2 import type { RecordKey } from "@atcute/lexicons"; 3 import type { 4 SystemsGazeBarometerCheck, ··· 6 SystemsGazeBarometerService, 7 } from "barometer-lexicon"; 8 9 + export interface Check { 10 record: SystemsGazeBarometerCheck.Main; 11 } 12 + export interface Service { 13 checks: Map<RecordKey, Check>; 14 record: SystemsGazeBarometerService.Main; 15 } ··· 17 class Store { 18 services; 19 host: SystemsGazeBarometerHost.Main | null; 20 + hostname: string; 21 22 constructor() { 23 this.services = new Map<RecordKey, Service>(); 24 this.host = null; 25 + this.hostname = os.hostname(); 26 } 27 } 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 import { schemas as BarometerSchemas } from "barometer-lexicon"; 3 import { config } from "./config"; 4 import { ok as clientOk } from "@atcute/client"; 5 - import { now as generateTid } from "@atcute/tid"; 6 import { atpClient } from "."; 7 8 export type Result<T, E> = ··· 64 Collection extends keyof typeof BarometerSchemas, 65 >( 66 record: InferOutput<(typeof BarometerSchemas)[Collection]>, 67 - rkey?: RecordKey, 68 ) => { 69 return await clientOk( 70 atpClient.post("com.atproto.repo.putRecord", { ··· 72 collection: record["$type"], 73 repo: config.repoDid, 74 record, 75 - rkey: rkey ?? generateTid(), 76 }, 77 }), 78 ); 79 };
··· 2 import { schemas as BarometerSchemas } from "barometer-lexicon"; 3 import { config } from "./config"; 4 import { ok as clientOk } from "@atcute/client"; 5 import { atpClient } from "."; 6 7 export type Result<T, E> = ··· 63 Collection extends keyof typeof BarometerSchemas, 64 >( 65 record: InferOutput<(typeof BarometerSchemas)[Collection]>, 66 + rkey: RecordKey, 67 ) => { 68 return await clientOk( 69 atpClient.post("com.atproto.repo.putRecord", { ··· 71 collection: record["$type"], 72 repo: config.repoDid, 73 record, 74 + rkey, 75 }, 76 }), 77 ); 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 + };