@esuyo/coding-container 1.0.2

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/src/config.js ADDED
@@ -0,0 +1,586 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import yaml from "js-yaml";
6
+ import { expandEnvMap } from "./utils/envsubst.js";
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ export const PROJECT_ROOT = path.resolve(__dirname, "..");
10
+
11
+ /**
12
+ * Global config directory: $XDG_CONFIG_HOME/coding-container or ~/.config/coding-container.
13
+ * Holds the shared settings (config.yaml) and, by default, the SSH key
14
+ * material (keys/).
15
+ */
16
+ export function globalConfigDir() {
17
+ const base = process.env.XDG_CONFIG_HOME
18
+ ? path.resolve(process.env.XDG_CONFIG_HOME)
19
+ : path.join(os.homedir(), ".config");
20
+ return path.join(base, "coding-container");
21
+ }
22
+
23
+ export function globalConfigPath() {
24
+ return path.join(globalConfigDir(), "config.yaml");
25
+ }
26
+
27
+ // Read-only reference; loadConfig() clones it before merging.
28
+ export const DEFAULTS = {
29
+ project: "my-project",
30
+ image: {
31
+ // registry is intentionally unset: it is environment-specific and is
32
+ // captured by `coding-container setup` (or set in the global config).
33
+ registry: "",
34
+ name: "coding-container",
35
+ // tag is intentionally unset: in global mode, deploys float on the
36
+ // cluster's coding-system ConfigMap (`coding-container system get`);
37
+ // set image.tag to pin this project to one global version. In custom
38
+ // mode (own dockerfile/buildArgs/name) image.tag is required.
39
+ dockerfile: "Dockerfile",
40
+ context: ".",
41
+ buildArgs: {
42
+ BASE_IMAGE: "ubuntu:26.04",
43
+ NODE_VERSION: "22",
44
+ EXTRA_APT_PACKAGES: "",
45
+ EXTRA_NPM_PACKAGES: "",
46
+ },
47
+ },
48
+ k8s: {
49
+ namespace: "coding",
50
+ replicas: 1,
51
+ serviceType: "NodePort",
52
+ nodePort: 30022,
53
+ imagePullSecret: "",
54
+ resources: {
55
+ requests: { cpu: "100m", memory: "128Mi" },
56
+ limits: { cpu: "2", memory: "8Gi" },
57
+ },
58
+ env: {},
59
+ // Secret-backed env for the coding container (e.g. provider API keys).
60
+ // Rendered into a managed `<project>-env` Secret and referenced via
61
+ // valueFrom.secretKeyRef, so `kubectl get deploy` never shows values.
62
+ // Values may use `${LOCAL_VAR}` (expanded from your shell at deploy
63
+ // time); a missing variable fails fast. Same `${}` expansion applies to
64
+ // k8s.env (which stays plaintext in the Deployment).
65
+ // secretEnv:
66
+ // OPENAI_API_KEY: ${OPENAI_API_KEY}
67
+ secretEnv: {},
68
+ // Extra containers in the pod (e.g. a Postgres database or a message
69
+ // queue for the app in the coding container). Each entry is a near-raw
70
+ // k8s container spec: 'name' and 'image' are required, 'env' may be a
71
+ // map or a k8s env list, everything else (ports, command, volumeMounts,
72
+ // resources, probes, ...) passes through unchanged. One knob is consumed
73
+ // by the service instead of the container: a port entry's optional
74
+ // 'nodePort' (30000-32767, unique across ssh, web.ports, and sidecars)
75
+ // exposes that port on every cluster node (handy for TCP services an
76
+ // HTTP Ingress can't route) and is stripped from the rendered
77
+ // ContainerPort.
78
+ sidecars: [],
79
+ },
80
+ nfs: {
81
+ // server / basePath are intentionally unset: they are environment-specific
82
+ // and are captured by `coding-container setup` (or set in the global config).
83
+ server: "",
84
+ basePath: "",
85
+ // Subfolder under basePath that backs /workspace. Unset -> the project
86
+ // name. Set it to pin a specific folder (e.g. reuse an existing one).
87
+ subPath: "",
88
+ // Extra NAS folders mounted into the container alongside /workspace.
89
+ // Each entry reuses nfs.server/nfs.basePath: 'subPath' is a folder under
90
+ // basePath (created on first mount if missing), 'mountPath' is an absolute
91
+ // path in the container, and 'readOnly' (default false) blocks container
92
+ // writes. All mounts reuse the single workspace NFS volume.
93
+ // volumes:
94
+ // - { subPath: shared-datasets, mountPath: /data, readOnly: true }
95
+ volumes: [],
96
+ },
97
+ ssh: {
98
+ user: "root",
99
+ port: 22,
100
+ keyName: "id_ed25519",
101
+ },
102
+ web: {
103
+ // Container ports to expose to the browser (dev servers, etc.). Each
104
+ // entry becomes a Traefik Ingress rule: its host routes to that container
105
+ // port on the project's service. DNS resolution of the hosts is up to you.
106
+ // An optional 'nodePort' (30000-32767, unique across ssh, web.ports, and
107
+ // sidecars) additionally exposes the port on every cluster node, for
108
+ // clients that can't use the Ingress hosts.
109
+ // web:
110
+ // ports:
111
+ // - { port: 3000, host: my-web.example.com, nodePort: 30080 }
112
+ // - { port: 8080, host: my-api.example.com, name: api }
113
+ ports: [],
114
+ },
115
+ ingress: {
116
+ // Ingress class that routes the web.ports rules (k3s ships Traefik).
117
+ className: "traefik",
118
+ },
119
+ container: {
120
+ workdir: "/workspace",
121
+ localPort: 2222,
122
+ },
123
+ };
124
+
125
+ function deepMerge(target, source) {
126
+ for (const [key, value] of Object.entries(source)) {
127
+ if (
128
+ value &&
129
+ typeof value === "object" &&
130
+ !Array.isArray(value) &&
131
+ target[key] &&
132
+ typeof target[key] === "object" &&
133
+ !Array.isArray(target[key])
134
+ ) {
135
+ deepMerge(target[key], value);
136
+ } else {
137
+ target[key] = value;
138
+ }
139
+ }
140
+ return target;
141
+ }
142
+
143
+ /**
144
+ * Resolve the relative path fields of one config layer (image.dockerfile,
145
+ * image.context, ssh.keyDir) against the directory that layer's file lives
146
+ * in. Built-in defaults resolve against the local config's directory so a
147
+ * project without a global config behaves exactly as before.
148
+ */
149
+ function withPaths(layer, baseDir) {
150
+ const out = structuredClone(layer);
151
+ if (out.image) {
152
+ if (out.image.dockerfile) {
153
+ out.image.dockerfilePath = path.resolve(baseDir, out.image.dockerfile);
154
+ }
155
+ if (out.image.context) {
156
+ out.image.contextPath = path.resolve(baseDir, out.image.context);
157
+ }
158
+ }
159
+ if (out.ssh && out.ssh.keyDir) {
160
+ out.ssh.keyDirPath = path.resolve(baseDir, out.ssh.keyDir);
161
+ }
162
+ return out;
163
+ }
164
+
165
+ function readYaml(file) {
166
+ return yaml.load(fs.readFileSync(file, "utf8")) || {};
167
+ }
168
+
169
+ /**
170
+ * Which project config to use: an explicit -c value wins, then a config.yaml
171
+ * in the current working directory, then the repo's own config.yaml.
172
+ */
173
+ export function resolveDefaultConfigPath(explicit) {
174
+ if (explicit) return explicit;
175
+ const cwdConfig = path.resolve(process.cwd(), "config.yaml");
176
+ return fs.existsSync(cwdConfig) ? cwdConfig : path.join(PROJECT_ROOT, "config.yaml");
177
+ }
178
+
179
+ /**
180
+ * Load and validate configuration.
181
+ *
182
+ * Layers, lowest to highest priority:
183
+ * 1. hardcoded DEFAULTS
184
+ * 2. global config (~/.config/coding-container/config.yaml, if present)
185
+ * 3. local project config (configPath)
186
+ * 4. CLI overrides (e.g. -p/--project, --tag)
187
+ *
188
+ * The cluster is a layer too, but it is read by the deploy/system commands,
189
+ * not here: in global mode the image tag floats on the coding-system
190
+ * ConfigMap in the cluster (see src/utils/system-config.js). loadConfig
191
+ * stays offline and pure; it only computes which mode the project is in:
192
+ * cfg.imageMode = "custom" when the project layer sets image.dockerfile,
193
+ * image.buildArgs or image.name (the project ships its own image at
194
+ * registry/coding-<project>), else "global" (the shared team image).
195
+ *
196
+ * @param {string} configPath path to the local project yaml file
197
+ * @param {object} overrides flat-ish overrides applied on top (e.g. from CLI flags)
198
+ */
199
+ export function loadConfig(configPath, overrides = {}) {
200
+ const resolved = path.resolve(configPath);
201
+ if (!fs.existsSync(resolved)) {
202
+ throw new Error(`Config file not found: ${resolved}`);
203
+ }
204
+ const localDir = path.dirname(resolved);
205
+ const gDir = globalConfigDir();
206
+
207
+ let cfg = withPaths(DEFAULTS, localDir);
208
+ if (fs.existsSync(globalConfigPath())) {
209
+ cfg = deepMerge(cfg, withPaths(readYaml(globalConfigPath()), gDir));
210
+ }
211
+ const local = readYaml(resolved);
212
+ cfg = deepMerge(cfg, withPaths(local, localDir));
213
+ cfg = deepMerge(cfg, overrides);
214
+
215
+ const localImage = (local && local.image) || {};
216
+ cfg.imageMode =
217
+ localImage.dockerfile || localImage.buildArgs || localImage.name ? "custom" : "global";
218
+ // Custom projects never touch the global repo: default their image name
219
+ // to coding-<project> unless they set image.name themselves.
220
+ if (cfg.imageMode === "custom" && !localImage.name) {
221
+ cfg.image.name = `coding-${cfg.project}`;
222
+ }
223
+
224
+ // Default key location: keys/ inside the global config dir (shared key
225
+ // pair for all projects; per-project known_hosts files live alongside).
226
+ if (!cfg.ssh.keyDirPath) cfg.ssh.keyDirPath = path.join(gDir, "keys");
227
+ // `${VAR}` expansion (local shell -> config) is scoped to env-ish fields
228
+ // only, so hosts/paths/ports can never surprise-expand. Missing variables
229
+ // fail fast here, before validate().
230
+ if (cfg.k8s.env != null) cfg.k8s.env = expandEnvMap(cfg.k8s.env, "k8s.env");
231
+ if (cfg.k8s.secretEnv != null) cfg.k8s.secretEnv = expandEnvMap(cfg.k8s.secretEnv, "k8s.secretEnv");
232
+ validate(cfg);
233
+ return cfg;
234
+ }
235
+
236
+ function validate(cfg) {
237
+ if (!cfg.project) throw new Error("config: 'project' is required");
238
+ if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(cfg.project)) {
239
+ throw new Error(
240
+ `config: 'project' must be a valid k8s name (lowercase alphanum and '-'): ${cfg.project}`
241
+ );
242
+ }
243
+ if (!cfg.image.registry) {
244
+ throw new Error(
245
+ "config: image.registry is not set. Run 'coding-container setup' " +
246
+ "(or set image.registry in your global config) to point at your registry."
247
+ );
248
+ }
249
+ if (!cfg.image.name) {
250
+ throw new Error("config: image.name is required");
251
+ }
252
+ if (!fs.existsSync(cfg.image.dockerfilePath)) {
253
+ throw new Error(`config: dockerfile not found: ${cfg.image.dockerfilePath}`);
254
+ }
255
+ if (!cfg.nfs.server) {
256
+ throw new Error(
257
+ "config: nfs.server is not set. Run 'coding-container setup' " +
258
+ "(or set nfs.server in your global config) to point at your NAS."
259
+ );
260
+ }
261
+ if (!cfg.nfs.basePath) {
262
+ throw new Error(
263
+ "config: nfs.basePath is not set. Run 'coding-container setup' " +
264
+ "(or set nfs.basePath in your global config) to the shared folder that " +
265
+ "holds the per-project workspaces."
266
+ );
267
+ }
268
+ if (cfg.nfs.subPath !== undefined && cfg.nfs.subPath !== "") {
269
+ if (
270
+ typeof cfg.nfs.subPath !== "string" ||
271
+ cfg.nfs.subPath.startsWith("/") ||
272
+ cfg.nfs.subPath.split("/").some((seg) => seg === "" || seg === "..")
273
+ ) {
274
+ throw new Error(
275
+ `config: nfs.subPath must be a relative folder name under nfs.basePath ` +
276
+ `(no leading '/', no empty or '..' segments): ${cfg.nfs.subPath}`
277
+ );
278
+ }
279
+ }
280
+ if (cfg.nfs.volumes !== undefined) {
281
+ if (!Array.isArray(cfg.nfs.volumes)) {
282
+ throw new Error(
283
+ "config: nfs.volumes must be a list of {subPath, mountPath, readOnly?} entries"
284
+ );
285
+ }
286
+ const workdir = cfg.container.workdir;
287
+ const sshKeyMount = "/root/.ssh/authorized_keys";
288
+ const workspaceSub = cfg.nfs.subPath || cfg.project;
289
+ const seenSubPaths = new Set();
290
+ const seenMountPaths = new Set();
291
+ for (const entry of cfg.nfs.volumes) {
292
+ const label =
293
+ entry && typeof entry === "object" && !Array.isArray(entry) && entry.subPath
294
+ ? `nfs.volumes entry "${entry.subPath}"`
295
+ : `nfs.volumes entry ${JSON.stringify(entry)}`;
296
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
297
+ throw new Error(`config: ${label} must be an object with 'subPath' and 'mountPath'`);
298
+ }
299
+ if (
300
+ typeof entry.subPath !== "string" ||
301
+ entry.subPath.startsWith("/") ||
302
+ entry.subPath.split("/").some((seg) => seg === "" || seg === "..")
303
+ ) {
304
+ throw new Error(
305
+ `config: ${label} 'subPath' must be a relative folder name under nfs.basePath ` +
306
+ `(no leading '/', no empty or '..' segments): ${JSON.stringify(entry.subPath)}`
307
+ );
308
+ }
309
+ if (entry.subPath === workspaceSub) {
310
+ throw new Error(
311
+ `config: ${label} 'subPath' collides with the /workspace folder ("${workspaceSub}")`
312
+ );
313
+ }
314
+ if (seenSubPaths.has(entry.subPath)) {
315
+ throw new Error(
316
+ `config: nfs.volumes lists subPath "${entry.subPath}" more than once`
317
+ );
318
+ }
319
+ seenSubPaths.add(entry.subPath);
320
+ if (
321
+ typeof entry.mountPath !== "string" ||
322
+ !entry.mountPath.startsWith("/") ||
323
+ entry.mountPath.split("/").slice(1).some((seg) => seg === "" || seg === "..")
324
+ ) {
325
+ throw new Error(
326
+ `config: ${label} 'mountPath' must be an absolute container path ` +
327
+ `(no trailing '/', no empty or '..' segments): ${JSON.stringify(entry.mountPath)}`
328
+ );
329
+ }
330
+ if (entry.mountPath === workdir || entry.mountPath.startsWith(`${workdir}/`)) {
331
+ throw new Error(
332
+ `config: ${label} 'mountPath' may not be ${workdir} or nested under it ` +
333
+ `(it would shadow the NFS-backed workspace): ${entry.mountPath}`
334
+ );
335
+ }
336
+ if (entry.mountPath === sshKeyMount) {
337
+ throw new Error(
338
+ `config: ${label} 'mountPath' collides with the SSH authorized_keys mount: ${entry.mountPath}`
339
+ );
340
+ }
341
+ if (seenMountPaths.has(entry.mountPath)) {
342
+ throw new Error(
343
+ `config: nfs.volumes lists mountPath "${entry.mountPath}" more than once`
344
+ );
345
+ }
346
+ for (const other of seenMountPaths) {
347
+ if (
348
+ entry.mountPath.startsWith(`${other}/`) ||
349
+ other.startsWith(`${entry.mountPath}/`)
350
+ ) {
351
+ throw new Error(
352
+ `config: ${label} 'mountPath' "${entry.mountPath}" is nested with ` +
353
+ `an existing nfs.volumes mount "${other}"`
354
+ );
355
+ }
356
+ }
357
+ seenMountPaths.add(entry.mountPath);
358
+ if (entry.readOnly !== undefined && typeof entry.readOnly !== "boolean") {
359
+ throw new Error(
360
+ `config: ${label} 'readOnly' must be a boolean: ${JSON.stringify(entry.readOnly)}`
361
+ );
362
+ }
363
+ }
364
+ }
365
+ const st = cfg.k8s.serviceType;
366
+ if (st !== "NodePort" && st !== "LoadBalancer") {
367
+ throw new Error("config: k8s.serviceType must be NodePort or LoadBalancer");
368
+ }
369
+ for (const field of ["env", "secretEnv"]) {
370
+ const map = cfg.k8s[field];
371
+ if (map == null) continue;
372
+ if (typeof map !== "object" || Array.isArray(map)) {
373
+ throw new Error(`config: k8s.${field} must be a map of NAME to value`);
374
+ }
375
+ for (const [name, value] of Object.entries(map)) {
376
+ if (value != null && typeof value === "object") {
377
+ throw new Error(
378
+ `config: k8s.${field} '${name}' must be a scalar (string, number or boolean), ` +
379
+ `got ${JSON.stringify(value)}`
380
+ );
381
+ }
382
+ }
383
+ }
384
+ // Shared by web.ports and k8s.sidecars: nodePorts must be unique across both.
385
+ const seenNodePorts = new Set();
386
+ if (cfg.k8s.sidecars !== undefined) {
387
+ if (!Array.isArray(cfg.k8s.sidecars)) {
388
+ throw new Error(
389
+ "config: k8s.sidecars must be a list of container entries (each with 'name' and 'image')"
390
+ );
391
+ }
392
+ // Ports already declared by the coding container: a sidecar must not
393
+ // redeclare them, or the service's numeric targetPort resolution breaks.
394
+ const codingPorts = new Set(
395
+ [cfg.ssh.port, ...(cfg.web?.ports || []).map((p) => p.port)].map(String)
396
+ );
397
+ const sidecarPorts = new Set();
398
+ // Service port names already taken (ssh + web.ports): an exposed sidecar
399
+ // port must not reuse one, or the service is invalid.
400
+ const svcPortNames = new Set([
401
+ "ssh",
402
+ ...(cfg.web?.ports || []).map((p) => p.name || `web-${p.port}`),
403
+ ]);
404
+ const seenNames = new Set(["coding"]);
405
+ for (const entry of cfg.k8s.sidecars) {
406
+ const label =
407
+ entry && typeof entry === "object" && entry.name
408
+ ? `k8s.sidecars entry "${entry.name}"`
409
+ : `k8s.sidecars entry ${JSON.stringify(entry)}`;
410
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
411
+ throw new Error(`config: ${label} must be an object with 'name' and 'image'`);
412
+ }
413
+ if (typeof entry.name !== "string" || !/^[a-z]([-a-z0-9]*[a-z0-9])?$/.test(entry.name) || entry.name.length > 63) {
414
+ throw new Error(
415
+ `config: ${label} needs a valid container name (lowercase a-z/0-9/-, max 63 chars): ${JSON.stringify(entry.name)}`
416
+ );
417
+ }
418
+ if (seenNames.has(entry.name)) {
419
+ throw new Error(`config: k8s.sidecars lists the container name "${entry.name}" more than once`);
420
+ }
421
+ seenNames.add(entry.name);
422
+ if (typeof entry.image !== "string" || !entry.image) {
423
+ throw new Error(`config: ${label} needs an 'image' (e.g. "postgres:16")`);
424
+ }
425
+ if (entry.ports !== undefined) {
426
+ if (!Array.isArray(entry.ports)) {
427
+ throw new Error(
428
+ `config: ${label} 'ports' must be a list of {containerPort, name?, protocol?, nodePort?} entries`
429
+ );
430
+ }
431
+ for (const port of entry.ports) {
432
+ if (
433
+ !port ||
434
+ typeof port !== "object" ||
435
+ !Number.isInteger(port.containerPort) ||
436
+ port.containerPort < 1 ||
437
+ port.containerPort > 65535
438
+ ) {
439
+ throw new Error(
440
+ `config: ${label} port entry must have an integer 'containerPort' (1-65535): ${JSON.stringify(port)}`
441
+ );
442
+ }
443
+ if (
444
+ port.name !== undefined &&
445
+ (!/^[a-z]([-a-z0-9]*[a-z0-9])?$/.test(port.name) || port.name.length > 15)
446
+ ) {
447
+ throw new Error(
448
+ `config: ${label} port name must be a <=15-char IANA service name (lowercase a-z/0-9/-): ${JSON.stringify(port.name)}`
449
+ );
450
+ }
451
+ if (port.protocol !== undefined && port.protocol !== "TCP" && port.protocol !== "UDP") {
452
+ throw new Error(
453
+ `config: ${label} port protocol must be TCP or UDP: ${JSON.stringify(port.protocol)}`
454
+ );
455
+ }
456
+ const key = String(port.containerPort);
457
+ if (codingPorts.has(key)) {
458
+ throw new Error(
459
+ `config: ${label} declares containerPort ${key}, which the coding container already uses (ssh or web.ports)`
460
+ );
461
+ }
462
+ if (sidecarPorts.has(key)) {
463
+ throw new Error(
464
+ `config: k8s.sidecars lists containerPort ${key} more than once (one container per port keeps the service's port resolution unambiguous)`
465
+ );
466
+ }
467
+ sidecarPorts.add(key);
468
+ if (port.nodePort !== undefined) {
469
+ if (
470
+ !Number.isInteger(port.nodePort) ||
471
+ port.nodePort < 30000 ||
472
+ port.nodePort > 32767
473
+ ) {
474
+ throw new Error(
475
+ `config: ${label} port nodePort must be an integer in 30000-32767: ${JSON.stringify(port.nodePort)}`
476
+ );
477
+ }
478
+ if (port.nodePort === cfg.k8s.nodePort) {
479
+ throw new Error(
480
+ `config: ${label} port nodePort ${port.nodePort} collides with the SSH NodePort (k8s.nodePort)`
481
+ );
482
+ }
483
+ if (seenNodePorts.has(port.nodePort)) {
484
+ throw new Error(
485
+ `config: k8s.sidecars lists nodePort ${port.nodePort} more than once`
486
+ );
487
+ }
488
+ seenNodePorts.add(port.nodePort);
489
+ const svcName = port.name || `nodeport-${port.containerPort}`;
490
+ if (svcPortNames.has(svcName)) {
491
+ throw new Error(
492
+ `config: ${label} port name "${svcName}" is already used by a service port ` +
493
+ `(ssh, web.ports, or another exposed sidecar port); give the sidecar port a different 'name'`
494
+ );
495
+ }
496
+ svcPortNames.add(svcName);
497
+ }
498
+ }
499
+ }
500
+ }
501
+ if (cfg.k8s.replicas !== 1) {
502
+ throw new Error(
503
+ "config: k8s.replicas must be 1 when k8s.sidecars is set — replicas share the same NAS folder, so stateful sidecars (databases, queues) would conflict"
504
+ );
505
+ }
506
+ }
507
+ if (cfg.web?.ports !== undefined) {
508
+ if (!Array.isArray(cfg.web.ports)) {
509
+ throw new Error("config: web.ports must be a list of {port, host} entries");
510
+ }
511
+ const seenPorts = new Set();
512
+ const seenHosts = new Set();
513
+ for (const entry of cfg.web.ports) {
514
+ const port = entry && entry.port;
515
+ if (!entry || typeof entry !== "object" || !Number.isInteger(port) || port < 1 || port > 65535) {
516
+ throw new Error(
517
+ `config: web.ports entry must be an object with a 'port' (1-65535) and a 'host': ${JSON.stringify(entry)}`
518
+ );
519
+ }
520
+ if (seenPorts.has(port)) {
521
+ throw new Error(`config: web.ports lists port ${port} more than once`);
522
+ }
523
+ seenPorts.add(port);
524
+ if (
525
+ typeof entry.host !== "string" ||
526
+ !/^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$/.test(entry.host)
527
+ ) {
528
+ throw new Error(
529
+ `config: web.ports entry for port ${port} has an invalid host (lowercase a-z/0-9/-/.): ${JSON.stringify(entry.host)}`
530
+ );
531
+ }
532
+ if (seenHosts.has(entry.host)) {
533
+ throw new Error(`config: web.ports entries share the host "${entry.host}"`);
534
+ }
535
+ seenHosts.add(entry.host);
536
+ if (
537
+ entry.name !== undefined &&
538
+ (!/^[a-z]([-a-z0-9]*[a-z0-9])?$/.test(entry.name) || entry.name.length > 15)
539
+ ) {
540
+ throw new Error(
541
+ `config: web port name must start with a letter, use a-z/0-9/- and be at most 15 chars: ${entry.name}`
542
+ );
543
+ }
544
+ if (entry.nodePort !== undefined) {
545
+ if (
546
+ !Number.isInteger(entry.nodePort) ||
547
+ entry.nodePort < 30000 ||
548
+ entry.nodePort > 32767
549
+ ) {
550
+ throw new Error(
551
+ `config: web.ports entry for port ${port} nodePort must be an integer in 30000-32767: ${JSON.stringify(entry.nodePort)}`
552
+ );
553
+ }
554
+ if (entry.nodePort === cfg.k8s.nodePort) {
555
+ throw new Error(
556
+ `config: web.ports entry for port ${port} nodePort ${entry.nodePort} collides with the SSH NodePort (k8s.nodePort)`
557
+ );
558
+ }
559
+ if (seenNodePorts.has(entry.nodePort)) {
560
+ throw new Error(
561
+ `config: web.ports entry for port ${port} nodePort ${entry.nodePort} collides with another web.ports or sidecar port nodePort`
562
+ );
563
+ }
564
+ seenNodePorts.add(entry.nodePort);
565
+ }
566
+ }
567
+ }
568
+ if (cfg.ingress?.className !== undefined && typeof cfg.ingress.className !== "string") {
569
+ throw new Error("config: ingress.className must be a string");
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Full image reference. The tag is required (no "latest" fallback): it comes
575
+ * from the project pin/--tag or, in global mode, from the coding-system
576
+ * ConfigMap via resolveDeployTag().
577
+ */
578
+ export function imageRef(cfg) {
579
+ if (!cfg.image.tag) {
580
+ throw new Error(
581
+ "image tag is required: set image.tag (or --tag), or in global mode let " +
582
+ "deploy float on the coding-system ConfigMap (see 'coding-container system get')"
583
+ );
584
+ }
585
+ return `${cfg.image.registry}/${cfg.image.name}:${cfg.image.tag}`;
586
+ }