Skip to content

Commit 377e8b0

Browse files
committed
Fix service split serving state and policy coverage
1 parent 6f50e02 commit 377e8b0

3 files changed

Lines changed: 538 additions & 170 deletions

File tree

scripts/migration/oneshot-service-split-cli.ts

Lines changed: 168 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,19 @@ import { resolve } from "node:path";
66
import postgres from "../../apps/cloud/node_modules/postgres/src/index.js";
77

88
import {
9+
operationStorageKey,
910
planMigration,
1011
renderOrgDiff,
1112
renderSummary,
13+
storageDataRecord,
1214
tenantHash,
1315
verifyPolicyRewriteNeverWidens,
16+
type BlobRow,
1417
type ConnectionRow,
15-
type DefinitionRow,
1618
type IntegrationRow,
1719
type MigrationInput,
1820
type OrgPlan,
21+
type PluginStorageRow,
1922
type ToolPolicyRow,
2023
type ToolRow,
2124
} from "./oneshot-service-split";
@@ -73,7 +76,11 @@ const readCompletedTenants = async (
7376
return rows.map((row) => row.tenant);
7477
};
7578

76-
const readDatabaseInput = async (sql: SqlClient, dryRun: boolean): Promise<MigrationInput> => {
79+
const readDatabaseInput = async (
80+
sql: SqlClient,
81+
dryRun: boolean,
82+
blobBackend: MigrationInput["blobBackend"],
83+
): Promise<MigrationInput> => {
7784
const completedTenants = await readCompletedTenants(sql, dryRun);
7885
const integrations = await readRows<IntegrationRow[]>(
7986
sql,
@@ -118,21 +125,40 @@ const readDatabaseInput = async (sql: SqlClient, dryRun: boolean): Promise<Migra
118125
WHERE (plugin_id = 'google' AND slug = 'google')
119126
OR (plugin_id = 'microsoft' AND slug = 'microsoft')
120127
)
128+
AND integration IN ('google', 'microsoft')
121129
ORDER BY tenant, integration, connection, name
122130
`,
123131
);
124-
const definitions = await readRows<DefinitionRow[]>(
125-
sql,
132+
const pluginStorage = await sql.unsafe<PluginStorageRow[]>(
126133
`
127-
SELECT tenant, owner, subject, integration, connection, plugin_id, name, schema,
128-
created_at::text, row_id
129-
FROM definition
134+
SELECT tenant, owner, subject, plugin_id, collection, key, data,
135+
created_at::text, updated_at::text, row_id
136+
FROM plugin_storage
130137
WHERE tenant IN (
131138
SELECT tenant FROM integration
132139
WHERE (plugin_id = 'google' AND slug = 'google')
133140
OR (plugin_id = 'microsoft' AND slug = 'microsoft')
141+
)
142+
AND plugin_id IN ('google', 'microsoft')
143+
AND collection = 'operation'
144+
AND (key LIKE $1 OR key LIKE $2 OR key LIKE 'google.%' OR key LIKE 'microsoft.%')
145+
ORDER BY tenant, plugin_id, collection, key
146+
`,
147+
[operationKeyPrefix("google"), operationKeyPrefix("microsoft")],
148+
);
149+
const blobs = await readRows<BlobRow[]>(
150+
sql,
151+
`
152+
SELECT id, namespace, key
153+
FROM blob
154+
WHERE namespace IN (
155+
SELECT 'o:' || tenant || '/' || plugin_id
156+
FROM integration
157+
WHERE (plugin_id = 'google' AND slug = 'google')
158+
OR (plugin_id = 'microsoft' AND slug = 'microsoft')
134159
)
135-
ORDER BY tenant, integration, connection, name
160+
AND (key LIKE 'spec/%' OR key LIKE 'defs/%')
161+
ORDER BY namespace, key
136162
`,
137163
);
138164
const policies = await readRows<ToolPolicyRow[]>(
@@ -153,10 +179,12 @@ const readDatabaseInput = async (sql: SqlClient, dryRun: boolean): Promise<Migra
153179
integrations,
154180
connections,
155181
tools,
156-
definitions,
182+
pluginStorage,
183+
blobs,
157184
policies,
158185
completedTenants,
159186
trafficLastTenant,
187+
blobBackend,
160188
};
161189
};
162190

@@ -181,56 +209,8 @@ const stableId = (...parts: readonly string[]): string => {
181209
return `oneshot_${hash.toString(36)}`;
182210
};
183211

184-
const serviceToolSql = (presetId: string): string => {
185-
const prefixesByPreset: Readonly<Record<string, readonly string[]>> = {
186-
"google-calendar": ["calendar."],
187-
"google-gmail": ["gmail."],
188-
"google-sheets": ["sheets."],
189-
"google-drive": ["drive."],
190-
"google-docs": ["docs."],
191-
"google-slides": ["slides."],
192-
"google-forms": ["forms."],
193-
"google-tasks": ["tasks."],
194-
"google-people": ["people."],
195-
"google-photos-library": ["photoslibrary."],
196-
"google-photos-picker": ["photospicker."],
197-
"google-chat": ["chat."],
198-
"google-keep": ["keep."],
199-
"google-youtube-data": ["youtube."],
200-
"google-search-console": ["searchconsole.", "webmasters."],
201-
"google-classroom": ["classroom."],
202-
"google-admin-directory": ["admin."],
203-
"google-admin-reports": ["admin."],
204-
"google-apps-script": ["script."],
205-
"google-bigquery": ["bigquery."],
206-
"google-cloud-resource-manager": ["cloudresourcemanager."],
207-
profile: ["meUser.", "usersUser.", "meProfilePhoto."],
208-
mail: [
209-
"meMessage.",
210-
"usersMessage.",
211-
"meMail",
212-
"usersMail",
213-
"meMailbox",
214-
"meOutlook",
215-
"usersOutlook",
216-
],
217-
calendar: ["meCalendar", "usersCalendar", "meEvent.", "usersEvent."],
218-
contacts: ["meContact", "usersContact", "mePerson.", "usersPerson."],
219-
tasks: ["meTodo.", "usersTodo."],
220-
planner: ["planner.", "mePlanner.", "usersPlanner."],
221-
files: ["drives", "meDrive", "usersDrive", "groupsDrive", "shares"],
222-
excel: ["workbook", "Workbook"],
223-
sites: ["sites"],
224-
onenote: ["meOnenote", "usersOnenote", "groupsOnenote", "sitesOnenote"],
225-
"teams-chat": ["chats", "meChat"],
226-
"teams-channels": ["teams", "teamwork", "meTeam", "groupsTeam"],
227-
"meetings-calls": ["communications", "meOnlineMeeting", "usersOnlineMeeting"],
228-
};
229-
const prefixes = prefixesByPreset[presetId] ?? [];
230-
const clauses = prefixes.map((prefix) => `name LIKE '${prefix.replaceAll("'", "''")}%'`);
231-
if (presetId.startsWith("google-")) clauses.push("name LIKE 'oauth2.%'");
232-
return clauses.length > 0 ? `(${clauses.join(" OR ")})` : "false";
233-
};
212+
const operationKeyPrefix = (integration: string): string =>
213+
`${operationStorageKey(integration, "").split(".").slice(0, 2).join(".")}.%`;
234214

235215
const applyOrg = async (sql: SqlClient, org: OrgPlan): Promise<void> => {
236216
if (org.completed) return;
@@ -296,45 +276,92 @@ const applyOrg = async (sql: SqlClient, org: OrgPlan): Promise<void> => {
296276
}
297277

298278
for (const integration of org.integrations) {
299-
const whereTools = serviceToolSql(integration.target.presetId);
300-
await tx.unsafe(
301-
`
302-
INSERT INTO tool (
303-
integration, connection, plugin_id, name, description, input_schema, output_schema,
304-
annotations, created_at, updated_at, row_id, tenant, owner, subject
305-
)
306-
SELECT $1, connection, plugin_id, name, description, input_schema, output_schema,
307-
annotations, created_at, $2, concat($3::text, '_', row_id), tenant, owner, subject
308-
FROM tool
309-
WHERE tenant = $4 AND integration = $5 AND ${whereTools}
310-
ON CONFLICT (tenant, owner, subject, integration, connection, name) DO NOTHING
311-
`,
312-
[
313-
integration.target.slug,
314-
now,
315-
stableId("tool", org.tenant, integration.target.slug),
316-
org.tenant,
317-
integration.source.slug,
318-
],
319-
);
320-
await tx.unsafe(
321-
`
322-
INSERT INTO definition (
323-
integration, connection, plugin_id, name, schema, created_at, row_id, tenant, owner, subject
324-
)
325-
SELECT $1, connection, plugin_id, name, schema, created_at, concat($2::text, '_', row_id),
326-
tenant, owner, subject
327-
FROM definition
328-
WHERE tenant = $3 AND integration = $4 AND ${whereTools}
329-
ON CONFLICT (tenant, owner, subject, integration, connection, name) DO NOTHING
330-
`,
331-
[
332-
integration.target.slug,
333-
stableId("definition", org.tenant, integration.target.slug),
334-
org.tenant,
335-
integration.source.slug,
336-
],
337-
);
279+
for (const toolName of integration.servingState.operationToolNames) {
280+
const [operation] = await tx.unsafe<
281+
{
282+
readonly data: unknown;
283+
readonly created_at: string;
284+
readonly updated_at: string;
285+
}[]
286+
>(
287+
`
288+
SELECT data, created_at::text, updated_at::text
289+
FROM plugin_storage
290+
WHERE tenant = $1
291+
AND owner = 'org'
292+
AND subject = ''
293+
AND plugin_id = $2
294+
AND collection = 'operation'
295+
AND (
296+
key = $3
297+
OR (
298+
data::jsonb ->> 'integration' = $4
299+
AND data::jsonb ->> 'toolName' = $5
300+
)
301+
)
302+
LIMIT 1
303+
`,
304+
[
305+
org.tenant,
306+
integration.source.plugin_id,
307+
operationStorageKey(integration.source.slug, toolName),
308+
integration.source.slug,
309+
toolName,
310+
],
311+
);
312+
if (!operation) {
313+
throw new Error(
314+
`Missing operation row for ${tenantHash(org.tenant)}/${integration.source.slug}/${toolName}`,
315+
);
316+
}
317+
await tx.unsafe(
318+
`
319+
INSERT INTO plugin_storage (
320+
plugin_id, collection, key, data, created_at, updated_at, row_id,
321+
tenant, owner, subject
322+
)
323+
VALUES ($1, 'operation', $2, $3::json, $4, $5, $6, $7, 'org', '')
324+
ON CONFLICT (tenant, owner, subject, plugin_id, collection, key)
325+
DO UPDATE SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at
326+
`,
327+
[
328+
integration.source.plugin_id,
329+
operationStorageKey(integration.target.slug, toolName),
330+
JSON.stringify(
331+
scrubJson({
332+
...storageDataRecord(operation),
333+
integration: integration.target.slug,
334+
toolName,
335+
}),
336+
),
337+
operation.created_at,
338+
now,
339+
stableId("operation", org.tenant, integration.target.slug, toolName),
340+
org.tenant,
341+
],
342+
);
343+
await tx.unsafe(
344+
`
345+
INSERT INTO tool (
346+
integration, connection, plugin_id, name, description, input_schema, output_schema,
347+
annotations, created_at, updated_at, row_id, tenant, owner, subject
348+
)
349+
SELECT $1, connection, plugin_id, name, description, input_schema, output_schema,
350+
annotations, created_at, $2, concat($3::text, '_', row_id), tenant, owner, subject
351+
FROM tool
352+
WHERE tenant = $4 AND integration = $5 AND name = $6
353+
ON CONFLICT (tenant, owner, subject, integration, connection, name) DO NOTHING
354+
`,
355+
[
356+
integration.target.slug,
357+
now,
358+
stableId("tool", org.tenant, integration.target.slug),
359+
org.tenant,
360+
integration.source.slug,
361+
toolName,
362+
],
363+
);
364+
}
338365
}
339366

340367
for (const policy of org.policies.filter((item) => item.action === "rewrite")) {
@@ -369,10 +396,6 @@ const applyOrg = async (sql: SqlClient, org: OrgPlan): Promise<void> => {
369396
}
370397

371398
for (const monolith of org.deleteMonoliths) {
372-
await tx.unsafe("DELETE FROM definition WHERE tenant = $1 AND integration = $2", [
373-
org.tenant,
374-
monolith.slug,
375-
]);
376399
await tx.unsafe("DELETE FROM tool WHERE tenant = $1 AND integration = $2", [
377400
org.tenant,
378401
monolith.slug,
@@ -399,7 +422,7 @@ const applyOrg = async (sql: SqlClient, org: OrgPlan): Promise<void> => {
399422
};
400423

401424
const writeDryRun = (planInput: MigrationInput): void => {
402-
const plan = planMigration(planInput);
425+
const plan = planMigration({ ...planInput, collectPolicyErrors: true });
403426
const neverWiden = verifyPolicyRewriteNeverWidens(plan, planInput);
404427
mkdirSync(outputDir, { recursive: true });
405428
for (const org of plan.orgs) {
@@ -414,7 +437,26 @@ const writeDryRun = (planInput: MigrationInput): void => {
414437
console.log(`policy_never_widen_checked=${neverWiden.checkedPolicies}`);
415438
console.log(`diff_dir=${outputDir}`);
416439
if (!neverWiden.ok) {
417-
throw new Error(`Policy rewrite widened ${neverWiden.widened.length} policy row(s)`);
440+
throw new Error(
441+
`Policy rewrite failed coverage checks: widened=${neverWiden.widened.length}, narrowed=${neverWiden.narrowed.length}`,
442+
);
443+
}
444+
if (
445+
plan.summary.integrationsMissingSpecBlob > 0 ||
446+
plan.summary.integrationsMissingDefsBlob > 0
447+
) {
448+
throw new Error(
449+
`Serving blob check failed: missing spec=${plan.summary.integrationsMissingSpecBlob}, missing defs=${plan.summary.integrationsMissingDefsBlob}`,
450+
);
451+
}
452+
const zeroOperationIntegrations = plan.orgs
453+
.filter((org) => !org.completed && org.hardErrors.length === 0)
454+
.flatMap((org) => org.integrations)
455+
.filter((integration) => integration.servingState.operationsToBuild === 0);
456+
if (zeroOperationIntegrations.length > 0) {
457+
throw new Error(
458+
`Serving operation check failed for ${zeroOperationIntegrations.length} planned integration(s)`,
459+
);
418460
}
419461
};
420462

@@ -434,15 +476,34 @@ const main = async (): Promise<void> => {
434476
...(usesLocalDatabase ? {} : { ssl: "require" as const }),
435477
}) as unknown as SqlClient;
436478
try {
437-
const input = await readDatabaseInput(sql, !apply);
438-
const plan = planMigration(input);
479+
const input = await readDatabaseInput(sql, !apply, usesLocalDatabase ? "database" : "external");
439480
if (!apply) {
440481
writeDryRun(input);
441482
return;
442483
}
484+
const plan = planMigration(input);
443485
const neverWiden = verifyPolicyRewriteNeverWidens(plan, input);
444486
if (!neverWiden.ok) {
445-
throw new Error(`Policy rewrite widened ${neverWiden.widened.length} policy row(s)`);
487+
throw new Error(
488+
`Policy rewrite failed coverage checks: widened=${neverWiden.widened.length}, narrowed=${neverWiden.narrowed.length}`,
489+
);
490+
}
491+
if (
492+
plan.summary.integrationsMissingSpecBlob > 0 ||
493+
plan.summary.integrationsMissingDefsBlob > 0
494+
) {
495+
throw new Error(
496+
`Serving blob check failed: missing spec=${plan.summary.integrationsMissingSpecBlob}, missing defs=${plan.summary.integrationsMissingDefsBlob}`,
497+
);
498+
}
499+
const zeroOperationIntegrations = plan.orgs
500+
.filter((org) => !org.completed && org.hardErrors.length === 0)
501+
.flatMap((org) => org.integrations)
502+
.filter((integration) => integration.servingState.operationsToBuild === 0);
503+
if (zeroOperationIntegrations.length > 0) {
504+
throw new Error(
505+
`Serving operation check failed for ${zeroOperationIntegrations.length} planned integration(s)`,
506+
);
446507
}
447508
for (const org of plan.orgs) {
448509
await applyOrg(sql, org);

0 commit comments

Comments
 (0)