@esuyo/coding-container 1.0.2 → 1.0.4
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 +30 -23
- package/config.yaml +7 -6
- package/package.json +1 -1
- package/src/commands/build.js +13 -0
- package/src/commands/delete.js +2 -1
- package/src/commands/deploy.js +10 -3
- package/src/commands/list.js +252 -0
- package/src/commands/setup.js +7 -5
- package/src/commands/ssh.js +10 -5
- package/src/commands/validate.js +5 -3
- package/src/config.js +34 -18
- package/src/index.js +20 -2
- package/src/utils/manifest.js +36 -24
- package/src/utils/project.js +3 -3
- package/src/utils/ssh-config.js +67 -0
package/README.md
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
# Esuyo coding-container
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
79
|
+
- **Pod stuck?** Usually NFS or image pull. See [Troubleshooting](./docs/troubleshooting.md).
|
|
74
80
|
|
|
75
81
|
## License
|
|
76
82
|
|
|
77
|
-
MIT
|
|
83
|
+
MIT. See [LICENSE](./LICENSE).
|
|
78
84
|
|
|
79
85
|
## Developer Documentation
|
|
80
86
|
|
|
81
|
-
- [Configuration](./docs/configuration.md)
|
|
82
|
-
- [Shared global image](./docs/global-image.md)
|
|
83
|
-
- [Web access](./docs/web-access.md)
|
|
84
|
-
- [Sidecars](./docs/sidecars.md)
|
|
85
|
-
- [CLI reference](./docs/cli-reference.md)
|
|
86
|
-
- [SSH and VS Code](./docs/ssh-and-vscode.md)
|
|
87
|
-
- [
|
|
88
|
-
- [
|
|
89
|
-
- [
|
|
90
|
-
- [
|
|
91
|
-
- [
|
|
92
|
-
- [
|
|
93
|
-
- [Plan:
|
|
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
|
|
13
|
-
#
|
|
14
|
-
# resolve (DNS) to your Traefik LoadBalancer
|
|
15
|
-
# (30000-32767) additionally reaches the port on every
|
|
16
|
-
# http://<node-ip>:<nodePort>/ (unique across ssh, web.ports,
|
|
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,
|
|
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
package/src/commands/build.js
CHANGED
|
@@ -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 || {})) {
|
package/src/commands/delete.js
CHANGED
|
@@ -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
|
}
|
package/src/commands/deploy.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
+
}
|
package/src/commands/setup.js
CHANGED
|
@@ -17,7 +17,7 @@ function yamlValue(v) {
|
|
|
17
17
|
return JSON.stringify(v);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
function renderTemplate(
|
|
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 (
|
|
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
|
-
|
|
41
|
-
|
|
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(
|
|
180
|
+
fs.writeFileSync(cfgFile, renderTemplate(values));
|
|
179
181
|
console.log(`created: ${cfgFile}`);
|
|
180
182
|
}
|
|
181
183
|
|
package/src/commands/ssh.js
CHANGED
|
@@ -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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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=${
|
|
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);
|
package/src/commands/validate.js
CHANGED
|
@@ -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
|
|
81
|
-
|
|
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
|
|
105
|
-
//
|
|
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,
|
|
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 (
|
|
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;
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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}
|
|
535
|
+
`config: web.ports entry for port ${port} needs a 'host' or a non-empty 'hosts' list`
|
|
530
536
|
);
|
|
531
537
|
}
|
|
532
|
-
|
|
533
|
-
|
|
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
|
|
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
|
|
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")
|
package/src/utils/manifest.js
CHANGED
|
@@ -78,17 +78,27 @@ export function secretManifest(cfg, { redact = false } = {}) {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
|
-
* Normalized web.ports entries: {name, port,
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
277
|
-
* container port on the project's service. Only rendered when
|
|
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).
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
}
|
package/src/utils/project.js
CHANGED
|
@@ -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
|
|
99
|
-
# per
|
|
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,
|
|
102
|
+
# - { port: 3000, hosts: [my-web.example.com, www.example.com] }
|
|
103
103
|
`
|
|
104
104
|
);
|
|
105
105
|
return { configPath, created: true, project, nodePort };
|
package/src/utils/ssh-config.js
CHANGED
|
@@ -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
|
+
}
|