@alienplatform/core 1.8.0 → 1.10.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 (87) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/dist/index.d.ts +772 -89
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +199 -33
  5. package/dist/index.js.map +1 -1
  6. package/dist/stack.js +579 -44
  7. package/dist/stack.js.map +1 -1
  8. package/dist/tests/index.js +1 -1
  9. package/package.json +1 -1
  10. package/src/__tests__/__snapshots__/stack.test.ts.snap +10 -4
  11. package/src/__tests__/stack.test.ts +185 -2
  12. package/src/compute-cluster.ts +213 -0
  13. package/src/container.ts +38 -26
  14. package/src/daemon.ts +79 -0
  15. package/src/generated/index.ts +42 -2
  16. package/src/generated/schemas/architecture.json +1 -0
  17. package/src/generated/schemas/capacityGroup.json +1 -0
  18. package/src/generated/schemas/capacityGroupScalePolicy.json +1 -0
  19. package/src/generated/schemas/computeChoiceRange.json +1 -0
  20. package/src/generated/schemas/computeCluster.json +1 -0
  21. package/src/generated/schemas/computePoolSelection.json +1 -0
  22. package/src/generated/schemas/computeSettings.json +1 -0
  23. package/src/generated/schemas/container.json +1 -1
  24. package/src/generated/schemas/containerOutputs.json +1 -1
  25. package/src/generated/schemas/containerPort.json +1 -1
  26. package/src/generated/schemas/daemon.json +1 -1
  27. package/src/generated/schemas/daemonOutputs.json +1 -1
  28. package/src/generated/schemas/daemonRuntime.json +1 -0
  29. package/src/generated/schemas/daemonRuntimeMount.json +1 -0
  30. package/src/generated/schemas/exposeProtocol.json +1 -1
  31. package/src/generated/schemas/gpuSpec.json +1 -0
  32. package/src/generated/schemas/machineProfile.json +1 -0
  33. package/src/generated/schemas/publicEndpoint.json +1 -0
  34. package/src/generated/schemas/publicEndpointOutput.json +1 -0
  35. package/src/generated/schemas/stack.json +1 -1
  36. package/src/generated/schemas/stackImportRequest.json +1 -1
  37. package/src/generated/schemas/stackImportResponse.json +1 -1
  38. package/src/generated/schemas/stackInputDefaultValue.json +1 -0
  39. package/src/generated/schemas/stackInputDefinition.json +1 -0
  40. package/src/generated/schemas/stackInputEnvironmentMapping.json +1 -0
  41. package/src/generated/schemas/stackInputEnvironmentVariableType.json +1 -0
  42. package/src/generated/schemas/stackInputKind.json +1 -0
  43. package/src/generated/schemas/stackInputProvider.json +1 -0
  44. package/src/generated/schemas/stackInputValidation.json +1 -0
  45. package/src/generated/schemas/stackSettings.json +1 -1
  46. package/src/generated/schemas/worker.json +1 -1
  47. package/src/generated/schemas/workerOutputs.json +1 -1
  48. package/src/generated/schemas/workerPublicEndpoint.json +1 -0
  49. package/src/generated/zod/architecture-schema.ts +13 -0
  50. package/src/generated/zod/capacity-group-scale-policy-schema.ts +27 -0
  51. package/src/generated/zod/capacity-group-schema.ts +27 -0
  52. package/src/generated/zod/compute-choice-range-schema.ts +17 -0
  53. package/src/generated/zod/compute-cluster-schema.ts +20 -0
  54. package/src/generated/zod/compute-pool-selection-schema.ts +22 -0
  55. package/src/generated/zod/compute-settings-schema.ts +18 -0
  56. package/src/generated/zod/container-outputs-schema.ts +5 -6
  57. package/src/generated/zod/container-port-schema.ts +1 -5
  58. package/src/generated/zod/container-schema.ts +7 -3
  59. package/src/generated/zod/daemon-outputs-schema.ts +4 -0
  60. package/src/generated/zod/daemon-runtime-mount-schema.ts +14 -0
  61. package/src/generated/zod/daemon-runtime-schema.ts +19 -0
  62. package/src/generated/zod/daemon-schema.ts +14 -2
  63. package/src/generated/zod/expose-protocol-schema.ts +2 -2
  64. package/src/generated/zod/gpu-spec-schema.ts +16 -0
  65. package/src/generated/zod/index.ts +42 -2
  66. package/src/generated/zod/machine-profile-schema.ts +25 -0
  67. package/src/generated/zod/public-endpoint-output-schema.ts +21 -0
  68. package/src/generated/zod/public-endpoint-schema.ts +22 -0
  69. package/src/generated/zod/stack-import-request-schema.ts +3 -0
  70. package/src/generated/zod/stack-input-default-value-schema.ts +25 -0
  71. package/src/generated/zod/stack-input-definition-schema.ts +43 -0
  72. package/src/generated/zod/stack-input-environment-mapping-schema.ts +20 -0
  73. package/src/generated/zod/stack-input-environment-variable-type-schema.ts +13 -0
  74. package/src/generated/zod/stack-input-kind-schema.ts +13 -0
  75. package/src/generated/zod/stack-input-provider-schema.ts +13 -0
  76. package/src/generated/zod/stack-input-validation-schema.ts +23 -0
  77. package/src/generated/zod/stack-schema.ts +4 -0
  78. package/src/generated/zod/stack-settings-schema.ts +5 -1
  79. package/src/generated/zod/worker-outputs-schema.ts +4 -5
  80. package/src/generated/zod/worker-public-endpoint-schema.ts +17 -0
  81. package/src/generated/zod/worker-schema.ts +4 -4
  82. package/src/index.ts +9 -0
  83. package/src/input.ts +380 -0
  84. package/src/stack.ts +19 -0
  85. package/src/worker.ts +24 -14
  86. package/src/generated/schemas/ingress.json +0 -1
  87. package/src/generated/zod/ingress-schema.ts +0 -13
