1import {
2 err,
3 expect,
4 getRecord,
5 getUri,
6 ok,
7 putRecord,
8 type CheckUri,
9 type CollectionUri,
10 type Result,
11 type ServiceUri,
12 type StateUri,
13} from "../utils";
14import {
15 parseCanonicalResourceUri,
16 safeParse,
17 type CanonicalResourceUri,
18 type ParsedCanonicalResourceUri,
19 type ResourceUri,
20} from "@atcute/lexicons";
21import store, { type Check, type Service, type State } from "../store";
22import { systemctlShow } from "../systemd";
23import { config } from "../config";
24import { now as generateTid } from "@atcute/tid";
25import * as v from "@atcute/lexicons/validations";
26import type { SystemsGazeBarometerService } from "barometer-lexicon";
27import type { SystemsGazeBarometerState } from "barometer-lexicon";
28
29// this is hacky but we want to make forService be optional so its okay
30const StateSchemaSubset = v.record(
31 v.tidString(),
32 v.object({
33 changedAt: v.optional(v.datetimeString()),
34 forService: v.optional(v.resourceUriString()),
35 generatedBy: v.optional(v.resourceUriString()),
36 reason: v.optional(v.string()),
37 state: v.literalEnum([
38 "systems.gaze.barometer.status.degraded",
39 "systems.gaze.barometer.status.healthy",
40 ]),
41 }),
42);
43
44interface PushRequest {
45 serviceName?: string; // service manager service name
46 state: v.InferOutput<typeof StateSchemaSubset>;
47}
48
49const parsePushRequest = (json: unknown): Result<PushRequest, string> => {
50 if (typeof json !== "object" || json === null) {
51 return err("invalid request");
52 }
53 if ("serviceName" in json && typeof json.serviceName !== "string") {
54 return err("serviceName is not a string");
55 }
56 if ("state" in json) {
57 const parsed = safeParse(StateSchemaSubset, json.state);
58 if (!parsed.ok) {
59 return err(`state is invalid: ${parsed.message}`);
60 }
61 } else {
62 return err("state not found");
63 }
64 return ok(json as PushRequest);
65};
66
67const error = <Error extends { msg: string }>(
68 error: Error,
69 status: number = 400,
70) => {
71 return new Response(JSON.stringify(error), { status });
72};
73export const POST = async (req: Bun.BunRequest) => {
74 const maybeData = parsePushRequest(await req.json());
75 if (!maybeData.ok) {
76 return error({
77 msg: `invalid request: ${maybeData.error}`,
78 });
79 }
80 const data = maybeData.value;
81
82 let service: Service | undefined = undefined;
83 let serviceAtUri: ServiceUri | undefined;
84 if (data.state.forService) {
85 serviceAtUri = data.state.forService as ServiceUri;
86 const maybeService = await store.getOrFetch(serviceAtUri);
87 if (!maybeService.ok)
88 return error({
89 msg: `could not fetch service: ${maybeService.error}`,
90 });
91 service = maybeService.value;
92 } else if (data.serviceName) {
93 const maybeService = await store.getServiceFromSystemd(data.serviceName);
94 if (!maybeService.ok)
95 return error({
96 msg: `could not fetch service from systemd: ${maybeService.error}`,
97 });
98 const [uri, srv] = maybeService.value;
99 serviceAtUri = uri;
100 service = srv;
101 } else {
102 return error({
103 msg: `either 'state.forService' or 'serviceName' must be provided`,
104 });
105 }
106
107 let check: Check | undefined = undefined;
108 if (data.state.generatedBy) {
109 const maybeCheck = await store.getOrFetch(
110 data.state.generatedBy as CheckUri,
111 );
112 if (!maybeCheck.ok) return error({ msg: maybeCheck.error });
113 check = maybeCheck.value;
114 if (check.record.forService !== serviceAtUri)
115 return error({
116 msg: `check record does not point to the same service as the state record service`,
117 });
118 // update services with check
119 service.checks.add(check.rkey);
120 store.services.set(serviceAtUri, service);
121 }
122
123 // get current state uri
124 const currentStateUri =
125 check && check.record.currentState
126 ? check.record.currentState
127 : service.record.currentState;
128
129 if (currentStateUri) {
130 // fetch current state
131 const record = await store.getOrFetch(currentStateUri as StateUri);
132 if (!record.ok) return error({ msg: record.error });
133 const currentState = record.value;
134
135 // check if the state has changed
136 if (currentState.record.state === data.state.state)
137 return error(
138 {
139 msg: `state can't be the same as the latest state`,
140 },
141 208,
142 );
143 }
144
145 const stateRecord: SystemsGazeBarometerState.Main = {
146 $type: "systems.gaze.barometer.state",
147 ...data.state,
148 forService: serviceAtUri,
149 changedAt: data.state.changedAt ?? new Date().toISOString(),
150 previous: currentStateUri,
151 };
152 const rkey = generateTid();
153 const result = await putRecord(stateRecord, rkey);
154
155 // store committed state in "cache"
156 store.states.set(result.uri as StateUri, { record: stateRecord, rkey });
157
158 // update check with new state url
159 if (check) {
160 check.record.currentState = result.uri;
161 store.checks.set(getUri("systems.gaze.barometer.check", check.rkey), check);
162 await putRecord(check.record, check.rkey);
163 } else {
164 // update service with new state url
165 service.record.currentState = result.uri;
166 store.services.set(serviceAtUri, service);
167 await putRecord(service.record, service.rkey);
168 }
169
170 return new Response(JSON.stringify({ cid: result.cid, uri: result.uri }));
171};