@esuyo/coding-container 1.0.15 → 1.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +3 -3
- package/README.md +1 -0
- package/config.yaml +1 -0
- package/package.json +1 -1
- package/src/commands/build.js +16 -0
- package/src/commands/deploy.js +17 -2
- package/src/commands/setup.js +128 -8
- package/src/commands/system.js +7 -1
- package/src/index.js +10 -0
- package/src/utils/exec.js +11 -3
- package/src/utils/namespaces.js +248 -0
- package/src/utils/registry.js +1 -0
- package/src/utils/system-config.js +8 -4
package/Dockerfile
CHANGED
|
@@ -61,9 +61,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
|
61
61
|
&& rm -rf /var/lib/apt/lists/*
|
|
62
62
|
|
|
63
63
|
# --- Coding agents / CLIs ---------------------------------------------------
|
|
64
|
-
# opencode (installer adds PATH for interactive shells only; ENV + profile.d
|
|
64
|
+
# opencode v2 (installer adds PATH for interactive shells only; ENV + profile.d
|
|
65
65
|
# cover non-interactive shells and login shells)
|
|
66
|
-
RUN curl -fsSL https://opencode.ai/install | bash
|
|
66
|
+
RUN curl -fsSL https://opencode.ai/v2/install | bash && /root/.opencode/bin/opencode --version
|
|
67
67
|
ENV PATH="/root/.opencode/bin:${PATH}"
|
|
68
68
|
RUN printf 'export PATH="/root/.opencode/bin:$PATH"\n' > /etc/profile.d/opencode.sh
|
|
69
69
|
# pi (pi.dev)
|
|
@@ -101,7 +101,7 @@ RUN mkdir -p /root/.ssh && \
|
|
|
101
101
|
# sshd builds each session's environment from scratch (USER/HOME/PATH/SHELL +
|
|
102
102
|
# PAM), so the container's env (image ENV + pod spec) is not visible in SSH
|
|
103
103
|
# sessions; the entrypoint republishes it for login shells.
|
|
104
|
-
RUN cat > /entrypoint.sh <<'ENTRYPOINT' && chmod +x /entrypoint.sh
|
|
104
|
+
RUN cat > /entrypoint.sh <<'ENTRYPOINT' && sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh
|
|
105
105
|
#!/bin/bash
|
|
106
106
|
mkdir -p /run/sshd
|
|
107
107
|
: > /etc/profile.d/99-container-env.sh
|
package/README.md
CHANGED
|
@@ -129,5 +129,6 @@ MIT. See [LICENSE](./LICENSE).
|
|
|
129
129
|
- [Project Structure](./docs/project-structure.md) — Folder layout
|
|
130
130
|
- [Development Guide](./docs/development.md) — Local setup for contributors
|
|
131
131
|
- [App config & setup files](./docs/config-files.md) — Persisting tool configs via /workspace
|
|
132
|
+
- [Windows setup](./docs/windows-setup.md) — Docker, kubectl, K3s, registry, and kubeconfig on Windows
|
|
132
133
|
- [Plan: Additional NFS volumes](./docs/build/additional-volumes.md) — Design history for extra volumes
|
|
133
134
|
- [Plan: Public prebuilt images](./docs/build/public-images.md) — Design history for public images
|
package/config.yaml
CHANGED
|
@@ -8,6 +8,7 @@ project: my-project
|
|
|
8
8
|
|
|
9
9
|
k8s:
|
|
10
10
|
nodePort: 30022
|
|
11
|
+
# namespace: unset here -> global default; use one name per developer for private work, or one shared name per team.
|
|
11
12
|
# Pin the pod to one named cluster node (kubectl get nodes). Empty/unset ->
|
|
12
13
|
# the scheduler picks a node.
|
|
13
14
|
# nodeName: worker-02
|
package/package.json
CHANGED
package/src/commands/build.js
CHANGED
|
@@ -20,6 +20,22 @@ export async function buildImage(cfg) {
|
|
|
20
20
|
`Fix image.context in your config.`
|
|
21
21
|
);
|
|
22
22
|
}
|
|
23
|
+
// A Dockerfile with Windows (CRLF) line endings bakes '\r' into the
|
|
24
|
+
// /entrypoint.sh heredoc, and the image then fails at boot with
|
|
25
|
+
// `exec /entrypoint.sh: no such file or directory` (exit 255).
|
|
26
|
+
// Normalizing line endings is content-preserving for the build, so fix
|
|
27
|
+
// it in place here (with a visible warning) instead of failing and
|
|
28
|
+
// asking the user to hand-edit the file.
|
|
29
|
+
const dockerfileText = fs.readFileSync(cfg.image.dockerfilePath, "utf8");
|
|
30
|
+
if (dockerfileText.includes("\r")) {
|
|
31
|
+
fs.writeFileSync(
|
|
32
|
+
cfg.image.dockerfilePath,
|
|
33
|
+
dockerfileText.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
|
|
34
|
+
);
|
|
35
|
+
console.warn(
|
|
36
|
+
`warning: normalized Windows (CRLF) line endings to Unix (LF) in ${cfg.image.dockerfilePath}`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
23
39
|
const ref = imageRef(cfg);
|
|
24
40
|
const args = ["build", "-t", ref, "-f", cfg.image.dockerfilePath];
|
|
25
41
|
for (const [key, value] of Object.entries(cfg.image.buildArgs || {})) {
|
package/src/commands/deploy.js
CHANGED
|
@@ -8,8 +8,10 @@ import { imageRef } from "../config.js";
|
|
|
8
8
|
import { addHostEntry, ephemeralKnownHostsFile, removeLegacyKnownHostsFile, sshAlias, sshConfigPath } from "../utils/ssh-config.js";
|
|
9
9
|
import { findNodePortOwner } from "../utils/project.js";
|
|
10
10
|
import { kubectlExec, resolveDeployTag, SYSTEM_CM_NAME } from "../utils/system-config.js";
|
|
11
|
+
import { NAMESPACE_LABEL } from "../utils/namespaces.js";
|
|
12
|
+
import { setupAnnotationPairs } from "./setup.js";
|
|
11
13
|
|
|
12
|
-
export async function ensureNamespace(ns) {
|
|
14
|
+
export async function ensureNamespace(ns, { setupValues } = {}) {
|
|
13
15
|
const { cmd, args, opts } = kubectlExec();
|
|
14
16
|
try {
|
|
15
17
|
exec(cmd, [...args, "get", "namespace", ns], opts);
|
|
@@ -17,6 +19,13 @@ export async function ensureNamespace(ns) {
|
|
|
17
19
|
console.log(`Creating namespace ${ns} ...`);
|
|
18
20
|
await run(cmd, [...args, "create", "namespace", ns], opts);
|
|
19
21
|
}
|
|
22
|
+
// Re-assert the label on every deploy/init: unlabeled namespaces are not
|
|
23
|
+
// listed as coding-container namespaces, so the invariant repairs itself.
|
|
24
|
+
await run(cmd, [...args, "label", "namespace", ns, NAMESPACE_LABEL, "--overwrite"], opts);
|
|
25
|
+
const pairs = setupAnnotationPairs(setupValues);
|
|
26
|
+
if (pairs.length) {
|
|
27
|
+
await run(cmd, [...args, "annotate", "namespace", ns, "--overwrite", ...pairs], opts);
|
|
28
|
+
}
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
export async function deploy(cfg, { wait = true } = {}) {
|
|
@@ -35,7 +44,13 @@ export async function deploy(cfg, { wait = true } = {}) {
|
|
|
35
44
|
const publicKey = readPublicKey(publicPath);
|
|
36
45
|
|
|
37
46
|
const ns = cfg.k8s.namespace;
|
|
38
|
-
await ensureNamespace(ns
|
|
47
|
+
await ensureNamespace(ns, {
|
|
48
|
+
setupValues: {
|
|
49
|
+
registry: cfg.image?.registry,
|
|
50
|
+
nfsServer: cfg.nfs?.server,
|
|
51
|
+
nfsBasePath: cfg.nfs?.basePath,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
39
54
|
|
|
40
55
|
// Fail fast on a taken NodePort: applying anyway leaves a partial state
|
|
41
56
|
// (deployment + ingress created, service invalid).
|
package/src/commands/setup.js
CHANGED
|
@@ -3,15 +3,74 @@ import path from "node:path";
|
|
|
3
3
|
import readline from "node:readline/promises";
|
|
4
4
|
import { globalConfigDir, globalConfigPath, PROJECT_ROOT } from "../config.js";
|
|
5
5
|
import { ensureKeyPair } from "../utils/keys.js";
|
|
6
|
+
import { exec, run } from "../utils/exec.js";
|
|
7
|
+
import { kubectlExec } from "../utils/system-config.js";
|
|
8
|
+
import { NAMESPACE_LABEL, SETUP_ANNOTATIONS, readNamespaceSetupValues, readDeploymentSetupValues } from "../utils/namespaces.js";
|
|
6
9
|
|
|
7
10
|
// Values that differ per environment. When neither a flag nor a prompt supplies
|
|
8
11
|
// one, the generated config gets a clearly-marked placeholder instead of a guess.
|
|
9
|
-
const PLACEHOLDERS = {
|
|
12
|
+
export const PLACEHOLDERS = {
|
|
10
13
|
registry: "your-registry.example.com/you",
|
|
11
14
|
nfsServer: "your-nas-ip-or-hostname",
|
|
12
15
|
nfsBasePath: "/path/to/shared/workspaces",
|
|
16
|
+
namespace: "coding",
|
|
13
17
|
};
|
|
14
18
|
|
|
19
|
+
// Same DNS-1123 pattern as defaultNamespace() in src/utils/instance.js:
|
|
20
|
+
// lowercase alphanumerics and '-', max 63 chars.
|
|
21
|
+
const NAMESPACE_RE = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/;
|
|
22
|
+
|
|
23
|
+
export function normalizeNamespace(value) {
|
|
24
|
+
const v = String(value ?? "").trim();
|
|
25
|
+
if (v.length < 1 || v.length > 63 || !NAMESPACE_RE.test(v)) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`namespace must be a valid k8s name (lowercase alphanumerics and '-', max 63 chars), got: ${JSON.stringify(value)}`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return v;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Which of the three setup-value annotations to (re)write. Skips blanks
|
|
35
|
+
* and the setup placeholders so a never-configured environment never
|
|
36
|
+
* pollutes cluster annotations with "your-registry.example.com/you".
|
|
37
|
+
*/
|
|
38
|
+
export function setupAnnotationPairs(setupValues) {
|
|
39
|
+
const skip = new Set(Object.values(PLACEHOLDERS));
|
|
40
|
+
const pairs = [];
|
|
41
|
+
if (!setupValues || typeof setupValues !== "object") return pairs;
|
|
42
|
+
for (const [field, key] of Object.entries(SETUP_ANNOTATIONS)) {
|
|
43
|
+
const v = setupValues[field];
|
|
44
|
+
if (typeof v !== "string") continue;
|
|
45
|
+
const t = v.trim();
|
|
46
|
+
if (!t || skip.has(t)) continue;
|
|
47
|
+
pairs.push(`${key}=${t}`);
|
|
48
|
+
}
|
|
49
|
+
return pairs;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Label + annotate an existing namespace; best effort, never throws.
|
|
54
|
+
* Only touches a namespace that already exists (creation happens on
|
|
55
|
+
* deploy/init), so setup stays fully usable offline.
|
|
56
|
+
*/
|
|
57
|
+
async function tagNamespaceBestEffort(ns, setupValues) {
|
|
58
|
+
try {
|
|
59
|
+
const { cmd, args, opts } = kubectlExec();
|
|
60
|
+
exec(cmd, [...args, "get", "namespace", ns], opts);
|
|
61
|
+
await run(cmd, [...args, "label", "namespace", ns, NAMESPACE_LABEL, "--overwrite"], opts);
|
|
62
|
+
const pairs = setupAnnotationPairs(setupValues);
|
|
63
|
+
if (pairs.length) {
|
|
64
|
+
await run(cmd, [...args, "annotate", "namespace", ns, "--overwrite", ...pairs], opts);
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.warn(
|
|
68
|
+
`warning: could not tag namespace "${ns}" (it will be tagged on the next deploy): ` +
|
|
69
|
+
String(err.message ?? err).split("\n")[0]
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
15
74
|
// Emit a YAML double-quoted scalar so special characters (":", "/", etc.) are safe.
|
|
16
75
|
function yamlValue(v) {
|
|
17
76
|
return JSON.stringify(v);
|
|
@@ -48,7 +107,7 @@ image:
|
|
|
48
107
|
EXTRA_NPM_PACKAGES: ""
|
|
49
108
|
|
|
50
109
|
k8s:
|
|
51
|
-
namespace:
|
|
110
|
+
namespace: ${yamlValue(values.namespace)}
|
|
52
111
|
replicas: 1
|
|
53
112
|
serviceType: NodePort
|
|
54
113
|
nodePort: 30022 # default; override per project if several run at once
|
|
@@ -92,13 +151,29 @@ container:
|
|
|
92
151
|
|
|
93
152
|
// Copy the Dockerfile from the repo into the global folder (the build context)
|
|
94
153
|
// and add a .dockerignore so the SSH keys / this config never enter the build
|
|
95
|
-
// context. Existing files are never overwritten.
|
|
154
|
+
// context. Existing files are never overwritten. The copy is normalized to
|
|
155
|
+
// Unix (LF) line endings: a Windows (CRLF) checkout would otherwise bake
|
|
156
|
+
// '\r' into /entrypoint.sh via the Dockerfile heredoc, and the image then
|
|
157
|
+
// fails at boot with `exec /entrypoint.sh: no such file or directory`
|
|
158
|
+
// (the kernel looks for the interpreter `/bin/bash\r`).
|
|
96
159
|
function provisionDockerfile(dir) {
|
|
97
160
|
const dst = path.join(dir, "Dockerfile");
|
|
98
161
|
if (fs.existsSync(dst)) {
|
|
99
|
-
|
|
162
|
+
// An existing Dockerfile from an older setup may carry Windows (CRLF)
|
|
163
|
+
// line endings, which would bake '\r' into /entrypoint.sh. Normalizing
|
|
164
|
+
// is content-preserving for the build, so repair it in place rather
|
|
165
|
+
// than leaving a file that can only produce a broken image.
|
|
166
|
+
const existing = fs.readFileSync(dst, "utf8");
|
|
167
|
+
if (existing.includes("\r")) {
|
|
168
|
+
fs.writeFileSync(dst, existing.replaceAll("\r\n", "\n").replaceAll("\r", "\n"));
|
|
169
|
+
console.log(`normalized: ${dst} (converted CRLF to LF)`);
|
|
170
|
+
} else {
|
|
171
|
+
console.log(`kept: ${dst} (already exists, left untouched)`);
|
|
172
|
+
}
|
|
100
173
|
} else {
|
|
101
|
-
|
|
174
|
+
const src = path.join(PROJECT_ROOT, "Dockerfile");
|
|
175
|
+
const content = fs.readFileSync(src, "utf8").replaceAll("\r\n", "\n");
|
|
176
|
+
fs.writeFileSync(dst, content);
|
|
102
177
|
console.log(`created: ${dst} (copied from the repo)`);
|
|
103
178
|
}
|
|
104
179
|
const ignore = path.join(dir, ".dockerignore");
|
|
@@ -136,12 +211,14 @@ async function captureEnvironment(opts) {
|
|
|
136
211
|
const rl = interactive
|
|
137
212
|
? readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
138
213
|
: null;
|
|
139
|
-
const willPrompt = Boolean(
|
|
214
|
+
const willPrompt = Boolean(
|
|
215
|
+
rl && (!opts.registry || !opts.nfsServer || !opts.nfsBasePath || !opts.namespace)
|
|
216
|
+
);
|
|
140
217
|
if (willPrompt) {
|
|
141
218
|
console.log("A few questions about your environment (Enter skips to a placeholder):");
|
|
142
219
|
}
|
|
143
220
|
try {
|
|
144
|
-
|
|
221
|
+
const values = {
|
|
145
222
|
registry: await ask(rl, opts.registry, {
|
|
146
223
|
flagName: "registry",
|
|
147
224
|
label: "Container registry",
|
|
@@ -160,7 +237,15 @@ async function captureEnvironment(opts) {
|
|
|
160
237
|
example: "/workspaces",
|
|
161
238
|
placeholder: PLACEHOLDERS.nfsBasePath,
|
|
162
239
|
}),
|
|
240
|
+
namespace: await ask(rl, opts.namespace, {
|
|
241
|
+
flagName: "namespace",
|
|
242
|
+
label: "Coding namespace",
|
|
243
|
+
example: "coding / team-alpha",
|
|
244
|
+
placeholder: PLACEHOLDERS.namespace,
|
|
245
|
+
}),
|
|
163
246
|
};
|
|
247
|
+
values.namespace = normalizeNamespace(values.namespace);
|
|
248
|
+
return values;
|
|
164
249
|
} finally {
|
|
165
250
|
if (rl) rl.close();
|
|
166
251
|
}
|
|
@@ -175,10 +260,45 @@ export async function setup(opts = {}) {
|
|
|
175
260
|
const cfgFile = globalConfigPath();
|
|
176
261
|
if (fs.existsSync(cfgFile)) {
|
|
177
262
|
console.log(`kept: ${cfgFile} (already exists, left untouched)`);
|
|
263
|
+
// An explicit --namespace still tags that namespace (label + any
|
|
264
|
+
// explicitly-passed values) without touching the kept config file.
|
|
265
|
+
if (typeof opts.namespace === "string" && opts.namespace.trim()) {
|
|
266
|
+
await tagNamespaceBestEffort(normalizeNamespace(opts.namespace), {
|
|
267
|
+
registry: opts.registry,
|
|
268
|
+
nfsServer: opts.nfsServer,
|
|
269
|
+
nfsBasePath: opts.nfsBasePath,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
178
272
|
} else {
|
|
179
|
-
|
|
273
|
+
// Existing-namespace flow: the GUI hides registry/NFS and sends only
|
|
274
|
+
// --namespace. Fill blanks from the namespace's stored annotations so
|
|
275
|
+
// a reselect needs no retyping. Legacy namespaces created before the
|
|
276
|
+
// annotations existed carry no stored values, so fall back to the live
|
|
277
|
+
// workloads (deployment NFS volume + image registry). Missing/offline
|
|
278
|
+
// degrades to the normal prompt-or-placeholder path below.
|
|
279
|
+
const effective = { ...opts };
|
|
280
|
+
if (typeof effective.namespace === "string" && effective.namespace.trim()) {
|
|
281
|
+
try {
|
|
282
|
+
const ns = normalizeNamespace(effective.namespace);
|
|
283
|
+
const stored = readNamespaceSetupValues(ns);
|
|
284
|
+
for (const field of ["registry", "nfsServer", "nfsBasePath"]) {
|
|
285
|
+
if (!effective[field] && stored[field]) effective[field] = stored[field];
|
|
286
|
+
}
|
|
287
|
+
const missing = ["registry", "nfsServer", "nfsBasePath"].some((f) => !effective[f]);
|
|
288
|
+
if (missing) {
|
|
289
|
+
const inferred = readDeploymentSetupValues(ns);
|
|
290
|
+
for (const field of ["registry", "nfsServer", "nfsBasePath"]) {
|
|
291
|
+
if (!effective[field] && inferred[field]) effective[field] = inferred[field];
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
/* invalid name surfaces via captureEnvironment validation */
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const values = await captureEnvironment(effective);
|
|
180
299
|
fs.writeFileSync(cfgFile, renderTemplate(values));
|
|
181
300
|
console.log(`created: ${cfgFile}`);
|
|
301
|
+
await tagNamespaceBestEffort(values.namespace, values);
|
|
182
302
|
}
|
|
183
303
|
|
|
184
304
|
const pair = ensureKeyPair(path.join(dir, "keys"), "id_ed25519");
|
package/src/commands/system.js
CHANGED
|
@@ -64,7 +64,13 @@ export async function systemInit(cfg, { tag, keep = 10 } = {}) {
|
|
|
64
64
|
} catch (err) {
|
|
65
65
|
if (!/not found/i.test(err.message)) throw err;
|
|
66
66
|
}
|
|
67
|
-
await ensureNamespace(ns
|
|
67
|
+
await ensureNamespace(ns, {
|
|
68
|
+
setupValues: {
|
|
69
|
+
registry: cfg.image?.registry,
|
|
70
|
+
nfsServer: cfg.nfs?.server,
|
|
71
|
+
nfsBasePath: cfg.nfs?.basePath,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
68
74
|
await patchSystemConfig(ns, { current: tag, previous: undefined, maxEver: tag, keep });
|
|
69
75
|
console.log(
|
|
70
76
|
`Created ${SYSTEM_CM_NAME} in namespace ${ns}: current=${tag} maxEver=${tag} keep=${keep}`
|
package/src/index.js
CHANGED
|
@@ -99,6 +99,16 @@ program
|
|
|
99
99
|
.option("--registry <registry>", "container registry (e.g. registry.example.com/you)")
|
|
100
100
|
.option("--nfs-server <server>", "NFS server IP or hostname (your NAS)")
|
|
101
101
|
.option("--nfs-base-path <path>", "NFS base path for per-project workspaces")
|
|
102
|
+
// Same DNS-1123 pattern as defaultNamespace() in src/utils/instance.js.
|
|
103
|
+
.option("--namespace <name>", "coding namespace (e.g. coding or team-alpha)", (value) => {
|
|
104
|
+
const v = String(value).trim();
|
|
105
|
+
if (v.length < 1 || v.length > 63 || !/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(v)) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`--namespace must be a valid k8s name (lowercase alphanumerics and '-', max 63 chars), got: ${JSON.stringify(value)}`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return v;
|
|
111
|
+
})
|
|
102
112
|
.action(async (opts) => {
|
|
103
113
|
await setup(opts);
|
|
104
114
|
});
|
package/src/utils/exec.js
CHANGED
|
@@ -8,7 +8,13 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
8
8
|
export function dockerExec() {
|
|
9
9
|
const overridden = Boolean(process.env.DOCKER_BIN);
|
|
10
10
|
const cmd = process.env.DOCKER_BIN || "docker";
|
|
11
|
-
|
|
11
|
+
// windowsHide keeps console tools (docker/kubectl/ssh-keygen) from
|
|
12
|
+
// flashing a terminal window when the parent has no console (notably the
|
|
13
|
+
// packaged desktop app). No-op on other platforms.
|
|
14
|
+
const opts = {
|
|
15
|
+
windowsHide: true,
|
|
16
|
+
...(overridden && process.platform === "win32" ? { shell: true } : {}),
|
|
17
|
+
};
|
|
12
18
|
return { cmd, opts };
|
|
13
19
|
}
|
|
14
20
|
|
|
@@ -64,6 +70,7 @@ export function ensureDockerAvailable() {
|
|
|
64
70
|
*/
|
|
65
71
|
export function exec(cmd, args = [], opts = {}) {
|
|
66
72
|
const res = spawnSync(cmd, args, {
|
|
73
|
+
windowsHide: true,
|
|
67
74
|
encoding: "utf8",
|
|
68
75
|
stdio: ["ignore", "pipe", "pipe"],
|
|
69
76
|
...opts,
|
|
@@ -81,7 +88,7 @@ export function exec(cmd, args = [], opts = {}) {
|
|
|
81
88
|
*/
|
|
82
89
|
export function run(cmd, args = [], opts = {}) {
|
|
83
90
|
return new Promise((resolve, reject) => {
|
|
84
|
-
const child = spawn(cmd, args, { stdio: "inherit", ...opts });
|
|
91
|
+
const child = spawn(cmd, args, { windowsHide: true, stdio: "inherit", ...opts });
|
|
85
92
|
child.on("error", reject);
|
|
86
93
|
child.on("close", (code) => {
|
|
87
94
|
if (code === 0) resolve();
|
|
@@ -96,6 +103,7 @@ export function run(cmd, args = [], opts = {}) {
|
|
|
96
103
|
export function runWithInput(cmd, args = [], input = "", opts = {}) {
|
|
97
104
|
return new Promise((resolve, reject) => {
|
|
98
105
|
const child = spawn(cmd, args, {
|
|
106
|
+
windowsHide: true,
|
|
99
107
|
stdio: ["pipe", "inherit", "inherit"],
|
|
100
108
|
...opts,
|
|
101
109
|
});
|
|
@@ -114,5 +122,5 @@ export function runWithInput(cmd, args = [], input = "", opts = {}) {
|
|
|
114
122
|
* Spawn a long-running command, returning the child handle.
|
|
115
123
|
*/
|
|
116
124
|
export function start(cmd, args = [], opts = {}) {
|
|
117
|
-
return spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], ...opts });
|
|
125
|
+
return spawn(cmd, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"], ...opts });
|
|
118
126
|
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { exec } from "./exec.js";
|
|
2
|
+
import { kubectlExec, SYSTEM_CM_NAME } from "./system-config.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Label every coding-container namespace carries. The namespaces endpoint
|
|
6
|
+
* lists with `-l coding-container=true` server-side, so unlabeled namespaces
|
|
7
|
+
* are not coding-container namespaces by definition and never show up.
|
|
8
|
+
*/
|
|
9
|
+
export const NAMESPACE_LABEL = "coding-container=true";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Free-form setup values live in annotations (labels are for selection).
|
|
13
|
+
* Written at setup/init time, read back to prefill the Setup form when an
|
|
14
|
+
* existing namespace is picked.
|
|
15
|
+
*/
|
|
16
|
+
export const SETUP_ANNOTATIONS = {
|
|
17
|
+
registry: "coding-container.io/registry",
|
|
18
|
+
nfsServer: "coding-container.io/nfs-server",
|
|
19
|
+
nfsBasePath: "coding-container.io/nfs-base-path",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function coerceItems(json) {
|
|
23
|
+
let parsed = json;
|
|
24
|
+
if (typeof parsed === "string") {
|
|
25
|
+
try {
|
|
26
|
+
parsed = JSON.parse(parsed);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.items)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return parsed.items;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pure merge of `kubectl get ns -l coding-container=true -o json` with
|
|
39
|
+
* `kubectl get cm coding-system -A -o json`. A namespace counts as
|
|
40
|
+
* `initialized` when a coding-system ConfigMap lives in it. Namespaces
|
|
41
|
+
* without annotations prefill namespace-only (setupValues {}).
|
|
42
|
+
*
|
|
43
|
+
* Both inputs accept an already-parsed object or a raw JSON string.
|
|
44
|
+
* Anything unparseable or wrongly shaped degrades to [] so Setup stays
|
|
45
|
+
* usable offline with a manual-input fallback.
|
|
46
|
+
*/
|
|
47
|
+
export function parseNamespaces(nsJson, cmJson) {
|
|
48
|
+
const nsItems = coerceItems(nsJson);
|
|
49
|
+
if (!nsItems) return [];
|
|
50
|
+
const cmItems = coerceItems(cmJson) ?? [];
|
|
51
|
+
const initializedByNs = new Set();
|
|
52
|
+
for (const cm of cmItems) {
|
|
53
|
+
const cmNs = cm?.metadata?.namespace;
|
|
54
|
+
// The kubectl call is already name-filtered server-side; the name check
|
|
55
|
+
// keeps the pure function honest when handed an unfiltered list.
|
|
56
|
+
if (typeof cmNs === "string" && cm?.metadata?.name === SYSTEM_CM_NAME) {
|
|
57
|
+
initializedByNs.add(cmNs);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const item of nsItems) {
|
|
62
|
+
const name = item?.metadata?.name;
|
|
63
|
+
if (typeof name !== "string" || !name) continue;
|
|
64
|
+
const annotations = item?.metadata?.annotations;
|
|
65
|
+
const setupValues = {};
|
|
66
|
+
if (annotations && typeof annotations === "object") {
|
|
67
|
+
for (const [field, key] of Object.entries(SETUP_ANNOTATIONS)) {
|
|
68
|
+
const v = annotations[key];
|
|
69
|
+
if (typeof v === "string" && v.trim()) setupValues[field] = v;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
out.push({
|
|
73
|
+
name,
|
|
74
|
+
initialized: initializedByNs.has(name),
|
|
75
|
+
setupValues,
|
|
76
|
+
createdAt:
|
|
77
|
+
typeof item?.metadata?.creationTimestamp === "string"
|
|
78
|
+
? item.metadata.creationTimestamp
|
|
79
|
+
: undefined,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read-only cluster edge: exactly two `get` calls, never anything
|
|
88
|
+
* destructive. A failed namespace list means the cluster is unreachable and
|
|
89
|
+
* throws (the namespaces endpoint maps that to `{ ok: false }`); a failed
|
|
90
|
+
* ConfigMap read degrades to all-`initialized: false` so a partial answer
|
|
91
|
+
* still lists the namespaces.
|
|
92
|
+
*/
|
|
93
|
+
export function getNamespaces() {
|
|
94
|
+
const { cmd, args, opts } = kubectlExec();
|
|
95
|
+
let nsJson;
|
|
96
|
+
try {
|
|
97
|
+
nsJson = exec(cmd, [...args, "get", "ns", "-l", NAMESPACE_LABEL, "-o", "json"], opts);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
throw new Error(`cannot list coding-container namespaces:\n${err.message}`);
|
|
100
|
+
}
|
|
101
|
+
let cmJson = '{"items":[]}';
|
|
102
|
+
try {
|
|
103
|
+
cmJson = exec(cmd, [...args, "get", "cm", SYSTEM_CM_NAME, "-A", "-o", "json"], opts);
|
|
104
|
+
} catch {
|
|
105
|
+
cmJson = '{"items":[]}';
|
|
106
|
+
}
|
|
107
|
+
return parseNamespaces(nsJson, cmJson);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Extract the setup registry (everything before the image name) from a
|
|
112
|
+
* container image reference. Strips tag/digest first, then drops the last
|
|
113
|
+
* path segment (the image name):
|
|
114
|
+
* "registry.example.com/you/coding-container:v1.0.0" -> "registry.example.com/you"
|
|
115
|
+
* "10.0.0.5:5000/coding-container:v1.0.0" -> "10.0.0.5:5000"
|
|
116
|
+
* Returns "" when there is no registry prefix to infer.
|
|
117
|
+
*/
|
|
118
|
+
export function registryFromImage(image) {
|
|
119
|
+
const raw = String(image ?? "").trim();
|
|
120
|
+
if (!raw) return "";
|
|
121
|
+
let s = raw.split("@")[0];
|
|
122
|
+
const lastSlash = s.lastIndexOf("/");
|
|
123
|
+
const lastColon = s.lastIndexOf(":");
|
|
124
|
+
if (lastColon > lastSlash) s = s.slice(0, lastColon);
|
|
125
|
+
const slash = s.lastIndexOf("/");
|
|
126
|
+
if (slash <= 0) return "";
|
|
127
|
+
return s.slice(0, slash).trim();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Placeholder values that must never count as live cluster state. Mirrors
|
|
132
|
+
* PLACEHOLDERS in src/commands/setup.js (kept local to avoid a cycle:
|
|
133
|
+
* setup.js imports this module). A broken deploy written from placeholders
|
|
134
|
+
* (e.g. the legacy `wicked` case) must not poison inference for the rest.
|
|
135
|
+
*/
|
|
136
|
+
const PLACEHOLDER_VALUES = new Set([
|
|
137
|
+
"your-registry.example.com/you",
|
|
138
|
+
"your-nas-ip-or-hostname",
|
|
139
|
+
"/path/to/shared/workspaces",
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
function isUsableSetupValue(v) {
|
|
143
|
+
if (typeof v !== "string") return false;
|
|
144
|
+
const t = v.trim();
|
|
145
|
+
return Boolean(t) && !PLACEHOLDER_VALUES.has(t);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Pure extraction of setup values from `kubectl get deploy -n <ns> -o json`.
|
|
150
|
+
* Reads the NFS volume (`volumes[].nfs.{server,path}`) and the container
|
|
151
|
+
* image registry across all workloads and returns the most common value
|
|
152
|
+
* per field (majority vote), so one broken deploy written from placeholders
|
|
153
|
+
* cannot override the healthy majority. Placeholders and blanks are ignored.
|
|
154
|
+
* Accepts an already-parsed object or a raw JSON string; anything
|
|
155
|
+
* unparseable or wrongly shaped degrades to {}.
|
|
156
|
+
*/
|
|
157
|
+
export function parseDeploymentSetupValues(deployJson) {
|
|
158
|
+
let parsed = deployJson;
|
|
159
|
+
if (typeof parsed === "string") {
|
|
160
|
+
try {
|
|
161
|
+
parsed = JSON.parse(parsed);
|
|
162
|
+
} catch {
|
|
163
|
+
return {};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.items)) return {};
|
|
167
|
+
const votes = { registry: new Map(), nfsServer: new Map(), nfsBasePath: new Map() };
|
|
168
|
+
const vote = (field, value) => {
|
|
169
|
+
if (!isUsableSetupValue(value)) return;
|
|
170
|
+
const t = value.trim();
|
|
171
|
+
votes[field].set(t, (votes[field].get(t) ?? 0) + 1);
|
|
172
|
+
};
|
|
173
|
+
for (const item of parsed.items) {
|
|
174
|
+
const podSpec = item?.spec?.template?.spec;
|
|
175
|
+
if (!podSpec || typeof podSpec !== "object") continue;
|
|
176
|
+
const volumes = Array.isArray(podSpec.volumes) ? podSpec.volumes : [];
|
|
177
|
+
for (const vol of volumes) {
|
|
178
|
+
const nfs = vol?.nfs;
|
|
179
|
+
if (!nfs || typeof nfs !== "object") continue;
|
|
180
|
+
vote("nfsServer", nfs.server);
|
|
181
|
+
let basePath = typeof nfs.path === "string" ? nfs.path.trim() : "";
|
|
182
|
+
if (basePath) basePath = basePath.replace(/\/+$/, "") || "/";
|
|
183
|
+
vote("nfsBasePath", basePath);
|
|
184
|
+
}
|
|
185
|
+
const containers = Array.isArray(podSpec.containers) ? podSpec.containers : [];
|
|
186
|
+
for (const c of containers) vote("registry", registryFromImage(c?.image));
|
|
187
|
+
}
|
|
188
|
+
const out = {};
|
|
189
|
+
for (const [field, counts] of Object.entries(votes)) {
|
|
190
|
+
let best = "";
|
|
191
|
+
let bestN = 0;
|
|
192
|
+
for (const [value, n] of counts) {
|
|
193
|
+
if (n > bestN) {
|
|
194
|
+
best = value;
|
|
195
|
+
bestN = n;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (best) out[field] = best;
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Best-effort read of one namespace's live workload values (deployments).
|
|
205
|
+
* Returns {} when the namespace has no deployments, is unreachable, or
|
|
206
|
+
* carries no NFS/image info. Never throws, so `setup` stays usable offline.
|
|
207
|
+
*/
|
|
208
|
+
export function readDeploymentSetupValues(ns) {
|
|
209
|
+
const name = String(ns ?? "").trim();
|
|
210
|
+
if (!name) return {};
|
|
211
|
+
try {
|
|
212
|
+
const { cmd, args, opts } = kubectlExec();
|
|
213
|
+
const out = exec(cmd, [...args, "get", "deploy", "-n", name, "-o", "json"], opts);
|
|
214
|
+
return parseDeploymentSetupValues(out);
|
|
215
|
+
} catch {
|
|
216
|
+
return {};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Best-effort read of one namespace's stored setup values (annotations).
|
|
222
|
+
* Returns {} when the namespace is missing, unreachable, or has no
|
|
223
|
+
* annotations. Never throws, so `setup` stays usable offline.
|
|
224
|
+
*/
|
|
225
|
+
export function readNamespaceSetupValues(ns) {
|
|
226
|
+
const name = String(ns ?? "").trim();
|
|
227
|
+
if (!name) return {};
|
|
228
|
+
try {
|
|
229
|
+
const { cmd, args, opts } = kubectlExec();
|
|
230
|
+
const out = exec(cmd, [...args, "get", "namespace", name, "-o", "json"], opts);
|
|
231
|
+
let obj;
|
|
232
|
+
try {
|
|
233
|
+
obj = typeof out === "string" ? JSON.parse(out) : out;
|
|
234
|
+
} catch {
|
|
235
|
+
return {};
|
|
236
|
+
}
|
|
237
|
+
const annotations = obj?.metadata?.annotations;
|
|
238
|
+
if (!annotations || typeof annotations !== "object") return {};
|
|
239
|
+
const values = {};
|
|
240
|
+
for (const [field, key] of Object.entries(SETUP_ANNOTATIONS)) {
|
|
241
|
+
const v = annotations[key];
|
|
242
|
+
if (typeof v === "string" && v.trim()) values[field] = v.trim();
|
|
243
|
+
}
|
|
244
|
+
return values;
|
|
245
|
+
} catch {
|
|
246
|
+
return {};
|
|
247
|
+
}
|
|
248
|
+
}
|
package/src/utils/registry.js
CHANGED
|
@@ -88,6 +88,7 @@ export function queryCredentialHelper(store, host, spawn = spawnSync) {
|
|
|
88
88
|
encoding: "utf8",
|
|
89
89
|
stdio: ["pipe", "pipe", "pipe"],
|
|
90
90
|
timeout: 15000,
|
|
91
|
+
windowsHide: true,
|
|
91
92
|
});
|
|
92
93
|
if (res.error || res.status !== 0 || !res.stdout) continue;
|
|
93
94
|
const parsed = JSON.parse(res.stdout.trim());
|
|
@@ -31,12 +31,16 @@ export const INIT_HINT =
|
|
|
31
31
|
export function kubectlExec() {
|
|
32
32
|
const bin = process.env.KUBECTL_BIN || "kubectl";
|
|
33
33
|
if (process.env.KUBECTL_BIN && /\.(c|m)?js$/.test(bin)) {
|
|
34
|
-
return { cmd: process.execPath, args: [bin], opts: {} };
|
|
34
|
+
return { cmd: process.execPath, args: [bin], opts: { windowsHide: true } };
|
|
35
35
|
}
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
// windowsHide keeps kubectl from flashing a terminal window when the
|
|
37
|
+
// parent has no console (notably the packaged desktop app). No-op elsewhere.
|
|
38
|
+
const opts = {
|
|
39
|
+
windowsHide: true,
|
|
40
|
+
...(process.env.KUBECTL_BIN && process.platform === "win32" && /\.(cmd|bat)$/i.test(bin)
|
|
38
41
|
? { shell: true }
|
|
39
|
-
: {}
|
|
42
|
+
: {}),
|
|
43
|
+
};
|
|
40
44
|
return { cmd: bin, args: [], opts };
|
|
41
45
|
}
|
|
42
46
|
|