@esuyo/coding-container 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +126 -0
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/config.yaml +27 -0
- package/package.json +39 -0
- package/src/commands/build.js +35 -0
- package/src/commands/create.js +38 -0
- package/src/commands/delete.js +24 -0
- package/src/commands/deploy.js +138 -0
- package/src/commands/gc.js +160 -0
- package/src/commands/push.js +36 -0
- package/src/commands/setup.js +190 -0
- package/src/commands/ssh.js +103 -0
- package/src/commands/system.js +220 -0
- package/src/commands/validate.js +86 -0
- package/src/config.js +586 -0
- package/src/index.js +281 -0
- package/src/utils/envsubst.js +39 -0
- package/src/utils/exec.js +71 -0
- package/src/utils/keys.js +30 -0
- package/src/utils/manifest.js +326 -0
- package/src/utils/project.js +106 -0
- package/src/utils/registry-api.js +308 -0
- package/src/utils/registry.js +204 -0
- package/src/utils/semver.js +42 -0
- package/src/utils/ssh-config.js +83 -0
- package/src/utils/system-config.js +162 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { exec } from "../utils/exec.js";
|
|
2
|
+
import { listTags, deleteTag } from "../utils/registry-api.js";
|
|
3
|
+
import { allTagsSortAsc, isSemver, semverSortAsc } from "../utils/semver.js";
|
|
4
|
+
import { kubectlExec } from "../utils/system-config.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The tag part of a full image reference; "latest" when untagged.
|
|
8
|
+
*/
|
|
9
|
+
export function imageTag(image) {
|
|
10
|
+
const lastSlash = image.lastIndexOf("/");
|
|
11
|
+
const lastColon = image.lastIndexOf(":");
|
|
12
|
+
if (lastColon > lastSlash) return image.slice(lastColon + 1);
|
|
13
|
+
return "latest";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Retention plan for one repo (global or coding-<project>):
|
|
18
|
+
* keep = {pinned, previous (global repos only), all in-use tags,
|
|
19
|
+
* the `keep` semver predecessors of pinned (closest first)}
|
|
20
|
+
* toDelete = everything else, oldest first.
|
|
21
|
+
* Non-semver tags (legacy timestamp tags) are never counted in the keep
|
|
22
|
+
* window — they are deletable unless in use (or literally pinned).
|
|
23
|
+
* In-use tags are never deleted, even more than `keep` behind pinned.
|
|
24
|
+
*/
|
|
25
|
+
export function planGc({ tags, inUse, pinned, previous, keep }) {
|
|
26
|
+
const sortedSemver = semverSortAsc(tags);
|
|
27
|
+
const keepSet = new Set();
|
|
28
|
+
if (pinned) keepSet.add(pinned);
|
|
29
|
+
if (previous) keepSet.add(previous);
|
|
30
|
+
for (const t of inUse) keepSet.add(t);
|
|
31
|
+
if (pinned && isSemver(pinned)) {
|
|
32
|
+
const idx = sortedSemver.indexOf(pinned);
|
|
33
|
+
if (idx > 0) {
|
|
34
|
+
for (const t of sortedSemver.slice(Math.max(0, idx - keep), idx)) keepSet.add(t);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const toDelete = [
|
|
38
|
+
...sortedSemver.filter((t) => !keepSet.has(t)),
|
|
39
|
+
...[...tags].filter((t) => !isSemver(t) && !keepSet.has(t)).sort(),
|
|
40
|
+
];
|
|
41
|
+
const kept = allTagsSortAsc([...tags].filter((t) => keepSet.has(t)));
|
|
42
|
+
const nonSemver = [...tags].filter((t) => !isSemver(t)).sort();
|
|
43
|
+
return { toDelete, kept, nonSemver };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Tags referenced by coding-container deployments in the namespace — the
|
|
48
|
+
* images the cluster currently runs, which must never be deleted.
|
|
49
|
+
*/
|
|
50
|
+
function inUseTags(cfg) {
|
|
51
|
+
const { cmd, args, opts } = kubectlExec();
|
|
52
|
+
let out;
|
|
53
|
+
try {
|
|
54
|
+
out = exec(cmd, [
|
|
55
|
+
...args,
|
|
56
|
+
"get", "deploy", "-n", cfg.k8s.namespace,
|
|
57
|
+
"-l", "coding-container=true", "-o", "json",
|
|
58
|
+
], opts);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`cannot list deployments in namespace ${cfg.k8s.namespace} to determine which ` +
|
|
62
|
+
`image tags are in use:\n${err.message}\n` +
|
|
63
|
+
`Refusing to delete tags without knowing which images are deployed.`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const tags = new Set();
|
|
67
|
+
for (const d of JSON.parse(out).items || []) {
|
|
68
|
+
const pod = d.spec?.template?.spec || {};
|
|
69
|
+
for (const c of [...(pod.containers || []), ...(pod.initContainers || [])]) {
|
|
70
|
+
if (c.image) tags.add(imageTag(c.image));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return tags;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* List tags of cfg's repo, planning and (unless dryRun) deleting the
|
|
78
|
+
* unreferenced ones per planGc(). Shared by bare `gc` (custom repo) and
|
|
79
|
+
* `system gc` (global repo).
|
|
80
|
+
*/
|
|
81
|
+
export async function gcTags(cfg, { pinned, previous, keep, dryRun }) {
|
|
82
|
+
console.log(`Listing tags of ${cfg.image.registry}/${cfg.image.name} ...`);
|
|
83
|
+
let tags;
|
|
84
|
+
try {
|
|
85
|
+
tags = await listTags(cfg);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (/was not found/i.test(err.message)) {
|
|
88
|
+
console.log("No tags found; nothing to do.");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
if (tags.length === 0) {
|
|
94
|
+
console.log("No tags found; nothing to do.");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const inUse = inUseTags(cfg);
|
|
99
|
+
const { toDelete, kept, nonSemver } = planGc({ tags, inUse, pinned, previous, keep });
|
|
100
|
+
console.log(
|
|
101
|
+
`Found ${tags.length} tag(s); in use: ${[...inUse].join(", ") || "none"}; ` +
|
|
102
|
+
`keeping ${kept.length}, ` +
|
|
103
|
+
(dryRun ? "would delete" : "deleting") + ` ${toDelete.length}.`
|
|
104
|
+
);
|
|
105
|
+
if (nonSemver.length) {
|
|
106
|
+
console.log(
|
|
107
|
+
`Note: ${nonSemver.length} non-semver tag(s) (${nonSemver.join(", ")}) are not ` +
|
|
108
|
+
`counted in the keep window (legacy tags); they are deleted unless in use.`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (toDelete.length === 0) {
|
|
113
|
+
console.log("Nothing to delete.");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (dryRun) {
|
|
117
|
+
for (const t of toDelete) console.log(` would delete ${t}`);
|
|
118
|
+
console.log("(dry run — nothing was deleted)");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const failed = [];
|
|
123
|
+
for (const t of toDelete) {
|
|
124
|
+
try {
|
|
125
|
+
await deleteTag(cfg, t);
|
|
126
|
+
console.log(`Deleted ${t}`);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
failed.push(t);
|
|
129
|
+
console.error(`Failed to delete ${t}: ${err.message}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (failed.length) {
|
|
133
|
+
throw new Error(`could not delete ${failed.length} tag(s): ${failed.join(", ")}`);
|
|
134
|
+
}
|
|
135
|
+
console.log(
|
|
136
|
+
`Deleted ${toDelete.length} tag(s). Disk space is reclaimed when the registry ` +
|
|
137
|
+
`runs its own garbage collection (automatic on GitLab/Harbor; ` +
|
|
138
|
+
`"registry garbage-collect" on a bare Docker registry).`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Bare `gc`: garbage-collects the current project's custom repo
|
|
144
|
+
* (registry/coding-<project>). Global-mode projects have no custom repo —
|
|
145
|
+
* the shared image is pruned by `system gc` instead.
|
|
146
|
+
*/
|
|
147
|
+
export async function gc(cfg, { keep = 5, dryRun = false } = {}) {
|
|
148
|
+
if (cfg.imageMode === "global") {
|
|
149
|
+
throw new Error(
|
|
150
|
+
"this project uses the shared global image — there is no project repo to prune. " +
|
|
151
|
+
"Use 'coding-container system gc' to garbage-collect the global image."
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (!cfg.image.tag) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
"custom image mode requires a tag: set image.tag in the project config or pass --tag"
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
await gcTags(cfg, { pinned: cfg.image.tag, previous: undefined, keep, dryRun });
|
|
160
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { dockerExec, run } from "../utils/exec.js";
|
|
2
|
+
import { imageRef } from "../config.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The raw docker push of cfg's image (cfg.image.tag must be set). Used by
|
|
6
|
+
* custom-mode projects and by `system create` (the only writer of the
|
|
7
|
+
* global image).
|
|
8
|
+
*/
|
|
9
|
+
export async function pushImage(cfg) {
|
|
10
|
+
const ref = imageRef(cfg);
|
|
11
|
+
console.log(`Pushing ${ref} to ${cfg.image.registry} ...`);
|
|
12
|
+
const { cmd, opts } = dockerExec();
|
|
13
|
+
try {
|
|
14
|
+
await run(cmd, ["push", ref], opts);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`Push failed for ${ref}. Make sure you are logged in to ${cfg.image.registry} (docker login ${cfg.image.registry}).\n${err.message}`
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
console.log(`Pushed ${ref}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function push(cfg) {
|
|
24
|
+
if (cfg.imageMode === "global") {
|
|
25
|
+
throw new Error(
|
|
26
|
+
"this project uses the shared global image — pushing here would publish to the global repo. " +
|
|
27
|
+
"Publish a new global version with: coding-container system create --tag vX.Y.Z"
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (!cfg.image.tag) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
"custom image mode requires a tag: set image.tag in the project config or pass --tag"
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
await pushImage(cfg);
|
|
36
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import readline from "node:readline/promises";
|
|
4
|
+
import { globalConfigDir, globalConfigPath, PROJECT_ROOT } from "../config.js";
|
|
5
|
+
import { ensureKeyPair } from "../utils/keys.js";
|
|
6
|
+
|
|
7
|
+
// Values that differ per environment. When neither a flag nor a prompt supplies
|
|
8
|
+
// one, the generated config gets a clearly-marked placeholder instead of a guess.
|
|
9
|
+
const PLACEHOLDERS = {
|
|
10
|
+
registry: "your-registry.example.com/you",
|
|
11
|
+
nfsServer: "your-nas-ip-or-hostname",
|
|
12
|
+
nfsBasePath: "/path/to/shared/workspaces",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Emit a YAML double-quoted scalar so special characters (":", "/", etc.) are safe.
|
|
16
|
+
function yamlValue(v) {
|
|
17
|
+
return JSON.stringify(v);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderTemplate(dir, values) {
|
|
21
|
+
return `# coding-container global configuration (created by 'coding-container setup')
|
|
22
|
+
#
|
|
23
|
+
# Shared by every project. Layering, lowest to highest priority:
|
|
24
|
+
# built-in defaults -> THIS file -> the project's config.yaml -> CLI flags
|
|
25
|
+
#
|
|
26
|
+
# Relative paths in this file resolve against this file's directory.
|
|
27
|
+
# The Dockerfile and the SSH key pair live next to this file:
|
|
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)
|
|
30
|
+
|
|
31
|
+
image:
|
|
32
|
+
registry: ${yamlValue(values.registry)}
|
|
33
|
+
name: coding-container
|
|
34
|
+
# tag: optional local fallback (e.g. for offline 'validate --manifest').
|
|
35
|
+
# Normal deploys float on the cluster's coding-system ConfigMap:
|
|
36
|
+
# coding-container system init --tag v1.0.0 (once, on a fresh cluster)
|
|
37
|
+
# coding-container system get (show current/previous/maxEver)
|
|
38
|
+
# Set tag in a project config to pin that project to one global version.
|
|
39
|
+
# Single image build shared by all projects (Dockerfile copied next to this file).
|
|
40
|
+
dockerfile: ${path.join(dir, "Dockerfile")}
|
|
41
|
+
context: ${dir}
|
|
42
|
+
buildArgs:
|
|
43
|
+
BASE_IMAGE: ubuntu:26.04
|
|
44
|
+
NODE_VERSION: "22"
|
|
45
|
+
EXTRA_APT_PACKAGES: ""
|
|
46
|
+
EXTRA_NPM_PACKAGES: ""
|
|
47
|
+
|
|
48
|
+
k8s:
|
|
49
|
+
namespace: coding
|
|
50
|
+
replicas: 1
|
|
51
|
+
serviceType: NodePort
|
|
52
|
+
nodePort: 30022 # default; override per project if several run at once
|
|
53
|
+
imagePullSecret: ""
|
|
54
|
+
resources:
|
|
55
|
+
requests:
|
|
56
|
+
cpu: 100m
|
|
57
|
+
memory: 128Mi
|
|
58
|
+
limits:
|
|
59
|
+
cpu: "2"
|
|
60
|
+
memory: 8Gi
|
|
61
|
+
env: {}
|
|
62
|
+
# Secret-backed env (values hidden from kubectl get deploy output):
|
|
63
|
+
# deployed as the managed <project>-env Secret and referenced via valueFrom.
|
|
64
|
+
# \${VAR} is expanded from your local shell at deploy time (missing vars
|
|
65
|
+
# fail fast); plain values stay in this file, so prefer \${} for real secrets.
|
|
66
|
+
# secretEnv:
|
|
67
|
+
# OPENAI_API_KEY: \${OPENAI_API_KEY}
|
|
68
|
+
|
|
69
|
+
nfs:
|
|
70
|
+
server: ${yamlValue(values.nfsServer)}
|
|
71
|
+
basePath: ${yamlValue(values.nfsBasePath)}
|
|
72
|
+
# Extra NAS folders mounted into the container alongside /workspace. Each
|
|
73
|
+
# subPath is a subfolder of basePath (created on first mount); readOnly
|
|
74
|
+
# (default false) blocks container writes.
|
|
75
|
+
# volumes:
|
|
76
|
+
# - { subPath: shared-datasets, mountPath: /data, readOnly: true }
|
|
77
|
+
|
|
78
|
+
ssh:
|
|
79
|
+
user: root
|
|
80
|
+
port: 22
|
|
81
|
+
keyName: id_ed25519
|
|
82
|
+
# keyDir intentionally unset: keys default to ./keys next to this file.
|
|
83
|
+
# Set ssh.keyDir in a project config to keep a per-project key pair instead.
|
|
84
|
+
|
|
85
|
+
container:
|
|
86
|
+
workdir: /workspace
|
|
87
|
+
localPort: 2222
|
|
88
|
+
`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Copy the Dockerfile from the repo into the global folder (the build context)
|
|
92
|
+
// and add a .dockerignore so the SSH keys / this config never enter the build
|
|
93
|
+
// context. Existing files are never overwritten.
|
|
94
|
+
function provisionDockerfile(dir) {
|
|
95
|
+
const dst = path.join(dir, "Dockerfile");
|
|
96
|
+
if (fs.existsSync(dst)) {
|
|
97
|
+
console.log(`kept: ${dst} (already exists, left untouched)`);
|
|
98
|
+
} else {
|
|
99
|
+
fs.copyFileSync(path.join(PROJECT_ROOT, "Dockerfile"), dst);
|
|
100
|
+
console.log(`created: ${dst} (copied from the repo)`);
|
|
101
|
+
}
|
|
102
|
+
const ignore = path.join(dir, ".dockerignore");
|
|
103
|
+
if (!fs.existsSync(ignore)) {
|
|
104
|
+
fs.writeFileSync(ignore, "keys/\nconfig.yaml\n*.pem\n");
|
|
105
|
+
console.log(`created: ${ignore}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Ask for one environment value. A flag wins; otherwise an interactive prompt
|
|
110
|
+
// (only on a real TTY); otherwise a clearly-marked placeholder. Never blocks
|
|
111
|
+
// when run non-interactively.
|
|
112
|
+
async function ask(rl, value, { flagName, label, example, placeholder }) {
|
|
113
|
+
if (value) return value;
|
|
114
|
+
if (rl) {
|
|
115
|
+
let text = "";
|
|
116
|
+
try {
|
|
117
|
+
text = await rl.question(`${label} [e.g. ${example}]: `);
|
|
118
|
+
} catch {
|
|
119
|
+
/* stdin closed (Ctrl-D) -> fall through to the placeholder */
|
|
120
|
+
}
|
|
121
|
+
const v = (text || "").trim();
|
|
122
|
+
if (v) return v;
|
|
123
|
+
console.warn(
|
|
124
|
+
`warning: no value for ${label.toLowerCase()}; wrote a placeholder. ` +
|
|
125
|
+
`Set it in the global config or re-run: coding-container setup --${flagName} <value>`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return placeholder;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Collect the environment-specific values for a fresh global config.
|
|
132
|
+
async function captureEnvironment(opts) {
|
|
133
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
134
|
+
const rl = interactive
|
|
135
|
+
? readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
136
|
+
: null;
|
|
137
|
+
const willPrompt = Boolean(rl && (!opts.registry || !opts.nfsServer || !opts.nfsBasePath));
|
|
138
|
+
if (willPrompt) {
|
|
139
|
+
console.log("A few questions about your environment (Enter skips to a placeholder):");
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
return {
|
|
143
|
+
registry: await ask(rl, opts.registry, {
|
|
144
|
+
flagName: "registry",
|
|
145
|
+
label: "Container registry",
|
|
146
|
+
example: "registry.example.com/you",
|
|
147
|
+
placeholder: PLACEHOLDERS.registry,
|
|
148
|
+
}),
|
|
149
|
+
nfsServer: await ask(rl, opts.nfsServer, {
|
|
150
|
+
flagName: "nfs-server",
|
|
151
|
+
label: "NFS server (NAS IP or hostname)",
|
|
152
|
+
example: "10.0.0.5",
|
|
153
|
+
placeholder: PLACEHOLDERS.nfsServer,
|
|
154
|
+
}),
|
|
155
|
+
nfsBasePath: await ask(rl, opts.nfsBasePath, {
|
|
156
|
+
flagName: "nfs-base-path",
|
|
157
|
+
label: "NFS base path (shared folder for per-project workspaces)",
|
|
158
|
+
example: "/workspaces",
|
|
159
|
+
placeholder: PLACEHOLDERS.nfsBasePath,
|
|
160
|
+
}),
|
|
161
|
+
};
|
|
162
|
+
} finally {
|
|
163
|
+
if (rl) rl.close();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function setup(opts = {}) {
|
|
168
|
+
const dir = globalConfigDir();
|
|
169
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
170
|
+
|
|
171
|
+
provisionDockerfile(dir);
|
|
172
|
+
|
|
173
|
+
const cfgFile = globalConfigPath();
|
|
174
|
+
if (fs.existsSync(cfgFile)) {
|
|
175
|
+
console.log(`kept: ${cfgFile} (already exists, left untouched)`);
|
|
176
|
+
} else {
|
|
177
|
+
const values = await captureEnvironment(opts);
|
|
178
|
+
fs.writeFileSync(cfgFile, renderTemplate(dir, values));
|
|
179
|
+
console.log(`created: ${cfgFile}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const pair = ensureKeyPair(path.join(dir, "keys"), "id_ed25519");
|
|
183
|
+
console.log(`keys: ${pair.private}`);
|
|
184
|
+
|
|
185
|
+
console.log(
|
|
186
|
+
`\nDone. Edit the global config if your environment differs (registry, NFS server),\n` +
|
|
187
|
+
`then run (once, on a fresh cluster): coding-container system init --tag v1.0.0\n` +
|
|
188
|
+
`and in any project folder: coding-container create`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { exec, run, start } from "../utils/exec.js";
|
|
4
|
+
import { ensureKeyPair } from "../utils/keys.js";
|
|
5
|
+
|
|
6
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
7
|
+
|
|
8
|
+
function portAvailable(port) {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
const srv = net.createServer();
|
|
11
|
+
srv.once("error", () => resolve(false));
|
|
12
|
+
srv.listen(port, "127.0.0.1", () => srv.close(() => resolve(true)));
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function findFreePort() {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const srv = net.createServer();
|
|
19
|
+
srv.once("error", reject);
|
|
20
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
21
|
+
const port = srv.address().port;
|
|
22
|
+
srv.close(() => resolve(port));
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function waitForReady(child, ms = 15000) {
|
|
28
|
+
const deadline = Date.now() + ms;
|
|
29
|
+
while (Date.now() < deadline) {
|
|
30
|
+
if (child.ready) return true;
|
|
31
|
+
await sleep(200);
|
|
32
|
+
}
|
|
33
|
+
return child.ready;
|
|
34
|
+
}
|
|
35
|
+
|
|
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.
|
|
40
|
+
*/
|
|
41
|
+
function sshCommonOptions(cfg, port, privateKey) {
|
|
42
|
+
return [
|
|
43
|
+
"-i",
|
|
44
|
+
privateKey,
|
|
45
|
+
"-p",
|
|
46
|
+
String(port),
|
|
47
|
+
"-o",
|
|
48
|
+
"StrictHostKeyChecking=no",
|
|
49
|
+
"-o",
|
|
50
|
+
`UserKnownHostsFile=${path.join(cfg.ssh.keyDirPath, `${cfg.project}-known_hosts`)}`,
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function sshDirect(cfg, privateKey) {
|
|
55
|
+
const nodeIp = exec("kubectl", [
|
|
56
|
+
"get",
|
|
57
|
+
"nodes",
|
|
58
|
+
"-o",
|
|
59
|
+
"jsonpath={.items[0].status.addresses[?(@.type==\"InternalIP\")].address}",
|
|
60
|
+
]);
|
|
61
|
+
await run("ssh", [...sshCommonOptions(cfg, cfg.k8s.nodePort, privateKey), `${cfg.ssh.user}@${nodeIp}`]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function ssh(cfg, { direct = false } = {}) {
|
|
65
|
+
const { private: privateKey } = ensureKeyPair(cfg.ssh.keyDirPath, cfg.ssh.keyName);
|
|
66
|
+
|
|
67
|
+
if (direct) {
|
|
68
|
+
await sshDirect(cfg, privateKey);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let localPort = cfg.container.localPort;
|
|
73
|
+
if (!(await portAvailable(localPort))) {
|
|
74
|
+
localPort = await findFreePort();
|
|
75
|
+
console.log(`Local port ${cfg.container.localPort} is in use, using ${localPort} instead`);
|
|
76
|
+
}
|
|
77
|
+
const pf = start("kubectl", [
|
|
78
|
+
"-n",
|
|
79
|
+
cfg.k8s.namespace,
|
|
80
|
+
"port-forward",
|
|
81
|
+
`svc/${cfg.project}`,
|
|
82
|
+
`${localPort}:${cfg.ssh.port}`,
|
|
83
|
+
]);
|
|
84
|
+
pf.stdout.on("data", (d) => {
|
|
85
|
+
if (String(d).includes("Forwarding from")) pf.ready = true;
|
|
86
|
+
});
|
|
87
|
+
pf.stderr.on("data", (d) => process.stderr.write(d));
|
|
88
|
+
|
|
89
|
+
if (!(await waitForReady(pf))) {
|
|
90
|
+
pf.kill();
|
|
91
|
+
throw new Error(`port-forward to svc/${cfg.project} did not start in time`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let failed = false;
|
|
95
|
+
try {
|
|
96
|
+
await run("ssh", [...sshCommonOptions(cfg, localPort, privateKey), `${cfg.ssh.user}@127.0.0.1`]);
|
|
97
|
+
} catch {
|
|
98
|
+
failed = true;
|
|
99
|
+
} finally {
|
|
100
|
+
pf.kill();
|
|
101
|
+
}
|
|
102
|
+
if (failed) process.exitCode = 255;
|
|
103
|
+
}
|