@@ -1,4 +1,4 @@
1
- import { di as StackStateSchema } from "../stack.js";
1
+ import { Ni as StackStateSchema } from "../stack.js";
2
2
  //#region src/tests/index.ts
3
3
  function getStackState() {
4
4
  return StackStateSchema.parse(JSON.parse(process.env.ALIEN_STACK_STATE));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alienplatform/core",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -31,10 +31,10 @@ exports[`ArtifactRegistry resource configuration > can be used in stack permissi
31
31
  "commandsEnabled": false,
32
32
  "environment": {},
33
33
  "id": "registry-user",
34
- "ingress": "private",
35
34
  "links": [],
36
35
  "memoryMb": 256,
37
36
  "permissions": "execution",
37
+ "publicEndpoints": [],
38
38
  "timeoutSeconds": 180,
39
39
  "triggers": [],
40
40
  "type": "worker",
@@ -145,10 +145,10 @@ exports[`Permissions system > creates a stack with custom permission sets 1`] =
145
145
  "commandsEnabled": false,
146
146
  "environment": {},
147
147
  "id": "test-worker",
148
- "ingress": "private",
149
148
  "links": [],
150
149
  "memoryMb": 256,
151
150
  "permissions": "execution",
151
+ "publicEndpoints": [],
152
152
  "timeoutSeconds": 180,
153
153
  "triggers": [],
154
154
  "type": "worker",
@@ -206,7 +206,6 @@ exports[`Stack builder validation > builds and validates a complex stack with pe
206
206
  "RUST_LOG": "info,alien_runtime_test_server=debug,alien_runtime=debug",
207
207
  },
208
208
  "id": "my-test-worker",
209
- "ingress": "public",
210
209
  "links": [
211
210
  {
212
211
  "id": "my-test-bucket",
@@ -215,6 +214,13 @@ exports[`Stack builder validation > builds and validates a complex stack with pe
215
214
  ],
216
215
  "memoryMb": 512,
217
216
  "permissions": "execution",
217
+ "publicEndpoints": [
218
+ {
219
+ "hostLabel": undefined,
220
+ "name": "api",
221
+ "wildcardSubdomains": false,
222
+ },
223
+ ],
218
224
  "timeoutSeconds": 30,
219
225
  "triggers": [],
220
226
  "type": "worker",
@@ -332,10 +338,10 @@ exports[`Stack builder validation > builds and validates a stack with worker sou
332
338
  "commandsEnabled": false,
333
339
  "environment": {},
334
340
  "id": "my-source-worker",
335
- "ingress": "private",
336
341
  "links": [],
337
342
  "memoryMb": 256,
338
343
  "permissions": "execution",
344
+ "publicEndpoints": [],
339
345
  "timeoutSeconds": 15,
340
346
  "triggers": [],
341
347
  "type": "worker",
@@ -7,6 +7,128 @@ import * as alien from "../index.js"
7
7
  const SHARED_IMAGE = "docker.io/library/rust:latest"
8
8
 
9
9
  describe("Stack builder validation", () => {
10
+ it("builds compute pools from portable requirements only", () => {
11
+ const compute = new alien.ComputeCluster("runtime")
12
+ .pool("nested", {
13
+ requirements: {
14
+ cpu: 4,
15
+ memory: "16Gi",
16
+ architecture: "x86_64",
17
+ nestedVirtualization: true,
18
+ },
19
+ scale: {
20
+ type: "fixed",
21
+ machines: { min: 2, max: 4, default: 2 },
22
+ },
23
+ })
24
+ .build()
25
+
26
+ expect(compute.config.capacityGroups).toEqual([
27
+ {
28
+ groupId: "nested",
29
+ profile: {
30
+ cpu: "4",
31
+ memoryBytes: 17179869184,
32
+ ephemeralStorageBytes: 21474836480,
33
+ gpu: undefined,
34
+ },
35
+ minSize: 2,
36
+ maxSize: 2,
37
+ nestedVirtualization: true,
38
+ },
39
+ ])
40
+ })
41
+
42
+ it("builds stack input definitions for deployment forms", () => {
43
+ const stackInputs = alien.inputs({
44
+ apiBaseUrl: alien.string({
45
+ providedBy: ["developer", "deployer"],
46
+ required: true,
47
+ label: "API base URL",
48
+ description: "Public URL used by the runtime service.",
49
+ placeholder: "https://api.example.com",
50
+ format: "url",
51
+ env: "API_BASE_URL",
52
+ }),
53
+ accessKey: alien.secret({
54
+ providedBy: "deployer",
55
+ required: true,
56
+ label: "Access key",
57
+ description: "Secret token used by the runtime service.",
58
+ minLength: 1,
59
+ env: {
60
+ name: "ACCESS_KEY",
61
+ targetResources: ["my-test-worker"],
62
+ type: "secret",
63
+ },
64
+ }),
65
+ deploymentTier: alien.enum(["starter", "enterprise"], {
66
+ providedBy: "developer",
67
+ required: false,
68
+ label: "Deployment tier",
69
+ description: "Controls default service sizing.",
70
+ default: "starter",
71
+ }),
72
+ })
73
+
74
+ const worker = new alien.Worker("my-test-worker")
75
+ .code({ type: "image", image: SHARED_IMAGE })
76
+ .permissions("execution")
77
+ .build()
78
+
79
+ const stack = new alien.Stack("my-test-stack").inputs(stackInputs).add(worker, "live").build()
80
+ const inputs = stack.inputs
81
+ expect(inputs).toBeDefined()
82
+ if (!inputs) {
83
+ throw new Error("expected stack inputs to be defined")
84
+ }
85
+
86
+ expect(inputs).toHaveLength(3)
87
+ expect(inputs.map(input => input.id)).toEqual(["apiBaseUrl", "accessKey", "deploymentTier"])
88
+ expect(inputs.find(input => input.id === "apiBaseUrl")).toMatchObject({
89
+ kind: "string",
90
+ providedBy: ["developer", "deployer"],
91
+ required: true,
92
+ validation: { format: "url" },
93
+ env: [{ name: "API_BASE_URL" }],
94
+ })
95
+ expect(inputs.find(input => input.id === "accessKey")).toMatchObject({
96
+ kind: "secret",
97
+ providedBy: ["deployer"],
98
+ env: [
99
+ {
100
+ name: "ACCESS_KEY",
101
+ targetResources: ["my-test-worker"],
102
+ type: "secret",
103
+ },
104
+ ],
105
+ })
106
+ expect(inputs.find(input => input.id === "deploymentTier")).toMatchObject({
107
+ kind: "enum",
108
+ default: {
109
+ type: "string",
110
+ value: "starter",
111
+ },
112
+ validation: {
113
+ values: ["starter", "enterprise"],
114
+ },
115
+ })
116
+ })
117
+
118
+ it("rejects non-portable stack input regex patterns", () => {
119
+ expect(() =>
120
+ alien.inputs({
121
+ apiBaseUrl: alien.string({
122
+ providedBy: "deployer",
123
+ required: true,
124
+ label: "API base URL",
125
+ description: "Public URL used by the runtime service.",
126
+ pattern: "(?=https://).*",
127
+ }),
128
+ }),
129
+ ).toThrow(/not portable/)
130
+ })
131
+
10
132
  it("builds a stateful container with persistent storage options", () => {
11
133
  const postgres = new alien.Container("postgres")
12
134
  .code({ type: "image", image: "postgres:16-alpine" })
@@ -26,6 +148,68 @@ describe("Stack builder validation", () => {
26
148
  })
27
149
  })
28
150
 
151
+ it("builds container and daemon wildcard public endpoint options", () => {
152
+ const container = new alien.Container("router")
153
+ .code({ type: "image", image: "nginx:latest" })
154
+ .cpu(0.25)
155
+ .memory("256Mi")
156
+ .permissions("execution")
157
+ .publicEndpoint("api", 8080, {
158
+ protocol: "http",
159
+ hostLabel: "edge",
160
+ wildcardSubdomains: true,
161
+ })
162
+ .build()
163
+
164
+ expect(container.config.ports).toEqual([
165
+ {
166
+ port: 8080,
167
+ },
168
+ ])
169
+ expect(container.config.publicEndpoints).toEqual([
170
+ {
171
+ name: "api",
172
+ port: 8080,
173
+ protocol: "http",
174
+ hostLabel: "edge",
175
+ wildcardSubdomains: true,
176
+ },
177
+ ])
178
+
179
+ const daemon = new alien.Daemon("gateway")
180
+ .code({ type: "image", image: "registry.example.com/gateway:latest" })
181
+ .cluster("compute")
182
+ .permissions("execution")
183
+ .publicEndpoint("api", 8080, {
184
+ protocol: "http",
185
+ hostLabel: "public",
186
+ wildcardSubdomains: true,
187
+ })
188
+ .healthCheck({
189
+ path: "/health",
190
+ method: "GET",
191
+ timeoutSeconds: 1,
192
+ failureThreshold: 3,
193
+ })
194
+ .build()
195
+
196
+ expect(daemon.config.publicEndpoints).toEqual([
197
+ {
198
+ name: "api",
199
+ port: 8080,
200
+ protocol: "http",
201
+ hostLabel: "public",
202
+ wildcardSubdomains: true,
203
+ },
204
+ ])
205
+ expect(daemon.config.healthCheck).toEqual({
206
+ path: "/health",
207
+ method: "GET",
208
+ timeoutSeconds: 1,
209
+ failureThreshold: 3,
210
+ })
211
+ })
212
+
29
213
  it("builds and validates a complex stack with permissions", () => {
30
214
  // Storage bucket
31
215
  const storage = new alien.Storage("my-test-bucket").publicRead(true).build()
@@ -36,7 +220,7 @@ describe("Stack builder validation", () => {
36
220
  .memoryMb(512)
37
221
  .timeoutSeconds(30)
38
222
  .permissions("execution")
39
- .ingress("public")
223
+ .publicEndpoint("api")
40
224
  .environment({
41
225
  RUST_LOG: "info,alien_runtime_test_server=debug,alien_runtime=debug",
42
226
  })
@@ -140,7 +324,6 @@ describe("Stack builder validation", () => {
140
324
  })
141
325
  .memoryMb(256)
142
326
  .timeoutSeconds(15)
143
- .ingress("private")
144
327
  .permissions("execution")
145
328
  .build()
146
329
 
@@ -0,0 +1,213 @@
1
+ import {
2
+ type ComputeCluster as ComputeClusterConfig,
3
+ ComputeClusterSchema,
4
+ type MachineProfile,
5
+ type ResourceType,
6
+ } from "./generated/index.js"
7
+ import { Resource } from "./resource.js"
8
+
9
+ export type {
10
+ ComputeCluster as ComputeClusterConfig,
11
+ CapacityGroup,
12
+ CapacityGroupScalePolicy,
13
+ ComputeChoiceRange as GeneratedComputeChoiceRange,
14
+ MachineProfile,
15
+ } from "./generated/index.js"
16
+ export {
17
+ ComputeClusterSchema as ComputeClusterConfigSchema,
18
+ CapacityGroupSchema,
19
+ CapacityGroupScalePolicySchema,
20
+ ComputeChoiceRangeSchema,
21
+ MachineProfileSchema,
22
+ } from "./generated/index.js"
23
+
24
+ /**
25
+ * Hardware requirements for a compute pool.
26
+ */
27
+ export type ComputePoolRequirements = {
28
+ cpu: number | string
29
+ memory: string
30
+ ephemeralStorage?: string
31
+ architecture?: "arm64" | "x86_64"
32
+ nestedVirtualization?: boolean
33
+ accelerators?: Array<{
34
+ type: string
35
+ count: number
36
+ }>
37
+ }
38
+
39
+ export type ComputeChoiceRange =
40
+ | number
41
+ | {
42
+ min: number
43
+ max: number
44
+ default: number
45
+ }
46
+
47
+ export type ComputePoolScale =
48
+ | {
49
+ type: "fixed"
50
+ machines: ComputeChoiceRange
51
+ }
52
+ | {
53
+ type: "autoscale"
54
+ min: ComputeChoiceRange
55
+ max: ComputeChoiceRange
56
+ }
57
+
58
+ export type ComputePoolInput = {
59
+ requirements: ComputePoolRequirements
60
+ scale: ComputePoolScale
61
+ }
62
+
63
+ /**
64
+ * Declares a ComputeCluster — the setup-owned machine boundary for daemons and
65
+ * containers. Each capacity group inside the cluster becomes a separate
66
+ * Auto Scaling Group (AWS), Managed Instance Group (GCP), or VM Scale Set
67
+ * (Azure). Daemons reference a cluster via `daemon.cluster(...)` and (when
68
+ * the cluster has more than one capacity group) a specific group via
69
+ * `daemon.pool(...)`.
70
+ *
71
+ * Application source declares portable pool requirements. Provider machine
72
+ * names are selected later through deployment settings.
73
+ */
74
+ export class ComputeCluster {
75
+ private _config: Partial<ComputeClusterConfig> = {
76
+ capacityGroups: [],
77
+ }
78
+
79
+ constructor(id: string) {
80
+ this._config.id = id
81
+ }
82
+
83
+ /**
84
+ * Returns the resource type for permission targets that apply to all
85
+ * compute-cluster resources.
86
+ */
87
+ public static any(): ResourceType {
88
+ return "compute-cluster"
89
+ }
90
+
91
+ public pool(groupId: string, config: ComputePoolInput): this {
92
+ const { minSize, maxSize } = selectedScaleBounds(config.scale)
93
+ this._config.capacityGroups!.push({
94
+ groupId,
95
+ profile: machineProfileFromRequirements(config.requirements),
96
+ minSize,
97
+ maxSize,
98
+ scalePolicy: scalePolicyFromInput(config.scale),
99
+ nestedVirtualization: config.requirements.nestedVirtualization,
100
+ })
101
+ return this
102
+ }
103
+
104
+ /**
105
+ * Sets the container CIDR block used for inter-container networking inside
106
+ * the cluster. Each machine gets a /24 subnet carved from this range.
107
+ * Defaults to 10.244.0.0/16 if not specified.
108
+ */
109
+ public containerCidr(cidr: string): this {
110
+ this._config.containerCidr = cidr
111
+ return this
112
+ }
113
+
114
+ /**
115
+ * Builds and validates the cluster configuration.
116
+ */
117
+ public build(): Resource {
118
+ const config = ComputeClusterSchema.parse(this._config)
119
+ return new Resource({
120
+ type: "compute-cluster",
121
+ ...config,
122
+ })
123
+ }
124
+ }
125
+
126
+ function selectedScaleBounds(scale: ComputePoolScale): { minSize: number; maxSize: number } {
127
+ if (scale.type === "fixed") {
128
+ const machines = defaultChoice(scale.machines)
129
+ return { minSize: machines, maxSize: machines }
130
+ }
131
+
132
+ return {
133
+ minSize: defaultChoice(scale.min),
134
+ maxSize: defaultChoice(scale.max),
135
+ }
136
+ }
137
+
138
+ function scalePolicyFromInput(
139
+ scale: ComputePoolScale,
140
+ ): ComputeClusterConfig["capacityGroups"][number]["scalePolicy"] {
141
+ if (scale.type === "fixed") {
142
+ return {
143
+ type: "fixed",
144
+ machines: choiceRange(scale.machines),
145
+ }
146
+ }
147
+
148
+ return {
149
+ type: "autoscale",
150
+ min: choiceRange(scale.min),
151
+ max: choiceRange(scale.max),
152
+ }
153
+ }
154
+
155
+ function choiceRange(choice: ComputeChoiceRange): { min: number; max: number; default: number } {
156
+ if (typeof choice === "number") {
157
+ return { min: choice, max: choice, default: choice }
158
+ }
159
+ return choice
160
+ }
161
+
162
+ function defaultChoice(choice: ComputeChoiceRange): number {
163
+ if (typeof choice === "number") {
164
+ return choice
165
+ }
166
+
167
+ return choice.default
168
+ }
169
+
170
+ function machineProfileFromRequirements(requirements: ComputePoolRequirements): MachineProfile {
171
+ return {
172
+ cpu: typeof requirements.cpu === "number" ? `${requirements.cpu}` : requirements.cpu,
173
+ memoryBytes: parseQuantityBytes(requirements.memory),
174
+ ephemeralStorageBytes: parseQuantityBytes(requirements.ephemeralStorage ?? "20Gi"),
175
+ architecture: requirements.architecture,
176
+ gpu: requirements.accelerators?.[0]
177
+ ? {
178
+ type: requirements.accelerators[0].type,
179
+ count: requirements.accelerators[0].count,
180
+ }
181
+ : undefined,
182
+ }
183
+ }
184
+
185
+ function parseQuantityBytes(value: string): number {
186
+ const match = value.match(/^([0-9]+(?:\.[0-9]+)?)(Ki|Mi|Gi|Ti|k|M|G|T)?$/)
187
+ if (!match) {
188
+ throw new Error(`Invalid memory/storage quantity: ${value}`)
189
+ }
190
+
191
+ const amount = Number(match[1])
192
+ const suffix = match[2]
193
+ const multiplier =
194
+ suffix === "Ti"
195
+ ? 1024 ** 4
196
+ : suffix === "Gi"
197
+ ? 1024 ** 3
198
+ : suffix === "Mi"
199
+ ? 1024 ** 2
200
+ : suffix === "Ki"
201
+ ? 1024
202
+ : suffix === "T"
203
+ ? 1000 ** 4
204
+ : suffix === "G"
205
+ ? 1000 ** 3
206
+ : suffix === "M"
207
+ ? 1000 ** 2
208
+ : suffix === "k"
209
+ ? 1000
210
+ : 1
211
+
212
+ return Math.round(amount * multiplier)
213
+ }
package/src/container.ts CHANGED
@@ -6,11 +6,21 @@ import {
6
6
  ContainerSchema,
7
7
  type HealthCheck,
8
8
  type PersistentStorage,
9
+ type PublicEndpoint,
9
10
  type ResourceSpec,
10
11
  type ResourceType,
11
12
  } from "./generated/index.js"
12
13
  import { Resource } from "./resource.js"
13
14
 
15
+ export type PublicEndpointOptions =
16
+ | "http"
17
+ | "tcp"
18
+ | {
19
+ protocol: "http" | "tcp"
20
+ hostLabel?: string
21
+ wildcardSubdomains?: boolean
22
+ }
23
+
14
24
  export type {
15
25
  Container as ContainerConfig,
16
26
  ContainerOutputs,
@@ -18,6 +28,7 @@ export type {
18
28
  ContainerAutoscaling,
19
29
  ContainerPort,
20
30
  ExposeProtocol,
31
+ PublicEndpoint,
21
32
  ContainerGpuSpec,
22
33
  ContainerStatus,
23
34
  HealthCheck,
@@ -53,6 +64,7 @@ export class Container {
53
64
  private _config: Partial<ContainerConfig> = {
54
65
  links: [],
55
66
  ports: [],
67
+ publicEndpoints: [],
56
68
  environment: {},
57
69
  stateful: false,
58
70
  // cluster is optional - if not set, ComputeClusterMutation will auto-assign
@@ -231,40 +243,40 @@ export class Container {
231
243
  }
232
244
 
233
245
  /**
234
- * Exposes a specific port publicly via load balancer.
246
+ * Exposes a named public endpoint for a container port.
247
+ *
248
+ * Endpoint names are the stable contract for URLs, DNS, and runtime
249
+ * environment injection.
250
+ *
251
+ * @param name Endpoint name, unique within this container.
235
252
  * @param port Port number to expose.
236
- * @param protocol "http" for HTTPS with TLS termination, "tcp" for TCP passthrough.
253
+ * @param options "http"/"tcp" or endpoint options.
237
254
  * @returns The Container builder instance.
238
255
  */
239
- public exposePort(port: number, protocol: "http" | "tcp"): this {
256
+ public publicEndpoint(name: string, port: number, options: PublicEndpointOptions = "http"): this {
240
257
  if (!this._config.ports) {
241
258
  this._config.ports = []
242
259
  }
243
-
244
- // Find existing port or add new one
245
- const existingPort = this._config.ports.find(p => p.port === port)
246
- if (existingPort) {
247
- existingPort.expose = protocol
248
- } else {
249
- this._config.ports.push({ port, expose: protocol })
250
- }
251
- return this
252
- }
253
-
254
- /**
255
- * Convenience method to expose the first/primary port publicly.
256
- * Must be called after .port() or .ports().
257
- * @param protocol "http" for HTTPS with TLS termination, "tcp" for TCP passthrough.
258
- * @returns The Container builder instance.
259
- */
260
- public expose(protocol: "http" | "tcp"): this {
261
- if (!this._config.ports || this._config.ports.length === 0) {
262
- throw new Error("Cannot expose port: no ports defined. Call .port() first.")
260
+ if (!this._config.publicEndpoints) {
261
+ this._config.publicEndpoints = []
263
262
  }
264
- if (!this._config.ports[0]) {
265
- throw new Error("Cannot expose port: ports array is empty")
263
+ const endpoint =
264
+ typeof options === "string"
265
+ ? { protocol: options, hostLabel: undefined, wildcardSubdomains: false }
266
+ : options
267
+
268
+ if (!this._config.ports.some(p => p.port === port)) {
269
+ this._config.ports.push({
270
+ port,
271
+ })
266
272
  }
267
- this._config.ports[0].expose = protocol
273
+ this._config.publicEndpoints.push({
274
+ name,
275
+ port,
276
+ protocol: endpoint.protocol,
277
+ hostLabel: endpoint.hostLabel,
278
+ wildcardSubdomains: endpoint.wildcardSubdomains ?? false,
279
+ } satisfies PublicEndpoint)
268
280
  return this
269
281
  }
270
282