@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,186 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Exact UTF-8 scaffold files; every unlisted path remains application-owned. */
|
|
6
|
+
export interface AppVersionSnapshot {
|
|
7
|
+
version: string;
|
|
8
|
+
schema: { min: number; max: number };
|
|
9
|
+
files: Readonly<Record<string, string>>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AppUpgradeInput {
|
|
13
|
+
/** Absolute canonical path (as returned by realpath) of an existing app directory. */
|
|
14
|
+
root: string;
|
|
15
|
+
from: AppVersionSnapshot;
|
|
16
|
+
to: AppVersionSnapshot;
|
|
17
|
+
/** Observed by the caller; planning and applying execute no database work. */
|
|
18
|
+
schemaVersion: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type AppUpgradeAction = "create" | "replace" | "remove" | "unchanged" | "preserved" | "already-applied";
|
|
22
|
+
export interface AppUpgradeConflict { path: string; reason: string }
|
|
23
|
+
export interface AppUpgradePlan {
|
|
24
|
+
fromVersion: string;
|
|
25
|
+
toVersion: string;
|
|
26
|
+
schemaVersion: number;
|
|
27
|
+
files: { path: string; action: AppUpgradeAction }[];
|
|
28
|
+
conflicts: AppUpgradeConflict[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class AppUpgradeConflictError extends Error {
|
|
32
|
+
constructor(public readonly conflicts: readonly AppUpgradeConflict[]) {
|
|
33
|
+
super(`Application upgrade refused: ${conflicts.map(({ path, reason }) => `${path || "schema"}: ${reason}`).join("; ")}`);
|
|
34
|
+
this.name = "AppUpgradeConflictError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Read-only comparison of explicit predecessor and candidate snapshots. */
|
|
39
|
+
export async function planAppUpgrade(input: AppUpgradeInput): Promise<AppUpgradePlan> {
|
|
40
|
+
return (await inspectUpgrade(input)).plan;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The caller must hold an exclusive maintenance window over the application tree.
|
|
45
|
+
* This is not a lock against concurrent editors or hostile filesystem mutation.
|
|
46
|
+
* All conflicts are checked before any writes. A later I/O failure can leave a
|
|
47
|
+
* partial upgrade: rerun the same snapshots to recognize exact completed bytes.
|
|
48
|
+
* Each replacement is atomic on a local filesystem; the complete upgrade is not.
|
|
49
|
+
* An abrupt process termination can leave a uniquely named temporary file; it is
|
|
50
|
+
* never treated as a managed app file or resumed operation.
|
|
51
|
+
* No SQL, dependency install, version marker, deployment or user hook is executed.
|
|
52
|
+
*/
|
|
53
|
+
export async function applyAppUpgrade(input: AppUpgradeInput): Promise<{ plan: AppUpgradePlan; applied: string[] }> {
|
|
54
|
+
const { root, plan, target } = await inspectUpgrade(input);
|
|
55
|
+
if (plan.conflicts.length) throw new AppUpgradeConflictError(plan.conflicts);
|
|
56
|
+
const applied: string[] = [];
|
|
57
|
+
for (const file of plan.files) {
|
|
58
|
+
const destination = join(root, file.path);
|
|
59
|
+
if (file.action === "remove") {
|
|
60
|
+
await fs.unlink(destination);
|
|
61
|
+
} else if (file.action === "create" || file.action === "replace") {
|
|
62
|
+
await fs.mkdir(dirname(destination), { recursive: true });
|
|
63
|
+
const temporary = join(dirname(destination), `.openlup-${randomUUID()}.tmp`);
|
|
64
|
+
// Rename replaces this directory entry rather than modifying a possible hardlink.
|
|
65
|
+
const mode = file.action === "replace" ? (await fs.stat(destination)).mode & 0o777 : 0o600;
|
|
66
|
+
let handle;
|
|
67
|
+
try {
|
|
68
|
+
handle = await fs.open(temporary, "wx", mode);
|
|
69
|
+
if (file.action === "replace") await handle.chmod(mode);
|
|
70
|
+
await handle.writeFile(target.get(file.path)!, "utf8");
|
|
71
|
+
await handle.close();
|
|
72
|
+
await fs.rename(temporary, destination);
|
|
73
|
+
} finally {
|
|
74
|
+
if (handle) {
|
|
75
|
+
await handle.close();
|
|
76
|
+
await fs.rm(temporary, { force: true });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} else continue;
|
|
80
|
+
applied.push(file.path);
|
|
81
|
+
}
|
|
82
|
+
return { plan, applied };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function inspectUpgrade(input: AppUpgradeInput): Promise<{
|
|
86
|
+
root: string; plan: AppUpgradePlan; target: Map<string, string>;
|
|
87
|
+
}> {
|
|
88
|
+
const before = snapshotFiles(input.from), target = snapshotFiles(input.to);
|
|
89
|
+
const paths = [...new Set([...before.keys(), ...target.keys()])].sort();
|
|
90
|
+
const aliases = new Set<string>();
|
|
91
|
+
for (const path of paths) {
|
|
92
|
+
const alias = path.normalize("NFC").toLowerCase().toUpperCase();
|
|
93
|
+
if (aliases.has(alias)) throw new Error(`Application upgrade path alias: ${path}`);
|
|
94
|
+
aliases.add(alias);
|
|
95
|
+
}
|
|
96
|
+
const directorySpellings = new Map<string, string>();
|
|
97
|
+
for (const path of paths) {
|
|
98
|
+
const components = path.split("/");
|
|
99
|
+
while (components.length > 1) {
|
|
100
|
+
components.pop();
|
|
101
|
+
const directory = components.join("/"), alias = directory.normalize("NFC").toLowerCase().toUpperCase();
|
|
102
|
+
if (aliases.has(alias)) throw new Error(`Application upgrade file-parent collision: ${path}`);
|
|
103
|
+
// Parents may not exist yet; both snapshots must still agree on their spelling.
|
|
104
|
+
const spelling = directorySpellings.get(alias);
|
|
105
|
+
if (spelling !== undefined && spelling !== directory) throw new Error(`Application upgrade path alias: ${path}`);
|
|
106
|
+
directorySpellings.set(alias, directory);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!Number.isSafeInteger(input.schemaVersion) || input.schemaVersion < 0) throw new Error("Application schema version must be a nonnegative safe integer");
|
|
110
|
+
const plan: AppUpgradePlan = {
|
|
111
|
+
fromVersion: input.from.version, toVersion: input.to.version,
|
|
112
|
+
schemaVersion: input.schemaVersion, files: [], conflicts: [],
|
|
113
|
+
};
|
|
114
|
+
for (const snapshot of [input.from, input.to]) {
|
|
115
|
+
if (input.schemaVersion < snapshot.schema.min || input.schemaVersion > snapshot.schema.max) {
|
|
116
|
+
plan.conflicts.push({ path: "", reason: `schema ${input.schemaVersion} is outside ${snapshot.version} range ${snapshot.schema.min}..${snapshot.schema.max}` });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const requestedRoot = resolve(input.root), rootStat = await fs.lstat(requestedRoot);
|
|
120
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) throw new Error("Application upgrade root must be an ordinary directory");
|
|
121
|
+
const root = await fs.realpath(requestedRoot);
|
|
122
|
+
if (input.root !== root) throw new Error("Application upgrade root must be an absolute canonical path");
|
|
123
|
+
for (const path of paths) {
|
|
124
|
+
const observed = await readFile(root, path);
|
|
125
|
+
if (typeof observed === "string") {
|
|
126
|
+
plan.conflicts.push({ path, reason: observed });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const oldBytes = before.has(path) ? Buffer.from(before.get(path)!, "utf8") : null;
|
|
130
|
+
const newBytes = target.has(path) ? Buffer.from(target.get(path)!, "utf8") : null;
|
|
131
|
+
const equal = (bytes: Buffer | null) => observed === null ? bytes === null : bytes !== null && observed.equals(bytes);
|
|
132
|
+
let action: AppUpgradeAction;
|
|
133
|
+
if (oldBytes !== null && newBytes !== null && oldBytes.equals(newBytes)) {
|
|
134
|
+
// An upstream-unchanged file carries no write authority over local customization.
|
|
135
|
+
action = equal(oldBytes) ? "unchanged" : "preserved";
|
|
136
|
+
} else if (equal(newBytes)) action = "already-applied";
|
|
137
|
+
else if (equal(oldBytes)) action = newBytes === null ? "remove" : oldBytes === null ? "create" : "replace";
|
|
138
|
+
else {
|
|
139
|
+
plan.conflicts.push({ path, reason: "current bytes match neither predecessor nor candidate" });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
plan.files.push({ path, action });
|
|
143
|
+
}
|
|
144
|
+
return { root, plan, target };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function snapshotFiles(snapshot: AppVersionSnapshot): Map<string, string> {
|
|
148
|
+
if (!snapshot || typeof snapshot.version !== "string" || !snapshot.version.trim()) throw new Error("Application snapshot needs an explicit version");
|
|
149
|
+
const range = snapshot.schema;
|
|
150
|
+
if (!range || !Number.isSafeInteger(range.min) || !Number.isSafeInteger(range.max) || range.min < 0 || range.max < range.min) throw new Error("Application snapshot needs an ordered nonnegative schema range");
|
|
151
|
+
if (!snapshot.files || typeof snapshot.files !== "object" || Array.isArray(snapshot.files)) throw new Error("Application snapshot files must be a record");
|
|
152
|
+
const result = new Map<string, string>();
|
|
153
|
+
for (const [path, content] of Object.entries(snapshot.files)) {
|
|
154
|
+
const segments = path.split("/");
|
|
155
|
+
if (!path || Buffer.from(path, "utf8").toString("utf8") !== path || /[\\:]/u.test(path) || [...path].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127) || segments.some(segment => !segment || segment === "." || segment === ".." || /[. ]$/u.test(segment)) || segments.some(segment => [".git", "node_modules"].includes(segment.toLowerCase()))) {
|
|
156
|
+
throw new Error(`Unsafe application upgrade path: ${path}`);
|
|
157
|
+
}
|
|
158
|
+
if (typeof content !== "string" || Buffer.from(content, "utf8").toString("utf8") !== content) throw new Error(`Application upgrade content must be valid UTF-8: ${path}`);
|
|
159
|
+
result.set(path, content);
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A string is a structural refusal; null means an absent path. Never follow links. */
|
|
165
|
+
async function readFile(root: string, path: string): Promise<Buffer | null | string> {
|
|
166
|
+
const segments = path.split("/");
|
|
167
|
+
let current = root;
|
|
168
|
+
for (const [index, segment] of segments.entries()) {
|
|
169
|
+
const parent = current;
|
|
170
|
+
current = join(current, segment);
|
|
171
|
+
let stat;
|
|
172
|
+
try { stat = await fs.lstat(current); }
|
|
173
|
+
catch (error) {
|
|
174
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
// A filesystem alias is not ownership of the differently spelled entry.
|
|
178
|
+
// Check only after lstat: ENOENT on a case-sensitive store stays absence.
|
|
179
|
+
if (!(await fs.readdir(parent)).includes(segment)) return "managed path spelling differs from directory entry";
|
|
180
|
+
if (stat.isSymbolicLink()) return "symlink in managed path";
|
|
181
|
+
const last = index === segments.length - 1;
|
|
182
|
+
if (last) return stat.isFile() ? fs.readFile(current) : "managed path is not a regular file";
|
|
183
|
+
if (!stat.isDirectory()) return "managed parent is not a directory";
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export {
|
|
2
|
+
planAppUpgrade,
|
|
3
|
+
applyAppUpgrade,
|
|
4
|
+
AppUpgradeConflictError,
|
|
5
|
+
type AppVersionSnapshot,
|
|
6
|
+
type AppUpgradeInput,
|
|
7
|
+
type AppUpgradeAction,
|
|
8
|
+
type AppUpgradeConflict,
|
|
9
|
+
type AppUpgradePlan,
|
|
10
|
+
} from "./appUpgrade.js";
|
|
11
|
+
export {
|
|
12
|
+
inspectLegacyPostgresInstallRoles,
|
|
13
|
+
type PostgresInstallQuery,
|
|
14
|
+
type LegacyPostgresInstallRequest,
|
|
15
|
+
type PostgresInstallRoleObservation,
|
|
16
|
+
type PostgresInstallMembershipObservation,
|
|
17
|
+
type LegacyPostgresInstallBlocker,
|
|
18
|
+
type LegacyPostgresInstallRoleReport,
|
|
19
|
+
} from "./postgresInstallQualification.js";
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** Read-only prerequisites of the immutable legacy fresh-install bootstrap, not install readiness. */
|
|
2
|
+
export interface PostgresInstallQuery {
|
|
3
|
+
query(sql: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[] }>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface LegacyPostgresInstallRequest {
|
|
7
|
+
/** Expected actual login: both current_user and session_user must match. */
|
|
8
|
+
migrationLogin: string;
|
|
9
|
+
/** Legacy bootstrap requires this to be the same login as the migrator. */
|
|
10
|
+
runtimeLogin: string;
|
|
11
|
+
roleMode: "installer-managed" | "operator-provisioned";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PostgresInstallRoleObservation {
|
|
15
|
+
name: string;
|
|
16
|
+
login: boolean;
|
|
17
|
+
superuser: boolean;
|
|
18
|
+
inherit: boolean;
|
|
19
|
+
createRole: boolean;
|
|
20
|
+
createDatabase: boolean;
|
|
21
|
+
replication: boolean;
|
|
22
|
+
bypassRls: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PostgresInstallMembershipObservation {
|
|
26
|
+
role: string;
|
|
27
|
+
member: string;
|
|
28
|
+
grantor: string;
|
|
29
|
+
admin: boolean;
|
|
30
|
+
inherit: boolean;
|
|
31
|
+
set: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type LegacyPostgresInstallBlocker =
|
|
35
|
+
| "migration_login_mismatch"
|
|
36
|
+
| "legacy_bootstrap_requires_superuser"
|
|
37
|
+
| "legacy_bootstrap_requires_shared_runtime_login"
|
|
38
|
+
| "runtime_login_missing_or_not_login"
|
|
39
|
+
| "legacy_bootstrap_requires_installer_managed_roles"
|
|
40
|
+
| "legacy_bootstrap_role_already_exists";
|
|
41
|
+
|
|
42
|
+
export interface LegacyPostgresInstallRoleReport {
|
|
43
|
+
scope: "legacy-fresh-bootstrap-role-prerequisites";
|
|
44
|
+
database: string;
|
|
45
|
+
currentUser: string;
|
|
46
|
+
sessionUser: string;
|
|
47
|
+
roles: PostgresInstallRoleObservation[];
|
|
48
|
+
memberships: PostgresInstallMembershipObservation[];
|
|
49
|
+
blockers: { code: LegacyPostgresInstallBlocker; role?: string }[];
|
|
50
|
+
rolePrerequisitesMet: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const BOOTSTRAP_ROLES = ["anon", "authenticated", "platform_acquisition_owner", "platform_acquisition_runtime"];
|
|
54
|
+
// One statement observes identities and the relevant role graph in the same statement snapshot.
|
|
55
|
+
// No SQL, identifiers, credentials or catalog expectations come from the caller.
|
|
56
|
+
// Bind functions, cast types and operators independently of the caller's search_path.
|
|
57
|
+
const OBSERVE = `SELECT pg_catalog.current_database() AS database, current_user::pg_catalog.text AS "currentUser",
|
|
58
|
+
session_user::pg_catalog.text AS "sessionUser",
|
|
59
|
+
(SELECT COALESCE(pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object('name', rolname, 'login', rolcanlogin,
|
|
60
|
+
'superuser', rolsuper, 'inherit', rolinherit, 'createRole', rolcreaterole,
|
|
61
|
+
'createDatabase', rolcreatedb, 'replication', rolreplication, 'bypassRls', rolbypassrls)
|
|
62
|
+
ORDER BY rolname), '[]'::pg_catalog.jsonb) FROM pg_catalog.pg_roles
|
|
63
|
+
WHERE rolname OPERATOR(pg_catalog.=) ANY($1::pg_catalog.text[]) OR rolname OPERATOR(pg_catalog.=) current_user) AS roles,
|
|
64
|
+
(SELECT COALESCE(pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object('role', granted.rolname, 'member', member.rolname,
|
|
65
|
+
'grantor', grantor.rolname, 'admin', m.admin_option, 'inherit', m.inherit_option, 'set', m.set_option)
|
|
66
|
+
ORDER BY granted.rolname, member.rolname, grantor.rolname), '[]'::pg_catalog.jsonb)
|
|
67
|
+
FROM pg_catalog.pg_auth_members m
|
|
68
|
+
JOIN pg_catalog.pg_roles granted ON granted.oid OPERATOR(pg_catalog.=) m.roleid
|
|
69
|
+
JOIN pg_catalog.pg_roles member ON member.oid OPERATOR(pg_catalog.=) m.member
|
|
70
|
+
JOIN pg_catalog.pg_roles grantor ON grantor.oid OPERATOR(pg_catalog.=) m.grantor
|
|
71
|
+
WHERE granted.rolname OPERATOR(pg_catalog.=) ANY($1::pg_catalog.text[])
|
|
72
|
+
OR member.rolname OPERATOR(pg_catalog.=) ANY($1::pg_catalog.text[])) AS memberships`;
|
|
73
|
+
|
|
74
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
75
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validRows<T>(value: unknown, texts: string[], flags: string[]): value is T[] {
|
|
79
|
+
return Array.isArray(value) && value.every(row => isRecord(row)
|
|
80
|
+
&& texts.every(key => typeof row[key] === "string" && row[key].length > 0)
|
|
81
|
+
&& flags.every(key => typeof row[key] === "boolean"));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Observes PostgreSQL 16+ legacy role prerequisites without changing the database.
|
|
86
|
+
* A true result says nothing about schema inventory, object ACLs, pooling, import safety or hosting.
|
|
87
|
+
* Query/driver errors are redacted because they can contain connection credentials.
|
|
88
|
+
*/
|
|
89
|
+
export async function inspectLegacyPostgresInstallRoles(
|
|
90
|
+
db: PostgresInstallQuery,
|
|
91
|
+
request: LegacyPostgresInstallRequest,
|
|
92
|
+
): Promise<LegacyPostgresInstallRoleReport> {
|
|
93
|
+
if (![request.migrationLogin, request.runtimeLogin].every(name => typeof name === "string" && name.trim().length > 0)
|
|
94
|
+
|| !["installer-managed", "operator-provisioned"].includes(request.roleMode)) {
|
|
95
|
+
throw new Error("postgres_install_request_invalid");
|
|
96
|
+
}
|
|
97
|
+
let rows: Record<string, unknown>[];
|
|
98
|
+
try {
|
|
99
|
+
({ rows } = await db.query(OBSERVE, [[...new Set([...BOOTSTRAP_ROLES, request.migrationLogin, request.runtimeLogin])]]));
|
|
100
|
+
} catch {
|
|
101
|
+
throw new Error("postgres_install_observation_failed");
|
|
102
|
+
}
|
|
103
|
+
const row = rows[0];
|
|
104
|
+
if (rows.length !== 1 || !row
|
|
105
|
+
|| ![row.database, row.currentUser, row.sessionUser].every(value => typeof value === "string" && value.length > 0)
|
|
106
|
+
|| !validRows<PostgresInstallRoleObservation>(row.roles, ["name"], ["login", "superuser", "inherit", "createRole", "createDatabase", "replication", "bypassRls"])
|
|
107
|
+
|| !validRows<PostgresInstallMembershipObservation>(row.memberships, ["role", "member", "grantor"], ["admin", "inherit", "set"])) {
|
|
108
|
+
throw new Error("postgres_install_observation_invalid");
|
|
109
|
+
}
|
|
110
|
+
const roles = row.roles;
|
|
111
|
+
const blockers: LegacyPostgresInstallRoleReport["blockers"] = [];
|
|
112
|
+
if (row.currentUser !== request.migrationLogin || row.sessionUser !== request.migrationLogin) blockers.push({ code: "migration_login_mismatch" });
|
|
113
|
+
if (!roles.some(role => role.name === row.currentUser && role.superuser)) blockers.push({ code: "legacy_bootstrap_requires_superuser" });
|
|
114
|
+
if (row.currentUser !== request.runtimeLogin) blockers.push({ code: "legacy_bootstrap_requires_shared_runtime_login" });
|
|
115
|
+
if (!roles.some(role => role.name === request.runtimeLogin && role.login)) blockers.push({ code: "runtime_login_missing_or_not_login" });
|
|
116
|
+
if (request.roleMode !== "installer-managed") blockers.push({ code: "legacy_bootstrap_requires_installer_managed_roles" });
|
|
117
|
+
for (const name of BOOTSTRAP_ROLES) {
|
|
118
|
+
if (roles.some(role => role.name === name)) blockers.push({ code: "legacy_bootstrap_role_already_exists", role: name });
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
scope: "legacy-fresh-bootstrap-role-prerequisites",
|
|
122
|
+
database: row.database as string, currentUser: row.currentUser as string, sessionUser: row.sessionUser as string,
|
|
123
|
+
roles, memberships: row.memberships, blockers, rolePrerequisitesMet: blockers.length === 0,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -34,6 +34,13 @@ export interface PlatformMigrationSource {
|
|
|
34
34
|
migrations: readonly PlatformMigrationFile[];
|
|
35
35
|
objectInventoryPayload?: string;
|
|
36
36
|
}): string[];
|
|
37
|
+
/**
|
|
38
|
+
* Optional trusted-source observation/refusal after exact-prefix validation, under the
|
|
39
|
+
* migration session lock. Runs once per operation before any pending SQL or dry-run result.
|
|
40
|
+
* The canonical source uses read-only observations; the callback shares the caller's existing
|
|
41
|
+
* database capability and is not a sandbox or a provisioning/per-unit execution hook.
|
|
42
|
+
*/
|
|
43
|
+
assertSession?(client: PoolClient, applied: readonly (PlatformMigrationManifestEntry & { position: number })[]): Promise<void>;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
export interface PostgresMigrationRunnerOptions {
|
|
@@ -60,6 +67,9 @@ type LedgerExistenceRow = { ledger_exists: unknown };
|
|
|
60
67
|
type AdvisoryUnlockRow = { unlocked: unknown };
|
|
61
68
|
const ACQUIRE_MIGRATION_LOCK = "SELECT pg_advisory_lock(hashtextextended($1, 0))";
|
|
62
69
|
const RELEASE_MIGRATION_LOCK = "SELECT pg_advisory_unlock(hashtextextended($1, 0)) AS unlocked";
|
|
70
|
+
const ACQUIRE_CATALOG_BOUND_MIGRATION_LOCK = "SELECT pg_catalog.pg_advisory_lock(pg_catalog.hashtextextended($1::pg_catalog.text, 0::pg_catalog.int8))";
|
|
71
|
+
const RELEASE_CATALOG_BOUND_MIGRATION_LOCK = "SELECT pg_catalog.pg_advisory_unlock(pg_catalog.hashtextextended($1::pg_catalog.text, 0::pg_catalog.int8)) AS unlocked";
|
|
72
|
+
const CATALOG_BOUND_LEDGER_EXISTENCE = "SELECT pg_catalog.to_regclass($1::pg_catalog.text) IS NOT NULL AS ledger_exists";
|
|
63
73
|
|
|
64
74
|
export function createPostgresMigrationRunner(
|
|
65
75
|
env: PostgresMigrationRunnerEnv,
|
|
@@ -79,8 +89,10 @@ export function createPostgresMigrationRunner(
|
|
|
79
89
|
async status(): Promise<{ pending: string[]; applied: string[] }> {
|
|
80
90
|
assertConnectionString(env);
|
|
81
91
|
const plan = loadPlan(root, options.source);
|
|
82
|
-
|
|
83
|
-
|
|
92
|
+
const catalogBoundSession = options.source.assertSession !== undefined;
|
|
93
|
+
return withMigrationSession(await getPool(), options.lockName, catalogBoundSession, async (client) => {
|
|
94
|
+
const applied = await readAppliedPrefix(client, plan.units, catalogBoundSession);
|
|
95
|
+
await options.source.assertSession?.(client, applied.map((unit) => ({ file: unit.catalogPath, sha256: unit.sha256, position: unit.position })));
|
|
84
96
|
if (applied.length === plan.units.length) await assertObjectInventory(client, plan, options);
|
|
85
97
|
return {
|
|
86
98
|
pending: plan.units.slice(applied.length).map((unit) => unit.catalogPath),
|
|
@@ -92,8 +104,10 @@ export function createPostgresMigrationRunner(
|
|
|
92
104
|
async apply(opts?: { dryRun?: boolean }): Promise<{ applied: string[] }> {
|
|
93
105
|
assertConnectionString(env);
|
|
94
106
|
const plan = loadPlan(root, options.source);
|
|
95
|
-
|
|
96
|
-
|
|
107
|
+
const catalogBoundSession = options.source.assertSession !== undefined;
|
|
108
|
+
return withMigrationSession(await getPool(), options.lockName, catalogBoundSession, async (client) => {
|
|
109
|
+
const already = await readAppliedPrefix(client, plan.units, catalogBoundSession);
|
|
110
|
+
await options.source.assertSession?.(client, already.map((unit) => ({ file: unit.catalogPath, sha256: unit.sha256, position: unit.position })));
|
|
97
111
|
const pending = plan.units.slice(already.length);
|
|
98
112
|
if (opts?.dryRun) {
|
|
99
113
|
if (pending.length === 0) await assertObjectInventory(client, plan, options);
|
|
@@ -115,7 +129,7 @@ export function createPostgresMigrationRunner(
|
|
|
115
129
|
throw new Error(`platform migration ${unit.catalogPath} failed: ${errorMessage(error)}`);
|
|
116
130
|
}
|
|
117
131
|
}
|
|
118
|
-
const complete = await readAppliedPrefix(client, plan.units);
|
|
132
|
+
const complete = await readAppliedPrefix(client, plan.units, catalogBoundSession);
|
|
119
133
|
if (complete.length !== plan.units.length) throw new Error("platform migration ledger is incomplete after apply");
|
|
120
134
|
await assertObjectInventory(client, plan, options);
|
|
121
135
|
return { applied: appliedNow };
|
|
@@ -127,6 +141,7 @@ export function createPostgresMigrationRunner(
|
|
|
127
141
|
async function withMigrationSession<T>(
|
|
128
142
|
pool: Pool,
|
|
129
143
|
lockName: string,
|
|
144
|
+
catalogBoundSession: boolean,
|
|
130
145
|
operation: (client: PoolClient) => Promise<T>,
|
|
131
146
|
): Promise<T> {
|
|
132
147
|
const client = await pool.connect();
|
|
@@ -135,7 +150,7 @@ async function withMigrationSession<T>(
|
|
|
135
150
|
let operationFailure: unknown;
|
|
136
151
|
let result!: T;
|
|
137
152
|
try {
|
|
138
|
-
await client.query(ACQUIRE_MIGRATION_LOCK, [lockName]);
|
|
153
|
+
await client.query(catalogBoundSession ? ACQUIRE_CATALOG_BOUND_MIGRATION_LOCK : ACQUIRE_MIGRATION_LOCK, [lockName]);
|
|
139
154
|
locked = true;
|
|
140
155
|
result = await operation(client);
|
|
141
156
|
} catch (error) {
|
|
@@ -146,7 +161,7 @@ async function withMigrationSession<T>(
|
|
|
146
161
|
let unlockFailure: unknown;
|
|
147
162
|
if (locked) {
|
|
148
163
|
try {
|
|
149
|
-
const unlocked = await client.query<AdvisoryUnlockRow>(RELEASE_MIGRATION_LOCK, [lockName]);
|
|
164
|
+
const unlocked = await client.query<AdvisoryUnlockRow>(catalogBoundSession ? RELEASE_CATALOG_BOUND_MIGRATION_LOCK : RELEASE_MIGRATION_LOCK, [lockName]);
|
|
150
165
|
if (unlocked.rows[0]?.unlocked !== true) throw new Error("PostgreSQL did not release the advisory lock");
|
|
151
166
|
} catch (error) {
|
|
152
167
|
unlockFailure = error;
|
|
@@ -194,9 +209,9 @@ function loadPlan(root: string, source: PlatformMigrationSource): LoadedPlan {
|
|
|
194
209
|
return Object.freeze({ manifest, migrations: catalog.migrations, units: Object.freeze(units) });
|
|
195
210
|
}
|
|
196
211
|
|
|
197
|
-
async function readAppliedPrefix(client: PoolClient, units: readonly PlannedMigration[]): Promise<readonly PlannedMigration[]> {
|
|
212
|
+
async function readAppliedPrefix(client: PoolClient, units: readonly PlannedMigration[], catalogBoundSession: boolean): Promise<readonly PlannedMigration[]> {
|
|
198
213
|
const exists = await client.query<LedgerExistenceRow>(
|
|
199
|
-
"SELECT to_regclass($1) IS NOT NULL AS ledger_exists",
|
|
214
|
+
catalogBoundSession ? CATALOG_BOUND_LEDGER_EXISTENCE : "SELECT to_regclass($1) IS NOT NULL AS ledger_exists",
|
|
200
215
|
[LEDGER],
|
|
201
216
|
);
|
|
202
217
|
if (exists.rows[0]?.ledger_exists !== true) return [];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { z } from "./validation.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Admin-facing projection of the platform alert ledger.
|
|
5
|
+
*
|
|
6
|
+
* Only what an operator needs to read; the ledger itself stays service-role only
|
|
7
|
+
* (platform_alerts REVOKEs ALL from `authenticated`), so this shape is served by
|
|
8
|
+
* /api/bff/admin/platform/alerts rather than by PostgREST.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const adminAlertSeveritySchema = z.enum(["p0", "p1", "p2", "p3"]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Audience split, derived from `platform_alerts.owner`:
|
|
15
|
+
* commerce -> a business operator can act on it
|
|
16
|
+
* platform -> engineering owns it; shown for context so the health pill is explainable
|
|
17
|
+
*/
|
|
18
|
+
export const adminAlertLaneSchema = z.enum(["commerce", "platform"]);
|
|
19
|
+
|
|
20
|
+
export const adminAlertHealthSchema = z.enum(["ok", "degraded", "down", "unknown"]);
|
|
21
|
+
|
|
22
|
+
const isoDateTimeSchema = z.string().datetime({ offset: true });
|
|
23
|
+
|
|
24
|
+
export const adminAlertSchema = z
|
|
25
|
+
.object({
|
|
26
|
+
id: z.string().min(1).max(128),
|
|
27
|
+
dedupeKey: z.string().min(1).max(256),
|
|
28
|
+
lane: adminAlertLaneSchema,
|
|
29
|
+
// Free text on purpose: platform_alerts.owner has no CHECK constraint, so an
|
|
30
|
+
// enum here would throw the whole admin shell on the first new owner string.
|
|
31
|
+
// The open-endedness is absorbed by `lane`.
|
|
32
|
+
owner: z.string().trim().min(1).max(128),
|
|
33
|
+
severity: adminAlertSeveritySchema,
|
|
34
|
+
status: z.enum(["open", "acknowledged"]),
|
|
35
|
+
// Server-decided so every consumer agrees; the client never re-derives it.
|
|
36
|
+
pageable: z.boolean(),
|
|
37
|
+
title: z.string().trim().min(1).max(512),
|
|
38
|
+
message: z.string().trim().max(4096),
|
|
39
|
+
supportCode: z.string().trim().max(128).nullable(),
|
|
40
|
+
runbookUrl: z.string().trim().max(1024).nullable(),
|
|
41
|
+
firstSeenAt: isoDateTimeSchema,
|
|
42
|
+
lastSeenAt: isoDateTimeSchema,
|
|
43
|
+
snoozedUntil: isoDateTimeSchema.nullable(),
|
|
44
|
+
})
|
|
45
|
+
.strict();
|
|
46
|
+
|
|
47
|
+
export const adminAlertsHeartbeatSchema = z
|
|
48
|
+
.object({
|
|
49
|
+
lastSuccessAt: isoDateTimeSchema.nullable(),
|
|
50
|
+
staleAfterSeconds: z.number().int().positive(),
|
|
51
|
+
stale: z.boolean(),
|
|
52
|
+
})
|
|
53
|
+
.strict();
|
|
54
|
+
|
|
55
|
+
export const adminAlertsSummarySchema = z
|
|
56
|
+
.object({
|
|
57
|
+
/** Drives the bell badge. */
|
|
58
|
+
commercePageableCount: z.number().int().min(0),
|
|
59
|
+
commerceTotalCount: z.number().int().min(0),
|
|
60
|
+
platformPageableCount: z.number().int().min(0),
|
|
61
|
+
/** Visible controlled exceptions; excluded from paging and health degradation. */
|
|
62
|
+
snoozedCount: z.number().int().min(0),
|
|
63
|
+
maxOpenSeverity: adminAlertSeveritySchema.nullable(),
|
|
64
|
+
})
|
|
65
|
+
.strict();
|
|
66
|
+
|
|
67
|
+
export const adminAlertsOverviewResponseSchema = z
|
|
68
|
+
.object({
|
|
69
|
+
health: adminAlertHealthSchema,
|
|
70
|
+
summary: adminAlertsSummarySchema,
|
|
71
|
+
heartbeat: adminAlertsHeartbeatSchema,
|
|
72
|
+
alerts: z.array(adminAlertSchema).max(50),
|
|
73
|
+
// Snoozed rows are returned separately from the active-list cap so a
|
|
74
|
+
// controlled exception never disappears behind routine diagnostics.
|
|
75
|
+
snoozedAlerts: z.array(adminAlertSchema).max(200),
|
|
76
|
+
generatedAt: isoDateTimeSchema,
|
|
77
|
+
})
|
|
78
|
+
.strict();
|