@openlup/platform 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +150 -138
- package/README.md +80 -3
- package/dist/composition/nodeHost.d.ts +1 -1
- package/dist/composition/nodeHost.js +3 -2
- package/dist/lifecycle/appUpgrade.d.ts +52 -0
- package/dist/lifecycle/appUpgrade.js +180 -0
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.js +2 -0
- package/dist/lifecycle/postgresInstallQualification.d.ts +51 -0
- package/dist/lifecycle/postgresInstallQualification.js +74 -0
- package/dist/migrations/postgresRunner.d.ts +9 -0
- package/dist/migrations/postgresRunner.js +17 -10
- package/dist/operations/adminAlertsSchemas.d.ts +153 -0
- package/dist/operations/adminAlertsSchemas.js +69 -0
- package/dist/operations/adminAlertsView.d.ts +73 -0
- package/dist/operations/adminAlertsView.js +184 -0
- package/dist/operations/alertPagingPolicy.d.ts +15 -0
- package/dist/operations/alertPagingPolicy.js +43 -0
- package/dist/operations/alertThrottlePolicy.d.ts +3 -0
- package/dist/operations/alertThrottlePolicy.js +18 -0
- package/dist/operations/clients.d.ts +17 -0
- package/dist/operations/clients.js +32 -0
- package/dist/operations/contracts/admin.d.ts +93 -0
- package/dist/operations/contracts/admin.js +1 -0
- package/dist/operations/contracts/adminAlertsContracts.d.ts +40 -0
- package/dist/operations/contracts/adminAlertsContracts.js +1 -0
- package/dist/operations/contracts/alertHumanContext.d.ts +7 -0
- package/dist/operations/contracts/alertHumanContext.js +1 -0
- package/dist/operations/contracts/index.d.ts +6 -0
- package/dist/operations/contracts/index.js +6 -0
- package/dist/operations/contracts/observabilityContracts.d.ts +41 -0
- package/dist/operations/contracts/observabilityContracts.js +1 -0
- package/dist/operations/contracts/observabilityPorts.d.ts +18 -0
- package/dist/operations/contracts/observabilityPorts.js +1 -0
- package/dist/operations/contracts/ports.d.ts +19 -0
- package/dist/operations/contracts/ports.js +1 -0
- package/dist/operations/errors.d.ts +6 -0
- package/dist/operations/errors.js +6 -0
- package/dist/operations/index.d.ts +9 -0
- package/dist/operations/index.js +9 -0
- package/dist/operations/schemas.d.ts +138 -0
- package/dist/operations/schemas.js +104 -0
- package/dist/operations/server/adminAlertsHandlers.d.ts +16 -0
- package/dist/operations/server/adminAlertsHandlers.js +35 -0
- package/dist/operations/server/adminPipelineHandlers.d.ts +12 -0
- package/dist/operations/server/adminPipelineHandlers.js +48 -0
- package/dist/operations/server/adminSettingsHandlers.d.ts +14 -0
- package/dist/operations/server/adminSettingsHandlers.js +82 -0
- package/dist/operations/server/controlPlaneHandler.d.ts +7 -0
- package/dist/operations/server/controlPlaneHandler.js +54 -0
- package/dist/operations/server/controlPlaneStore.d.ts +12 -0
- package/dist/operations/server/controlPlaneStore.js +138 -0
- package/dist/operations/server/controlPlaneWatchdog.d.ts +7 -0
- package/dist/operations/server/controlPlaneWatchdog.js +20 -0
- package/dist/operations/server/index.d.ts +12 -0
- package/dist/operations/server/index.js +12 -0
- package/dist/operations/server/migrations.d.ts +14 -0
- package/dist/operations/server/migrations.js +7 -0
- package/dist/operations/server/platformWatchdogDecision.d.ts +34 -0
- package/dist/operations/server/platformWatchdogDecision.js +71 -0
- package/dist/operations/server/platformWatchdogDeliveryPolicy.d.ts +10 -0
- package/dist/operations/server/platformWatchdogDeliveryPolicy.js +53 -0
- package/dist/operations/server/platformWatchdogQueue.d.ts +11 -0
- package/dist/operations/server/platformWatchdogQueue.js +94 -0
- package/dist/operations/server/platformWatchdogService.d.ts +47 -0
- package/dist/operations/server/platformWatchdogService.js +105 -0
- package/dist/operations/validation.d.ts +2 -0
- package/dist/operations/validation.js +3 -0
- package/dist/portability/filesystemObjects.d.ts +12 -0
- package/dist/portability/filesystemObjects.js +191 -0
- package/dist/portability/index.d.ts +5 -0
- package/dist/portability/index.js +4 -0
- package/dist/portability/objects.d.ts +63 -0
- package/dist/portability/objects.js +138 -0
- package/dist/portability/postgresRows.d.ts +64 -0
- package/dist/portability/postgresRows.js +182 -0
- package/dist/scheduler/advisoryLock.js +20 -2
- package/dist/scheduler/nodeScheduler.d.ts +2 -2
- package/dist/scheduler/nodeScheduler.js +35 -6
- package/package.json +27 -2
- package/release-gates.json +73 -7
- package/sql/20260814010000_platform_control_plane.sql +286 -0
- package/src/composition/nodeHost.ts +4 -3
- package/src/lifecycle/appUpgrade.ts +186 -0
- package/src/lifecycle/index.ts +19 -0
- package/src/lifecycle/postgresInstallQualification.ts +125 -0
- package/src/migrations/postgresRunner.ts +24 -9
- package/src/operations/adminAlertsSchemas.ts +78 -0
- package/src/operations/adminAlertsView.ts +238 -0
- package/src/operations/alertPagingPolicy.ts +50 -0
- package/src/operations/alertThrottlePolicy.ts +22 -0
- package/src/operations/clients.ts +44 -0
- package/src/operations/contracts/admin.ts +24 -0
- package/src/operations/contracts/adminAlertsContracts.ts +13 -0
- package/src/operations/contracts/alertHumanContext.ts +17 -0
- package/src/operations/contracts/index.ts +6 -0
- package/src/operations/contracts/observabilityContracts.ts +40 -0
- package/src/operations/contracts/observabilityPorts.ts +30 -0
- package/src/operations/contracts/ports.ts +22 -0
- package/src/operations/errors.ts +6 -0
- package/src/operations/index.ts +9 -0
- package/src/operations/schemas.ts +122 -0
- package/src/operations/server/adminAlertsHandlers.ts +61 -0
- package/src/operations/server/adminPipelineHandlers.ts +73 -0
- package/src/operations/server/adminSettingsHandlers.ts +114 -0
- package/src/operations/server/controlPlaneHandler.ts +56 -0
- package/src/operations/server/controlPlaneStore.ts +150 -0
- package/src/operations/server/controlPlaneWatchdog.ts +28 -0
- package/src/operations/server/index.ts +12 -0
- package/src/operations/server/migrations.ts +7 -0
- package/src/operations/server/platformWatchdogDecision.ts +93 -0
- package/src/operations/server/platformWatchdogDeliveryPolicy.ts +92 -0
- package/src/operations/server/platformWatchdogQueue.ts +96 -0
- package/src/operations/server/platformWatchdogService.ts +209 -0
- package/src/operations/validation.ts +3 -0
- package/src/portability/filesystemObjects.ts +138 -0
- package/src/portability/index.ts +12 -0
- package/src/portability/objects.ts +160 -0
- package/src/portability/postgresRows.ts +169 -0
- package/src/scheduler/advisoryLock.ts +20 -5
- package/src/scheduler/nodeScheduler.ts +28 -8
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createBffResponder, type BffResponderOptions } from "../../bff/index.js";
|
|
2
|
+
import type { HttpRequest, HttpResponse } from "../../http/index.js";
|
|
3
|
+
import { adminAlertsOverviewResponseSchema } from "../index.js";
|
|
4
|
+
import { buildAlertsOverview } from "../index.js";
|
|
5
|
+
import type {
|
|
6
|
+
PlatformAlertLedgerRow,
|
|
7
|
+
WatchdogHeartbeatRow,
|
|
8
|
+
} from "../index.js";
|
|
9
|
+
import type { OperationsAuthorizationResult } from "../contracts/index.js";
|
|
10
|
+
type AuthorizeAdmin = () => Promise<OperationsAuthorizationResult>;
|
|
11
|
+
|
|
12
|
+
export interface AdminAlertsReadPort {
|
|
13
|
+
listLiveAlerts(): Promise<PlatformAlertLedgerRow[]>;
|
|
14
|
+
readWatchdogHeartbeat(): Promise<WatchdogHeartbeatRow>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AdminAlertsOverviewDeps extends BffResponderOptions {
|
|
18
|
+
readPort: AdminAlertsReadPort;
|
|
19
|
+
authorizeAdmin: AuthorizeAdmin;
|
|
20
|
+
now?: () => Date;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createAdminAlertsOverviewHandler({
|
|
24
|
+
readPort,
|
|
25
|
+
authorizeAdmin,
|
|
26
|
+
now = () => new Date(), responseRequestId,
|
|
27
|
+
}: AdminAlertsOverviewDeps) {
|
|
28
|
+
const { sendBffError, sendBffSuccess, sendMethodNotAllowed } = createBffResponder({ responseRequestId });
|
|
29
|
+
return async function handler(req: HttpRequest, res: HttpResponse): Promise<void> {
|
|
30
|
+
if (req.method !== "GET") {
|
|
31
|
+
sendMethodNotAllowed(res, ["GET"]);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const authorization = await authorizeAdmin();
|
|
36
|
+
if (authorization.ok === false) {
|
|
37
|
+
sendBffError(res, authorization.code, authorization.message);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
// Heartbeat and alerts are read together: an empty alert list is only
|
|
43
|
+
// "healthy" if the watchdog actually reported recently.
|
|
44
|
+
const [alerts, heartbeat] = await Promise.all([
|
|
45
|
+
readPort.listLiveAlerts(),
|
|
46
|
+
readPort.readWatchdogHeartbeat(),
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const overview = buildAlertsOverview(alerts, heartbeat, now());
|
|
50
|
+
const response = adminAlertsOverviewResponseSchema.safeParse(overview);
|
|
51
|
+
if (!response.success) {
|
|
52
|
+
sendBffError(res, "INVALID_RESPONSE", "Admin alerts overview returned invalid response");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
sendBffSuccess(res, response.data);
|
|
57
|
+
} catch {
|
|
58
|
+
sendBffError(res, "UPSTREAM_UNAVAILABLE", "Admin alerts overview failed");
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createBffResponder, type BffResponderOptions, type BffResponder } from "../../bff/index.js";
|
|
2
|
+
import type { HttpRequest, HttpResponse } from "../../http/index.js";
|
|
3
|
+
import {
|
|
4
|
+
adminPipelineReadRequestSchema,
|
|
5
|
+
adminPipelineReadResponseSchema,
|
|
6
|
+
} from "../index.js";
|
|
7
|
+
import type { AdminPipelineReadPort } from "../contracts/index.js";
|
|
8
|
+
import type { OperationsAuthorizationResult } from "../contracts/index.js";
|
|
9
|
+
|
|
10
|
+
interface AdminPipelineBaseDeps extends BffResponderOptions {
|
|
11
|
+
authorizeAdmin: (req: HttpRequest) => Promise<OperationsAuthorizationResult>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AdminPipelineReadDeps extends AdminPipelineBaseDeps {
|
|
15
|
+
pipelinePort: Pick<AdminPipelineReadPort, "readPipeline">;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createAdminPipelineReadHandler({
|
|
19
|
+
pipelinePort,
|
|
20
|
+
authorizeAdmin, responseRequestId,
|
|
21
|
+
}: AdminPipelineReadDeps) {
|
|
22
|
+
const responder = createBffResponder({ responseRequestId });
|
|
23
|
+
const { sendBffError, sendBffSuccess, sendMethodNotAllowed } = responder;
|
|
24
|
+
return async function handler(req: HttpRequest, res: HttpResponse): Promise<void> {
|
|
25
|
+
if (req.method !== "GET") {
|
|
26
|
+
sendMethodNotAllowed(res, ["GET"]);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const authorization = await readAuthorization(req, res, authorizeAdmin, responder);
|
|
31
|
+
if (!authorization) return;
|
|
32
|
+
|
|
33
|
+
const request = adminPipelineReadRequestSchema.safeParse(req.query);
|
|
34
|
+
if (!request.success) {
|
|
35
|
+
sendBffError(res, "BAD_REQUEST", "Invalid admin pipeline read request", {
|
|
36
|
+
details: request.error.flatten(),
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const result = await pipelinePort.readPipeline(request.data);
|
|
43
|
+
const response = adminPipelineReadResponseSchema.safeParse(result);
|
|
44
|
+
if (!response.success) {
|
|
45
|
+
sendBffError(res, "INVALID_RESPONSE", "Admin pipeline read returned invalid response");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
sendBffSuccess(res, response.data);
|
|
50
|
+
} catch {
|
|
51
|
+
sendBffError(res, "UPSTREAM_UNAVAILABLE", "Admin pipeline read failed");
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function readAuthorization(
|
|
57
|
+
req: HttpRequest,
|
|
58
|
+
res: HttpResponse,
|
|
59
|
+
authorizeAdmin: (req: HttpRequest) => Promise<OperationsAuthorizationResult>,
|
|
60
|
+
{ sendBffError }: BffResponder,
|
|
61
|
+
): Promise<true | null> {
|
|
62
|
+
try {
|
|
63
|
+
const authorization = await authorizeAdmin(req);
|
|
64
|
+
if (authorization.ok === false) {
|
|
65
|
+
sendBffError(res, authorization.code, authorization.message);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
sendBffError(res, "UPSTREAM_UNAVAILABLE", "Admin authorization failed");
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createBffResponder, type BffResponderOptions } from "../../bff/index.js";
|
|
2
|
+
import type { HttpRequest, HttpResponse } from "../../http/index.js";
|
|
3
|
+
import {
|
|
4
|
+
adminSettingsReadRequestSchema,
|
|
5
|
+
adminSettingsReadResponseSchema,
|
|
6
|
+
adminSettingsUpdateRequestSchema,
|
|
7
|
+
adminSettingsUpdateResponseSchema,
|
|
8
|
+
} from "../index.js";
|
|
9
|
+
import type { AdminSettingsOperationsPort } from "../contracts/index.js";
|
|
10
|
+
import type { OperationsAuthorizationResult } from "../contracts/index.js";
|
|
11
|
+
|
|
12
|
+
export interface AdminSettingsReadDeps extends BffResponderOptions {
|
|
13
|
+
settingsPort: Pick<AdminSettingsOperationsPort, "readSettings">;
|
|
14
|
+
authorizeAdmin: (req: HttpRequest) => Promise<OperationsAuthorizationResult>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AdminSettingsUpdateDeps extends BffResponderOptions {
|
|
18
|
+
settingsPort: Pick<AdminSettingsOperationsPort, "updateSetting">;
|
|
19
|
+
authorizeAdmin: (req: HttpRequest) => Promise<OperationsAuthorizationResult>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createAdminSettingsReadHandler({
|
|
23
|
+
settingsPort,
|
|
24
|
+
authorizeAdmin, responseRequestId,
|
|
25
|
+
}: AdminSettingsReadDeps) {
|
|
26
|
+
const { sendBffError, sendBffSuccess, sendMethodNotAllowed } = createBffResponder({ responseRequestId });
|
|
27
|
+
return async function handler(req: HttpRequest, res: HttpResponse): Promise<void> {
|
|
28
|
+
if (req.method !== "GET") {
|
|
29
|
+
sendMethodNotAllowed(res, ["GET"]);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let authorization: OperationsAuthorizationResult;
|
|
34
|
+
try {
|
|
35
|
+
authorization = await authorizeAdmin(req);
|
|
36
|
+
} catch {
|
|
37
|
+
sendBffError(res, "UPSTREAM_UNAVAILABLE", "Admin authorization failed");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (authorization.ok === false) {
|
|
42
|
+
sendBffError(res, authorization.code, authorization.message);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const request = adminSettingsReadRequestSchema.safeParse(req.query);
|
|
47
|
+
if (!request.success) {
|
|
48
|
+
sendBffError(res, "BAD_REQUEST", "Invalid admin settings read request", {
|
|
49
|
+
details: request.error.flatten(),
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const result = await settingsPort.readSettings(request.data);
|
|
56
|
+
const response = adminSettingsReadResponseSchema.safeParse(result);
|
|
57
|
+
if (!response.success) {
|
|
58
|
+
sendBffError(res, "INVALID_RESPONSE", "Admin settings read returned invalid response");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
sendBffSuccess(res, response.data);
|
|
63
|
+
} catch {
|
|
64
|
+
sendBffError(res, "UPSTREAM_UNAVAILABLE", "Admin settings read failed");
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createAdminSettingsUpdateHandler({
|
|
70
|
+
settingsPort,
|
|
71
|
+
authorizeAdmin, responseRequestId,
|
|
72
|
+
}: AdminSettingsUpdateDeps) {
|
|
73
|
+
const { sendBffError, sendBffSuccess, sendMethodNotAllowed } = createBffResponder({ responseRequestId });
|
|
74
|
+
return async function handler(req: HttpRequest, res: HttpResponse): Promise<void> {
|
|
75
|
+
if (req.method !== "POST") {
|
|
76
|
+
sendMethodNotAllowed(res, ["POST"]);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let authorization: OperationsAuthorizationResult;
|
|
81
|
+
try {
|
|
82
|
+
authorization = await authorizeAdmin(req);
|
|
83
|
+
} catch {
|
|
84
|
+
sendBffError(res, "UPSTREAM_UNAVAILABLE", "Admin authorization failed");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (authorization.ok === false) {
|
|
89
|
+
sendBffError(res, authorization.code, authorization.message);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const request = adminSettingsUpdateRequestSchema.safeParse(req.body);
|
|
94
|
+
if (!request.success) {
|
|
95
|
+
sendBffError(res, "BAD_REQUEST", "Invalid admin settings update request", {
|
|
96
|
+
details: request.error.flatten(),
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const result = await settingsPort.updateSetting(request.data);
|
|
103
|
+
const response = adminSettingsUpdateResponseSchema.safeParse(result);
|
|
104
|
+
if (!response.success) {
|
|
105
|
+
sendBffError(res, "INVALID_RESPONSE", "Admin settings update returned invalid response");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
sendBffSuccess(res, response.data);
|
|
110
|
+
} catch {
|
|
111
|
+
sendBffError(res, "UPSTREAM_UNAVAILABLE", "Admin settings update failed");
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createBffResponder, type BffResponderOptions, type BffResponder } from "../../bff/index.js";
|
|
2
|
+
import type { HttpRequest, HttpResponse } from "../../http/index.js";
|
|
3
|
+
import { platformControlPlaneMutationResponseSchema, platformControlPlaneMutationSchema, platformControlPlaneReadResponseSchema } from "../index.js";
|
|
4
|
+
import { PlatformControlPlaneConflictError } from "../index.js";
|
|
5
|
+
import type { PlatformControlPlanePort } from "../contracts/index.js";
|
|
6
|
+
|
|
7
|
+
export function createPlatformControlPlaneHandler(deps: {
|
|
8
|
+
port: PlatformControlPlanePort;
|
|
9
|
+
authorizeAdmin: () => Promise<boolean>;
|
|
10
|
+
} & BffResponderOptions) {
|
|
11
|
+
const { sendBffError, sendBffSuccess, sendMethodNotAllowed } = createBffResponder(deps);
|
|
12
|
+
return async (req: HttpRequest, res: HttpResponse): Promise<void> => {
|
|
13
|
+
if (!(await deps.authorizeAdmin())) {
|
|
14
|
+
sendBffError(res, "UNAUTHORIZED", "Admin session required");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
if (req.method === "GET") {
|
|
19
|
+
const parsed = platformControlPlaneReadResponseSchema.safeParse(
|
|
20
|
+
await deps.port.readControlPlane({}),
|
|
21
|
+
);
|
|
22
|
+
if (!parsed.success) return invalidResponse(res, sendBffError);
|
|
23
|
+
sendBffSuccess(res, parsed.data);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (req.method === "POST") {
|
|
27
|
+
const parsed = platformControlPlaneMutationSchema.safeParse(body(req.body));
|
|
28
|
+
if (!parsed.success) {
|
|
29
|
+
sendBffError(res, "BAD_REQUEST", "Invalid platform control-plane request");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const response = platformControlPlaneMutationResponseSchema.safeParse(
|
|
33
|
+
await deps.port.mutateControlPlane(parsed.data),
|
|
34
|
+
);
|
|
35
|
+
if (!response.success) return invalidResponse(res, sendBffError);
|
|
36
|
+
sendBffSuccess(res, response.data);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
sendMethodNotAllowed(res, ["GET", "POST"]);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error instanceof PlatformControlPlaneConflictError) {
|
|
42
|
+
sendBffError(res, "CONFLICT", error.message);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
sendBffError(res, "UPSTREAM_UNAVAILABLE", "Platform control plane unavailable");
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function body(value: unknown): unknown {
|
|
51
|
+
if (typeof value !== "string") return value;
|
|
52
|
+
try { return JSON.parse(value); } catch { return null; }
|
|
53
|
+
}
|
|
54
|
+
function invalidResponse(res: HttpResponse, sendBffError: BffResponder["sendBffError"]): void {
|
|
55
|
+
sendBffError(res, "INVALID_RESPONSE", "Platform control plane returned invalid response");
|
|
56
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { PlatformControlPlaneConflictError, PlatformControlPlaneUnavailableError } from "../index.js";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
PlatformControlPlaneMutation,
|
|
6
|
+
PlatformControlPlaneMutationResponse,
|
|
7
|
+
PlatformControlPlaneReadResponse,
|
|
8
|
+
} from "../contracts/index.js";
|
|
9
|
+
import {
|
|
10
|
+
type PlatformControlPlanePort,
|
|
11
|
+
} from "../contracts/index.js";
|
|
12
|
+
import type { PgQueryExecutor } from "../../data/index.js";
|
|
13
|
+
|
|
14
|
+
const rpc = (name: string, types: string[]) =>
|
|
15
|
+
`SELECT * FROM public.${name}(${types.map((type, index) => `$${index + 1}::${type}`).join(", ")})`;
|
|
16
|
+
|
|
17
|
+
export const platformControlPlaneSql = {
|
|
18
|
+
active: rpc("platform_control_operator_is_active", ["uuid"]),
|
|
19
|
+
setControl: rpc("platform_control_set_control", ["uuid", "text", "text", "text", "boolean"]),
|
|
20
|
+
heartbeat: rpc("platform_control_record_heartbeat", ["uuid", "text", "text", "text", "integer", "text", "boolean"]),
|
|
21
|
+
controls: rpc("platform_control_list_controls", ["uuid"]),
|
|
22
|
+
jobs: rpc("platform_control_list_jobs", ["uuid"]),
|
|
23
|
+
alerts: rpc("platform_control_list_alerts", ["uuid"]),
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
export function createPlatformControlPlanePort(
|
|
27
|
+
client: PgQueryExecutor,
|
|
28
|
+
operatorId: string,
|
|
29
|
+
): PlatformControlPlanePort {
|
|
30
|
+
return {
|
|
31
|
+
async readControlPlane() {
|
|
32
|
+
try {
|
|
33
|
+
const [controls, jobs, alerts] = await Promise.all([
|
|
34
|
+
client.query(platformControlPlaneSql.controls, [operatorId]),
|
|
35
|
+
client.query(platformControlPlaneSql.jobs, [operatorId]),
|
|
36
|
+
client.query(platformControlPlaneSql.alerts, [operatorId]),
|
|
37
|
+
]);
|
|
38
|
+
return {
|
|
39
|
+
controls: controls.rows.map((row) => ({
|
|
40
|
+
controlKey: text(row.control_key), enabled: bool(row.enabled),
|
|
41
|
+
revision: positive(row.revision), updatedAt: timestamp(row.updated_at),
|
|
42
|
+
})),
|
|
43
|
+
jobs: jobs.rows.map((row) => ({
|
|
44
|
+
jobName: text(row.job_name), enabled: bool(row.enabled),
|
|
45
|
+
activeDriver: driver(row.active_driver), lastStatus: jobStatus(row.last_status),
|
|
46
|
+
lastStartedAt: nullableTimestamp(row.last_started_at),
|
|
47
|
+
lastFinishedAt: nullableTimestamp(row.last_finished_at),
|
|
48
|
+
lastSuccessAt: nullableTimestamp(row.last_success_at),
|
|
49
|
+
})),
|
|
50
|
+
alerts: alerts.rows.map((row) => ({
|
|
51
|
+
dedupeKey: text(row.dedupe_key), severity: severity(row.severity),
|
|
52
|
+
status: alertStatus(row.status), owner: text(row.owner), title: text(row.title),
|
|
53
|
+
firstSeenAt: timestamp(row.first_seen_at), lastSeenAt: timestamp(row.last_seen_at),
|
|
54
|
+
resolvedAt: nullableTimestamp(row.resolved_at),
|
|
55
|
+
})),
|
|
56
|
+
} satisfies PlatformControlPlaneReadResponse;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw mapError(error);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
async mutateControlPlane(request) {
|
|
63
|
+
try {
|
|
64
|
+
const fingerprint = digest(JSON.stringify(request));
|
|
65
|
+
const result = request.action === "set-control"
|
|
66
|
+
? await client.query(platformControlPlaneSql.setControl, [
|
|
67
|
+
operatorId, request.idempotencyKey, fingerprint, request.controlKey, request.enabled,
|
|
68
|
+
])
|
|
69
|
+
: await client.query(platformControlPlaneSql.heartbeat, [
|
|
70
|
+
operatorId, request.idempotencyKey, fingerprint, request.health,
|
|
71
|
+
request.firingCount, request.maxSeverity, request.promotionReady,
|
|
72
|
+
]);
|
|
73
|
+
return mutation(first(result.rows), request.action);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw mapError(error);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function isPlatformControlOperatorActive(
|
|
82
|
+
client: PgQueryExecutor,
|
|
83
|
+
principalId: string,
|
|
84
|
+
): Promise<boolean> {
|
|
85
|
+
try {
|
|
86
|
+
return first((await client.query(platformControlPlaneSql.active, [principalId])).rows)
|
|
87
|
+
?.platform_control_operator_is_active === true;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw mapError(error);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function mutation(
|
|
94
|
+
row: Record<string, unknown> | undefined,
|
|
95
|
+
expected: PlatformControlPlaneMutation["action"],
|
|
96
|
+
): PlatformControlPlaneMutationResponse {
|
|
97
|
+
if (!row || row.action !== expected) throw new PlatformControlPlaneUnavailableError();
|
|
98
|
+
return {
|
|
99
|
+
action: expected,
|
|
100
|
+
replayed: bool(row.replayed),
|
|
101
|
+
revision: row.revision == null ? null : positive(row.revision),
|
|
102
|
+
recordedAt: timestamp(row.recorded_at),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function digest(value: string): string {
|
|
107
|
+
return createHash("sha256").update(value).digest("hex");
|
|
108
|
+
}
|
|
109
|
+
function first(rows: Record<string, unknown>[]): Record<string, unknown> | undefined { return rows[0]; }
|
|
110
|
+
function text(value: unknown): string {
|
|
111
|
+
if (typeof value !== "string" || value.length === 0) throw new PlatformControlPlaneUnavailableError();
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
function bool(value: unknown): boolean {
|
|
115
|
+
if (typeof value !== "boolean") throw new PlatformControlPlaneUnavailableError();
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
function positive(value: unknown): number {
|
|
119
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) throw new PlatformControlPlaneUnavailableError();
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
function timestamp(value: unknown): string {
|
|
123
|
+
const result = text(value);
|
|
124
|
+
if (!Number.isFinite(Date.parse(result))) throw new PlatformControlPlaneUnavailableError();
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
function nullableTimestamp(value: unknown): string | null { return value == null ? null : timestamp(value); }
|
|
128
|
+
function driver(value: unknown): "worker" | "scheduler" | "operator" {
|
|
129
|
+
if (value === "worker" || value === "scheduler" || value === "operator") return value;
|
|
130
|
+
throw new PlatformControlPlaneUnavailableError();
|
|
131
|
+
}
|
|
132
|
+
function jobStatus(value: unknown): "running" | "success" | "failed" | "skipped" | null {
|
|
133
|
+
if (value == null) return null;
|
|
134
|
+
if (value === "running" || value === "success" || value === "failed" || value === "skipped") return value;
|
|
135
|
+
throw new PlatformControlPlaneUnavailableError();
|
|
136
|
+
}
|
|
137
|
+
function severity(value: unknown): "p0" | "p1" | "p2" | "p3" {
|
|
138
|
+
if (value === "p0" || value === "p1" || value === "p2" || value === "p3") return value;
|
|
139
|
+
throw new PlatformControlPlaneUnavailableError();
|
|
140
|
+
}
|
|
141
|
+
function alertStatus(value: unknown): "open" | "acknowledged" | "resolved" {
|
|
142
|
+
if (value === "open" || value === "acknowledged" || value === "resolved") return value;
|
|
143
|
+
throw new PlatformControlPlaneUnavailableError();
|
|
144
|
+
}
|
|
145
|
+
function mapError(error: unknown): Error {
|
|
146
|
+
if (error instanceof PlatformControlPlaneUnavailableError || error instanceof PlatformControlPlaneConflictError) return error;
|
|
147
|
+
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
|
|
148
|
+
if (code === "23505") return new PlatformControlPlaneConflictError("platform_control_idempotency_conflict");
|
|
149
|
+
return new PlatformControlPlaneUnavailableError("platform_control_plane_unavailable");
|
|
150
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PlatformControlPlanePort } from "../contracts/index.js";
|
|
2
|
+
|
|
3
|
+
export type DirectPlatformWatchdogResult = { status: number; body: Record<string, unknown> };
|
|
4
|
+
|
|
5
|
+
/** Existing public-bundle evaluation of persisted neutral job facts. No provider calls. */
|
|
6
|
+
export async function runControlPlaneWatchdogTick(
|
|
7
|
+
port: PlatformControlPlanePort,
|
|
8
|
+
now: Date,
|
|
9
|
+
checkOnly: boolean,
|
|
10
|
+
): Promise<DirectPlatformWatchdogResult> {
|
|
11
|
+
const snapshot = await port.readControlPlane({});
|
|
12
|
+
const failedJobs = snapshot.jobs.filter((job) => job.enabled && job.lastStatus === "failed");
|
|
13
|
+
const health = failedJobs.length > 0 ? "degraded" as const : "healthy" as const;
|
|
14
|
+
const recorded = checkOnly ? null : await port.mutateControlPlane({
|
|
15
|
+
action: "record-heartbeat",
|
|
16
|
+
idempotencyKey: `platform-watchdog:${now.toISOString()}`,
|
|
17
|
+
health,
|
|
18
|
+
firingCount: failedJobs.length,
|
|
19
|
+
maxSeverity: failedJobs.length > 0 ? "p2" : null,
|
|
20
|
+
promotionReady: failedJobs.length === 0,
|
|
21
|
+
});
|
|
22
|
+
return { status: 200, body: {
|
|
23
|
+
ok: true, health, firingCount: failedJobs.length,
|
|
24
|
+
failedJobs: failedJobs.map((job) => job.jobName),
|
|
25
|
+
checkedAt: now.toISOString(), heartbeatRecorded: recorded != null,
|
|
26
|
+
replayed: recorded?.replayed ?? false,
|
|
27
|
+
} };
|
|
28
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Server mechanisms; authorization, persistence and provider transports are injected. */
|
|
2
|
+
export * from "./adminSettingsHandlers.js";
|
|
3
|
+
export * from "./adminPipelineHandlers.js";
|
|
4
|
+
export * from "./adminAlertsHandlers.js";
|
|
5
|
+
export * from "./controlPlaneHandler.js";
|
|
6
|
+
export * from "./controlPlaneStore.js";
|
|
7
|
+
export * from "./platformWatchdogService.js";
|
|
8
|
+
export * from "./platformWatchdogDecision.js";
|
|
9
|
+
export * from "./platformWatchdogDeliveryPolicy.js";
|
|
10
|
+
export * from "./platformWatchdogQueue.js";
|
|
11
|
+
export * from "./controlPlaneWatchdog.js";
|
|
12
|
+
export * from "./migrations.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Sparse projection only: install the complete canonical native prefix, never this unit alone. */
|
|
2
|
+
export const OPERATIONS_MIGRATIONS = {
|
|
3
|
+
native: { units: [{ position: 28, catalogPath: "db/platform/migrations/20260814010000_platform_control_plane.sql",
|
|
4
|
+
sha256: "b0c8ef6424ebc6f1a84cb132c37908f4831fa40484a729c3713cfb35936559c9",
|
|
5
|
+
url: new URL("../../../sql/20260814010000_platform_control_plane.sql", import.meta.url) }] },
|
|
6
|
+
managed: { verified: false },
|
|
7
|
+
} as const;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AlertDecision,
|
|
3
|
+
AlertNotificationOutcome,
|
|
4
|
+
AlertSeverity,
|
|
5
|
+
} from "../contracts/index.js";
|
|
6
|
+
import type { AlertLedgerPort, AlertSinkPort } from "../contracts/index.js";
|
|
7
|
+
import { shouldPage } from "../index.js";
|
|
8
|
+
import { throttleSecondsForSeverity } from "../index.js";
|
|
9
|
+
import {
|
|
10
|
+
isActionabilitySuppressed,
|
|
11
|
+
isDeliveryBackoff,
|
|
12
|
+
isPagingSuppressed,
|
|
13
|
+
notificationSchedule,
|
|
14
|
+
shouldAttemptNotification,
|
|
15
|
+
} from "./platformWatchdogDeliveryPolicy.js";
|
|
16
|
+
|
|
17
|
+
const BELOW_PAGING_SEVERITY_OUTCOME: AlertNotificationOutcome<"webhook"> = {
|
|
18
|
+
channel: "webhook", status: "skipped", provider: "webhook", error: "below_paging_severity",
|
|
19
|
+
};
|
|
20
|
+
const NON_PAGEABLE_DIAGNOSTIC_OUTCOME: AlertNotificationOutcome<"webhook"> = {
|
|
21
|
+
channel: "webhook", status: "skipped", provider: "webhook", error: "non_pageable_diagnostic",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function emptyWatchdogDecisionCounts() {
|
|
25
|
+
return {
|
|
26
|
+
notified: 0,
|
|
27
|
+
notificationFailures: 0,
|
|
28
|
+
deliveryBackoffCount: 0,
|
|
29
|
+
throttledNotifications: 0,
|
|
30
|
+
skippedNotifications: 0,
|
|
31
|
+
belowThreshold: 0,
|
|
32
|
+
suppressed: 0,
|
|
33
|
+
actionableCriticalCount: 0,
|
|
34
|
+
suppressedCriticalCount: 0,
|
|
35
|
+
actionablePageableCount: 0,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One complete ledger transition; callers must serialize the same dedupe key. */
|
|
40
|
+
export async function processWatchdogDecision<Channel extends string>(
|
|
41
|
+
decision: AlertDecision<Channel>,
|
|
42
|
+
{
|
|
43
|
+
ledgerPort, sinkPort, now, pagingMinSeverity, notificationThrottleSeconds,
|
|
44
|
+
}: {
|
|
45
|
+
ledgerPort: AlertLedgerPort<Channel>;
|
|
46
|
+
sinkPort: AlertSinkPort<Channel>;
|
|
47
|
+
now: Date;
|
|
48
|
+
pagingMinSeverity: AlertSeverity;
|
|
49
|
+
notificationThrottleSeconds?: number;
|
|
50
|
+
},
|
|
51
|
+
) {
|
|
52
|
+
const counts = emptyWatchdogDecisionCounts();
|
|
53
|
+
const alert = await ledgerPort.upsertOpenAlert(decision, now);
|
|
54
|
+
const critical = isCriticalDecision(decision);
|
|
55
|
+
const actionableSuppressed = isActionabilitySuppressed(alert, now);
|
|
56
|
+
const pagingSuppressed = isPagingSuppressed(alert, now);
|
|
57
|
+
const pageable = decision.paging !== "never" && shouldPage(decision.severity, pagingMinSeverity);
|
|
58
|
+
|
|
59
|
+
// Acknowledgement silences pages, but only a snooze suppresses actionability.
|
|
60
|
+
if (critical) {
|
|
61
|
+
if (actionableSuppressed) counts.suppressedCriticalCount += 1;
|
|
62
|
+
else counts.actionableCriticalCount += 1;
|
|
63
|
+
}
|
|
64
|
+
if (pageable && !pagingSuppressed) counts.actionablePageableCount += 1;
|
|
65
|
+
if (pageable && !pagingSuppressed && isDeliveryBackoff(alert, now)) counts.deliveryBackoffCount += 1;
|
|
66
|
+
if (pagingSuppressed) {
|
|
67
|
+
counts.suppressed += 1;
|
|
68
|
+
return counts;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const throttleSeconds = notificationThrottleSeconds ?? throttleSecondsForSeverity(decision.severity);
|
|
72
|
+
if (!shouldAttemptNotification(alert, now, throttleSeconds)) {
|
|
73
|
+
counts.throttledNotifications += 1;
|
|
74
|
+
return counts;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Non-pageable and below-threshold alerts retain their skipped-attempt audit.
|
|
78
|
+
const nonPageable = decision.paging === "never";
|
|
79
|
+
const paging = !nonPageable && shouldPage(decision.severity, pagingMinSeverity);
|
|
80
|
+
const outcome = paging
|
|
81
|
+
? await sinkPort.send(decision, alert)
|
|
82
|
+
: nonPageable ? NON_PAGEABLE_DIAGNOSTIC_OUTCOME : BELOW_PAGING_SEVERITY_OUTCOME;
|
|
83
|
+
if (outcome.status === "sent") counts.notified += 1;
|
|
84
|
+
else if (outcome.status === "failed") counts.notificationFailures += 1;
|
|
85
|
+
else counts.skippedNotifications += 1;
|
|
86
|
+
if (!paging) counts.belowThreshold += 1;
|
|
87
|
+
await ledgerPort.recordNotification(alert.id, outcome, now, notificationSchedule(alert, outcome, now, throttleSeconds));
|
|
88
|
+
return counts;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function isCriticalDecision(decision: AlertDecision): boolean {
|
|
92
|
+
return decision.paging !== "never" && (decision.severity === "p0" || decision.severity === "p1");
|
|
93
|
+
}
|