Skip to content

Commit 18a048b

Browse files
committed
refactor: align ksvc spec with operator patterns, bump Knative to v1.20.0
- Add standard k8s labels (managed-by, part-of, component, name, instance) - Add networking.knative.dev/visibility: cluster-local label - Add explicit containerPort: 8080 - Add /tmp emptyDir volume + volumeMount (writable scratch space) - Add autoscaling.knative.dev/target annotation support - Extract mergeAndReplace() helper shared by seed + sync handler - Export KnativeServiceSpec type for downstream consumers - Bump Knative version from v1.17.0 to v1.20.0 in all CI workflows - Update unit tests to verify labels, ports, volumes, target annotation
1 parent 46a6327 commit 18a048b

7 files changed

Lines changed: 196 additions & 73 deletions

File tree

.github/workflows/spike-knative-kind.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,20 @@ jobs:
3636
# ── Install Knative Serving CRDs + core ──────────────────────────
3737
- name: Install Knative Serving CRDs
3838
run: |
39-
KNATIVE_VERSION=v1.17.0
39+
KNATIVE_VERSION=v1.20.0
4040
echo "Installing Knative Serving ${KNATIVE_VERSION} CRDs..."
4141
kubectl apply -f "https://github.com/knative/serving/releases/download/knative-${KNATIVE_VERSION}/serving-crds.yaml"
4242
4343
- name: Install Knative Serving core
4444
run: |
45-
KNATIVE_VERSION=v1.17.0
45+
KNATIVE_VERSION=v1.20.0
4646
echo "Installing Knative Serving ${KNATIVE_VERSION} core..."
4747
kubectl apply -f "https://github.com/knative/serving/releases/download/knative-${KNATIVE_VERSION}/serving-core.yaml"
4848
4949
# ── Install a networking layer (Kourier is simplest for CI) ──────
5050
- name: Install Kourier networking
5151
run: |
52-
KNATIVE_VERSION=v1.17.0
52+
KNATIVE_VERSION=v1.20.0
5353
echo "Installing Kourier networking layer..."
5454
kubectl apply -f "https://github.com/knative/net-kourier/releases/download/knative-${KNATIVE_VERSION}/kourier.yaml"
5555

.github/workflows/test-provisioning-e2e.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,19 +88,19 @@ jobs:
8888
# ── Knative Serving ────────────────────────────────────────────────
8989
- name: Install Knative Serving CRDs
9090
run: |
91-
KNATIVE_VERSION=v1.17.0
91+
KNATIVE_VERSION=v1.20.0
9292
echo "Installing Knative Serving ${KNATIVE_VERSION} CRDs..."
9393
kubectl apply -f "https://github.com/knative/serving/releases/download/knative-${KNATIVE_VERSION}/serving-crds.yaml"
9494
9595
- name: Install Knative Serving core
9696
run: |
97-
KNATIVE_VERSION=v1.17.0
97+
KNATIVE_VERSION=v1.20.0
9898
echo "Installing Knative Serving ${KNATIVE_VERSION} core..."
9999
kubectl apply -f "https://github.com/knative/serving/releases/download/knative-${KNATIVE_VERSION}/serving-core.yaml"
100100
101101
- name: Install Kourier networking
102102
run: |
103-
KNATIVE_VERSION=v1.17.0
103+
KNATIVE_VERSION=v1.20.0
104104
echo "Installing Kourier networking layer..."
105105
kubectl apply -f "https://github.com/knative/net-kourier/releases/download/knative-${KNATIVE_VERSION}/kourier.yaml"
106106

packages/provisioning-handlers/src/handlers/function-sync-resources.ts

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Logger } from '@pgpmjs/logger';
1212

1313
import { getK8sClient, isNotFound } from '../k8s-client';
1414
import { buildKnativeServiceSpec, resolveNamespaceName } from '../knative';
15+
import { mergeAndReplace } from '../seed';
1516
import type { ProvisioningContext, ProvisioningHandler } from '../types';
1617

