@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/workload.ts CHANGED
@@ -1,18 +1,26 @@
1
1
  import type { k8s, network } from "@highstate/library"
2
2
  import type { types } from "@pulumi/kubernetes"
3
- import type { Except } from "type-fest"
3
+ import type { DistributedOmit, Except } from "type-fest"
4
4
  import type { DeploymentArgs } from "./deployment"
5
5
  import type { JobArgs } from "./job"
6
6
  import type { StatefulSetArgs } from "./stateful-set"
7
- import { AccessPointRoute, type AccessPointRouteArgs, mergeEndpoints } from "@highstate/common"
7
+ import {
8
+ AccessPointRoute,
9
+ type AccessPointRouteArgs,
10
+ type GatewayHttpRuleArgs,
11
+ type GatewayRuleArgs,
12
+ mergeEndpoints,
13
+ } from "@highstate/common"
8
14
  import { type TerminalSpec, trimIndentation, type UnitTerminal } from "@highstate/contract"
9
15
  import {
10
16
  type ComponentResourceOptions,
11
17
  type DeepInput,
12
- fileFromString,
13
18
  type InputArray,
19
+ type InputRecord,
20
+ makeFileOutput,
14
21
  normalize,
15
22
  normalizeInputs,
23
+ toPromise,
16
24
  } from "@highstate/pulumi"
