Skip to content

Commit d7aa164

Browse files
committed
feat: add inline provisioning handlers for K8s infrastructure
Add @constructive-io/provisioning-handlers package with 4 built-in task handlers that create K8s infrastructure when function definitions are inserted/updated in the database: - namespace:provision — creates K8s namespaces - namespace:sync-secrets — syncs DB secrets to K8s Secrets - function:provision — creates Knative Services from function definitions - function:sync-resources — updates Knative Service specs on changes All handlers are idempotent (handle 409 Conflict) and gracefully skip in dev mode when K8S_API_URL is not set. Wire into compute-worker via doWorkProvisioning() dispatch method. Update PlatformFunctionDefinition with image/scaling/resource fields. Update FunctionDiscovery SQL to include new columns.
1 parent 59cb03c commit d7aa164

14 files changed

Lines changed: 701 additions & 3 deletions

File tree

job/compute-worker/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@constructive-io/job-pg": "^2.5.4",
1616
"@constructive-io/job-utils": "^2.5.4",
1717
"@constructive-io/module-loader": "workspace:^",
18+
"@constructive-io/provisioning-handlers": "workspace:^",
1819
"@pgpmjs/logger": "^2.4.3",
1920
"pg": "8.20.0"
2021
},

job/compute-worker/src/discovery.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
* fetches the function definition and caches it for `ttlMs` (default 60 s).
99
*/
1010

11-
import { TtlCache } from '@constructive-io/module-loader';
1211
import type { ComputeModuleLoader } from '@constructive-io/module-loader';
12+
import { TtlCache } from '@constructive-io/module-loader';
1313
import { Logger } from '@pgpmjs/logger';
1414
import type { Pool } from 'pg';
1515

