@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.
@@ -0,0 +1,326 @@
1
+ import crypto from "node:crypto";
2
+ import yaml from "js-yaml";
3
+ import { imageRef } from "../config.js";
4
+
5
+ function nfsBasePath(cfg) {
6
+ return cfg.nfs.basePath.replace(/\/+$/, "");
7
+ }
8
+
9
+ /**
10
+ * NAS subfolder under basePath that backs /workspace: nfs.subPath when set,
11
+ * else the project name.
12
+ */
13
+ export function workspaceSubPath(cfg) {
14
+ return cfg.nfs.subPath || cfg.project;
15
+ }
16
+
17
+ export function configMapManifest(cfg, publicKey) {
18
+ return {
19
+ apiVersion: "v1",
20
+ kind: "ConfigMap",
21
+ metadata: {
22
+ name: `${cfg.project}-ssh`,
23
+ namespace: cfg.k8s.namespace,
24
+ labels: { app: cfg.project, "coding-container": "true" },
25
+ },
26
+ data: {
27
+ authorized_keys: publicKey,
28
+ },
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Secret-backed env (k8s.secretEnv) lives in a managed `<project>-env`
34
+ * Secret. Normalized entries (sorted by name for a stable hash/render).
35
+ */
36
+ export function secretEnvName(cfg) {
37
+ return `${cfg.project}-env`;
38
+ }
39
+
40
+ export function secretEnvEntries(cfg) {
41
+ return Object.entries(cfg.k8s?.secretEnv ?? {})
42
+ .filter(([, value]) => value != null)
43
+ .map(([name, value]) => [name, String(value)])
44
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
45
+ }
46
+
47
+ /**
48
+ * Short content hash of the secret data. Stamped onto the pod template so a
49
+ * rotated value changes the Deployment spec and rolls the pods on the next
50
+ * deploy (a Secret edit alone would not restart anything).
51
+ */
52
+ export function secretsHash(cfg) {
53
+ const entries = secretEnvEntries(cfg);
54
+ if (!entries.length) return null;
55
+ return crypto
56
+ .createHash("sha256")
57
+ .update(entries.map(([name, value]) => `${name}=${value}`).join("\n"))
58
+ .digest("hex")
59
+ .slice(0, 16);
60
+ }
61
+
62
+ export function secretManifest(cfg, { redact = false } = {}) {
63
+ const entries = secretEnvEntries(cfg);
64
+ if (!entries.length) return null;
65
+ return {
66
+ apiVersion: "v1",
67
+ kind: "Secret",
68
+ metadata: {
69
+ name: secretEnvName(cfg),
70
+ namespace: cfg.k8s.namespace,
71
+ labels: { app: cfg.project, "coding-container": "true" },
72
+ },
73
+ type: "Opaque",
74
+ stringData: Object.fromEntries(
75
+ entries.map(([name, value]) => [name, redact ? "***REDACTED***" : value])
76
+ ),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Normalized web.ports entries: {name, port, host, nodePort?}. Names default
82
+ * to web-<port>; nodePort (30000-32767) is an optional service-level knob
83
+ * that also exposes the port on every cluster node.
84
+ */
85
+ export function webPorts(cfg) {
86
+ return (cfg.web?.ports || []).map((entry) => ({
87
+ name: entry.name || `web-${entry.port}`,
88
+ port: entry.port,
89
+ host: entry.host,
90
+ ...(entry.nodePort !== undefined ? { nodePort: entry.nodePort } : {}),
91
+ }));
92
+ }
93
+
94
+ /**
95
+ * nfs.volumes entries as volumeMounts on the shared `workspace` NFS volume.
96
+ * Each entry mounts a subfolder of nfs.basePath (subPath) at its own
97
+ * absolute container path; readOnly defaults to false.
98
+ */
99
+ export function extraVolumeMounts(cfg) {
100
+ return (cfg.nfs?.volumes || []).map((entry) => ({
101
+ name: "workspace",
102
+ mountPath: entry.mountPath,
103
+ subPath: entry.subPath,
104
+ ...(entry.readOnly ? { readOnly: true } : {}),
105
+ }));
106
+ }
107
+
108
+ /**
109
+ * k8s.sidecars entries as container specs. Near pass-through: every field
110
+ * (ports, command, args, volumeMounts, resources, probes, ...) is copied
111
+ * verbatim; 'env' additionally accepts a plain map (converted to a k8s env
112
+ * list) to match the k8s.env convention.
113
+ */
114
+ export function sidecarContainers(cfg) {
115
+ return (cfg.k8s.sidecars || []).map((entry) => {
116
+ const container = structuredClone(entry);
117
+ if (container.env !== undefined && !Array.isArray(container.env)) {
118
+ container.env = Object.entries(container.env).map(([name, value]) => ({
119
+ name,
120
+ value: String(value),
121
+ }));
122
+ }
123
+ // A port's optional 'nodePort' is a service-level knob: strip it so the
124
+ // rendered ContainerPort stays a clean k8s spec.
125
+ if (container.ports) {
126
+ for (const port of container.ports) delete port.nodePort;
127
+ }
128
+ return container;
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Sidecar ports exposed on the cluster nodes: k8s.sidecars port entries with
134
+ * a 'nodePort' set. Each renders a service port shaped like the ssh one
135
+ * (port/targetPort = container port, nodePort = the configured NodePort).
136
+ */
137
+ export function sidecarNodePorts(cfg) {
138
+ return (cfg.k8s.sidecars || []).flatMap((entry) =>
139
+ (entry.ports || [])
140
+ .filter((port) => port.nodePort !== undefined)
141
+ .map((port) => ({
142
+ name: port.name || `nodeport-${port.containerPort}`,
143
+ port: port.containerPort,
144
+ targetPort: port.containerPort,
145
+ protocol: port.protocol || "TCP",
146
+ nodePort: port.nodePort,
147
+ }))
148
+ );
149
+ }
150
+
151
+ export function deploymentManifest(cfg, pullSecret = null) {
152
+ const resources = cfg.k8s.resources || {};
153
+ const container = {
154
+ name: "coding",
155
+ image: imageRef(cfg),
156
+ // Always re-check the registry on pod start: with an unpinned (latest)
157
+ // tag, IfNotPresent makes nodes silently serve a stale cached image.
158
+ imagePullPolicy: "Always",
159
+ ports: [
160
+ { name: "ssh", containerPort: cfg.ssh.port, protocol: "TCP" },
161
+ ...webPorts(cfg).map((p) => ({ name: p.name, containerPort: p.port, protocol: "TCP" })),
162
+ ],
163
+ volumeMounts: [
164
+ {
165
+ name: "workspace",
166
+ mountPath: cfg.container.workdir,
167
+ subPath: workspaceSubPath(cfg),
168
+ },
169
+ {
170
+ name: "ssh-keys",
171
+ mountPath: "/root/.ssh/authorized_keys",
172
+ subPath: "authorized_keys",
173
+ },
174
+ ...extraVolumeMounts(cfg),
175
+ ],
176
+ };
177
+ if (cfg.k8s.env && Object.keys(cfg.k8s.env).length) {
178
+ container.env = Object.entries(cfg.k8s.env)
179
+ .filter(([, value]) => value != null)
180
+ .map(([name, value]) => ({
181
+ name,
182
+ value: String(value),
183
+ }));
184
+ }
185
+ // Secret-backed env renders after plain env, so on a user-created name
186
+ // collision the secret reference wins (collision is the user's problem:
187
+ // don't use the same NAME in both).
188
+ const secretEntries = secretEnvEntries(cfg);
189
+ if (secretEntries.length) {
190
+ container.env = [
191
+ ...(container.env ?? []),
192
+ ...secretEntries.map(([name]) => ({
193
+ name,
194
+ valueFrom: { secretKeyRef: { name: secretEnvName(cfg), key: name } },
195
+ })),
196
+ ];
197
+ }
198
+ if (Object.keys(resources).length) container.resources = resources;
199
+
200
+ const hash = secretsHash(cfg);
201
+
202
+ return {
203
+ apiVersion: "apps/v1",
204
+ kind: "Deployment",
205
+ metadata: {
206
+ name: cfg.project,
207
+ namespace: cfg.k8s.namespace,
208
+ labels: { app: cfg.project, "coding-container": "true" },
209
+ },
210
+ spec: {
211
+ replicas: cfg.k8s.replicas,
212
+ selector: { matchLabels: { app: cfg.project } },
213
+ template: {
214
+ metadata: {
215
+ labels: { app: cfg.project, "coding-container": "true" },
216
+ ...(hash ? { annotations: { "coding-container-secrets-hash": hash } } : {}),
217
+ },
218
+ spec: {
219
+ ...(pullSecret ? { imagePullSecrets: [{ name: pullSecret }] } : {}),
220
+ containers: [container, ...sidecarContainers(cfg)],
221
+ volumes: [
222
+ {
223
+ name: "workspace",
224
+ nfs: { server: cfg.nfs.server, path: nfsBasePath(cfg) },
225
+ },
226
+ {
227
+ name: "ssh-keys",
228
+ configMap: { name: `${cfg.project}-ssh` },
229
+ },
230
+ ],
231
+ },
232
+ },
233
+ },
234
+ };
235
+ }
236
+
237
+ export function serviceManifest(cfg) {
238
+ const ports = [
239
+ {
240
+ name: "ssh",
241
+ port: cfg.ssh.port,
242
+ targetPort: cfg.ssh.port,
243
+ protocol: "TCP",
244
+ ...(cfg.k8s.serviceType === "NodePort" ? { nodePort: cfg.k8s.nodePort } : {}),
245
+ },
246
+ // Web ports are cluster ports for the Ingress (Traefik); an entry with a
247
+ // nodePort is additionally reachable on every cluster node.
248
+ ...webPorts(cfg).map((p) => ({
249
+ name: p.name,
250
+ port: p.port,
251
+ targetPort: p.port,
252
+ protocol: "TCP",
253
+ ...(p.nodePort !== undefined ? { nodePort: p.nodePort } : {}),
254
+ })),
255
+ // Sidecar ports marked with a nodePort (databases and other TCP services
256
+ // that Traefik's HTTP Ingress can't route) become NodePorts like ssh.
257
+ ...sidecarNodePorts(cfg),
258
+ ];
259
+ return {
260
+ apiVersion: "v1",
261
+ kind: "Service",
262
+ metadata: {
263
+ name: cfg.project,
264
+ namespace: cfg.k8s.namespace,
265
+ labels: { app: cfg.project, "coding-container": "true" },
266
+ },
267
+ spec: {
268
+ type: cfg.k8s.serviceType,
269
+ selector: { app: cfg.project },
270
+ ports,
271
+ },
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Ingress with one rule per web port: the entry's host routes to that
277
+ * container port on the project's service. Only rendered when web.ports is
278
+ * non-empty.
279
+ */
280
+ export function ingressManifest(cfg) {
281
+ return {
282
+ apiVersion: "networking.k8s.io/v1",
283
+ kind: "Ingress",
284
+ metadata: {
285
+ name: cfg.project,
286
+ namespace: cfg.k8s.namespace,
287
+ labels: { app: cfg.project, "coding-container": "true" },
288
+ },
289
+ spec: {
290
+ ingressClassName: cfg.ingress.className,
291
+ rules: webPorts(cfg).map((p) => ({
292
+ host: p.host,
293
+ http: {
294
+ paths: [
295
+ {
296
+ path: "/",
297
+ pathType: "Prefix",
298
+ backend: { service: { name: cfg.project, port: { number: p.port } } },
299
+ },
300
+ ],
301
+ },
302
+ })),
303
+ },
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Full multi-document manifest as a string, ready for `kubectl apply -f -`.
309
+ * The managed env Secret is rendered between the ssh ConfigMap and the
310
+ * Deployment (absent when k8s.secretEnv is empty). With redactSecrets the
311
+ * Secret's values print as ***REDACTED*** (for `validate --manifest`, which
312
+ * must never leak secrets to terminal/logs); deploy always uses real values.
313
+ */
314
+ export function fullManifest(cfg, publicKey, pullSecret = null, opts = {}) {
315
+ const secret = secretManifest(cfg, { redact: Boolean(opts.redactSecrets) });
316
+ const docs = [
317
+ configMapManifest(cfg, publicKey),
318
+ ...(secret ? [secret] : []),
319
+ deploymentManifest(cfg, pullSecret),
320
+ serviceManifest(cfg),
321
+ ];
322
+ if (webPorts(cfg).length) docs.push(ingressManifest(cfg));
323
+ return docs
324
+ .map((doc) => yaml.dump(doc, { lineWidth: -1, noRefs: true }))
325
+ .join("---\n");
326
+ }
@@ -0,0 +1,106 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { exec } from "./exec.js";
4
+ import { kubectlExec } from "./system-config.js";
5
+ import { DEFAULTS } from "../config.js";
6
+
7
+ const NODEPORT_MIN = 30000;
8
+ const NODEPORT_MAX = 32767;
9
+
10
+ /**
11
+ * Derive a valid k8s project name (DNS-1123 label) from a folder name.
12
+ */
13
+ export function deriveProjectName(dir) {
14
+ let name = path
15
+ .basename(dir)
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9-]+/g, "-")
18
+ .replace(/-+/g, "-")
19
+ .replace(/^-+|-+$/g, "");
20
+ if (!name) name = "project";
21
+ if (name.length > 63) name = name.slice(0, 63).replace(/-+$/g, "");
22
+ return name;
23
+ }
24
+
25
+ function usedNodePorts() {
26
+ const out = exec("kubectl", [
27
+ "get",
28
+ "svc",
29
+ "-A",
30
+ "-o",
31
+ "jsonpath={range .items[*]}{.spec.ports[*].nodePort}{' '}{end}",
32
+ ]);
33
+ return new Set(
34
+ out
35
+ .split(/\s+/)
36
+ .filter((t) => /^\d+$/.test(t))
37
+ .map(Number)
38
+ );
39
+ }
40
+
41
+ /**
42
+ * The service that holds a NodePort, or null when the port is free.
43
+ * Scans every namespace: NodePorts are allocated cluster-wide.
44
+ */
45
+ export function findNodePortOwner(port) {
46
+ const { cmd, args, opts } = kubectlExec();
47
+ // -o json (no jsonpath) keeps the argument free of quotes a shell could eat.
48
+ const out = exec(cmd, [...args, "get", "svc", "-A", "-o", "json"], opts);
49
+ const { items = [] } = JSON.parse(out);
50
+ for (const svc of items) {
51
+ const nodePorts = (svc.spec?.ports || []).map((p) => p.nodePort).filter(Boolean);
52
+ if (nodePorts.includes(port)) {
53
+ return { namespace: svc.metadata.namespace, name: svc.metadata.name };
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+
59
+ /**
60
+ * First free NodePort starting at startPort. If the cluster is unreachable,
61
+ * falls back to startPort (the deploy step will surface the real error).
62
+ */
63
+ export function allocateNodePort(startPort = DEFAULTS.k8s.nodePort) {
64
+ let used;
65
+ try {
66
+ used = usedNodePorts();
67
+ } catch {
68
+ return startPort;
69
+ }
70
+ for (let port = Math.max(startPort, NODEPORT_MIN); port <= NODEPORT_MAX; port++) {
71
+ if (!used.has(port)) return port;
72
+ }
73
+ throw new Error(`No free NodePort in ${NODEPORT_MIN}-${NODEPORT_MAX}`);
74
+ }
75
+
76
+ /**
77
+ * Create a minimal config.yaml in dir (project name from the folder or an
78
+ * explicit override, first free NodePort) unless one already exists.
79
+ * Returns { configPath, created, project, nodePort }.
80
+ */
81
+ export function ensureProjectConfig(dir, explicitProject) {
82
+ const configPath = path.join(dir, "config.yaml");
83
+ if (fs.existsSync(configPath)) {
84
+ return { configPath, created: false };
85
+ }
86
+ fs.mkdirSync(dir, { recursive: true });
87
+ const project = explicitProject || deriveProjectName(dir);
88
+ const nodePort = allocateNodePort();
89
+ fs.writeFileSync(
90
+ configPath,
91
+ `# coding-container project config (auto-created by 'coding-container create')
92
+ # Shared settings come from the global config at ~/.config/coding-container/config.yaml.
93
+ project: ${project}
94
+
95
+ k8s:
96
+ nodePort: ${nodePort}
97
+
98
+ # Expose container ports to the browser through Traefik (one Ingress host
99
+ # per port). DNS for the hosts must resolve to your Traefik LoadBalancer.
100
+ # web:
101
+ # ports:
102
+ # - { port: 3000, host: my-web.example.com }
103
+ `
104
+ );
105
+ return { configPath, created: true, project, nodePort };
106
+ }