@highstate/k8s 0.19.1 → 0.20.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.
Files changed (105) hide show
  1. package/dist/{chunk-FE4SHRAJ.js → chunk-23X5SXQG.js} +22 -7
  2. package/dist/chunk-23X5SXQG.js.map +1 -0
  3. package/dist/{chunk-LGHFSXNT.js → chunk-ADHZK6V2.js} +14 -10
  4. package/dist/chunk-ADHZK6V2.js.map +1 -0
  5. package/dist/{chunk-VCXWCZ43.js → chunk-BTAEFJ5N.js} +27 -15
  6. package/dist/chunk-BTAEFJ5N.js.map +1 -0
  7. package/dist/{chunk-BR2CLUUD.js → chunk-IXE3OKB4.js} +27 -8
  8. package/dist/chunk-IXE3OKB4.js.map +1 -0
  9. package/dist/{chunk-TWBMG6TD.js → chunk-OG2OPX7B.js} +30 -12
  10. package/dist/chunk-OG2OPX7B.js.map +1 -0
  11. package/dist/{chunk-DCUMJSO6.js → chunk-P26SQ2ZB.js} +17 -51
  12. package/dist/chunk-P26SQ2ZB.js.map +1 -0
  13. package/dist/{chunk-MIC2BHGS.js → chunk-PG27ZY2H.js} +25 -7
  14. package/dist/chunk-PG27ZY2H.js.map +1 -0
  15. package/dist/chunk-PZYGZSN5.js +54 -0
  16. package/dist/{chunk-PZ5AY32C.js.map → chunk-PZYGZSN5.js.map} +1 -1
  17. package/dist/{chunk-YIJUVPU2.js → chunk-S77TE7UC.js} +27 -15
  18. package/dist/chunk-S77TE7UC.js.map +1 -0
  19. package/dist/{chunk-P2VOUU7E.js → chunk-SZKOAHNX.js} +383 -205
  20. package/dist/chunk-SZKOAHNX.js.map +1 -0
  21. package/dist/chunk-TOLFVF4S.js +889 -0
  22. package/dist/chunk-TOLFVF4S.js.map +1 -0
  23. package/dist/{chunk-RVB4WWZZ.js → chunk-TVKT3ZYX.js} +174 -18
  24. package/dist/chunk-TVKT3ZYX.js.map +1 -0
  25. package/dist/cron-job-RKB2HYTO.js +7 -0
  26. package/dist/{cron-job-NX4HD4FI.js.map → cron-job-RKB2HYTO.js.map} +1 -1
  27. package/dist/deployment-T35TUOL2.js +7 -0
  28. package/dist/{deployment-O2LJ5WR5.js.map → deployment-T35TUOL2.js.map} +1 -1
  29. package/dist/highstate.manifest.json +3 -2
  30. package/dist/impl/dynamic-endpoint-resolver.js +90 -0
  31. package/dist/impl/dynamic-endpoint-resolver.js.map +1 -0
  32. package/dist/impl/gateway-route.js +159 -62
  33. package/dist/impl/gateway-route.js.map +1 -1
  34. package/dist/impl/tls-certificate.js +6 -5
  35. package/dist/impl/tls-certificate.js.map +1 -1
  36. package/dist/index.js +106 -23
  37. package/dist/index.js.map +1 -1
  38. package/dist/job-PE4AKOHB.js +7 -0
  39. package/dist/job-PE4AKOHB.js.map +1 -0
  40. package/dist/stateful-set-LUIRHQJY.js +7 -0
  41. package/dist/{stateful-set-VJYKTQ72.js.map → stateful-set-LUIRHQJY.js.map} +1 -1
  42. package/dist/units/cert-manager/index.js +7 -8
  43. package/dist/units/cert-manager/index.js.map +1 -1
  44. package/dist/units/cluster-patch/index.js +6 -6
  45. package/dist/units/cluster-patch/index.js.map +1 -1
  46. package/dist/units/dns01-issuer/index.js +52 -15
  47. package/dist/units/dns01-issuer/index.js.map +1 -1
  48. package/dist/units/existing-cluster/index.js +39 -18
  49. package/dist/units/existing-cluster/index.js.map +1 -1
  50. package/dist/units/gateway-api/index.js +2 -2
  51. package/dist/units/reduced-access-cluster/index.js +8 -8
  52. package/dist/units/reduced-access-cluster/index.js.map +1 -1
  53. package/package.json +9 -7
  54. package/src/cluster.ts +12 -8
  55. package/src/config-map.ts +15 -5
  56. package/src/container.ts +4 -2
  57. package/src/cron-job.ts +25 -4
  58. package/src/deployment.ts +32 -17
  59. package/src/gateway/backend.ts +3 -3
  60. package/src/gateway/gateway.ts +12 -56
  61. package/src/helm.ts +354 -22
  62. package/src/impl/dynamic-endpoint-resolver.ts +109 -0
  63. package/src/impl/gateway-route.ts +231 -57
  64. package/src/impl/tls-certificate.ts +8 -3
  65. package/src/index.ts +1 -0
  66. package/src/job.ts +23 -5
  67. package/src/kubectl.ts +166 -0
  68. package/src/namespace.ts +47 -3
  69. package/src/network-policy.ts +1 -1
  70. package/src/pvc.ts +12 -2
  71. package/src/rbac.ts +28 -5
  72. package/src/scripting/environment.ts +3 -2
  73. package/src/secret.ts +15 -5
  74. package/src/service.ts +28 -6
  75. package/src/shared.ts +30 -2
  76. package/src/stateful-set.ts +32 -17
  77. package/src/tls.ts +31 -5
  78. package/src/units/cluster-patch/index.ts +5 -5
  79. package/src/units/dns01-issuer/index.ts +56 -12
  80. package/src/units/existing-cluster/index.ts +36 -15
  81. package/src/units/reduced-access-cluster/index.ts +6 -3
  82. package/src/worker.ts +4 -2
  83. package/src/workload.ts +453 -213
  84. package/dist/chunk-4G6LLC2X.js +0 -240
  85. package/dist/chunk-4G6LLC2X.js.map +0 -1
  86. package/dist/chunk-BR2CLUUD.js.map +0 -1
  87. package/dist/chunk-DCUMJSO6.js.map +0 -1
  88. package/dist/chunk-FE4SHRAJ.js.map +0 -1
  89. package/dist/chunk-KMLRI5UZ.js +0 -155
  90. package/dist/chunk-KMLRI5UZ.js.map +0 -1
  91. package/dist/chunk-LGHFSXNT.js.map +0 -1
  92. package/dist/chunk-MIC2BHGS.js.map +0 -1
  93. package/dist/chunk-OBDQONMV.js +0 -401
  94. package/dist/chunk-OBDQONMV.js.map +0 -1
  95. package/dist/chunk-P2VOUU7E.js.map +0 -1
  96. package/dist/chunk-PZ5AY32C.js +0 -9
  97. package/dist/chunk-RVB4WWZZ.js.map +0 -1
  98. package/dist/chunk-TWBMG6TD.js.map +0 -1
  99. package/dist/chunk-VCXWCZ43.js.map +0 -1
  100. package/dist/chunk-YIJUVPU2.js.map +0 -1
  101. package/dist/cron-job-NX4HD4FI.js +0 -8
  102. package/dist/deployment-O2LJ5WR5.js +0 -8
  103. package/dist/job-SYME6Y43.js +0 -8
  104. package/dist/job-SYME6Y43.js.map +0 -1
  105. package/dist/stateful-set-VJYKTQ72.js +0 -8