17
25
  import {
18
26
  type ComponentResource,
@@ -26,7 +34,7 @@ import {
26
34
  } from "@pulumi/pulumi"
27
35
  import { sha256 } from "crypto-hash"
28
36
  import { deepmerge } from "deepmerge-ts"
29
- import { filter, flat, isNonNullish, unique, uniqueBy } from "remeda"
37
+ import { filter, flat, isNonNullish, omit, unique, uniqueBy } from "remeda"
30
38
  import {
31
39
  type Container,
32
40
  getFallbackContainerName,
@@ -39,7 +47,14 @@ import { Namespace } from "./namespace"
39
47
  import { NetworkPolicy, type NetworkPolicyArgs } from "./network-policy"
40
48
  import { podSpecDefaults } from "./pod"
41
49
  import { mapContainerPortToServicePort, Service, type ServiceArgs } from "./service"
42
- import { commonExtraArgs, images, NamespacedResource, type ScopedResourceArgs } from "./shared"
50
+ import {
51
+ commonExtraArgs,
52
+ getClusterKubeconfigContent,
53
+ images,
54
+ NamespacedResource,
55
+ type ScopedResourceArgs,
56
+ type SelectorLike,
57
+ } from "./shared"
43
58
 
44
59
  export type WorkloadTerminalArgs = {
45
60
  /**
@@ -84,31 +99,150 @@ export type WorkloadArgs = ScopedResourceArgs & {
84
99
 
85
100
  export const workloadExtraArgs = [...commonExtraArgs, "container", "containers"] as const
86
101
 
87
- export type ExposableWorkloadRouteArgs = Except<
88
- AccessPointRouteArgs,
89
- "endpoints" | "gatewayNativeData" | "tlsCertificateNativeData"
90
- >
102
+ /**
103
+ * Filters pod template containers for patch operations to include only containers owned by the patch args.
104
+ *
105
+ * This prevents patching containers from existing workload spec that are not declared via workload container args.
106
+ *
107
+ * @param template The merged pod template that will be sent to Kubernetes.
108
+ * @param ownedTemplate The pod template generated from workload container args.
109
+ * @returns The pod template with filtered container lists.
110
+ */
111
+ export function filterPatchOwnedContainersInTemplate(
112
+ template: Unwrap<types.input.core.v1.PodTemplateSpec>,
113
+ ownedTemplate: Unwrap<types.input.core.v1.PodTemplateSpec>,
114
+ ): Unwrap<types.input.core.v1.PodTemplateSpec> {
115
+ const ownedContainerNames = unique(
116
+ (ownedTemplate.spec?.containers ?? []).map(container => container.name).filter(isNonNullish),
117
+ )
118
+
119
+ const ownedInitContainerNames = unique(
120
+ (ownedTemplate.spec?.initContainers ?? [])
121
+ .map(container => container.name)
122
+ .filter(isNonNullish),
123
+ )
124
+
125
+ const filterByOwnedNames = <TContainer extends { name?: string }>(
126
+ source: TContainer[] | undefined,
127
+ ownedNames: string[],
128
+ ): TContainer[] | undefined => {
129
+ if (!source || source.length === 0 || ownedNames.length === 0) {
130
+ return undefined
131
+ }
132
+
133
+ const filtered = source.filter(container =>
134
+ container.name ? ownedNames.includes(container.name) : false,
135
+ )
136
+
137
+ return filtered.length > 0 ? filtered : undefined
138
+ }
139
+
140
+ const containers = filterByOwnedNames(template.spec?.containers, ownedContainerNames)
141
+ const initContainers = filterByOwnedNames(template.spec?.initContainers, ownedInitContainerNames)
142
+
143
+ const {
144
+ containers: _containers,
145
+ initContainers: _initContainers,
146
+ ...restSpec
147
+ } = template.spec ?? {}
148
+
149
+ const spec = {
150
+ ...restSpec,
151
+ ...(containers ? { containers } : {}),
152
+ ...(initContainers ? { initContainers } : {}),
153
+ } as Partial<Unwrap<types.input.core.v1.PodSpec>>
154
+
155
+ return {
156
+ ...template,
157
+ spec: spec as Unwrap<types.input.core.v1.PodSpec>,
158
+ }
159
+ }
160
+
161
+ type WorkloadHttpGatewayRuleArgs = DistributedOmit<GatewayHttpRuleArgs, "backend" | "backends"> & {
162
+ /**
163
+ * The service port to route to.
164
+ *
165
+ * Can be either a numeric port or a named port from the workload service.
166
+ *
167
+ * If not specified, it first falls back to the servicePort of the route,
168
+ * then to the first port of the workload service.
169
+ */
170
+ servicePort?: Input<number | string>
171
+ }
172
+
173
+ type WorkloadTcpUdpGatewayRuleArgs = DistributedOmit<GatewayRuleArgs, "backend" | "backends"> & {
174
+ /**
175
+ * The service port to route to.
176
+ *
177
+ * Can be either a numeric port or a named port from the workload service.
178
+ *
179
+ * If not specified, it first falls back to the servicePort of the route,
180
+ * then to the first port of the workload service.
181
+ */
182
+ servicePort?: Input<number | string>
183
+ }
91
184
 
92
- export type ExposableWorkloadArgs = WorkloadArgs & {
185
+ type WorkloadGatewayRuleArgs = WorkloadHttpGatewayRuleArgs | WorkloadTcpUdpGatewayRuleArgs
186
+
187
+ export type WorkloadRouteArgs = Except<AccessPointRouteArgs, "backend" | "backends" | "rules"> & {
188
+ /**
189
+ * The service port to route to by default.
190
+ *
191
+ * Can be either a numeric port or a named port from the workload service.
192
+ *
193
+ * Can be overridden by `rules.*.servicePort`.
194
+ * If omitted, the first port of the workload service is used.
195
+ */
196
+ servicePort?: Input<number | string>
197
+ } & (
198
+ | {
199
+ type: "http"
200
+
201
+ /**
202
+ * The path to match for the `default` rule of the listener.
203
+ */
204
+ path?: Input<string>
205
+
206
+ /**
207
+ * The paths to match for the `default` rule of the listener.
208
+ */
209
+ paths?: Input<string[]>
210
+
211
+ /**
212
+ * The rules of the route.
213
+ */
214
+ rules?: InputRecord<WorkloadHttpGatewayRuleArgs>
215
+ }
216
+ | {
217
+ type: "tcp" | "udp"
218
+
219
+ /**
220
+ * The rules of the route.
221
+ */
222
+ rules?: InputRecord<WorkloadTcpUdpGatewayRuleArgs>
223
+ }
224
+ )
225
+
226
+ export type WorkloadServiceArgs = WorkloadArgs & {
93
227
  service?: Input<Omit<ServiceArgs, "cluster" | "namespace">>
94
228
 
95
229
  /**
96
230
  * The configuration for the access point route to create.
97
231
  */
98
- route?: Input<ExposableWorkloadRouteArgs>
232
+ route?: Input<WorkloadRouteArgs>
99
233
 
100
234
  /**
101
235
  * The configuration for the access point routes to create.
102
236
  */
103
- routes?: InputArray<ExposableWorkloadRouteArgs>
237
+ routes?: InputArray<WorkloadRouteArgs>
104
238
 
105
239
  /**
106
240
  * The existing workload to patch.
107
241
  */
108
- existing?: Input<k8s.ExposableWorkload>
242
+ existing?: Input<k8s.Workload>
109
243
  }
110
244
 
111
- export const exposableWorkloadExtraArgs = [
245
+ export const workloadServiceExtraArgs = [
112
246
  ...workloadExtraArgs,
113
247
  "service",
114
248
  "route",
@@ -116,9 +250,8 @@ export const exposableWorkloadExtraArgs = [
116
250
  ] as const
117
251
 
118
252
  export type WorkloadType = "Deployment" | "StatefulSet" | "Job" | "CronJob"
119
- export type ExposableWorkloadType = "Deployment" | "StatefulSet"
120
253
 
121
- export type GenericWorkloadArgs = Omit<ExposableWorkloadArgs, "existing"> & {
254
+ export type GenericWorkloadArgs = Omit<WorkloadServiceArgs, "existing"> & {
122
255
  /**
123
256
  * The type of workload to create.
124
257
  *
@@ -136,14 +269,14 @@ export type GenericWorkloadArgs = Omit<ExposableWorkloadArgs, "existing"> & {
136
269
  *
137
270
  * Will be ignored for other workload types.
138
271
  */
139
- deployment?: Input<DeploymentArgs>
272
+ deployment?: Input<Omit<DeploymentArgs, "name" | "namespace">>
140
273
 
141
274
  /**
142
275
  * The args specific to the "StatefulSet" workload type.
143
276
  *
144
277
  * Will be ignored for other workload types.
145
278
  */
146
- statefulSet?: Input<StatefulSetArgs>
279
+ statefulSet?: Input<Omit<StatefulSetArgs, "name" | "namespace">>
147
280
 
148
281
  /**
149
282
  * The args specific to the "Job" workload type.
@@ -160,34 +293,6 @@ export type GenericWorkloadArgs = Omit<ExposableWorkloadArgs, "existing"> & {
160
293
  cronJob?: Input<JobArgs>
161
294
  }
162
295
 
163
- export type GenericExposableWorkloadArgs = Omit<ExposableWorkloadArgs, "existing"> & {
164
- /**
165
- * The type of workload to create.
166
- *
167
- * Will be ignored if the `existing` argument is provided.
168
- */
169
- defaultType: ExposableWorkloadType
170
-
171
- /**
172
- * The existing workload to patch.
173
- */
174
- existing: Input<k8s.ExposableWorkload | undefined>
175
-
176
- /**
177
- * The args specific to the "Deployment" workload type.
178
- *
179
- * Will be ignored for other workload types.
180
- */
181
- deployment?: Input<DeploymentArgs>
182
-
183
- /**
184
- * The args specific to the "StatefulSet" workload type.
185
- *
186
- * Will be ignored for other workload types.
187
- */
188
- statefulSet?: Input<StatefulSetArgs>
189
- }
190
-
191
296
  export function getWorkloadComponents(
192
297
  name: string,
193
298
  args: WorkloadArgs,
@@ -315,9 +420,9 @@ export function getWorkloadComponents(
315
420
  return { labels, containers, volumes, podSpec, podTemplate, networkPolicy }
316
421
  }
317
422
 
318
- export function getExposableWorkloadComponents(
423
+ export function getWorkloadServiceComponents(
319
424
  name: string,
320
- args: ExposableWorkloadArgs,
425
+ args: WorkloadServiceArgs,
321
426
  parent: () => ComponentResource,
322
427
  opts: ComponentResourceOptions | undefined,
323
428
  isForPatch?: boolean,
@@ -361,7 +466,7 @@ export function getExposableWorkloadComponents(
361
466
  routesArgs: normalizeInputs(args.route, args.routes),
362
467
  service,
363
468
  namespace: output(args.namespace),
364
- }).apply(({ routesArgs, service, namespace }) => {
469
+ }).apply(async ({ routesArgs, service, namespace }) => {
365
470
  if (!routesArgs.length || !service) {
366
471
  return []
367
472
  }
@@ -370,16 +475,114 @@ export function getExposableWorkloadComponents(
370
475
  return []
371
476
  }
372
477
 
373
- return routesArgs.map((routeArgs, index) => {
374
- return new AccessPointRoute(`${name}.${index}`, {
375
- ...routeArgs,
376
- endpoints: service.endpoints,
478
+ const serviceEndpoints = await toPromise(service.endpoints)
479
+ const servicePorts = await toPromise(service.spec.ports)
377
480
 
378
- // pass the native data to the route to allow implementation to use it
379
- gatewayNativeData: service,
380
- tlsCertificateNativeData: namespace,
381
- })
382
- })
481
+ const resolveServiceEndpoints = async (
482
+ servicePort: Input<number | string> | undefined,
483
+ routeName: string,
484
+ ): Promise<network.L4Endpoint[]> => {
485
+ if (serviceEndpoints.length === 0) {
486
+ throw new Error(`No endpoints found for workload service in route "${routeName}"`)
487
+ }
488
+
489
+ let resolvedServicePort: number | undefined
490
+
491
+ if (servicePort != null) {
492
+ const requestedServicePort = await toPromise(servicePort)
493
+
494
+ if (typeof requestedServicePort === "string") {
495
+ const namedPort = servicePorts?.find(port => port.name === requestedServicePort)
496
+
497
+ if (!namedPort) {
498
+ throw new Error(
499
+ `Named port "${requestedServicePort}" not found for workload service in route "${routeName}"`,
500
+ )
501
+ }
502
+
503
+ resolvedServicePort = namedPort.port
504
+ } else {
505
+ resolvedServicePort = requestedServicePort
506
+ }
507
+ } else {
508
+ resolvedServicePort = serviceEndpoints[0]?.port
509
+ }
510
+
511
+ if (resolvedServicePort == null) {
512
+ throw new Error(
513
+ `Unable to resolve service port for workload service in route "${routeName}"`,
514
+ )
515
+ }
516
+
517
+ const filteredEndpoints = serviceEndpoints.filter(
518
+ endpoint => endpoint.port === resolvedServicePort,
519
+ )
520
+
521
+ if (filteredEndpoints.length === 0) {
522
+ throw new Error(
523
+ `No endpoints with port ${resolvedServicePort} found for workload service in route "${routeName}"`,
524
+ )
525
+ }
526
+
527
+ return filteredEndpoints
528
+ }
529
+
530
+ return await Promise.all(
531
+ routesArgs.map(async (routeArgs, index) => {
532
+ const routeName = `${name}.${index}`
533
+ const routeRules = (await toPromise(routeArgs.rules)) as
534
+ | Record<string, WorkloadGatewayRuleArgs>
535
+ | undefined
536
+ const routeRuleValues = Object.values(routeRules ?? {})
537
+ const needsDefaultBackend =
538
+ routeRuleValues.length === 0 || routeRuleValues.some(rule => rule.servicePort == null)
539
+
540
+ const defaultServiceEndpoints = needsDefaultBackend
541
+ ? await resolveServiceEndpoints(routeArgs.servicePort, routeName)
542
+ : undefined
543
+
544
+ const resolvedRules = routeRules
545
+ ? await Promise.all(
546
+ Object.entries(routeRules).map(async ([ruleName, rule]) => {
547
+ const ruleServiceEndpoints = await resolveServiceEndpoints(
548
+ rule.servicePort ?? routeArgs.servicePort,
549
+ `${routeName}:${ruleName}`,
550
+ )
551
+
552
+ return [
553
+ ruleName,
554
+ {
555
+ ...omit(rule, ["servicePort"]),
556
+ backend: {
557
+ endpoints: ruleServiceEndpoints,
558
+ },
559
+ },
560
+ ] as const
561
+ }),
562
+ )
563
+ : undefined
564
+
565
+ const resolvedRulesInput = resolvedRules
566
+ ? (Object.fromEntries(resolvedRules) as unknown as InputRecord<GatewayRuleArgs>)
567
+ : undefined
568
+
569
+ return new AccessPointRoute(routeName, {
570
+ ...omit(routeArgs, ["servicePort", "rules"]),
571
+ ...(defaultServiceEndpoints
572
+ ? {
573
+ backend: {
574
+ endpoints: defaultServiceEndpoints,
575
+ },
576
+ }
577
+ : {}),
578
+ rules: resolvedRulesInput,
579
+ metadata: {
580
+ ...(routeArgs.metadata ?? {}),
581
+ "k8s.namespace": namespace,
582
+ },
583
+ })
584
+ }),
585
+ )
383
586
  })
384
587
 
385
588
  return { labels, containers, volumes, podSpec, podTemplate, networkPolicy, service, routes }
@@ -409,17 +612,81 @@ export abstract class Workload extends NamespacedResource {
409
612
  * Will be created if one or more containers have `allowedEndpoints` defined.
410
613
  */
411
614
  readonly networkPolicy: Output<NetworkPolicy | undefined>,
615
+
616
+ protected readonly _service: Output<Service | undefined> = output(undefined),
617
+
618
+ /**
619
+ * The access point routes associated with the workload.
620
+ */
621
+ readonly routes: Output<AccessPointRoute[]> = output([]),
412
622
  ) {
413
623
  super(type, name, args, opts, metadata, namespace)
414
624
  }
415
625
 
626
+ abstract get entity(): Output<k8s.Workload>
627
+
416
628
  protected abstract get templateMetadata(): Output<types.output.meta.v1.ObjectMeta>
417
629
 
418
630
  protected abstract getTerminalMeta(): Output<UnitTerminal["meta"]>
419
631
 
420
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: for pulumi which for some reason tries to copy all properties
421
632
  private set terminal(_value: never) {}
422
633
 
634
+ private set logsTerminal(_value: never) {}
635
+
636
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: for pulumi which for some reason tries to copy all properties
637
+ private set terminals(_value: never) {}
638
+
639
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: for pulumi which for some reason tries to copy all properties
640
+ private set optionalService(_value: never) {}
641
+
642
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: for pulumi which for some reason tries to copy all properties
643
+ private set service(_value: never) {}
644
+
645
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: for pulumi which for some reason tries to copy all properties
646
+ private set selector(_value: never) {}
647
+
648
+ /**
649
+ * The service associated with the workload.
650
+ */
651
+ get optionalService(): Output<Service | undefined> {
652
+ return this._service
653
+ }
654
+
655
+ /**
656
+ * The service associated with the workload.
657
+ *
658
+ * Will throw an error if the service is not available.
659
+ */
660
+ get service(): Output<Service> {
661
+ return this._service.apply(service => {
662
+ if (!service) {
663
+ throw new Error(`The service of the workload "${this.name}" is not available.`)
664
+ }
665
+
666
+ return service
667
+ })
668
+ }
669
+
670
+ /**
671
+ * The merged and deduplicated L3 endpoints of all routes.
672
+ */
673
+ get endpoints(): Output<network.L3Endpoint[]> {
674
+ return this.routes.apply(routes =>
675
+ output(routes.map(route => route.route.endpoints))
676
+ .apply(endpoints => flat(endpoints))
677
+ .apply(mergeEndpoints),
678
+ )
679
+ }
680
+
681
+ /**
682
+ * The selector matching pods created from this workload's template labels.
683
+ */
684
+ get selector(): Output<SelectorLike> {
685
+ return this.podTemplate.apply(template => ({
686
+ matchLabels: template.metadata?.labels,
687
+ }))
688
+ }
689
+
423
690
  /**
424
691
  * The instance terminal to interact with the workload's pods.
425
692
  */
@@ -445,11 +712,15 @@ export abstract class Workload extends NamespacedResource {
445
712
  command: ["bash", "/welcome.sh"],
446
713
 
447
714
  files: {
448
- "/kubeconfig": fileFromString("kubeconfig", this.cluster.kubeconfig, { isSecret: true }),
449
-
450
- "/welcome.sh": fileFromString(
451
- "welcome.sh",
452
- interpolate`
715
+ "/kubeconfig": makeFileOutput({
716
+ name: "kubeconfig",
717
+ content: getClusterKubeconfigContent(this.cluster),
718
+ isSecret: true,
719
+ }),
720
+
721
+ "/welcome.sh": makeFileOutput({
722
+ name: "welcome.sh",
723
+ content: interpolate`
453
724
  #!/bin/bash
454
725
  set -euo pipefail
455
726
 
@@ -494,7 +765,7 @@ export abstract class Workload extends NamespacedResource {
494
765
  # execute into the selected pod
495
766
  exec kubectl exec -it -n "$NAMESPACE" "$SELECTED_POD" -c "$CONTAINER_NAME" -- "$SHELL"
496
767
  `.apply(trimIndentation),
497
- ),
768
+ }),
498
769
  },
499
770
 
500
771
  env: {
@@ -504,6 +775,104 @@ export abstract class Workload extends NamespacedResource {
504
775
  })
505
776
  }
506
777
 
778
+ /**
779
+ * The instance terminal to view the workload's logs.
780
+ */
781
+ get logsTerminal(): Output<UnitTerminal> {
782
+ const containerName = this.podTemplate.spec.containers.apply(containers => containers[0].name)
783
+
784
+ const podLabelSelector = this.templateMetadata
785
+ .apply(meta => meta.labels ?? {})
786
+ .apply(labels =>
787
+ Object.entries(labels)
788
+ .map(([key, value]) => `${key}=${value}`)
789
+ .join(","),
790
+ )
791
+
792
+ return output({
793
+ name: interpolate`${this.metadata.name}.logs`,
794
+
795
+ meta: output(this.getTerminalMeta()).apply(meta => ({
796
+ ...meta,
797
+ title: `${meta.title} Logs`,
798
+ globalTitle: `${meta.globalTitle} | Logs`,
799
+ description: `The logs of ${meta.title.toLowerCase()}.`,
800
+ })),
801
+
802
+ spec: {
803
+ image: images["terminal-kubectl"].image,
804
+ command: ["bash", "/welcome.sh"],
805
+
806
+ files: {
807
+ "/kubeconfig": makeFileOutput({
808
+ name: "kubeconfig",
809
+ content: getClusterKubeconfigContent(this.cluster),
810
+ isSecret: true,
811
+ }),
812
+
813
+ "/welcome.sh": makeFileOutput({
814
+ name: "welcome.sh",
815
+ content: interpolate`
816
+ #!/bin/bash
817
+ set -euo pipefail
818
+
819
+ NAMESPACE="${this.metadata.namespace}"
820
+ RESOURCE_TYPE="${this.kind.toLowerCase()}"
821
+ RESOURCE_NAME="${this.metadata.name}"
822
+ CONTAINER_NAME="${containerName}"
823
+ LABEL_SELECTOR="${podLabelSelector}"
824
+
825
+ echo "Connecting to logs of $RESOURCE_TYPE \"$RESOURCE_NAME\" in namespace \"$NAMESPACE\""
826
+
827
+ # get all pods for this workload
828
+ PODS=$(kubectl get pods -n "$NAMESPACE" -l "$LABEL_SELECTOR" -o jsonpath='{.items[*].metadata.name}' 2>/dev/null || echo "")
829
+
830
+ if [ -z "$PODS" ]; then
831
+ echo "No pods found"
832
+ exit 1
833
+ fi
834
+
835
+ # convert space-separated string to array
836
+ read -ra POD_ARRAY <<< "$PODS"
837
+
838
+ if [ \${#POD_ARRAY[@]} -eq 1 ]; then
839
+ # single pod found, connect directly
840
+ SELECTED_POD="\${POD_ARRAY[0]}"
841
+ echo "Found single pod: $SELECTED_POD"
842
+ else
843
+ # multiple pods found, use fzf for selection
844
+ echo "Found \${#POD_ARRAY[@]} pods. Please select one."
845
+
846
+ SELECTED_POD=$(printf '%s\n' "\${POD_ARRAY[@]}" | fzf --prompt="Select pod: " --height 10 --border --info=inline)
847
+
848
+ if [ -z "$SELECTED_POD" ]; then
849
+ echo "No pod selected"
850
+ exit 1
851
+ fi
852
+
853
+ echo "Selected pod: $SELECTED_POD"
854
+ fi
855
+
856
+ # stream logs for the selected pod
857
+ exec kubectl logs -f -n "$NAMESPACE" "$SELECTED_POD" -c "$CONTAINER_NAME"
858
+ `.apply(trimIndentation),
859
+ }),
860
+ },
861
+
862
+ env: {
863
+ KUBECONFIG: "/kubeconfig",
864
+ },
865
+ },
866
+ })
867
+ }
868
+
869
+ /**
870
+ * The instance terminals to interact with the workload's pods and view its logs.
871
+ */
872
+ get terminals(): Output<UnitTerminal>[] {
873
+ return [this.logsTerminal, this.terminal]
874
+ }
875
+
507
876
  /**
508
877
  * Creates a terminal with a custom command.
509
878
  *
@@ -517,9 +886,7 @@ export abstract class Workload extends NamespacedResource {
517
886
  command: InputArray<string>,
518
887
  spec?: { env?: DeepInput<TerminalSpec["env"]>; files?: DeepInput<TerminalSpec["files"]> },
519
888
  ): Output<UnitTerminal> {
520
- const containerName = output(this.containers).apply(containers => {
521
- return containers[0]?.name ?? this.name
522
- })
889
+ const containerName = this.podTemplate.spec.containers.apply(containers => containers[0].name)
523
890
 
524
891
  return output({
525
892
  name,
@@ -539,7 +906,7 @@ export abstract class Workload extends NamespacedResource {
539
906
  "-it",
540
907
  "-n",
541
908
  this.metadata.namespace,
542
- `${this.kind.toLowerCase()}/${this.metadata.name}`,
909
+ interpolate`${this.kind.toLowerCase()}/${this.metadata.name}`,
543
910
  "-c",
544
911
  containerName,
545
912
  "--",
@@ -547,7 +914,11 @@ export abstract class Workload extends NamespacedResource {
547
914
  ]),
548
915
 
549
916
  files: {
550
- "/kubeconfig": fileFromString("kubeconfig", this.cluster.kubeconfig, { isSecret: true }),
917
+ "/kubeconfig": makeFileOutput({
918
+ name: "kubeconfig",
919
+ content: getClusterKubeconfigContent(this.cluster),
920
+ isSecret: true,
921
+ }),
551
922
  ...spec?.files,
552
923
  },
553
924
 
@@ -627,13 +998,23 @@ export abstract class Workload extends NamespacedResource {
627
998
  if (args.defaultType === "Deployment") {
628
999
  const { Deployment } = await import("./deployment")
629
1000
 
630
- return Deployment.create(name, deepmerge(args, args.deployment), opts)
1001
+ const deploymentArgs = deepmerge(
1002
+ omit(args, ["defaultType", "existing", "deployment", "statefulSet", "job", "cronJob"]),
1003
+ args.deployment ?? {},
1004
+ ) as DeploymentArgs
1005
+
1006
+ return Deployment.create(name, deploymentArgs, opts)
631
1007
  }
632
1008
 
633
1009
  if (args.defaultType === "StatefulSet") {
634
1010
  const { StatefulSet } = await import("./stateful-set")
635
1011
 
636
- return StatefulSet.create(name, deepmerge(args, args.statefulSet), opts)
1012
+ const statefulSetArgs = deepmerge(
1013
+ omit(args, ["defaultType", "existing", "deployment", "statefulSet", "job", "cronJob"]),
1014
+ args.statefulSet ?? {},
1015
+ ) as StatefulSetArgs
1016
+
1017
+ return StatefulSet.create(name, statefulSetArgs, opts)
637
1018
  }
638
1019
 
639
1020
  if (args.defaultType === "Job") {
@@ -652,144 +1033,3 @@ export abstract class Workload extends NamespacedResource {
652
1033
  })
653
1034
  }
654
1035
  }
655
-
656
- export abstract class ExposableWorkload extends Workload {
657
- protected constructor(
658
- type: string,
659
- name: string,
660
- args: Inputs,
661
- opts: ComponentResourceOptions | undefined,
662
-
663
- metadata: Output<types.output.meta.v1.ObjectMeta>,
664
- namespace: Output<Namespace>,
665
-
666
- terminalArgs: Output<Unwrap<WorkloadTerminalArgs>>,
667
- containers: Output<Container[]>,
668
- podTemplate: Output<types.output.core.v1.PodTemplateSpec>,
669
- networkPolicy: Output<NetworkPolicy | undefined>,
670
-
671
- protected readonly _service: Output<Service | undefined>,
672
-
673
- /**
674
- * The access point routes associated with the workload.
675
- */
676
- readonly routes: Output<AccessPointRoute[]>,
677
- ) {
678
- super(
679
- type,
680
- name,
681
- args,
682
- opts,
683
- metadata,
684
- namespace,
685
- terminalArgs,
686
- containers,
687
- podTemplate,
688
- networkPolicy,
689
- )
690
- }
691
-
692
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: for pulumi which for some reason tries to copy all properties
693
- private set optionalService(_value: never) {}
694
-
695
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: for pulumi which for some reason tries to copy all properties
696
- private set service(_value: never) {}
697
-
698
- /**
699
- * The service associated with the workload.
700
- */
701
- get optionalService(): Output<Service | undefined> {
702
- return this._service
703
- }
704
-
705
- /**
706
- * The service associated with the workload.
707
- *
708
- * Will throw an error if the service is not available.
709
- */
710
- get service(): Output<Service> {
711
- return this._service.apply(service => {
712
- if (!service) {
713
- throw new Error(`The service of the workload "${this.name}" is not available.`)
714
- }
715
-
716
- return service
717
- })
718
- }
719
-
720
- /**
721
- * The merged and deduplicated L3 endpoints of all routes.
722
- */
723
- get endpoints(): Output<network.L3Endpoint[]> {
724
- return this.routes.apply(routes =>
725
- output(routes.map(route => route.route.endpoints))
726
- .apply(endpoints => flat(endpoints))
727
- .apply(mergeEndpoints),
728
- )
729
- }
730
-
731
- /**
732
- * The entity of the workload.
733
- */
734
- abstract get entity(): Output<k8s.ExposableWorkload>
735
-
736
- /**
737
- * The sped of the underlying Kubernetes workload.
738
- */
739
- abstract get spec(): Output<
740
- types.output.apps.v1.DeploymentSpec | types.output.apps.v1.StatefulSetSpec
741
- >
742
-
743
- /**
744
- * Creates a generic exposable workload or patches the existing one.
745
- */
746
- static createOrPatchGeneric(
747
- name: string,
748
- args: GenericExposableWorkloadArgs,
749
- opts?: CustomResourceOptions,
750
- ): Output<ExposableWorkload> {
751
- return output(args).apply(async args => {
752
- if (args.existing?.kind === "Deployment") {
753
- const { Deployment } = await import("./deployment")
754
-
755
- return Deployment.patch(
756
- name,
757
- {
758
- ...deepmerge(args, args.deployment),
759
- name: args.existing.metadata.name,
760
- namespace: Namespace.forResourceAsync(args.existing, output(args.namespace).cluster),
761
- },
762
- opts,
763
- )
764
- }
765
-
766
- if (args.existing?.kind === "StatefulSet") {
767
- const { StatefulSet } = await import("./stateful-set")
768
-
769
- return StatefulSet.patch(
770
- name,
771
- {
772
- ...deepmerge(args, args.statefulSet),
773
- name: args.existing.metadata.name,
774
- namespace: Namespace.forResourceAsync(args.existing, output(args.namespace).cluster),
775
- },
776
- opts,
777
- )
778
- }
779
-
780
- if (args.defaultType === "Deployment") {
781
- const { Deployment } = await import("./deployment")
782
-
783
- return Deployment.create(name, deepmerge(args, args.deployment), opts)
784
- }
785
-
786
- if (args.defaultType === "StatefulSet") {
787
- const { StatefulSet } = await import("./stateful-set")
788
-
789
- return StatefulSet.create(name, deepmerge(args, args.statefulSet), opts)
790
- }
791
-
792
- throw new Error(`Unknown workload type: ${args.defaultType as string}`)
793
- })
794
- }
795
- }