@@ -22,7 +22,9 @@ const COLUMNS = `
2222
is_invocable, is_built_in, max_attempts,
2323
priority, queue_name, scope, namespace_id,
2424
required_configs, required_secrets, description,
25-
runtime, inputs, outputs`;
25+
runtime, inputs, outputs,
26+
image, concurrency, scale_min, scale_max,
27+
timeout_seconds, resources`;
2628

2729
export class FunctionDiscovery {
2830
private cache: TtlCache<PlatformFunctionDefinition | null>;

job/compute-worker/src/index.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
import poolManager from '@constructive-io/job-pg';
1616
import type { PgClientLike } from '@constructive-io/job-utils';
1717
import * as jobs from '@constructive-io/job-utils';
18-
import { ComputeModuleLoader } from '@constructive-io/module-loader';
1918
import type { GraphExecutionModuleConfig } from '@constructive-io/module-loader';
19+
import { ComputeModuleLoader } from '@constructive-io/module-loader';
20+
import type { ProvisioningContext } from '@constructive-io/provisioning-handlers';
21+
import { getProvisioningHandler } from '@constructive-io/provisioning-handlers';
2022
import { Logger } from '@pgpmjs/logger';
2123
import type { Pool, PoolClient } from 'pg';
2224

@@ -61,6 +63,8 @@ export type {
6163
PlatformFunctionDefinition,
6264
} from './types';
6365
export { isGraphNodePayload } from './types';
66+
export type { ProvisioningContext, ProvisioningHandler } from '@constructive-io/provisioning-handlers';
67+
export { getProvisioningHandler, registerProvisioningHandler } from '@constructive-io/provisioning-handlers';
6468

6569
const log = new Logger('compute:worker');
6670

@@ -299,6 +303,13 @@ export default class ComputeWorker {
299303
// so the code stays explicit about the source.
300304
const functionName = graphNode ? payload.node_type : task_identifier;
301305

306+
// Check for provisioning handler before function discovery
307+
const provisioningHandler = getProvisioningHandler(functionName);
308+
if (provisioningHandler) {
309+
await this.doWorkProvisioning(job, provisioningHandler, payload as Record<string, unknown>);
310+
return;
311+
}
312+
302313
const fn = await this.discovery.resolve(functionName);
303314
if (!fn) {
304315
throw new Error(`Function "${functionName}" is not registered in platform_function_definitions`);
@@ -327,6 +338,83 @@ export default class ComputeWorker {
327338
}
328339
}
329340

341+
// ─── Provisioning dispatch (in-process, K8s infrastructure) ──────────────
342+
343+
private async doWorkProvisioning(
344+
job: ComputeJobRow,
345+
handler: (payload: Record<string, unknown>, context: ProvisioningContext) => Promise<Record<string, unknown>>,
346+
payload: Record<string, unknown>
347+
): Promise<void> {
348+
const { task_identifier } = job;
349+
const databaseId = job.database_id || this.platformDatabaseId;
350+
const scope = job.entity_id ? 'org' : 'platform';
351+
352+
await this.setJobGUCs(job);
353+
354+
const { id: invocationId } = await this.tracker.create({
355+
task_identifier,
356+
payload,
357+
job_id: job.id,
358+
database_id: databaseId,
359+
actor_id: job.actor_id,
360+
scope,
361+
});
362+
363+
const reqStart = process.hrtime();
364+
try {
365+
const result = await handler(payload, {
366+
pool: this.pgPool,
367+
databaseId,
368+
k8sApiUrl: process.env.K8S_API_URL || null,
369+
});
370+
371+
const elapsed = process.hrtime(reqStart);
372+
const ms = Math.round((elapsed[0] * 1e9 + elapsed[1]) / 1e6);
373+
await this.tracker.complete(
374+
invocationId, ms, undefined,
375+
scope, scope === 'org' ? databaseId : undefined
376+
);
377+
378+
await this.computeLog.log({
379+
task_identifier,
380+
job_id: job.id,
381+
invocation_id: invocationId,
382+
database_id: databaseId,
383+
entity_id: job.entity_id,
384+
organization_id: job.organization_id,
385+
entity_type: job.entity_type,
386+
actor_id: job.actor_id,
387+
status: 'completed',
388+
duration_ms: ms,
389+
});
390+
391+
log.info(`provisioning ${task_identifier} completed in ${ms}ms`, result);
392+
} catch (err: any) {
393+
const elapsed = process.hrtime(reqStart);
394+
const ms = Math.round((elapsed[0] * 1e9 + elapsed[1]) / 1e6);
395+
await this.tracker.fail(
396+
invocationId, ms, err.message,
397+
scope, scope === 'org' ? databaseId : undefined
398+
);
399+
400+
await this.computeLog.log({
401+
task_identifier,
402+
job_id: job.id,
403+
invocation_id: invocationId,
404+
database_id: databaseId,
405+
entity_id: job.entity_id,
406+
organization_id: job.organization_id,
407+
entity_type: job.entity_type,
408+
actor_id: job.actor_id,
409+
status: 'failed',
410+
duration_ms: ms,
411+
error: err.message,
412+
});
413+
414+
throw err;
415+
}
416+
}
417+
330418
// ─── Inline dispatch (in-process, no HTTP) ──────────────────────────────
331419

332420
private async doWorkInline(

job/compute-worker/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ export interface PlatformFunctionDefinition {
4444
runtime: FunctionRuntime | null;
4545
inputs: FunctionPortDefinition[] | null;
4646
outputs: FunctionPortDefinition[] | null;
47+
image: string | null;
48+
concurrency: number;
49+
scale_min: number;
50+
scale_max: number;
51+
timeout_seconds: number;
52+
resources: Record<string, unknown>;
4753
}
4854

4955
// ─── Invocation Record ───────────────────────────────────────────────────────
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "@constructive-io/provisioning-handlers",
3+
"version": "0.1.0",
4+
"description": "Built-in provisioning handlers — creates K8s namespaces, secrets, and Knative Services from database events",
5+
"author": "Constructive <developers@constructive.io>",
6+
"license": "SEE LICENSE IN LICENSE",
7+
"main": "dist/index.js",
8+
"types": "dist/index.d.ts",
9+
"files": [
10+
"dist",
11+
"README.md"
12+
],
13+
"publishConfig": {
14+
"access": "public"
15+
},
16+
"scripts": {
17+
"build": "tsc -p tsconfig.json",
18+
"clean": "rimraf dist"
19+
},
20+
"dependencies": {
21+
"@constructive-io/module-loader": "workspace:^",
22+
"@kubernetesjs/ops": "^0.0.4",
23+
"@pgpmjs/logger": "^2.4.3"
24+
},
25+
"peerDependencies": {
26+
"pg": "^8.0.0"
27+
}
28+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* function:provision — creates a Knative Service for a function
3+
* definition. Inline functions and those without an image are skipped.
4+
* Writes the resulting service_url back to function_definitions.
5+
* Idempotent: handles 409 Conflict.
6+
*/
7+
8+
import { ComputeModuleLoader } from '@constructive-io/module-loader';
9+
import { InterwebClient } from '@kubernetesjs/ops';
10+
import { Logger } from '@pgpmjs/logger';
11+
12+
import type { ProvisioningContext, ProvisioningHandler } from '../types';
13+
14+
const log = new Logger('provisioning:function');
15+
16+
/**
17+
* Build the Knative Service spec from a function definition row.
18+
*/
19+
function buildKnativeService(
20+
fnRow: Record<string, unknown>,
21+
namespaceName: string
22+
) {
23+
const image = fnRow.image as string;
24+
const concurrency = (fnRow.concurrency as number) ?? 0;
25+
const scaleMin = (fnRow.scale_min as number) ?? 0;
26+
const scaleMax = (fnRow.scale_max as number) ?? 0;
27+
const timeoutSeconds = (fnRow.timeout_seconds as number) ?? 300;
28+
const resources = (fnRow.resources as Record<string, unknown>) ?? {};
29+
const fnName = fnRow.name as string;
30+
31+
const annotations: Record<string, string> = {};
32+
if (scaleMin > 0) annotations['autoscaling.knative.dev/minScale'] = String(scaleMin);
33+
if (scaleMax > 0) annotations['autoscaling.knative.dev/maxScale'] = String(scaleMax);
34+
35+
return {
36+
apiVersion: 'serving.knative.dev/v1',
37+
kind: 'Service',
38+
metadata: { name: fnName, namespace: namespaceName },
39+
spec: {
40+
template: {
41+
metadata: {
42+
annotations: Object.keys(annotations).length > 0 ? annotations : undefined,
43+
},
44+
spec: {
45+
containerConcurrency: concurrency || undefined,
46+
timeoutSeconds,
47+
containers: [
48+
{
49+
image,
50+
envFrom: [{ secretRef: { name: `${namespaceName}-secrets` } }],
51+
...(Object.keys(resources).length > 0 ? { resources } : {}),
52+
},
53+
],
54+
},
55+
},
56+
},
57+
};
58+
}
59+
60+
export const handleFunctionProvision: ProvisioningHandler = async (
61+
payload: Record<string, unknown>,
62+
context: ProvisioningContext
63+
): Promise<Record<string, unknown>> => {
64+
const { pool, databaseId, k8sApiUrl } = context;
65+
const functionId = payload.id as string;
66+
67+
if (!functionId) {
68+
throw new Error('function:provision — missing "id" in payload');
69+
}
70+
71+
const loader = new ComputeModuleLoader(pool);
72+
const config = await loader.load(databaseId);
73+
const publicSchema = config.functionModule?.publicSchema ?? 'constructive_compute_public';
74+
const definitionsTable = config.functionModule?.definitionsTable ?? 'platform_function_definitions';
75+
76+
const { rows } = await pool.query(
77+
`SELECT id, name, task_identifier, service_url, runtime, image,
78+
concurrency, scale_min, scale_max, timeout_seconds, resources,
79+
namespace_id
80+
FROM "${publicSchema}"."${definitionsTable}"
81+
WHERE id = $1`,
82+
[functionId]
83+
);
84+
85+
if (rows.length === 0) {
86+
throw new Error(`function:provision — function_definition id=${functionId} not found`);
87+
}
88+
89+
const fnRow = rows[0] as Record<string, unknown>;
90+
91+
// Skip inline functions or those without an image
92+
if (fnRow.runtime === 'inline' || !fnRow.image) {
93+
log.info(
94+
`skipping function "${fnRow.name}" — ${fnRow.runtime === 'inline' ? 'inline runtime' : 'no image'}`
95+
);
96+
return { skipped: true, reason: fnRow.runtime === 'inline' ? 'inline-runtime' : 'no-image' };
97+
}
98+
99+
if (!k8sApiUrl) {
100+
log.info(
101+
`[dev-mode] would provision Knative Service for "${fnRow.name}" (image=${fnRow.image}) — skipping (no K8S_API_URL)`
102+
);
103+
return { skipped: true, reason: 'no-k8s' };
104+
}
105+
106+
// Resolve namespace name
107+
let namespaceName = 'default';
108+
if (fnRow.namespace_id) {
109+
const { rows: nsRows } = await pool.query(
110+
`SELECT name FROM metaschema_public.namespace WHERE id = $1`,
111+
[fnRow.namespace_id]
112+
);
113+
if (nsRows.length > 0) {
114+
namespaceName = nsRows[0].name as string;
115+
}
116+
}
117+
118+
const client = new InterwebClient({
119+
restEndpoint: k8sApiUrl,
120+
kubeconfig: '',
121+
namespace: namespaceName,
122+
context: '',
123+
});
124+
125+
// Ensure namespace exists
126+
try {
127+
await client.createCoreV1Namespace({
128+
query: {},
129+
body: {
130+
apiVersion: 'v1',
131+
kind: 'Namespace',
132+
metadata: { name: namespaceName },
133+
},
134+
});
135+
log.info(`ensured K8s namespace "${namespaceName}"`);
136+
} catch (err: any) {
137+
if (err?.status === 409 || err?.statusCode === 409 || String(err?.message).includes('AlreadyExists')) {
138+
// Namespace already exists — fine
139+
} else {
140+
throw err;
141+
}
142+
}
143+
144+
// Create Knative Service
145+
const serviceSpec = buildKnativeService(fnRow, namespaceName);
146+
let serviceUrl: string | null = null;
147+
148+
try {
149+
const svc = await client.createServingKnativeDevV1NamespacedService({
150+
query: {},
151+
path: { namespace: namespaceName },
152+
body: serviceSpec,
153+
});
154+
serviceUrl = svc?.status?.url ?? svc?.status?.address?.url ?? null;
155+
log.info(`created Knative Service "${fnRow.name}" in namespace "${namespaceName}"`);
156+
} catch (err: any) {
157+
if (err?.status === 409 || err?.statusCode === 409 || String(err?.message).includes('AlreadyExists')) {
158+
log.info(`Knative Service "${fnRow.name}" already exists — replacing`);
159+
const svc = await client.replaceServingKnativeDevV1NamespacedService({
160+
query: {},
161+
path: { name: fnRow.name as string, namespace: namespaceName },
162+
body: serviceSpec,
163+
});
164+
serviceUrl = svc?.status?.url ?? svc?.status?.address?.url ?? null;
165+
} else {
166+
throw err;
167+
}
168+
}
169+
170+
// Write service_url back to function_definitions
171+
if (serviceUrl) {
172+
await pool.query(
173+
`UPDATE "${publicSchema}"."${definitionsTable}" SET service_url = $1 WHERE id = $2`,
174+
[serviceUrl, functionId]
175+
);
176+
log.info(`updated service_url for "${fnRow.name}" → ${serviceUrl}`);
177+
}
178+
179+
return { provisioned: true, name: fnRow.name as string, serviceUrl };
180+
};

0 commit comments

Comments
 (0)