package/src/helm.ts CHANGED
@@ -1,16 +1,17 @@
1
1
  import type { UnitTerminal } from "@highstate/contract"
2
+ import type { common } from "@highstate/library"
3
+ import type { DistributedOmit } from "type-fest"
2
4
  import type { Namespace } from "./namespace"
3
5
  import type { Workload, WorkloadTerminalArgs } from "./workload"
4
6
  import { mkdir, readFile, unlink } from "node:fs/promises"
5
7
  import { resolve } from "node:path"
6
- import { AccessPointRoute, type AccessPointRouteArgs } from "@highstate/common"
7
8
  import {
8
- type InputArray,
9
- type InputRecord,
10
- normalize,
11
- normalizeInputs,
12
- toPromise,
13
- } from "@highstate/pulumi"
9
+ AccessPointRoute,
10
+ type AccessPointRouteArgs,
11
+ type GatewayHttpRuleArgs,
12
+ type GatewayRuleArgs,
13
+ } from "@highstate/common"
14
+ import { type InputRecord, normalize, normalizeInputs, toPromise } from "@highstate/pulumi"
14
15
  import { local } from "@pulumi/command"
15
16
  import { apps, core, helm, type types } from "@pulumi/kubernetes"
16
17
  import {
@@ -30,6 +31,112 @@ import { createServiceSpec, Service, type ServiceArgs } from "./service"
30
31
  import { getNamespaceName, getProvider, type NamespaceLike } from "./shared"
31
32
  import { StatefulSet } from "./stateful-set"
32
33
 
34
+ type ChartRouteBaseArgs = DistributedOmit<
35
+ AccessPointRouteArgs,
36
+ "accessPoint" | "backend" | "backends" | "rules" | "type" | "path" | "paths"
37
+ > & {
38
+ /**
39
+ * The name of the service to route to.
40
+ *
41
+ * If not specified, it will route to the main service of the chart.
42
+ */
43
+ serviceName?: string
44
+
45
+ /**
46
+ * The service port to route to.
47
+ *
48
+ * Can be either a numeric port or a named port from the matched service.
49
+ *
50
+ * If not specified, it first falls back to the servicePort of the chart,
51
+ * then to the first port of the matched service.
52
+ */
53
+ servicePort?: Input<number | string>
54
+
55
+ /**
56
+ * The access point to use for the route.
57
+ *
58
+ * If not specified, it will use the access point provided at the chart level, or throw an error if the chart level access point is not provided.
59
+ */
60
+ accessPoint?: Input<common.AccessPoint>
61
+
62
+ /**
63
+ * The protocol type for the route.
64
+ */
65
+ type: "http" | "tcp" | "udp"
66
+ }
67
+
68
+ type ChartHttpGatewayRuleArgs = DistributedOmit<GatewayHttpRuleArgs, "backend" | "backends"> & {
69
+ /**
70
+ * The name of the service to route to.
71
+ *
72
+ * If not specified, it first falls back to the serviceName of the route, then to the main service of the chart.
73
+ */
74
+ serviceName?: string
75
+
76
+ /**
77
+ * The service port to route to.
78
+ *
79
+ * Can be either a numeric port or a named port from the matched service.
80
+ *
81
+ * If not specified, it first falls back to the servicePort of the route,
82
+ * then to the servicePort of the chart,
83
+ * then to the first port of the matched service.
84
+ */
85
+ servicePort?: Input<number | string>
86
+ }
87
+
88
+ type ChartTcpUdpGatewayRuleArgs = DistributedOmit<GatewayRuleArgs, "backend" | "backends"> & {
89
+ /**
90
+ * The name of the service to route to.
91
+ *
92
+ * If not specified, it first falls back to the serviceName of the route, then to the main service of the chart.
93
+ */
94
+ serviceName?: string
95
+
96
+ /**
97
+ * The service port to route to.
98
+ *
99
+ * Can be either a numeric port or a named port from the matched service.
100
+ *
101
+ * If not specified, it first falls back to the servicePort of the route,
102
+ * then to the servicePort of the chart,
103
+ * then to the first port of the matched service.
104
+ */
105
+ servicePort?: Input<number | string>
106
+ }
107
+
108
+ type ChartGatewayRuleArgs = ChartHttpGatewayRuleArgs | ChartTcpUdpGatewayRuleArgs
109
+
110
+ export type ChartRouteArgs = ChartRouteBaseArgs &
111
+ (
112
+ | {
113
+ type: "http"
114
+
115
+ /**
116
+ * The path to match for the `default` rule of the listener.
117
+ */
118
+ path?: Input<string>
119
+
120
+ /**
121
+ * The paths to match for the `default` rule of the listener.
122
+ */
123
+ paths?: Input<string[]>
124
+
125
+ /**
126
+ * The rules of the route.
127
+ */
128
+ rules?: InputRecord<ChartHttpGatewayRuleArgs>
129
+ }
130
+ | {
131
+ type: "tcp" | "udp"
132
+
133
+ /**
134
+ * The rules of the route.
135
+ */
136
+ rules?: InputRecord<ChartTcpUdpGatewayRuleArgs>
137
+ }
138
+ )
139
+
33
140
  export type ChartArgs = Omit<
34
141
  helm.v4.ChartArgs,
35
142
  "chart" | "version" | "repositoryOpts" | "namespace"
@@ -46,6 +153,15 @@ export type ChartArgs = Omit<
46
153
  */
47
154
  serviceName?: string
48
155
 
156
+ /**
157
+ * The service port to route to by default.
158
+ *
159
+ * Can be either a numeric port or a named port from the matched service.
160
+ *
161
+ * Can be overridden by route.servicePort and rule.servicePort.
162
+ */
163
+ servicePort?: Input<number | string>
164
+
49
165
  /**
50
166
  * The extra args to pass to the main service of the chart.
51
167
  *
@@ -68,12 +184,17 @@ export type ChartArgs = Omit<
68
184
  /**
69
185
  * The configuration for the access point route to create.
70
186
  */
71
- route?: Input<Omit<AccessPointRouteArgs, "endpoints" | "customData">>
187
+ route?: Input<ChartRouteArgs>
72
188
 
73
189
  /**
74
190
  * The configuration for the access point routes to create.
75
191
  */
76
- routes?: InputArray<Omit<AccessPointRouteArgs, "endpoints" | "customData">>
192
+ routes?: InputRecord<ChartRouteArgs>
193
+
194
+ /**
195
+ * The access point to use for the routes.
196
+ */
197
+ accessPoint?: Input<common.AccessPoint>
77
198
 
78
199
  /**
79
200
  * The network policy to apply to the chart.
@@ -158,7 +279,9 @@ export class Chart extends ComponentResource {
158
279
  ...resourceArgs.props,
159
280
  spec: {
160
281
  ...spec,
161
- ...omit(serviceSpec, ["ports"]),
282
+ ...(serviceSpec.ports?.length !== 0
283
+ ? serviceSpec
284
+ : omit(serviceSpec, ["ports"])),
162
285
  },
163
286
  },
164
287
  opts: resourceArgs.opts,
@@ -172,23 +295,101 @@ export class Chart extends ComponentResource {
172
295
  )
173
296
  })
174
297
 
175
- this.routes = output(normalizeInputs(args.route, args.routes)).apply(async routes => {
298
+ this.routes = output(
299
+ normalizeInputs(
300
+ args.route ? { name: "default", route: args.route } : undefined,
301
+ args.routes
302
+ ? Object.entries(args.routes).map(([name, route]) => ({ name, route }))
303
+ : undefined,
304
+ ),
305
+ ).apply(async routes => {
176
306
  if (routes.length === 0) {
177
307
  return []
178
308
  }
179
309
 
180
310
  return await Promise.all(
181
- routes.map(async route => {
182
- return new AccessPointRoute(
183
- name,
184
- {
185
- ...route,
311
+ routes.map(async ({ name: routeName, route }) => {
312
+ const { serviceName: _serviceName, rules: _rules, ...baseRoute } = route
313
+ const accessPoint = route.accessPoint ?? args.accessPoint
314
+
315
+ if (!accessPoint) {
316
+ throw new Error(
317
+ `Access point is required for chart route "${name}-${routeName}". Set it on the route or on Chart args.accessPoint`,
318
+ )
319
+ }
320
+
321
+ const namespace = await toPromise(args.namespace)
322
+
323
+ const routeRules = (await toPromise(route.rules)) as Record<string, ChartGatewayRuleArgs>
324
+ const routeRuleValues = Object.values(routeRules ?? {})
325
+
326
+ const defaultServiceName = route.serviceName ?? args.serviceName ?? name
327
+ const defaultServicePort = route.servicePort ?? args.servicePort
328
+ const needsDefaultBackend = routeRuleValues.length === 0
329
+
330
+ const defaultService = needsDefaultBackend
331
+ ? await this.getService(defaultServiceName)
332
+ : undefined
333
+
334
+ const defaultServiceEndpoints =
335
+ needsDefaultBackend && defaultService
336
+ ? await this.resolveServiceEndpoints(
337
+ defaultService,
338
+ defaultServiceName,
339
+ defaultServicePort,
340
+ `${name}-${routeName}`,
341
+ )
342
+ : undefined
343
+
344
+ const resolvedRules = routeRules
345
+ ? await Promise.all(
346
+ Object.entries(routeRules).map(async ([ruleName, rule]) => {
347
+ const ruleServiceName = rule.serviceName ?? defaultServiceName
348
+ const ruleService = await this.getService(ruleServiceName)
349
+ const ruleServicePort = rule.servicePort ?? route.servicePort ?? args.servicePort
350
+ const ruleServiceEndpoints = await this.resolveServiceEndpoints(
351
+ ruleService,
352
+ ruleServiceName,
353
+ ruleServicePort,
354
+ `${name}-${routeName}:${ruleName}`,
355
+ )
186
356
 
187
- endpoints: this.service.endpoints,
357
+ return [
358
+ ruleName,
359
+ [
360
+ {
361
+ ...omit(rule, ["serviceName", "servicePort"]),
362
+ backend: {
363
+ endpoints: ruleServiceEndpoints,
364
+ },
365
+ },
366
+ ],
367
+ ] as const
368
+ }),
369
+ )
370
+ : undefined
371
+
372
+ const resolvedRulesInput = resolvedRules
373
+ ? (Object.fromEntries(resolvedRules) as unknown as InputRecord<GatewayRuleArgs[]>)
374
+ : undefined
188
375
 
189
- // pass the native data to the route to allow implementation to use it
190
- gatewayNativeData: await toPromise(this.service),
191
- tlsCertificateNativeData: await toPromise(args.namespace),
376
+ return new AccessPointRoute(
377
+ `${name}-${routeName}`,
378
+ {
379
+ ...baseRoute,
380
+ accessPoint,
381
+ ...(defaultService
382
+ ? {
383
+ backend: {
384
+ endpoints: defaultServiceEndpoints,
385
+ },
386
+ }
387
+ : {}),
388
+ rules: resolvedRulesInput,
389
+ metadata: {
390
+ ...(route.metadata ?? {}),
391
+ "k8s.namespace": namespace,
392
+ },
192
393
  },
193
394
  { ...opts, parent: this },
194
395
  )
@@ -254,19 +455,102 @@ export class Chart extends ComponentResource {
254
455
 
255
456
  private set service(_value: never) {}
256
457
 
257
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: for pulumi which for some reason tries to copy all properties
258
458
  private set terminals(_value: never) {}
259
459
 
260
460
  get service(): Output<Service> {
261
461
  return this.getServiceOutput(undefined)
262
462
  }
263
463
 
464
+ get deployment(): Output<Deployment> {
465
+ return this.getDeploymentOutput(this.name)
466
+ }
467
+
468
+ get statefulSet(): Output<StatefulSet> {
469
+ return this.getStatefulSetOutput(this.name)
470
+ }
471
+
264
472
  get terminals(): Output<UnitTerminal[]> {
265
- return this.workloads.apply(workloads => output(workloads.map(workload => workload.terminal)))
473
+ return this.workloads.apply(workloads => {
474
+ const terminalsByWorkload = workloads.map(workload =>
475
+ output({ terminals: workload.terminals, workloadName: workload.metadata.name }),
476
+ )
477
+
478
+ return output(terminalsByWorkload).apply(workloadTerminals => {
479
+ const hasMultipleWorkloads = workloadTerminals.length > 1
480
+
481
+ return workloadTerminals.flatMap(({ terminals, workloadName }) => {
482
+ if (!hasMultipleWorkloads) {
483
+ return terminals
484
+ }
485
+
486
+ return terminals.map(terminal => ({
487
+ ...terminal,
488
+ meta: {
489
+ ...terminal.meta,
490
+ title: `${terminal.meta.title} | ${workloadName}`,
491
+ },
492
+ }))
493
+ })
494
+ })
495
+ })
266
496
  }
267
497
 
268
498
  private readonly services = new Map<string, Service>()
269
499
 
500
+ private async resolveServiceEndpoints(
501
+ service: Service,
502
+ serviceName: string,
503
+ servicePort: Input<number | string> | undefined,
504
+ routeName: string,
505
+ ) {
506
+ const endpoints = await toPromise(service.endpoints)
507
+ const servicePorts = await toPromise(service.spec.ports)
508
+
509
+ if (endpoints.length === 0) {
510
+ throw new Error(
511
+ `No endpoints found for service "${serviceName}" in chart route "${routeName}"`,
512
+ )
513
+ }
514
+
515
+ let resolvedServicePort: number | undefined
516
+
517
+ if (servicePort != null) {
518
+ const requestedServicePort = await toPromise(servicePort)
519
+
520
+ if (typeof requestedServicePort === "string") {
521
+ const namedPort = servicePorts?.find(port => port.name === requestedServicePort)
522
+
523
+ if (!namedPort) {
524
+ throw new Error(
525
+ `Named port "${requestedServicePort}" not found for service "${serviceName}" in chart route "${routeName}"`,
526
+ )
527
+ }
528
+
529
+ resolvedServicePort = namedPort.port
530
+ } else {
531
+ resolvedServicePort = requestedServicePort
532
+ }
533
+ } else {
534
+ resolvedServicePort = endpoints[0]?.port
535
+ }
536
+
537
+ if (resolvedServicePort == null) {
538
+ throw new Error(
539
+ `Unable to resolve service port for service "${serviceName}" in chart route "${routeName}"`,
540
+ )
541
+ }
542
+
543
+ const filteredEndpoints = endpoints.filter(endpoint => endpoint.port === resolvedServicePort)
544
+
545
+ if (filteredEndpoints.length === 0) {
546
+ throw new Error(
547
+ `No endpoints with port ${resolvedServicePort} found for service "${serviceName}" in chart route "${routeName}"`,
548
+ )
549
+ }
550
+
551
+ return filteredEndpoints
552
+ }
553
+
270
554
  getServiceOutput(name: string | undefined): Output<Service> {
271
555
  return output({ args: this.args, chart: this.chart }).apply(({ args, chart }) => {
272
556
  const resolvedName = name ?? args.serviceName ?? this.name
@@ -289,9 +573,57 @@ export class Chart extends ComponentResource {
289
573
  })
290
574
  }
291
575
 
576
+ getWorkloadOutput(name: string): Output<Workload> {
577
+ return this.workloads.apply(async workloads => {
578
+ const workloadsWithNames = await toPromise(
579
+ workloads.map(workload => output({ workload, name: workload.metadata.name })),
580
+ )
581
+
582
+ const item = workloadsWithNames.find(w => w.name === name)
583
+
584
+ if (!item) {
585
+ throw new Error(`Workload with name '${name}' not found in the chart workloads`)
586
+ }
587
+
588
+ return item.workload
589
+ })
590
+ }
591
+
592
+ getDeploymentOutput(name: string): Output<Deployment> {
593
+ return this.getWorkloadOutput(name).apply(workload => {
594
+ if (workload instanceof Deployment) {
595
+ return workload
596
+ }
597
+
598
+ throw new Error(`Workload with name '${name}' is not a Deployment`)
599
+ })
600
+ }
601
+
602
+ getStatefulSetOutput(name: string): Output<StatefulSet> {
603
+ return this.getWorkloadOutput(name).apply(workload => {
604
+ if (workload instanceof StatefulSet) {
605
+ return workload
606
+ }
607
+
608
+ throw new Error(`Workload with name '${name}' is not a StatefulSet`)
609
+ })
610
+ }
611
+
292
612
  getService(name?: string): Promise<Service> {
293
613
  return toPromise(this.getServiceOutput(name))
294
614
  }
615
+
616
+ getWorkload(name: string): Promise<Workload> {
617
+ return toPromise(this.getWorkloadOutput(name))
618
+ }
619
+
620
+ getDeployment(name: string): Promise<Deployment> {
621
+ return toPromise(this.getDeploymentOutput(name))
622
+ }
623
+
624
+ getStatefulSet(name: string): Promise<StatefulSet> {
625
+ return toPromise(this.getStatefulSetOutput(name))
626
+ }
295
627
  }
296
628
 
297
629
  export type RenderedChartArgs = {
@@ -0,0 +1,109 @@
1
+ import { crc32 } from "node:zlib"
2
+ import {
3
+ dynamicEndpointResolverMediator,
4
+ endpointToString,
5
+ MaterializedFile,
6
+ parseEndpoint,
7
+ rebaseEndpoint,
8
+ } from "@highstate/common"
9
+ import { z } from "@highstate/contract"
10
+ import { k8s } from "@highstate/library"
11
+ import { getUnitStateId } from "@highstate/pulumi"
12
+ import spawn, { type Subprocess, SubprocessError } from "nano-spawn"
13
+ import { isEndpointFromCluster } from "../service"
14
+
15
+ export const resolveDynamicEndpoint = dynamicEndpointResolverMediator.implement(
16
+ z.object({ cluster: k8s.clusterEntity.schema }),
17
+ async ({ endpoint }, { cluster }) => {
18
+ if (!isEndpointFromCluster(endpoint, cluster)) {
19
+ throw new Error(
20
+ `Endpoint "${endpointToString(endpoint)}" is not from cluster "${cluster.id}"`,
21
+ )
22
+ }
23
+
24
+ const { name, namespace } = endpoint.metadata["k8s.service"]
25
+
26
+ const localPort = getStablePort(`${cluster.id}:${namespace}:${name}`)
27
+
28
+ let subprocess: Subprocess
29
+
30
+ return {
31
+ endpoint: rebaseEndpoint(endpoint, parseEndpoint(`localhost:${localPort}`)),
32
+
33
+ setup: async () => {
34
+ console.log(
35
+ `[port-forward] starting port-forward for service "${name}" in namespace "${namespace}" on local port ${localPort}`,
36
+ )
37
+
38
+ const config = MaterializedFile.for(cluster.kubeconfig)
39
+ await using _ = await config.open()
40
+
41
+ subprocess = spawn("kubectl", [
42
+ "--kubeconfig",
43
+ config.path,
44
+ "port-forward",
45
+ `service/${name}`,
46
+ "-n",
47
+ namespace,
48
+ `${localPort}:${endpoint.port}`,
49
+ ])
50
+
51
+ // catch the error when the process is killed to prevent unhandled promise rejection
52
+ subprocess.catch(err => {
53
+ if (err instanceof SubprocessError && err.signalName === "SIGTERM") {
54
+ // correct termination
55
+ return
56
+ }
57
+
58
+ throw err
59
+ })
60
+
61
+ const nodeProcess = await subprocess.nodeChildProcess
62
+
63
+ await new Promise<void>((resolve, reject) => {
64
+ nodeProcess.stdout?.once("data", (data: Buffer) => {
65
+ const output = data.toString()
66
+
67
+ if (output.includes("Forwarding from")) {
68
+ resolve()
69
+ } else {
70
+ reject(new Error(`Failed to start port-forward: ${output}`))
71
+ }
72
+ })
73
+
74
+ nodeProcess.stderr?.once("data", (data: Buffer) => {
75
+ reject(new Error(`Failed to start port-forward: ${data.toString()}`))
76
+ })
77
+ })
78
+
79
+ console.log(
80
+ `[port-forward] port-forward is ready for service "${name}" in namespace "${namespace}" on local port ${localPort}`,
81
+ )
82
+ },
83
+
84
+ dispose: async () => {
85
+ console.log(
86
+ `[port-forward] stopping port-forward for service "${name}" in namespace "${namespace}" on local port ${localPort}`,
87
+ )
88
+
89
+ const nodeProcess = await subprocess.nodeChildProcess
90
+ nodeProcess.kill()
91
+ },
92
+ }
93
+ },
94
+ )
95
+
96
+ /**
97
+ * Return a stable port number based on the given id.
98
+ * This is important because Pulumi stores the resolved endpoint in the state,
99
+ * and uses it in destroy operations.
100
+ */
101
+ function getStablePort(id: string): number {
102
+ // also add state ID to ensure different ports for the same service in different states which may run in parallel
103
+ const hash = crc32(`${getUnitStateId()}:${id}`)
104
+
105
+ const minPort = 30000
106
+ const maxPort = 60000
107
+
108
+ return (Math.abs(hash) % (maxPort - minPort)) + minPort
109
+ }