@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/Dockerfile +126 -0
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/config.yaml +27 -0
- package/package.json +39 -0
- package/src/commands/build.js +35 -0
- package/src/commands/create.js +38 -0
- package/src/commands/delete.js +24 -0
- package/src/commands/deploy.js +138 -0
- package/src/commands/gc.js +160 -0
- package/src/commands/push.js +36 -0
- package/src/commands/setup.js +190 -0
- package/src/commands/ssh.js +103 -0
- package/src/commands/system.js +220 -0
- package/src/commands/validate.js +86 -0
- package/src/config.js +586 -0
- package/src/index.js +281 -0
- package/src/utils/envsubst.js +39 -0
- package/src/utils/exec.js +71 -0
- package/src/utils/keys.js +30 -0
- package/src/utils/manifest.js +326 -0
- package/src/utils/project.js +106 -0
- package/src/utils/registry-api.js +308 -0
- package/src/utils/registry.js +204 -0
- package/src/utils/semver.js +42 -0
- package/src/utils/ssh-config.js +83 -0
- package/src/utils/system-config.js +162 -0
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
|
+
}
|