@mizchi/k1c 0.2.0 → 0.4.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,9 @@ $ 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 literal host (Workers Route per wildcard host) | working |
53
+ | `AccessApplication` (CRD) | Cloudflare Access self-hosted app with inline policies | working |
54
+ | `CustomHostname` | — | not implemented (see [`TODO.md`](TODO.md)) |
53
55
 
54
56
  See [`docs/resources.md`](docs/resources.md) for the full mapping and limitations,
55
57
  and [`TODO.md`](TODO.md) for what's queued.
@@ -137,13 +139,14 @@ Versioning is automated via [release-please](https://github.com/googleapis/relea
137
139
  - Conventional Commit messages on `main` (`feat:`, `fix:`, `chore:`, etc.) feed
138
140
  into a release PR that bumps `package.json`, updates `CHANGELOG.md`, and
139
141
  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.
142
+ - The same `release-please.yml` workflow then runs a `publish` job (gated on
143
+ `release_created == true`) that publishes `@mizchi/k1c` to npm via
144
+ [OIDC trusted publishing](https://docs.npmjs.com/trusted-publishers)
145
+ (no `NPM_TOKEN`) with `--provenance` SLSA attestation.
143
146
 
144
147
  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.
148
+ pointing at `mizchi/k1c` + the `release-please.yml` workflow. After that the
149
+ entire flow (PR → merge → tag → npm) is hands-off.
147
150
 
148
151
  ## License
149
152
 
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,8 @@ export async function lower(resources, options) {
29
30
  const jobs = [];
30
31
  const dnsRecords = [];
31
32
  const logpushJobs = [];
33
+ const ingresses = [];
34
+ const accessApplications = [];
32
35
  for (const r of resources) {
33
36
  const label = labelOf(r);
34
37
  switch (r.kind) {
@@ -85,6 +88,12 @@ export async function lower(resources, options) {
85
88
  case 'LogpushJob':
86
89
  logpushJobs.push(r);
87
90
  break;
91
+ case 'Ingress':
92
+ ingresses.push(r);
93
+ break;
94
+ case 'AccessApplication':
95
+ accessApplications.push(r);
96
+ break;
88
97
  }
89
98
  }
90
99
  const desired = [];
@@ -144,8 +153,63 @@ export async function lower(resources, options) {
144
153
  if (out !== null)
145
154
  desired.push(out);
146
155
  }
156
+ for (const ing of ingresses) {
157
+ for (const out of await lowerIngress(ing, tables, warnings, options)) {
158
+ desired.push(out);
159
+ }
160
+ }
161
+ for (const app of accessApplications) {
162
+ desired.push(lowerAccessApplication(app));
163
+ }
147
164
  return { desired, warnings };
148
165
  }
166
+ function ruleToWire(rule) {
167
+ if ('email' in rule)
168
+ return { email: { email: rule.email.email } };
169
+ if ('emailDomain' in rule)
170
+ return { email_domain: { domain: rule.emailDomain.domain } };
171
+ if ('everyone' in rule)
172
+ return { everyone: {} };
173
+ if ('ip' in rule)
174
+ return { ip: { ip: rule.ip.ip } };
175
+ if ('country' in rule)
176
+ return { country: { country_code: rule.country.code } };
177
+ if ('serviceToken' in rule)
178
+ return { service_token: { token_id: rule.serviceToken.tokenId } };
179
+ return { any_valid_service_token: {} };
180
+ }
181
+ function policyToWire(p) {
182
+ return {
183
+ name: p.name,
184
+ decision: p.decision,
185
+ include: p.include.map(ruleToWire),
186
+ ...(p.exclude !== undefined ? { exclude: p.exclude.map(ruleToWire) } : {}),
187
+ ...(p.require !== undefined ? { require: p.require.map(ruleToWire) } : {}),
188
+ ...(p.sessionDuration !== undefined ? { session_duration: p.sessionDuration } : {}),
189
+ };
190
+ }
191
+ function lowerAccessApplication(app) {
192
+ const ns = app.metadata.namespace ?? 'default';
193
+ const name = app.metadata.name;
194
+ const properties = {
195
+ appName: `k1c-${ns}-${name}`,
196
+ domain: app.spec.domain,
197
+ ...(app.spec.sessionDuration !== undefined
198
+ ? { sessionDuration: app.spec.sessionDuration }
199
+ : {}),
200
+ ...(app.spec.autoRedirectToIdentity !== undefined
201
+ ? { autoRedirectToIdentity: app.spec.autoRedirectToIdentity }
202
+ : {}),
203
+ ...(app.spec.allowedIdps !== undefined ? { allowedIdps: [...app.spec.allowedIdps] } : {}),
204
+ policies: app.spec.policies.map(policyToWire),
205
+ };
206
+ return {
207
+ resourceType: 'AccessApplication',
208
+ ref: refOf(app),
209
+ label: `${ns}/${name}`,
210
+ properties,
211
+ };
212
+ }
149
213
  function lowerVectorize(v) {
150
214
  const ns = v.metadata.namespace ?? 'default';
151
215
  const name = v.metadata.name;
@@ -316,6 +380,150 @@ function lowerService(s, deployments, rollouts, warnings) {
316
380
  ],
317
381
  };
318
382
  }
383
+ async function lowerIngress(ing, tables, warnings, options) {
384
+ const ns = ing.metadata.namespace ?? 'default';
385
+ const name = ing.metadata.name;
386
+ const ref = refOf(ing);
387
+ const annotations = ing.metadata.annotations ?? {};
388
+ const zoneId = annotations['cloudflare.com/zone-id'];
389
+ if (!zoneId) {
390
+ throw new LowerError(`Ingress ${ns}/${name}: missing required annotation \`cloudflare.com/zone-id\``);
391
+ }
392
+ const literalHosts = new Set();
393
+ const wildcardHosts = new Set();
394
+ for (const rule of ing.spec.rules) {
395
+ if (rule.host === undefined)
396
+ continue;
397
+ if (rule.host.startsWith('*.')) {
398
+ wildcardHosts.add(rule.host);
399
+ }
400
+ else {
401
+ literalHosts.add(rule.host);
402
+ }
403
+ }
404
+ if (literalHosts.size === 0 && wildcardHosts.size === 0) {
405
+ throw new LowerError(`Ingress ${ns}/${name}: at least one rule must specify a host (literal or wildcard) so traffic can be bound to the router`);
406
+ }
407
+ // Assign one binding name per unique backend Service.
408
+ const backendBindings = new Map(); // serviceName -> bindingName
409
+ const serviceDeps = [];
410
+ function bindingFor(serviceName) {
411
+ const existing = backendBindings.get(serviceName);
412
+ if (existing !== undefined)
413
+ return existing;
414
+ const id = `b${backendBindings.size}`;
415
+ backendBindings.set(serviceName, id);
416
+ return id;
417
+ }
418
+ function resolveBackendScript(serviceName, where) {
419
+ const target = tables.serviceTargets.get(`${ns}/${serviceName}`);
420
+ if (target === undefined) {
421
+ throw new LowerError(`Ingress ${ns}/${name}: backend Service "${serviceName}" referenced by ${where} is not found (or has no matching workload) in namespace "${ns}"`);
422
+ }
423
+ pushUnique(serviceDeps, {
424
+ apiVersion: 'v1',
425
+ kind: 'Service',
426
+ namespace: ns,
427
+ name: serviceName,
428
+ });
429
+ return target;
430
+ }
431
+ // Pre-walk to collect bindings and validate.
432
+ const routes = [];
433
+ for (const rule of ing.spec.rules) {
434
+ const paths = [...rule.http.paths]
435
+ .map((p) => ({
436
+ path: p.path,
437
+ pathType: p.pathType,
438
+ backend: p.backend,
439
+ }))
440
+ // Longest path first so prefix matching picks the most specific.
441
+ .sort((a, b) => b.path.length - a.path.length);
442
+ const routerPaths = paths.map((p) => {
443
+ const sName = p.backend.service.name;
444
+ resolveBackendScript(sName, `rule host=${rule.host ?? '<any>'} path=${p.path}`);
445
+ return {
446
+ path: p.path,
447
+ pathType: p.pathType,
448
+ backendBinding: bindingFor(sName),
449
+ };
450
+ });
451
+ routes.push({ host: rule.host ?? null, paths: routerPaths });
452
+ }
453
+ let defaultBackendBinding = null;
454
+ if (ing.spec.defaultBackend !== undefined) {
455
+ const sName = ing.spec.defaultBackend.service.name;
456
+ resolveBackendScript(sName, 'defaultBackend');
457
+ defaultBackendBinding = bindingFor(sName);
458
+ }
459
+ // Build the router Worker.
460
+ const routerScriptName = `k1c--${ns}--${name}--ingress`;
461
+ const routerSource = generateRouter({ routes, defaultBackend: defaultBackendBinding });
462
+ const bindings = [];
463
+ for (const [serviceName, bindingName] of backendBindings) {
464
+ const target = tables.serviceTargets.get(`${ns}/${serviceName}`);
465
+ bindings.push({ type: 'service', name: bindingName, service: target });
466
+ }
467
+ const baseProperties = {
468
+ scriptName: routerScriptName,
469
+ entrypoint: '<k1c-generated:ingress-router>',
470
+ entrypointContent: routerSource,
471
+ compatibilityDate: annotations['cloudflare.com/compatibility-date'] ?? DEFAULT_COMPATIBILITY_DATE,
472
+ bindings,
473
+ };
474
+ const properties = {
475
+ ...baseProperties,
476
+ entrypointHash: await hashEntrypoint(baseProperties, options),
477
+ };
478
+ const routerRef = { ...ref, name: `${name}--router` };
479
+ const results = [
480
+ {
481
+ resourceType: 'Worker',
482
+ ref: routerRef,
483
+ label: `${ns}/${name}--router`,
484
+ properties,
485
+ ...(serviceDeps.length > 0 ? { dependsOn: serviceDeps } : {}),
486
+ },
487
+ ];
488
+ // One Custom Domain per literal host, all pointing at the router Worker.
489
+ const environment = annotations['cloudflare.com/environment'] ?? 'production';
490
+ for (const host of literalHosts) {
491
+ const cdRef = { ...ref, name: `${name}--cd--${host}` };
492
+ results.push({
493
+ resourceType: 'CustomDomain',
494
+ ref: cdRef,
495
+ label: host,
496
+ properties: {
497
+ hostname: host,
498
+ service: routerScriptName,
499
+ zoneId,
500
+ environment,
501
+ },
502
+ dependsOn: [routerRef],
503
+ });
504
+ }
505
+ // Wildcard hosts cannot be bound via Custom Domain (which takes literal
506
+ // hostnames only); fall back to a zone-scoped Workers Route at `<host>/*`.
507
+ // The router Worker already matches the wildcard in-source, so this just
508
+ // delivers traffic to it.
509
+ for (const host of wildcardHosts) {
510
+ const pattern = `${host}/*`;
511
+ const routeRef = { ...ref, name: `${name}--route--${host}` };
512
+ const routeProps = {
513
+ zoneId,
514
+ pattern,
515
+ scriptName: routerScriptName,
516
+ };
517
+ results.push({
518
+ resourceType: 'WorkerRoute',
519
+ ref: routeRef,
520
+ label: pattern,
521
+ properties: routeProps,
522
+ dependsOn: [routerRef],
523
+ });
524
+ }
525
+ return results;
526
+ }
319
527
  function findWorkloadBySelector(selector, namespace, deployments, rollouts) {
320
528
  for (const d of deployments) {
321
529
  if ((d.metadata.namespace ?? 'default') !== namespace)
@@ -838,8 +1046,22 @@ async function buildContainerProperties(kind, ns, name, scriptName, container, t
838
1046
  dataset: vol.analyticsEngineRef.dataset,
839
1047
  });
840
1048
  }
1049
+ else if (vol.mtlsCertificateRef) {
1050
+ bindings.push({
1051
+ type: 'mtls_certificate',
1052
+ name: mount.mountPath,
1053
+ certificateId: vol.mtlsCertificateRef.certificateId,
1054
+ });
1055
+ }
1056
+ else if (vol.pipelinesRef) {
1057
+ bindings.push({
1058
+ type: 'pipelines',
1059
+ name: mount.mountPath,
1060
+ pipeline: vol.pipelinesRef.pipelineId,
1061
+ });
1062
+ }
841
1063
  else {
842
- throw new LowerError(`${ctxLabel}: volume "${vol.name}" has no recognised reference (r2BucketRef, kvNamespaceRef, serviceRef, hyperdriveRef, d1DatabaseRef, queueRef, vectorizeRef, or analyticsEngineRef)`);
1064
+ throw new LowerError(`${ctxLabel}: volume "${vol.name}" has no recognised reference (r2BucketRef, kvNamespaceRef, serviceRef, hyperdriveRef, d1DatabaseRef, queueRef, vectorizeRef, analyticsEngineRef, mtlsCertificateRef, or pipelinesRef)`);
843
1065
  }
844
1066
  }
845
1067
  // Pod-level annotations for the no-config bindings: AI, Browser Rendering,