@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 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` / `CustomHostname` / Zero Trust Access | | not implemented (see [`TODO.md`](TODO.md)) |
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
- - Merging that release PR triggers `.github/workflows/publish.yml`, which
141
- publishes `@mizchi/k1c` to npm via [OIDC trusted publishing](https://docs.npmjs.com/trusted-publishers)
142
- (no `NPM_TOKEN` involved) with `--provenance` SLSA attestation.
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 `publish.yml` workflow. After that the entire
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: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
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
@@ -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 analyticsEngineRef)`);
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,