1718
const log = new Logger('provisioning:function-sync');
@@ -67,26 +68,7 @@ export const handleFunctionSyncResources: ProvisioningHandler = async (
6768
const serviceSpec = buildKnativeServiceSpec(fnRow, namespaceName);
6869

6970
try {
70-
// GET the existing service to retrieve metadata required for PUT
71-
const existing = await client.readServingKnativeDevV1NamespacedService({
72-
query: {},
73-
path: { name: fnName, namespace: namespaceName },
74-
});
75-
if (serviceSpec.metadata) {
76-
serviceSpec.metadata.resourceVersion = existing?.metadata?.resourceVersion;
77-
// Preserve Knative-managed annotations (e.g. serving.knative.dev/creator)
78-
const existingAnnotations = (existing?.metadata?.annotations ?? {}) as Record<string, string>;
79-
serviceSpec.metadata.annotations = { ...existingAnnotations, ...serviceSpec.metadata.annotations };
80-
const existingLabels = (existing?.metadata?.labels ?? {}) as Record<string, string>;
81-
serviceSpec.metadata.labels = { ...existingLabels, ...serviceSpec.metadata.labels };
82-
}
83-
84-
const svc = await client.replaceServingKnativeDevV1NamespacedService({
85-
query: {},
86-
path: { name: fnName, namespace: namespaceName },
87-
body: serviceSpec,
88-
});
89-
71+
const svc = await mergeAndReplace(client, serviceSpec, fnName, namespaceName);
9072
const serviceUrl = svc?.status?.url ?? svc?.status?.address?.url ?? null;
9173
log.info(`updated Knative Service "${fnName}" in namespace "${namespaceName}"`);
9274

packages/provisioning-handlers/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export {
44
registerProvisioningHandler,
55
} from './registry';
66
export { getK8sClient, isConflict, isNotFound } from './k8s-client';
7+
export type { KnativeServiceSpec } from './knative';
78
export { buildKnativeServiceSpec, resolveNamespaceName } from './knative';
89
export type { ProvisionSeedOptions, ProvisionSeedResult } from './seed';
9-
export { provision } from './seed';
10+
export { mergeAndReplace, provision } from './seed';

packages/provisioning-handlers/src/knative.ts

Lines changed: 103 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,142 @@
11
/**
22
* Shared Knative Service helpers — used by both the seed script
33
* and the function:sync-resources queue handler.
4+
*
5+
* Spec shape mirrors constructive-cloud's Go operator
6+
* (operator/internal/resources/knative.go) so the two systems
7+
* produce functionally identical Knative Services.
48
*/
59

610
import type { Pool } from 'pg';
711

12+
// ── Constants ────────────────────────────────────────────────────────────────
13+
14+
const DEFAULT_PORT = 8080;
15+
const DEFAULT_TIMEOUT = 300;
16+
const DEFAULT_SCALE_TARGET = 50;
17+
const MANAGED_BY = 'provisioning-handlers';
18+
19+
// ── Types ────────────────────────────────────────────────────────────────────
20+
21+
export interface KnativeServiceSpec {
22+
apiVersion: 'serving.knative.dev/v1';
23+
kind: 'Service';
24+
metadata: {
25+
name: string;
26+
namespace: string;
27+
labels: Record<string, string>;
28+
annotations: Record<string, string>;
29+
resourceVersion?: string;
30+
};
31+
spec: {
32+
template: {
33+
metadata: {
34+
labels: Record<string, string>;
35+
annotations?: Record<string, string>;
36+
};
37+
spec: {
38+
containerConcurrency?: number;
39+
timeoutSeconds: number;
40+
containers: Array<{
41+
image: string;
42+
ports: Array<{ containerPort: number }>;
43+
envFrom: Array<{ secretRef: { name: string } }>;
44+
resources?: Record<string, unknown>;
45+
volumeMounts: Array<{ name: string; mountPath: string }>;
46+
}>;
47+
volumes: Array<{ name: string; emptyDir: Record<string, never> }>;
48+
};
49+
};
50+
};
51+
}
52+
53+
// ── Labels (matches operator/internal/util/labels.go) ────────────────────────
54+
55+
function componentLabels(namespaceName: string, fnName: string): Record<string, string> {
56+
return {
57+
'app.kubernetes.io/managed-by': MANAGED_BY,
58+
'app.kubernetes.io/part-of': namespaceName,
59+
'app.kubernetes.io/component': 'function',
60+
'app.kubernetes.io/name': fnName,
61+
'app.kubernetes.io/instance': `${namespaceName}-${fnName}`,
62+
'networking.knative.dev/visibility': 'cluster-local',
63+
};
64+
}
65+
66+
// ── Builder ──────────────────────────────────────────────────────────────────
67+
868
/**
9-
* Build the Knative Service spec from a function definition row.
69+
* Build a Knative Service spec from a function definition row.
70+
*
71+
* Mirrors the operator's `BuildKnativeService()`:
72+
* - Standard k8s labels + cluster-local visibility
73+
* - Autoscaling annotations (minScale, maxScale, target)
74+
* - Explicit containerPort
75+
* - /tmp emptyDir volume (writable scratch space)
76+
* - envFrom bulk secret ref
1077
*/
1178
export function buildKnativeServiceSpec(
1279
fnRow: Record<string, unknown>,
1380
namespaceName: string
14-
) {
81+
): KnativeServiceSpec {
82+
const fnName = fnRow.name as string;
1583
const image = fnRow.image as string;
1684
const concurrency = (fnRow.concurrency as number) ?? 0;
1785
const scaleMin = (fnRow.scale_min as number) ?? 0;
1886
const scaleMax = (fnRow.scale_max as number) ?? 0;
19-
const timeoutSeconds = (fnRow.timeout_seconds as number) ?? 300;
87+
const scaleTarget = (fnRow.scale_target as number) ?? 0;
88+
const timeoutSeconds = (fnRow.timeout_seconds as number) ?? DEFAULT_TIMEOUT;
2089
const resources = (fnRow.resources as Record<string, unknown>) ?? {};
21-
const fnName = fnRow.name as string;
2290

23-
const annotations: Record<string, string> = {};
24-
if (scaleMin > 0) annotations['autoscaling.knative.dev/minScale'] = String(scaleMin);
25-
if (scaleMax > 0) annotations['autoscaling.knative.dev/maxScale'] = String(scaleMax);
91+
// Template-level autoscaling annotations
92+
const templateAnnotations: Record<string, string> = {};
93+
if (scaleMin > 0) templateAnnotations['autoscaling.knative.dev/minScale'] = String(scaleMin);
94+
if (scaleMax > 0) templateAnnotations['autoscaling.knative.dev/maxScale'] = String(scaleMax);
95+
if (scaleTarget > 0 || (scaleMin > 0 && scaleMax > 0)) {
96+
templateAnnotations['autoscaling.knative.dev/target'] = String(scaleTarget || DEFAULT_SCALE_TARGET);
97+
}
98+
99+
const labels = componentLabels(namespaceName, fnName);
100+
101+
const container: KnativeServiceSpec['spec']['template']['spec']['containers'][0] = {
102+
image,
103+
ports: [{ containerPort: DEFAULT_PORT }],
104+
envFrom: [{ secretRef: { name: `${namespaceName}-secrets` } }],
105+
volumeMounts: [{ name: 'tmp', mountPath: '/tmp' }],
106+
};
107+
108+
if (Object.keys(resources).length > 0) {
109+
container.resources = resources;
110+
}
26111

27112
return {
28113
apiVersion: 'serving.knative.dev/v1',
29114
kind: 'Service',
30-
metadata: { name: fnName, namespace: namespaceName } as {
31-
name: string;
32-
namespace: string;
33-
resourceVersion?: string;
34-
annotations?: Record<string, string>;
35-
labels?: Record<string, string>;
115+
metadata: {
116+
name: fnName,
117+
namespace: namespaceName,
118+
labels: { ...labels },
119+
annotations: {},
36120
},
37121
spec: {
38122
template: {
39123
metadata: {
40-
annotations: Object.keys(annotations).length > 0 ? annotations : undefined,
124+
labels: { ...labels },
125+
annotations: Object.keys(templateAnnotations).length > 0 ? templateAnnotations : undefined,
41126
},
42127
spec: {
43128
containerConcurrency: concurrency || undefined,
44129
timeoutSeconds,
45-
containers: [
46-
{
47-
image,
48-
envFrom: [{ secretRef: { name: `${namespaceName}-secrets` } }],
49-
...(Object.keys(resources).length > 0 ? { resources } : {}),
50-
},
51-
],
130+
containers: [container],
131+
volumes: [{ name: 'tmp', emptyDir: {} }],
52132
},
53133
},
54134
},
55135
};
56136
}
57137

138+
// ── Namespace resolver ───────────────────────────────────────────────────────
139+
58140
/**
59141
* Resolve a namespace name from a namespace_id. Returns 'default' if null.
60142
*/

packages/provisioning-handlers/src/seed.ts

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,50 @@ import { Logger } from '@pgpmjs/logger';
1717
import type { Pool } from 'pg';
1818

1919
import { getK8sClient, isConflict } from './k8s-client';
20+
import type { KnativeServiceSpec } from './knative';
2021
import { buildKnativeServiceSpec, resolveNamespaceName } from './knative';
2122

2223
const log = new Logger('provisioning:seed');
2324

25+
// ── Helpers ──────────────────────────────────────────────────────────────────
26+
27+
/**
28+
* GET → merge immutable metadata → PUT.
29+
*
30+
* Knative's admission webhook sets immutable annotations
31+
* (e.g. serving.knative.dev/creator) and K8s requires
32+
* resourceVersion for optimistic-concurrency on PUT.
33+
* This helper fetches both from the live object and merges
34+
* them into the desired spec before replacing.
35+
*/
36+
export async function mergeAndReplace(
37+
client: import('@kubernetesjs/ops').InterwebClient,
38+
spec: KnativeServiceSpec,
39+
name: string,
40+
namespace: string
41+
) {
42+
const existing = await client.readServingKnativeDevV1NamespacedService({
43+
query: {},
44+
path: { name, namespace },
45+
});
46+
47+
spec.metadata.resourceVersion = existing?.metadata?.resourceVersion;
48+
49+
const existingAnnotations = (existing?.metadata?.annotations ?? {}) as Record<string, string>;
50+
spec.metadata.annotations = { ...existingAnnotations, ...spec.metadata.annotations };
51+
52+
const existingLabels = (existing?.metadata?.labels ?? {}) as Record<string, string>;
53+
spec.metadata.labels = { ...existingLabels, ...spec.metadata.labels };
54+
55+
return client.replaceServingKnativeDevV1NamespacedService({
56+
query: {},
57+
path: { name, namespace },
58+
body: spec,
59+
});
60+
}
61+
62+
// ── Types ────────────────────────────────────────────────────────────────────
63+
2464
export interface ProvisionSeedOptions {
2565
pool: Pool;
2666
databaseId: string;
@@ -191,25 +231,7 @@ export async function provision(opts: ProvisionSeedOptions): Promise<ProvisionSe
191231
result.functions.push({ name: fnName, namespace: namespaceName, serviceUrl, status: 'created' });
192232
} catch (err: unknown) {
193233
if (isConflict(err)) {
194-
// GET the existing service to retrieve metadata required for PUT
195-
const existing = await client.readServingKnativeDevV1NamespacedService({
196-
query: {},
197-
path: { name: fnName, namespace: namespaceName },
198-
});
199-
if (serviceSpec.metadata) {
200-
serviceSpec.metadata.resourceVersion = existing?.metadata?.resourceVersion;
201-
// Preserve Knative-managed annotations (e.g. serving.knative.dev/creator)
202-
const existingAnnotations = (existing?.metadata?.annotations ?? {}) as Record<string, string>;
203-
serviceSpec.metadata.annotations = { ...existingAnnotations, ...serviceSpec.metadata.annotations };
204-
const existingLabels = (existing?.metadata?.labels ?? {}) as Record<string, string>;
205-
serviceSpec.metadata.labels = { ...existingLabels, ...serviceSpec.metadata.labels };
206-
}
207-
208-
const svc = await client.replaceServingKnativeDevV1NamespacedService({
209-
query: {},
210-
path: { name: fnName, namespace: namespaceName },
211-
body: serviceSpec,
212-
});
234+
const svc = await mergeAndReplace(client, serviceSpec, fnName, namespaceName);
213235
serviceUrl = svc?.status?.url ?? svc?.status?.address?.url ?? null;
214236
log.info(`replaced Knative Service "${fnName}" in "${namespaceName}"`);
215237
result.functions.push({ name: fnName, namespace: namespaceName, serviceUrl, status: 'exists' });

0 commit comments

Comments
 (0)