@esuyo/coding-container 1.0.2 → 1.0.3

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/README.md CHANGED
@@ -1,20 +1,26 @@
1
1
  # Esuyo coding-container
2
2
 
3
- > Spin up a ready-to-code SSH container on your k3s cluster in one command.
3
+ Spin up a ready-to-code SSH container on your Kubernetes cluster in one command.
4
4
 
5
- `coding-container` gives every project its own consistent coding environment. One command deploys a container with Ubuntu 26.04, Node.js 22, [opencode](https://opencode.ai), [pi](https://pi.dev), [herdr](https://herdr.dev), turbo, Playwright, and common dev tools (plus pnpm/yarn via corepack and a Postgres client) to your k3s cluster. You SSH in and land in `/workspace` — a folder backed by your NAS, so your work survives restarts and stays separate per project.
5
+ `coding-container` gives every project its own consistent coding environment on Kubernetes.
6
+ One command deploys an SSH-accessible container per project, you land in `/workspace`, backed by your NAS so work survives restarts.
7
+ The default image is just a starting point (Ubuntu, Node.js, and common dev tools); the Dockerfile is yours to customize with any language or toolchain, plus optional sidecars for databases and queues.
8
+
9
+ For private, internal Kubernetes clusters only. Do not expose to the public web. Every project opens NodePorts on all cluster nodes (SSH plus optional web/sidecar ports) and serves Ingress hosts without authentication or TLS by default. Only run this on a trusted internal network (homelab, LAN, or VPN-gated cluster) behind a firewall. See [Architecture](./docs/architecture.md#security-notes) for details.
6
10
 
7
11
  ## Features
8
12
 
9
13
  - One command to deploy a ready-to-code container (`create`), SSH in (`ssh`), and tear down (`delete`)
10
- - Persistent per-project workspace on your NAS — files survive restarts
14
+ - Kubernetes-native: renders Deployment, Service, Ingress, ConfigMap, and Secret manifests and applies them with `kubectl`
15
+ - Persistent per-project workspace on your NAS, so files survive restarts
11
16
  - Generates a dedicated SSH key; VS Code Remote-SSH ready
12
17
  - Optional browser access to dev servers and sidecars for databases and queues
13
- - Shared team image with versioning, or a custom image per project
18
+ - Shared team image with versioning, or a custom image per project. Bring your own Dockerfile for any language or toolchain
14
19
 
15
20
  ## Prerequisites
16
21
 
17
- - Node.js >= 18, Docker, `kubectl` pointed at your k3s cluster
22
+ - A private, internal Kubernetes cluster (any CNCF-conformant distribution) with `kubectl` access. Do not use on a publicly exposed cluster
23
+ - Node.js >= 18, Docker, `kubectl` on `PATH`
18
24
  - `docker login` to your container registry
19
25
  - OpenSSH (`ssh`, `ssh-keygen`)
20
26
  - Your NAS reachable from the cluster nodes
@@ -58,38 +64,39 @@ See [Configuration](./docs/configuration.md) for layering, multiple projects, an
58
64
 
59
65
  ```sh
60
66
  coding-container create # deploy this project
61
- coding-container ssh # connect — you land in /workspace
67
+ coding-container ssh # connect, you land in /workspace
62
68
  coding-container delete # remove cluster resources (files stay safe on the NAS)
63
69
  ```
64
70
 
65
- Working in another folder? `cd` there and run `create` — a minimal `config.yaml` is scaffolded automatically. Each project gets its own container and workspace.
71
+ Working in another folder? `cd` there and run `create`: a minimal `config.yaml` is scaffolded automatically. Each project gets its own container and workspace.
66
72
 
67
- For everything else — web URLs, databases, image versions, all commands and flags — see [CLI reference](./docs/cli-reference.md) and the guides below.
73
+ For everything else (web URLs, databases, image versions, all commands and flags), see [CLI reference](./docs/cli-reference.md) and the guides below.
68
74
 
69
75
  ## Troubleshooting
70
76
 
71
77
  - **Missing registry or NAS settings?** Run `coding-container setup`.
72
78
  - **Push auth error?** Run `docker login <your-registry>` first.
73
- - **Pod stuck?** Usually NFS or image pull — see [Troubleshooting](./docs/troubleshooting.md).
79
+ - **Pod stuck?** Usually NFS or image pull. See [Troubleshooting](./docs/troubleshooting.md).
74
80
 
75
81
  ## License
76
82
 
77
- MIT — see [LICENSE](./LICENSE).
83
+ MIT. See [LICENSE](./LICENSE).
78
84
 
79
85
  ## Developer Documentation
80
86
 
81
- - [Configuration](./docs/configuration.md) — Config layering, multiple projects, NAS volumes
82
- - [Shared global image](./docs/global-image.md) — Team image versions, publishing, rollback, cleanup
83
- - [Web access](./docs/web-access.md) — Browser access to dev servers via Traefik
84
- - [Sidecars](./docs/sidecars.md) — Databases, queues, and other pod companions
85
- - [CLI reference](./docs/cli-reference.md) — All commands and flags
86
- - [SSH and VS Code](./docs/ssh-and-vscode.md) — Key rotation and Remote-SSH setup
87
- - [Troubleshooting](./docs/troubleshooting.md) — Common failures and fixes
88
- - [Architecture](./docs/architecture.md) — Tech stack and system design
89
- - [Project Structure](./docs/project-structure.md) — Folder layout
90
- - [Development Guide](./docs/development.md) — Local setup for contributors
91
- - [App config & setup files](./docs/config-files.md) — Persisting tool configs via /workspace
92
- - [Plan: Additional NFS volumes](./docs/build/additional-volumes.md) — Design history for extra volumes
93
- - [Plan: Public prebuilt images](./docs/build/public-images.md) — Design history for public images
87
+ - [Configuration](./docs/configuration.md): config layering, multiple projects, NAS volumes
88
+ - [Shared global image](./docs/global-image.md): team image versions, publishing, rollback, cleanup
89
+ - [Web access](./docs/web-access.md): browser access to dev servers via Traefik
90
+ - [Sidecars](./docs/sidecars.md): databases, queues, and other pod companions
91
+ - [CLI reference](./docs/cli-reference.md): all commands and flags
92
+ - [SSH and VS Code](./docs/ssh-and-vscode.md): key rotation and Remote-SSH setup
93
+ - [SSH image paste](./docs/ssh-image-paste.md): clipboard images into opencode over SSH
94
+ - [Troubleshooting](./docs/troubleshooting.md): common failures and fixes
95
+ - [Architecture](./docs/architecture.md): tech stack and system design
96
+ - [Project Structure](./docs/project-structure.md): folder layout
97
+ - [Development Guide](./docs/development.md): local setup for contributors
98
+ - [App config & setup files](./docs/config-files.md): persisting tool configs via /workspace
99
+ - [Plan: Additional NFS volumes](./docs/build/additional-volumes.md): design history for extra volumes
100
+ - [Plan: Public prebuilt images](./docs/build/public-images.md): design history for public images
94
101
 
95
102
  ---
package/config.yaml CHANGED
@@ -9,14 +9,15 @@ project: my-project
9
9
  k8s:
10
10
  nodePort: 30022
11
11
 
12
- # Expose container ports to the browser through Traefik: one Ingress host per
13
- # port, each routing to that port on this project's service. The hosts must
14
- # resolve (DNS) to your Traefik LoadBalancer IP. An optional nodePort
15
- # (30000-32767) additionally reaches the port on every cluster node at
16
- # http://<node-ip>:<nodePort>/ (unique across ssh, web.ports, sidecars).
12
+ # Expose container ports to the browser through Traefik: one Ingress rule per
13
+ # host ('hosts' list, or a single 'host'), each routing to that port on this
14
+ # project's service. The hosts must resolve (DNS) to your Traefik LoadBalancer
15
+ # IP. An optional nodePort (30000-32767) additionally reaches the port on every
16
+ # cluster node at http://<node-ip>:<nodePort>/ (unique across ssh, web.ports,
17
+ # sidecars).
17
18
  # web:
18
19
  # ports:
19
- # - { port: 3000, host: my-web.example.com, nodePort: 30080 }
20
+ # - { port: 3000, hosts: [my-web.example.com, www.example.com], nodePort: 30080 }
20
21
  # - { port: 8080, host: my-api.example.com, name: api }
21
22
 
22
23
  # Extra NAS folders mounted into the container alongside /workspace. Each
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esuyo/coding-container",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "CLI to build, publish and deploy SSH-accessible coding containers on k3s",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import { dockerExec, run } from "../utils/exec.js";
2
3
  import { imageRef } from "../config.js";
3
4
 
@@ -7,6 +8,18 @@ import { imageRef } from "../config.js";
7
8
  * global image).
8
9
  */
9
10
  export async function buildImage(cfg) {
11
+ if (!cfg.image.dockerfilePath || !fs.existsSync(cfg.image.dockerfilePath)) {
12
+ throw new Error(
13
+ `config: dockerfile not found: ${cfg.image.dockerfilePath ?? "(unset)"}. ` +
14
+ `Run 'coding-container setup' to provision one, or fix image.dockerfile in your config.`
15
+ );
16
+ }
17
+ if (!cfg.image.contextPath || !fs.existsSync(cfg.image.contextPath)) {
18
+ throw new Error(
19
+ `config: build context not found: ${cfg.image.contextPath ?? "(unset)"}. ` +
20
+ `Fix image.context in your config.`
21
+ );
22
+ }
10
23
  const ref = imageRef(cfg);
11
24
  const args = ["build", "-t", ref, "-f", cfg.image.dockerfilePath];
12
25
  for (const [key, value] of Object.entries(cfg.image.buildArgs || {})) {
@@ -1,5 +1,5 @@
1
1
  import { run } from "../utils/exec.js";
2
- import { removeHostEntry, sshAlias, sshConfigPath } from "../utils/ssh-config.js";
2
+ import { removeHostEntry, removeLegacyKnownHostsFile, sshAlias, sshConfigPath } from "../utils/ssh-config.js";
3
3
 
4
4
  export async function deleteProject(cfg, { keepSecrets = false } = {}) {
5
5
  const ns = cfg.k8s.namespace;
@@ -21,4 +21,5 @@ export async function deleteProject(cfg, { keepSecrets = false } = {}) {
21
21
  } catch (err) {
22
22
  console.warn(`Warning: could not update SSH config: ${err.message}`);
23
23
  }
24
+ removeLegacyKnownHostsFile(cfg.ssh.keyDirPath, cfg.project);
24
25
  }
@@ -4,7 +4,7 @@ import { ensureKeyPair, readPublicKey } from "../utils/keys.js";
4
4
  import { ensurePullSecret } from "../utils/registry.js";
5
5
  import { fullManifest, webPorts, sidecarNodePorts as sidecarNodePortsList } from "../utils/manifest.js";
6
6
  import { imageRef } from "../config.js";
7
- import { addHostEntry, sshAlias, sshConfigPath } from "../utils/ssh-config.js";
7
+ import { addHostEntry, ephemeralKnownHostsFile, removeLegacyKnownHostsFile, sshAlias, sshConfigPath } from "../utils/ssh-config.js";
8
8
  import { findNodePortOwner } from "../utils/project.js";
9
9
  import { kubectlExec, resolveDeployTag, SYSTEM_CM_NAME } from "../utils/system-config.js";
10
10
 
@@ -97,7 +97,9 @@ export async function deploy(cfg, { wait = true } = {}) {
97
97
  if (web.length) {
98
98
  console.log("Web (Traefik Ingress):");
99
99
  for (const p of web) {
100
- console.log(` http://${p.host}/ (container port ${p.port})`);
100
+ for (const host of p.hosts) {
101
+ console.log(` http://${host}/ (container port ${p.port})`);
102
+ }
101
103
  }
102
104
  const webNodePorts = web.filter((p) => p.nodePort !== undefined);
103
105
  if (webNodePorts.length) {
@@ -117,6 +119,8 @@ export async function deploy(cfg, { wait = true } = {}) {
117
119
  }
118
120
 
119
121
  // Register an SSH alias for the container (VS Code Remote-SSH / ssh).
122
+ // Host keys are ephemeral, so the alias points at the OS null device and
123
+ // any legacy per-project known_hosts file is removed.
120
124
  if (nodeIp) {
121
125
  try {
122
126
  const alias = sshAlias(cfg.project);
@@ -126,8 +130,11 @@ export async function deploy(cfg, { wait = true } = {}) {
126
130
  port: cfg.k8s.nodePort,
127
131
  user: cfg.ssh.user,
128
132
  identityFile: path.join(cfg.ssh.keyDirPath, cfg.ssh.keyName),
129
- knownHostsFile: path.join(cfg.ssh.keyDirPath, `${cfg.project}-known_hosts`),
133
+ knownHostsFile: ephemeralKnownHostsFile(),
130
134
  });
135
+ if (removeLegacyKnownHostsFile(cfg.ssh.keyDirPath, cfg.project)) {
136
+ console.log(`Removed stale known_hosts for "${cfg.project}"`);
137
+ }
131
138
  console.log(`SSH alias "${alias}" written to ${sshConfigPath()} (connect: ssh ${alias})`);
132
139
  } catch (err) {
133
140
  console.warn(`Warning: could not update SSH config: ${err.message}`);
@@ -0,0 +1,252 @@
1
+ import {
2
+ addHostEntry,
3
+ ephemeralKnownHostsFile,
4
+ listHostEntries,
5
+ sshConfigPath,
6
+ } from "../utils/ssh-config.js";
7
+ import { exec } from "../utils/exec.js";
8
+ import { kubectlExec } from "../utils/system-config.js";
9
+
10
+ /**
11
+ * Ordering for the `list` table: by host, then port, then alias.
12
+ * IPv4 hosts compare octet-by-octet so 10.0.0.7 sorts before 10.0.0.19;
13
+ * anything else falls back to a string comparison. Missing values sort last.
14
+ */
15
+ function compareIpv4(a, b) {
16
+ const pa = a.split(".").map(Number);
17
+ const pb = b.split(".").map(Number);
18
+ for (let i = 0; i < 4; i++) {
19
+ if (pa[i] !== pb[i]) return pa[i] - pb[i];
20
+ }
21
+ return 0;
22
+ }
23
+
24
+ const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
25
+
26
+ function compareHost(a, b) {
27
+ if (a === undefined && b === undefined) return 0;
28
+ if (a === undefined) return 1;
29
+ if (b === undefined) return -1;
30
+ if (IPV4.test(a) && IPV4.test(b)) {
31
+ const diff = compareIpv4(a, b);
32
+ if (diff !== 0) return diff;
33
+ } else {
34
+ const diff = a.localeCompare(b);
35
+ if (diff !== 0) return diff;
36
+ }
37
+ return 0;
38
+ }
39
+
40
+ function comparePort(a, b) {
41
+ if (a === undefined && b === undefined) return 0;
42
+ if (a === undefined) return 1;
43
+ if (b === undefined) return -1;
44
+ const na = Number(a);
45
+ const nb = Number(b);
46
+ if (Number.isInteger(na) && Number.isInteger(nb) && na !== nb) return na - nb;
47
+ if (a !== b) return String(a).localeCompare(String(b));
48
+ return 0;
49
+ }
50
+
51
+ function compareByHostPort(a, b) {
52
+ return compareHost(a.hostName, b.hostName) || comparePort(a.port, b.port) || a.alias.localeCompare(b.alias);
53
+ }
54
+
55
+ /**
56
+ * List the SSH connections the CLI manages (the `coding-<project>` aliases
57
+ * `deploy`/`create` write to ~/.ssh/config), ordered by host then port.
58
+ * Read-only: never touches the cluster, the config.yaml layers, or the SSH
59
+ * config file itself.
60
+ */
61
+ export function listSsh({ file } = {}) {
62
+ const sshConfig = file ?? sshConfigPath();
63
+ const entries = listHostEntries(sshConfig).sort(compareByHostPort);
64
+ if (entries.length === 0) {
65
+ console.log(`No coding-container SSH connections found in ${sshConfig}.`);
66
+ console.log("Deploy a project to register one: coding-container create");
67
+ return;
68
+ }
69
+ const rows = entries.map((e) => ({
70
+ alias: e.alias,
71
+ host: e.hostName ?? "-",
72
+ port: e.port ?? "-",
73
+ user: e.user ?? "-",
74
+ identityFile: e.identityFile ?? "-",
75
+ }));
76
+ const widths = {
77
+ alias: Math.max("ALIAS".length, ...rows.map((r) => r.alias.length)),
78
+ host: Math.max("HOST".length, ...rows.map((r) => r.host.length)),
79
+ port: Math.max("PORT".length, ...rows.map((r) => r.port.length)),
80
+ user: Math.max("USER".length, ...rows.map((r) => r.user.length)),
81
+ };
82
+ const pad = (s, w) => s.padEnd(w, " ");
83
+ console.log(
84
+ `${pad("ALIAS", widths.alias)} ${pad("HOST", widths.host)} ${pad("PORT", widths.port)} ${pad("USER", widths.user)} IDENTITYFILE`
85
+ );
86
+ for (const r of rows) {
87
+ console.log(
88
+ `${pad(r.alias, widths.alias)} ${pad(r.host, widths.host)} ${pad(r.port, widths.port)} ${pad(r.user, widths.user)} ${r.identityFile}`
89
+ );
90
+ }
91
+ console.log(`\n${entries.length} connection(s) from ${sshConfig} (connect: ssh <alias>)`);
92
+ }
93
+
94
+ /**
95
+ * Pick the IP of a healthy cluster node. A NodePort service is reachable
96
+ * through *any* node, so when the cluster grows (or a node is replaced and
97
+ * its IP changes) the stored HostName just needs to point at a node that is
98
+ * currently Ready — it does not need to be the node the pod runs on.
99
+ */
100
+ function getReadyNodeIp() {
101
+ const { cmd, args, opts } = kubectlExec();
102
+ let out;
103
+ try {
104
+ out = exec(cmd, [...args, "get", "nodes", "-o", "json"], opts);
105
+ } catch (err) {
106
+ throw new Error(`cannot list cluster nodes:\n${err.message}`);
107
+ }
108
+ let nodes;
109
+ try {
110
+ nodes = JSON.parse(out);
111
+ } catch (err) {
112
+ throw new Error(`cannot parse 'kubectl get nodes' output: ${err.message}`);
113
+ }
114
+ const items = nodes.items ?? [];
115
+ const addressOf = (node) => {
116
+ const addrs = node.status?.addresses ?? [];
117
+ return (
118
+ addrs.find((a) => a.type === "InternalIP")?.address ??
119
+ addrs.find((a) => a.type === "ExternalIP")?.address
120
+ );
121
+ };
122
+ const ready = items.filter((n) =>
123
+ (n.status?.conditions ?? []).some((c) => c.type === "Ready" && c.status === "True")
124
+ );
125
+ // No Ready conditions at all (e.g. a stubbed/apiserver-less response):
126
+ // fall back to any node that has an address.
127
+ const candidates = ready.length ? ready : items;
128
+ for (const node of candidates) {
129
+ const ip = addressOf(node);
130
+ if (ip) return ip;
131
+ }
132
+ throw new Error("no cluster node with an InternalIP/ExternalIP address found");
133
+ }
134
+
135
+ /**
136
+ * All services in the cluster, indexed by service name. An alias
137
+ * `coding-<project>` maps to the service `<project>` (in whatever namespace
138
+ * the project was deployed to), so the index is searched across namespaces.
139
+ */
140
+ function indexServicesByName() {
141
+ const { cmd, args, opts } = kubectlExec();
142
+ let out;
143
+ try {
144
+ out = exec(cmd, [...args, "get", "svc", "-A", "-o", "json"], opts);
145
+ } catch (err) {
146
+ throw new Error(`cannot list cluster services:\n${err.message}`);
147
+ }
148
+ let parsed;
149
+ try {
150
+ parsed = JSON.parse(out);
151
+ } catch (err) {
152
+ throw new Error(`cannot parse 'kubectl get svc' output: ${err.message}`);
153
+ }
154
+ const byName = new Map();
155
+ for (const svc of parsed.items ?? []) {
156
+ const name = svc.metadata?.name;
157
+ if (!name) continue;
158
+ const sshPort = (svc.spec?.ports ?? []).find((p) => p.name === "ssh");
159
+ const record = {
160
+ namespace: svc.metadata?.namespace ?? "",
161
+ nodePort: sshPort?.nodePort,
162
+ };
163
+ if (!byName.has(name)) byName.set(name, []);
164
+ byName.get(name).push(record);
165
+ }
166
+ return byName;
167
+ }
168
+
169
+ /**
170
+ * Re-point every managed SSH alias at a currently-Ready node and resync its
171
+ * Port from the live Service's ssh nodePort.
172
+ *
173
+ * Why this exists: `deploy` captures one node's IP at deploy time. On a
174
+ * single-node cluster that never changes, but once the cluster grows (or a
175
+ * node is replaced) the stored IP can go stale — the pod moving nodes is
176
+ * harmless by itself (NodePort is reachable via any node), a dead/stale node
177
+ * IP is what breaks `ssh coding-<project>` and VS Code Remote-SSH. Refresh
178
+ *ing the aliases fixes that without redeploying.
179
+ *
180
+ * Stale entries (no matching service — e.g. after `delete` on another
181
+ * machine) and ambiguous ones (same service name in several namespaces) are
182
+ * left untouched with a warning. Nothing outside the managed block is modified.
183
+ */
184
+ export function listUpdateSsh({ file, dryRun = false } = {}) {
185
+ const sshConfig = file ?? sshConfigPath();
186
+ const entries = listHostEntries(sshConfig);
187
+ if (entries.length === 0) {
188
+ console.log(`No coding-container SSH connections found in ${sshConfig}.`);
189
+ console.log("Deploy a project to register one: coding-container create");
190
+ return;
191
+ }
192
+ const nodeIp = getReadyNodeIp();
193
+ const services = indexServicesByName();
194
+ let updated = 0;
195
+ let current = 0;
196
+ for (const entry of entries) {
197
+ const project = entry.alias.replace(/^coding-/, "");
198
+ const matches = services.get(project) ?? [];
199
+ if (matches.length === 0) {
200
+ console.warn(
201
+ `warning: no service "${project}" found in any namespace — leaving "${entry.alias}" untouched (stale entry? run 'coding-container delete' to remove it)`
202
+ );
203
+ continue;
204
+ }
205
+ if (matches.length > 1) {
206
+ const where = matches.map((m) => `"${m.namespace}"`).join(", ");
207
+ console.warn(
208
+ `warning: service "${project}" exists in several namespaces (${where}) — leaving "${entry.alias}" untouched (edit ~/.ssh/config manually)`
209
+ );
210
+ continue;
211
+ }
212
+ const [{ namespace, nodePort }] = matches;
213
+ const wantPort = nodePort !== undefined && nodePort !== null ? String(nodePort) : entry.port;
214
+ if (!entry.identityFile) {
215
+ console.warn(`warning: "${entry.alias}" has no IdentityFile — leaving it untouched`);
216
+ continue;
217
+ }
218
+ if (entry.hostName === nodeIp && entry.port === wantPort) {
219
+ console.log(`"${entry.alias}": already current (${nodeIp}:${entry.port}, service "${project}" in "${namespace}")`);
220
+ current++;
221
+ continue;
222
+ }
223
+ const what =
224
+ entry.hostName !== nodeIp && entry.port !== wantPort
225
+ ? `host ${entry.hostName ?? "-"} -> ${nodeIp}, port ${entry.port ?? "-"} -> ${wantPort}`
226
+ : entry.hostName !== nodeIp
227
+ ? `host ${entry.hostName ?? "-"} -> ${nodeIp}`
228
+ : `port ${entry.port ?? "-"} -> ${wantPort}`;
229
+ if (dryRun) {
230
+ console.log(`"${entry.alias}": would update ${what} (service "${project}" in "${namespace}")`);
231
+ continue;
232
+ }
233
+ addHostEntry(
234
+ {
235
+ alias: entry.alias,
236
+ hostName: nodeIp,
237
+ port: wantPort,
238
+ user: entry.user ?? "root",
239
+ identityFile: entry.identityFile,
240
+ knownHostsFile: entry.knownHostsFile ?? ephemeralKnownHostsFile(),
241
+ },
242
+ sshConfig
243
+ );
244
+ console.log(`"${entry.alias}": updated ${what} (service "${project}" in "${namespace}")`);
245
+ updated++;
246
+ }
247
+ if (dryRun) {
248
+ console.log(`\nDry run: no changes written to ${sshConfig}.`);
249
+ } else {
250
+ console.log(`\n${updated} updated, ${current} already current (${entries.length} total) in ${sshConfig}.`);
251
+ }
252
+ }
@@ -17,7 +17,7 @@ function yamlValue(v) {
17
17
  return JSON.stringify(v);
18
18
  }
19
19
 
20
- function renderTemplate(dir, values) {
20
+ function renderTemplate(values) {
21
21
  return `# coding-container global configuration (created by 'coding-container setup')
22
22
  #
23
23
  # Shared by every project. Layering, lowest to highest priority:
@@ -26,7 +26,7 @@ function renderTemplate(dir, values) {
26
26
  # Relative paths in this file resolve against this file's directory.
27
27
  # The Dockerfile and the SSH key pair live next to this file:
28
28
  # Dockerfile - the image build (copied here from the repo on first run)
29
- # keys/ - the shared SSH key pair (per-project known_hosts alongside)
29
+ # keys/ - the shared SSH key pair (host keys are ephemeral, never stored)
30
30
 
31
31
  image:
32
32
  registry: ${yamlValue(values.registry)}
@@ -37,8 +37,10 @@ image:
37
37
  # coding-container system get (show current/previous/maxEver)
38
38
  # Set tag in a project config to pin that project to one global version.
39
39
  # Single image build shared by all projects (Dockerfile copied next to this file).
40
- dockerfile: ${path.join(dir, "Dockerfile")}
41
- context: ${dir}
40
+ # Relative paths resolve against this file's directory, so this config
41
+ # survives renames/moves of the config folder.
42
+ dockerfile: Dockerfile
43
+ context: .
42
44
  buildArgs:
43
45
  BASE_IMAGE: ubuntu:26.04
44
46
  NODE_VERSION: "22"
@@ -175,7 +177,7 @@ export async function setup(opts = {}) {
175
177
  console.log(`kept: ${cfgFile} (already exists, left untouched)`);
176
178
  } else {
177
179
  const values = await captureEnvironment(opts);
178
- fs.writeFileSync(cfgFile, renderTemplate(dir, values));
180
+ fs.writeFileSync(cfgFile, renderTemplate(values));
179
181
  console.log(`created: ${cfgFile}`);
180
182
  }
181
183
 
@@ -1,7 +1,7 @@
1
1
  import net from "node:net";
2
- import path from "node:path";
3
2
  import { exec, run, start } from "../utils/exec.js";
4
3
  import { ensureKeyPair } from "../utils/keys.js";
4
+ import { ephemeralKnownHostsFile, removeLegacyKnownHostsFile } from "../utils/ssh-config.js";
5
5
 
6
6
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
7
7
 
@@ -34,9 +34,10 @@ async function waitForReady(child, ms = 15000) {
34
34
  }
35
35
 
36
36
  /**
37
- * Per-project known_hosts file so ephemeral pod host keys (regenerated on
38
- * every image build) never pollute the user's global known_hosts, and a
39
- * changed key never blocks the connection.
37
+ * Pod host keys are ephemeral (regenerated on every image build), so host
38
+ * keys are never persisted (UserKnownHostsFile -> OS null device). A stored
39
+ * key would go stale after a rebuild and VS Code Remote-SSH would refuse
40
+ * forwarding with "Port forwarding is disabled".
40
41
  */
41
42
  function sshCommonOptions(cfg, port, privateKey) {
42
43
  return [
@@ -47,7 +48,7 @@ function sshCommonOptions(cfg, port, privateKey) {
47
48
  "-o",
48
49
  "StrictHostKeyChecking=no",
49
50
  "-o",
50
- `UserKnownHostsFile=${path.join(cfg.ssh.keyDirPath, `${cfg.project}-known_hosts`)}`,
51
+ `UserKnownHostsFile=${ephemeralKnownHostsFile()}`,
51
52
  ];
52
53
  }
53
54
 
@@ -63,6 +64,10 @@ async function sshDirect(cfg, privateKey) {
63
64
 
64
65
  export async function ssh(cfg, { direct = false } = {}) {
65
66
  const { private: privateKey } = ensureKeyPair(cfg.ssh.keyDirPath, cfg.ssh.keyName);
67
+ // One-time cleanup of the pre-null-device file so a stale key left over
68
+ // from before the switch can't break VS Code / Zed aliases still
69
+ // pointing at it until the next deploy rewrites them.
70
+ removeLegacyKnownHostsFile(cfg.ssh.keyDirPath, cfg.project);
66
71
 
67
72
  if (direct) {
68
73
  await sshDirect(cfg, privateKey);
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { imageRef } from "../config.js";
4
- import { fullManifest, secretEnvEntries } from "../utils/manifest.js";
4
+ import { fullManifest, secretEnvEntries, webPorts } from "../utils/manifest.js";
5
5
  import { readPublicKey } from "../utils/keys.js";
6
6
  import { getSystemConfig, SYSTEM_CM_NAME } from "../utils/system-config.js";
7
7
 
@@ -77,8 +77,10 @@ export function validate(cfg, { manifest = false } = {}) {
77
77
  if (sidecars.length) {
78
78
  console.log(` sidecars: ${sidecars.map((s) => s.name).join(", ")}`);
79
79
  }
80
- for (const p of cfg.web?.ports || []) {
81
- console.log(` web: container port ${p.port} -> http://${p.host}/`);
80
+ for (const p of webPorts(cfg)) {
81
+ for (const host of p.hosts) {
82
+ console.log(` web: container port ${p.port} -> http://${host}/`);
83
+ }
82
84
  }
83
85
  for (const v of cfg.nfs.volumes || []) {
84
86
  console.log(` volume: ${v.subPath} -> ${v.mountPath}${v.readOnly ? " (read-only)" : ""}`);
package/src/config.js CHANGED
@@ -101,19 +101,20 @@ export const DEFAULTS = {
101
101
  },
102
102
  web: {
103
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.
104
+ // entry becomes one Traefik Ingress rule per host: 'host' and/or 'hosts'
105
+ // (each a single host or a list) route to that container port on the
106
+ // project's service. DNS resolution of the hosts is up to you.
106
107
  // An optional 'nodePort' (30000-32767, unique across ssh, web.ports, and
107
108
  // sidecars) additionally exposes the port on every cluster node, for
108
109
  // clients that can't use the Ingress hosts.
109
110
  // web:
110
111
  // ports:
111
- // - { port: 3000, host: my-web.example.com, nodePort: 30080 }
112
+ // - { port: 3000, hosts: [my-web.example.com, www.example.com], nodePort: 30080 }
112
113
  // - { port: 8080, host: my-api.example.com, name: api }
113
114
  ports: [],
114
115
  },
115
116
  ingress: {
116
- // Ingress class that routes the web.ports rules (k3s ships Traefik).
117
+ // Ingress class that routes the web.ports rules (Traefik, bundled with many Kubernetes distributions).
117
118
  className: "traefik",
118
119
  },
119
120
  container: {
@@ -222,7 +223,7 @@ export function loadConfig(configPath, overrides = {}) {
222
223
  }
223
224
 
224
225
  // Default key location: keys/ inside the global config dir (shared key
225
- // pair for all projects; per-project known_hosts files live alongside).
226
+ // pair for all projects; host keys are ephemeral and never stored).
226
227
  if (!cfg.ssh.keyDirPath) cfg.ssh.keyDirPath = path.join(gDir, "keys");
227
228
  // `${VAR}` expansion (local shell -> config) is scoped to env-ish fields
228
229
  // only, so hosts/paths/ports can never surprise-expand. Missing variables
@@ -249,9 +250,11 @@ function validate(cfg) {
249
250
  if (!cfg.image.name) {
250
251
  throw new Error("config: image.name is required");
251
252
  }
252
- if (!fs.existsSync(cfg.image.dockerfilePath)) {
253
- throw new Error(`config: dockerfile not found: ${cfg.image.dockerfilePath}`);
254
- }
253
+ // NOTE: the Dockerfile is intentionally NOT checked here. Only the build
254
+ // path (buildImage, used by `build`, custom-mode `create` and
255
+ // `system create`) needs it; `deploy`/`validate`/`ssh`/etc. in global mode
256
+ // run fine without one, so requiring it here would break deploys over a
257
+ // stale or missing Dockerfile.
255
258
  if (!cfg.nfs.server) {
256
259
  throw new Error(
257
260
  "config: nfs.server is not set. Run 'coding-container setup' " +
@@ -506,7 +509,7 @@ function validate(cfg) {
506
509
  }
507
510
  if (cfg.web?.ports !== undefined) {
508
511
  if (!Array.isArray(cfg.web.ports)) {
509
- throw new Error("config: web.ports must be a list of {port, host} entries");
512
+ throw new Error("config: web.ports must be a list of {port, host|hosts} entries");
510
513
  }
511
514
  const seenPorts = new Set();
512
515
  const seenHosts = new Set();
@@ -514,25 +517,38 @@ function validate(cfg) {
514
517
  const port = entry && entry.port;
515
518
  if (!entry || typeof entry !== "object" || !Number.isInteger(port) || port < 1 || port > 65535) {
516
519
  throw new Error(
517
- `config: web.ports entry must be an object with a 'port' (1-65535) and a 'host': ${JSON.stringify(entry)}`
520
+ `config: web.ports entry must be an object with a 'port' (1-65535) and a 'host' or 'hosts': ${JSON.stringify(entry)}`
518
521
  );
519
522
  }
520
523
  if (seenPorts.has(port)) {
521
524
  throw new Error(`config: web.ports lists port ${port} more than once`);
522
525
  }
523
526
  seenPorts.add(port);
524
- if (
525
- typeof entry.host !== "string" ||
526
- !/^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$/.test(entry.host)
527
- ) {
527
+ const hosts = [];
528
+ for (const value of [entry.host, entry.hosts]) {
529
+ if (value === undefined) continue;
530
+ if (Array.isArray(value)) hosts.push(...value);
531
+ else hosts.push(value);
532
+ }
533
+ if (hosts.length === 0) {
528
534
  throw new Error(
529
- `config: web.ports entry for port ${port} has an invalid host (lowercase a-z/0-9/-/.): ${JSON.stringify(entry.host)}`
535
+ `config: web.ports entry for port ${port} needs a 'host' or a non-empty 'hosts' list`
530
536
  );
531
537
  }
532
- if (seenHosts.has(entry.host)) {
533
- throw new Error(`config: web.ports entries share the host "${entry.host}"`);
538
+ for (const host of hosts) {
539
+ if (
540
+ typeof host !== "string" ||
541
+ !/^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$/.test(host)
542
+ ) {
543
+ throw new Error(
544
+ `config: web.ports entry for port ${port} has an invalid host (lowercase a-z/0-9/-/.): ${JSON.stringify(host)}`
545
+ );
546
+ }
547
+ if (seenHosts.has(host)) {
548
+ throw new Error(`config: web.ports entries share the host "${host}"`);
549
+ }
550
+ seenHosts.add(host);
534
551
  }
535
- seenHosts.add(entry.host);
536
552
  if (
537
553
  entry.name !== undefined &&
538
554
  (!/^[a-z]([-a-z0-9]*[a-z0-9])?$/.test(entry.name) || entry.name.length > 15)
package/src/index.js CHANGED
@@ -10,6 +10,7 @@ import { push } from "./commands/push.js";
10
10
  import { deploy } from "./commands/deploy.js";
11
11
  import { create } from "./commands/create.js";
12
12
  import { ssh } from "./commands/ssh.js";
13
+ import { listSsh, listUpdateSsh } from "./commands/list.js";
13
14
  import { gc } from "./commands/gc.js";
14
15
  import { deleteProject } from "./commands/delete.js";
15
16
  import { validate } from "./commands/validate.js";
@@ -32,7 +33,7 @@ const { version } = require("../package.json");
32
33
 
33
34
  program
34
35
  .name("coding-container")
35
- .description("Build, publish and deploy SSH-accessible coding containers on k3s")
36
+ .description("Build, publish and deploy SSH-accessible coding containers on Kubernetes")
36
37
  .version(version)
37
38
  // Positional options: flags before the subcommand are global, flags after
38
39
  // belong to the subcommand. Required so `system create --tag vX.Y.Z` is
@@ -118,7 +119,7 @@ program
118
119
 
119
120
  program
120
121
  .command("deploy")
121
- .description("deploy the image to the k3s cluster")
122
+ .description("deploy the image to the Kubernetes cluster")
122
123
  .option("--no-wait", "do not wait for the rollout to finish")
123
124
  .action(async (opts) => {
124
125
  const cfg = cfgFrom();
@@ -134,6 +135,23 @@ program
134
135
  await ssh(cfg, { direct: opts.direct });
135
136
  });
136
137
 
138
+ const list = program
139
+ .command("list")
140
+ .description("list the SSH connections registered by coding-container (aliases in ~/.ssh/config)")
141
+ .action(() => {
142
+ listSsh();
143
+ });
144
+
145
+ list
146
+ .command("update")
147
+ .description(
148
+ "refresh the SSH aliases from the live cluster (Ready node IP + service nodePorts; fixes stale entries after nodes change)"
149
+ )
150
+ .option("--dry-run", "show what would change without writing anything")
151
+ .action(async (opts) => {
152
+ await listUpdateSsh({ dryRun: opts.dryRun });
153
+ });
154
+
137
155
  program
138
156
  .command("delete")
139
157
  .alias("destroy")
@@ -78,17 +78,27 @@ export function secretManifest(cfg, { redact = false } = {}) {
78
78
  }
79
79
 
80
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.
81
+ * Normalized web.ports entries: {name, port, hosts, nodePort?}. 'hosts' is
82
+ * the entry's 'host' plus its 'hosts' value (each a single host or a list),
83
+ * deduplicated; the Ingress renders one rule per host. Names default to
84
+ * web-<port>; nodePort (30000-32767) is an optional service-level knob that
85
+ * also exposes the port on every cluster node.
84
86
  */
85
87
  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
- }));
88
+ return (cfg.web?.ports || []).map((entry) => {
89
+ const hosts = [];
90
+ for (const value of [entry.host, entry.hosts]) {
91
+ if (value === undefined) continue;
92
+ if (Array.isArray(value)) hosts.push(...value);
93
+ else hosts.push(value);
94
+ }
95
+ return {
96
+ name: entry.name || `web-${entry.port}`,
97
+ port: entry.port,
98
+ hosts: [...new Set(hosts)],
99
+ ...(entry.nodePort !== undefined ? { nodePort: entry.nodePort } : {}),
100
+ };
101
+ });
92
102
  }
93
103
 
94
104
  /**
@@ -273,9 +283,9 @@ export function serviceManifest(cfg) {
273
283
  }
274
284
 
275
285
  /**
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.
286
+ * Ingress with one rule per web host: every host in a web port entry routes
287
+ * to that entry's container port on the project's service. Only rendered when
288
+ * web.ports is non-empty.
279
289
  */
280
290
  export function ingressManifest(cfg) {
281
291
  return {
@@ -288,18 +298,20 @@ export function ingressManifest(cfg) {
288
298
  },
289
299
  spec: {
290
300
  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
- })),
301
+ rules: webPorts(cfg).flatMap((p) =>
302
+ p.hosts.map((host) => ({
303
+ host,
304
+ http: {
305
+ paths: [
306
+ {
307
+ path: "/",
308
+ pathType: "Prefix",
309
+ backend: { service: { name: cfg.project, port: { number: p.port } } },
310
+ },
311
+ ],
312
+ },
313
+ }))
314
+ ),
303
315
  },
304
316
  };
305
317
  }
@@ -95,11 +95,11 @@ project: ${project}
95
95
  k8s:
96
96
  nodePort: ${nodePort}
97
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.
98
+ # Expose container ports to the browser through Traefik (one Ingress rule
99
+ # per host). DNS for the hosts must resolve to your Traefik LoadBalancer.
100
100
  # web:
101
101
  # ports:
102
- # - { port: 3000, host: my-web.example.com }
102
+ # - { port: 3000, hosts: [my-web.example.com, www.example.com] }
103
103
  `
104
104
  );
105
105
  return { configPath, created: true, project, nodePort };
@@ -18,6 +18,40 @@ export function sshAlias(project) {
18
18
  return `coding-${project}`;
19
19
  }
20
20
 
21
+ /**
22
+ * Pod host keys are ephemeral (regenerated on every image build), so there
23
+ * is no point in persisting them. Pointing UserKnownHostsFile at the OS null
24
+ * device means a rebuilt image can never leave a stale key behind to break
25
+ * clients that need forwarding (VS Code Remote-SSH disables forwarding after
26
+ * a host-key change even with StrictHostKeyChecking=no).
27
+ */
28
+ export function ephemeralKnownHostsFile() {
29
+ return process.platform === "win32" ? "NUL" : "/dev/null";
30
+ }
31
+
32
+ /**
33
+ * Pre-null-device location of a project's host keys. Kept only so commands
34
+ * can clean up the legacy file once.
35
+ */
36
+ export function legacyKnownHostsFile(keyDirPath, project) {
37
+ return path.join(keyDirPath, `${project}-known_hosts`);
38
+ }
39
+
40
+ /**
41
+ * Delete the legacy per-project known_hosts file if it exists.
42
+ * Returns true when a file was removed.
43
+ */
44
+ export function removeLegacyKnownHostsFile(keyDirPath, project) {
45
+ const file = legacyKnownHostsFile(keyDirPath, project);
46
+ try {
47
+ if (!fs.existsSync(file)) return false;
48
+ fs.rmSync(file, { force: true });
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
21
55
  function readManaged(file) {
22
56
  if (!fs.existsSync(file)) return { before: "", entries: {}, after: "" };
23
57
  const lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
@@ -81,3 +115,36 @@ export function removeHostEntry(alias, file = sshConfigPath()) {
81
115
  writeManaged(file, before, entries, after);
82
116
  return true;
83
117
  }
118
+
119
+ /**
120
+ * Parse one managed Host block (array of raw lines, first is `Host <alias>`)
121
+ * into a structured entry. Unknown keys are ignored; missing keys stay undefined.
122
+ */
123
+ function parseHostBlock(alias, lines) {
124
+ const entry = { alias };
125
+ for (const line of lines.slice(1)) {
126
+ const m = line.trim().match(/^(\S+)\s+(.*\S)\s*$/);
127
+ if (!m) continue;
128
+ const key = m[1].toLowerCase();
129
+ const value = m[2];
130
+ if (key === "hostname") entry.hostName = value;
131
+ else if (key === "port") entry.port = value;
132
+ else if (key === "user") entry.user = value;
133
+ else if (key === "identityfile") entry.identityFile = value;
134
+ else if (key === "userknownhostsfile") entry.knownHostsFile = value;
135
+ else if (key === "stricthostkeychecking") entry.strictHostKeyChecking = value;
136
+ }
137
+ return entry;
138
+ }
139
+
140
+ /**
141
+ * List the container host entries in the managed section, sorted by alias.
142
+ * Returns [] when the ssh config (or the managed block) is missing.
143
+ * Each entry is { alias, hostName?, port?, user?, identityFile?, ... }.
144
+ */
145
+ export function listHostEntries(file = sshConfigPath()) {
146
+ const { entries } = readManaged(file);
147
+ return Object.entries(entries)
148
+ .map(([alias, lines]) => parseHostBlock(alias, lines))
149
+ .sort((a, b) => a.alias.localeCompare(b.alias));
150
+ }