@mizchi/k1c 0.1.0 → 0.3.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/README.md +8 -6
- package/dist/cli/main.js +21 -1
- package/dist/ingress/router-template.d.ts +30 -0
- package/dist/ingress/router-template.js +50 -0
- package/dist/manifest/lower.js +155 -1
- package/dist/manifest/schemas.d.ts +1568 -111
- package/dist/manifest/schemas.js +32 -0
- package/dist/manifest/types.d.ts +33 -1
- package/dist/providers/worker.d.ts +8 -0
- package/dist/providers/worker.js +22 -0
- package/dist/reconciler/plan.js +42 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,7 +49,8 @@ $ K1C_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=... pnpm k1c apply -f manifest.yaml
|
|
|
49
49
|
| `DNSRecord` (CRD) | DNS records | working |
|
|
50
50
|
| `LogpushJob` (CRD) | Logpush (zone- or account-scoped) | working |
|
|
51
51
|
| `ai` / `browser` / `version_metadata` / `analytics_engine` Worker bindings | annotation- / volume-driven | working |
|
|
52
|
-
| `Ingress` /
|
|
52
|
+
| `Ingress` (`networking.k8s.io/v1`) | generated router Worker + Custom Domain per host | working |
|
|
53
|
+
| `CustomHostname` / Zero Trust Access | — | not implemented (see [`TODO.md`](TODO.md)) |
|
|
53
54
|
|
|
54
55
|
See [`docs/resources.md`](docs/resources.md) for the full mapping and limitations,
|
|
55
56
|
and [`TODO.md`](TODO.md) for what's queued.
|
|
@@ -137,13 +138,14 @@ Versioning is automated via [release-please](https://github.com/googleapis/relea
|
|
|
137
138
|
- Conventional Commit messages on `main` (`feat:`, `fix:`, `chore:`, etc.) feed
|
|
138
139
|
into a release PR that bumps `package.json`, updates `CHANGELOG.md`, and
|
|
139
140
|
cuts a Git tag plus a GitHub Release.
|
|
140
|
-
-
|
|
141
|
-
publishes `@mizchi/k1c` to npm via
|
|
142
|
-
|
|
141
|
+
- The same `release-please.yml` workflow then runs a `publish` job (gated on
|
|
142
|
+
`release_created == true`) that publishes `@mizchi/k1c` to npm via
|
|
143
|
+
[OIDC trusted publishing](https://docs.npmjs.com/trusted-publishers)
|
|
144
|
+
(no `NPM_TOKEN`) with `--provenance` SLSA attestation.
|
|
143
145
|
|
|
144
146
|
The npm package must be registered as a trusted publisher on `npmjs.com` once,
|
|
145
|
-
pointing at `mizchi/k1c` + the `
|
|
146
|
-
flow (PR → merge → tag → npm) is hands-off.
|
|
147
|
+
pointing at `mizchi/k1c` + the `release-please.yml` workflow. After that the
|
|
148
|
+
entire flow (PR → merge → tag → npm) is hands-off.
|
|
147
149
|
|
|
148
150
|
## License
|
|
149
151
|
|
package/dist/cli/main.js
CHANGED
|
@@ -152,7 +152,27 @@ function isApi404(err) {
|
|
|
152
152
|
return err.status === 404;
|
|
153
153
|
}
|
|
154
154
|
main().then((code) => process.exit(code), (err) => {
|
|
155
|
-
process.stderr.write(`fatal: ${
|
|
155
|
+
process.stderr.write(`fatal: ${formatError(err)}\n`);
|
|
156
156
|
process.exit(1);
|
|
157
157
|
});
|
|
158
|
+
function formatError(err) {
|
|
159
|
+
if (err instanceof Error)
|
|
160
|
+
return err.stack ?? err.message;
|
|
161
|
+
// Provider errors are plain objects shaped as { code, recoverable, message, ... };
|
|
162
|
+
// formatting them through `String(err)` yields "[object Object]" and loses the
|
|
163
|
+
// useful fields. Render the structured form instead.
|
|
164
|
+
if (err !== null && typeof err === 'object') {
|
|
165
|
+
const e = err;
|
|
166
|
+
if (typeof e.message === 'string') {
|
|
167
|
+
return typeof e.code === 'string' ? `[${e.code}] ${e.message}` : e.message;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
return JSON.stringify(err);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return Object.prototype.toString.call(err);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return String(err);
|
|
177
|
+
}
|
|
158
178
|
//# sourceMappingURL=main.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates the JavaScript source for a k1c Ingress router Worker.
|
|
3
|
+
*
|
|
4
|
+
* The router holds the Ingress routing table inline as a frozen literal and
|
|
5
|
+
* dispatches each request to the matching backend Worker via a `service`
|
|
6
|
+
* binding. Host matching supports k8s-style wildcards (`*.example.com`).
|
|
7
|
+
* Path matching follows k8s Ingress semantics: `Prefix` matches segment-wise
|
|
8
|
+
* (`/foo` matches `/foo` and `/foo/bar` but not `/foobar`), `Exact` requires
|
|
9
|
+
* full equality, `ImplementationSpecific` is treated as `Prefix`.
|
|
10
|
+
*/
|
|
11
|
+
export interface RouterRoute {
|
|
12
|
+
/**
|
|
13
|
+
* Hostname rule. Either a literal host (`api.example.com`), a wildcard
|
|
14
|
+
* (`*.example.com`), or null for the catch-all rule (`spec.rules[].host` omitted).
|
|
15
|
+
*/
|
|
16
|
+
readonly host: string | null;
|
|
17
|
+
readonly paths: ReadonlyArray<RouterPath>;
|
|
18
|
+
}
|
|
19
|
+
export interface RouterPath {
|
|
20
|
+
readonly path: string;
|
|
21
|
+
readonly pathType: 'Prefix' | 'Exact' | 'ImplementationSpecific';
|
|
22
|
+
/** Binding identifier on `env` (e.g. `b0`, `b1`, ...) referencing the backend Worker. */
|
|
23
|
+
readonly backendBinding: string;
|
|
24
|
+
}
|
|
25
|
+
export interface RouterTemplateOptions {
|
|
26
|
+
readonly routes: ReadonlyArray<RouterRoute>;
|
|
27
|
+
readonly defaultBackend: string | null;
|
|
28
|
+
}
|
|
29
|
+
export declare function generateRouter(opts: RouterTemplateOptions): string;
|
|
30
|
+
//# sourceMappingURL=router-template.d.ts.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates the JavaScript source for a k1c Ingress router Worker.
|
|
3
|
+
*
|
|
4
|
+
* The router holds the Ingress routing table inline as a frozen literal and
|
|
5
|
+
* dispatches each request to the matching backend Worker via a `service`
|
|
6
|
+
* binding. Host matching supports k8s-style wildcards (`*.example.com`).
|
|
7
|
+
* Path matching follows k8s Ingress semantics: `Prefix` matches segment-wise
|
|
8
|
+
* (`/foo` matches `/foo` and `/foo/bar` but not `/foobar`), `Exact` requires
|
|
9
|
+
* full equality, `ImplementationSpecific` is treated as `Prefix`.
|
|
10
|
+
*/
|
|
11
|
+
export function generateRouter(opts) {
|
|
12
|
+
const tableLiteral = JSON.stringify(opts.routes, null, 2);
|
|
13
|
+
const defaultLiteral = opts.defaultBackend === null ? 'null' : JSON.stringify(opts.defaultBackend);
|
|
14
|
+
return `// k1c Ingress router (generated)
|
|
15
|
+
const ROUTES = ${tableLiteral};
|
|
16
|
+
const DEFAULT = ${defaultLiteral};
|
|
17
|
+
|
|
18
|
+
export default {
|
|
19
|
+
async fetch(request, env) {
|
|
20
|
+
const url = new URL(request.url);
|
|
21
|
+
const host = (request.headers.get('host') ?? url.host).toLowerCase();
|
|
22
|
+
const path = url.pathname;
|
|
23
|
+
|
|
24
|
+
for (const rule of ROUTES) {
|
|
25
|
+
if (!matchHost(rule.host, host)) continue;
|
|
26
|
+
for (const p of rule.paths) {
|
|
27
|
+
if (matchPath(p, path)) return env[p.backendBinding].fetch(request);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (DEFAULT !== null) return env[DEFAULT].fetch(request);
|
|
31
|
+
return new Response('Not Found', { status: 404 });
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function matchHost(rule, host) {
|
|
36
|
+
if (rule === null) return true;
|
|
37
|
+
const r = rule.toLowerCase();
|
|
38
|
+
if (r.startsWith('*.')) return host.endsWith(r.slice(1));
|
|
39
|
+
return host === r;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function matchPath(p, path) {
|
|
43
|
+
if (p.pathType === 'Exact') return path === p.path;
|
|
44
|
+
if (p.path === '/') return true;
|
|
45
|
+
if (path === p.path) return true;
|
|
46
|
+
return path.startsWith(p.path + '/');
|
|
47
|
+
}
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=router-template.js.map
|
package/dist/manifest/lower.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { generateDispatcher } from "../canary/dispatcher-template.js";
|
|
4
|
+
import { generateRouter } from "../ingress/router-template.js";
|
|
4
5
|
export class LowerError extends Error {
|
|
5
6
|
constructor(message) {
|
|
6
7
|
super(message);
|
|
@@ -29,6 +30,7 @@ export async function lower(resources, options) {
|
|
|
29
30
|
const jobs = [];
|
|
30
31
|
const dnsRecords = [];
|
|
31
32
|
const logpushJobs = [];
|
|
33
|
+
const ingresses = [];
|
|
32
34
|
for (const r of resources) {
|
|
33
35
|
const label = labelOf(r);
|
|
34
36
|
switch (r.kind) {
|
|
@@ -85,6 +87,9 @@ export async function lower(resources, options) {
|
|
|
85
87
|
case 'LogpushJob':
|
|
86
88
|
logpushJobs.push(r);
|
|
87
89
|
break;
|
|
90
|
+
case 'Ingress':
|
|
91
|
+
ingresses.push(r);
|
|
92
|
+
break;
|
|
88
93
|
}
|
|
89
94
|
}
|
|
90
95
|
const desired = [];
|
|
@@ -144,6 +149,11 @@ export async function lower(resources, options) {
|
|
|
144
149
|
if (out !== null)
|
|
145
150
|
desired.push(out);
|
|
146
151
|
}
|
|
152
|
+
for (const ing of ingresses) {
|
|
153
|
+
for (const out of await lowerIngress(ing, tables, warnings, options)) {
|
|
154
|
+
desired.push(out);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
147
157
|
return { desired, warnings };
|
|
148
158
|
}
|
|
149
159
|
function lowerVectorize(v) {
|
|
@@ -316,6 +326,136 @@ function lowerService(s, deployments, rollouts, warnings) {
|
|
|
316
326
|
],
|
|
317
327
|
};
|
|
318
328
|
}
|
|
329
|
+
async function lowerIngress(ing, tables, warnings, options) {
|
|
330
|
+
const ns = ing.metadata.namespace ?? 'default';
|
|
331
|
+
const name = ing.metadata.name;
|
|
332
|
+
const ref = refOf(ing);
|
|
333
|
+
const annotations = ing.metadata.annotations ?? {};
|
|
334
|
+
const zoneId = annotations['cloudflare.com/zone-id'];
|
|
335
|
+
if (!zoneId) {
|
|
336
|
+
throw new LowerError(`Ingress ${ns}/${name}: missing required annotation \`cloudflare.com/zone-id\``);
|
|
337
|
+
}
|
|
338
|
+
const literalHosts = new Set();
|
|
339
|
+
let hasWildcardHost = false;
|
|
340
|
+
for (const rule of ing.spec.rules) {
|
|
341
|
+
if (rule.host === undefined)
|
|
342
|
+
continue;
|
|
343
|
+
if (rule.host.startsWith('*.')) {
|
|
344
|
+
hasWildcardHost = true;
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
literalHosts.add(rule.host);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (literalHosts.size === 0) {
|
|
351
|
+
throw new LowerError(`Ingress ${ns}/${name}: at least one rule must specify a literal (non-wildcard) host so a Custom Domain can be created`);
|
|
352
|
+
}
|
|
353
|
+
if (hasWildcardHost) {
|
|
354
|
+
warnings.push({
|
|
355
|
+
ref,
|
|
356
|
+
message: `Ingress ${ns}/${name}: wildcard hosts are matched in-router only; create a Workers Route or extra Custom Domain manifest to actually receive traffic for the wildcard`,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
// Assign one binding name per unique backend Service.
|
|
360
|
+
const backendBindings = new Map(); // serviceName -> bindingName
|
|
361
|
+
const serviceDeps = [];
|
|
362
|
+
function bindingFor(serviceName) {
|
|
363
|
+
const existing = backendBindings.get(serviceName);
|
|
364
|
+
if (existing !== undefined)
|
|
365
|
+
return existing;
|
|
366
|
+
const id = `b${backendBindings.size}`;
|
|
367
|
+
backendBindings.set(serviceName, id);
|
|
368
|
+
return id;
|
|
369
|
+
}
|
|
370
|
+
function resolveBackendScript(serviceName, where) {
|
|
371
|
+
const target = tables.serviceTargets.get(`${ns}/${serviceName}`);
|
|
372
|
+
if (target === undefined) {
|
|
373
|
+
throw new LowerError(`Ingress ${ns}/${name}: backend Service "${serviceName}" referenced by ${where} is not found (or has no matching workload) in namespace "${ns}"`);
|
|
374
|
+
}
|
|
375
|
+
pushUnique(serviceDeps, {
|
|
376
|
+
apiVersion: 'v1',
|
|
377
|
+
kind: 'Service',
|
|
378
|
+
namespace: ns,
|
|
379
|
+
name: serviceName,
|
|
380
|
+
});
|
|
381
|
+
return target;
|
|
382
|
+
}
|
|
383
|
+
// Pre-walk to collect bindings and validate.
|
|
384
|
+
const routes = [];
|
|
385
|
+
for (const rule of ing.spec.rules) {
|
|
386
|
+
const paths = [...rule.http.paths]
|
|
387
|
+
.map((p) => ({
|
|
388
|
+
path: p.path,
|
|
389
|
+
pathType: p.pathType,
|
|
390
|
+
backend: p.backend,
|
|
391
|
+
}))
|
|
392
|
+
// Longest path first so prefix matching picks the most specific.
|
|
393
|
+
.sort((a, b) => b.path.length - a.path.length);
|
|
394
|
+
const routerPaths = paths.map((p) => {
|
|
395
|
+
const sName = p.backend.service.name;
|
|
396
|
+
resolveBackendScript(sName, `rule host=${rule.host ?? '<any>'} path=${p.path}`);
|
|
397
|
+
return {
|
|
398
|
+
path: p.path,
|
|
399
|
+
pathType: p.pathType,
|
|
400
|
+
backendBinding: bindingFor(sName),
|
|
401
|
+
};
|
|
402
|
+
});
|
|
403
|
+
routes.push({ host: rule.host ?? null, paths: routerPaths });
|
|
404
|
+
}
|
|
405
|
+
let defaultBackendBinding = null;
|
|
406
|
+
if (ing.spec.defaultBackend !== undefined) {
|
|
407
|
+
const sName = ing.spec.defaultBackend.service.name;
|
|
408
|
+
resolveBackendScript(sName, 'defaultBackend');
|
|
409
|
+
defaultBackendBinding = bindingFor(sName);
|
|
410
|
+
}
|
|
411
|
+
// Build the router Worker.
|
|
412
|
+
const routerScriptName = `k1c--${ns}--${name}--ingress`;
|
|
413
|
+
const routerSource = generateRouter({ routes, defaultBackend: defaultBackendBinding });
|
|
414
|
+
const bindings = [];
|
|
415
|
+
for (const [serviceName, bindingName] of backendBindings) {
|
|
416
|
+
const target = tables.serviceTargets.get(`${ns}/${serviceName}`);
|
|
417
|
+
bindings.push({ type: 'service', name: bindingName, service: target });
|
|
418
|
+
}
|
|
419
|
+
const baseProperties = {
|
|
420
|
+
scriptName: routerScriptName,
|
|
421
|
+
entrypoint: '<k1c-generated:ingress-router>',
|
|
422
|
+
entrypointContent: routerSource,
|
|
423
|
+
compatibilityDate: annotations['cloudflare.com/compatibility-date'] ?? DEFAULT_COMPATIBILITY_DATE,
|
|
424
|
+
bindings,
|
|
425
|
+
};
|
|
426
|
+
const properties = {
|
|
427
|
+
...baseProperties,
|
|
428
|
+
entrypointHash: await hashEntrypoint(baseProperties, options),
|
|
429
|
+
};
|
|
430
|
+
const routerRef = { ...ref, name: `${name}--router` };
|
|
431
|
+
const results = [
|
|
432
|
+
{
|
|
433
|
+
resourceType: 'Worker',
|
|
434
|
+
ref: routerRef,
|
|
435
|
+
label: `${ns}/${name}--router`,
|
|
436
|
+
properties,
|
|
437
|
+
...(serviceDeps.length > 0 ? { dependsOn: serviceDeps } : {}),
|
|
438
|
+
},
|
|
439
|
+
];
|
|
440
|
+
// One Custom Domain per literal host, all pointing at the router Worker.
|
|
441
|
+
const environment = annotations['cloudflare.com/environment'] ?? 'production';
|
|
442
|
+
for (const host of literalHosts) {
|
|
443
|
+
const cdRef = { ...ref, name: `${name}--cd--${host}` };
|
|
444
|
+
results.push({
|
|
445
|
+
resourceType: 'CustomDomain',
|
|
446
|
+
ref: cdRef,
|
|
447
|
+
label: host,
|
|
448
|
+
properties: {
|
|
449
|
+
hostname: host,
|
|
450
|
+
service: routerScriptName,
|
|
451
|
+
zoneId,
|
|
452
|
+
environment,
|
|
453
|
+
},
|
|
454
|
+
dependsOn: [routerRef],
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
return results;
|
|
458
|
+
}
|
|
319
459
|
function findWorkloadBySelector(selector, namespace, deployments, rollouts) {
|
|
320
460
|
for (const d of deployments) {
|
|
321
461
|
if ((d.metadata.namespace ?? 'default') !== namespace)
|
|
@@ -838,8 +978,22 @@ async function buildContainerProperties(kind, ns, name, scriptName, container, t
|
|
|
838
978
|
dataset: vol.analyticsEngineRef.dataset,
|
|
839
979
|
});
|
|
840
980
|
}
|
|
981
|
+
else if (vol.mtlsCertificateRef) {
|
|
982
|
+
bindings.push({
|
|
983
|
+
type: 'mtls_certificate',
|
|
984
|
+
name: mount.mountPath,
|
|
985
|
+
certificateId: vol.mtlsCertificateRef.certificateId,
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
else if (vol.pipelinesRef) {
|
|
989
|
+
bindings.push({
|
|
990
|
+
type: 'pipelines',
|
|
991
|
+
name: mount.mountPath,
|
|
992
|
+
pipeline: vol.pipelinesRef.pipelineId,
|
|
993
|
+
});
|
|
994
|
+
}
|
|
841
995
|
else {
|
|
842
|
-
throw new LowerError(`${ctxLabel}: volume "${vol.name}" has no recognised reference (r2BucketRef, kvNamespaceRef, serviceRef, hyperdriveRef, d1DatabaseRef, queueRef, vectorizeRef, or
|
|
996
|
+
throw new LowerError(`${ctxLabel}: volume "${vol.name}" has no recognised reference (r2BucketRef, kvNamespaceRef, serviceRef, hyperdriveRef, d1DatabaseRef, queueRef, vectorizeRef, analyticsEngineRef, mtlsCertificateRef, or pipelinesRef)`);
|
|
843
997
|
}
|
|
844
998
|
}
|
|
845
999
|
// Pod-level annotations for the no-config bindings: AI, Browser Rendering,
|