@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,220 @@
|
|
|
1
|
+
import { exec } from "../utils/exec.js";
|
|
2
|
+
import { listTags } from "../utils/registry-api.js";
|
|
3
|
+
import { isSemver, compareSemver, semverSortAsc } from "../utils/semver.js";
|
|
4
|
+
import {
|
|
5
|
+
getSystemConfig,
|
|
6
|
+
patchSystemConfig,
|
|
7
|
+
kubectlExec,
|
|
8
|
+
SYSTEM_CM_NAME,
|
|
9
|
+
} from "../utils/system-config.js";
|
|
10
|
+
import { buildImage } from "./build.js";
|
|
11
|
+
import { pushImage } from "./push.js";
|
|
12
|
+
import { gcTags } from "./gc.js";
|
|
13
|
+
import { ensureNamespace } from "./deploy.js";
|
|
14
|
+
|
|
15
|
+
function requireGlobalMode(cfg) {
|
|
16
|
+
if (cfg.imageMode === "custom") {
|
|
17
|
+
throw new Error(
|
|
18
|
+
"system commands manage the shared global image and must be run from a " +
|
|
19
|
+
"global-mode project (or the repo root) — this project uses its own custom image."
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function requireSemverTag(tag, what = "--tag") {
|
|
25
|
+
if (!tag) throw new Error(`${what} is required (semver vX.Y.Z)`);
|
|
26
|
+
if (!isSemver(tag)) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`${what} must be semver vX.Y.Z (human-picked, no timestamps, no pre-release), got: ${tag}`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Registry tags of the global repo; an empty list when the repo has not
|
|
35
|
+
* been created in the registry yet (nothing pushed so far).
|
|
36
|
+
*/
|
|
37
|
+
async function globalTags(cfg) {
|
|
38
|
+
try {
|
|
39
|
+
return await listTags(cfg);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (/was not found/i.test(err.message)) return [];
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create the coding-system ConfigMap (fresh cluster only). Fails when the
|
|
48
|
+
* ConfigMap already exists — there is exactly one, per cluster.
|
|
49
|
+
*/
|
|
50
|
+
export async function systemInit(cfg, { tag, keep = 10 } = {}) {
|
|
51
|
+
requireGlobalMode(cfg);
|
|
52
|
+
requireSemverTag(tag);
|
|
53
|
+
if (!Number.isInteger(keep) || keep < 0) {
|
|
54
|
+
throw new Error(`--keep must be a non-negative integer, got: ${keep}`);
|
|
55
|
+
}
|
|
56
|
+
const ns = cfg.k8s.namespace;
|
|
57
|
+
const { cmd, args, opts } = kubectlExec();
|
|
58
|
+
try {
|
|
59
|
+
exec(cmd, [...args, "get", "cm", SYSTEM_CM_NAME, "-n", ns], opts);
|
|
60
|
+
throw new Error(
|
|
61
|
+
`the ${SYSTEM_CM_NAME} ConfigMap already exists in namespace ${ns} — ` +
|
|
62
|
+
`inspect it with 'coding-container system get' and publish new versions with 'system create --tag vX.Y.Z'`
|
|
63
|
+
);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (!/not found/i.test(err.message)) throw err;
|
|
66
|
+
}
|
|
67
|
+
await ensureNamespace(ns);
|
|
68
|
+
await patchSystemConfig(ns, { current: tag, previous: undefined, maxEver: tag, keep });
|
|
69
|
+
console.log(
|
|
70
|
+
`Created ${SYSTEM_CM_NAME} in namespace ${ns}: current=${tag} maxEver=${tag} keep=${keep}`
|
|
71
|
+
);
|
|
72
|
+
console.log(`Global image: ${cfg.image.registry}/${cfg.image.name}:${tag}`);
|
|
73
|
+
console.log("Projects now deploy it on their next 'coding-container deploy'.");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Show the coding-system state and the resolved global image reference.
|
|
78
|
+
*/
|
|
79
|
+
export function systemGet(cfg) {
|
|
80
|
+
requireGlobalMode(cfg);
|
|
81
|
+
const sys = getSystemConfig(cfg.k8s.namespace);
|
|
82
|
+
console.log(`${SYSTEM_CM_NAME} ConfigMap in namespace ${cfg.k8s.namespace}:`);
|
|
83
|
+
console.log(` current: ${sys.current}`);
|
|
84
|
+
console.log(` previous: ${sys.previous ?? "none (no rollback target recorded yet)"}`);
|
|
85
|
+
console.log(` maxEver: ${sys.maxEver}`);
|
|
86
|
+
console.log(` keep: ${sys.keep}`);
|
|
87
|
+
console.log(`Global image: ${cfg.image.registry}/${cfg.image.name}:${sys.current}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* List the global repo's registry tags, semver-sorted. Read-only helper
|
|
92
|
+
* for picking the next version.
|
|
93
|
+
*/
|
|
94
|
+
export async function systemList(cfg) {
|
|
95
|
+
requireGlobalMode(cfg);
|
|
96
|
+
console.log(`Listing tags of ${cfg.image.registry}/${cfg.image.name} ...`);
|
|
97
|
+
const tags = await globalTags(cfg);
|
|
98
|
+
if (tags.length === 0) {
|
|
99
|
+
console.log("No tags found.");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
let sys = null;
|
|
103
|
+
try {
|
|
104
|
+
sys = getSystemConfig(cfg.k8s.namespace);
|
|
105
|
+
} catch {
|
|
106
|
+
/* offline or not initialized — just list */
|
|
107
|
+
}
|
|
108
|
+
for (const t of [...semverSortAsc(tags)].reverse()) {
|
|
109
|
+
let mark = "";
|
|
110
|
+
if (sys) {
|
|
111
|
+
if (t === sys.current) mark = " <- current";
|
|
112
|
+
else if (t === sys.previous) mark = " (previous)";
|
|
113
|
+
else if (t === sys.maxEver) mark = " (maxEver)";
|
|
114
|
+
}
|
|
115
|
+
console.log(` ${t}${mark}`);
|
|
116
|
+
}
|
|
117
|
+
const other = tags.filter((t) => !isSemver(t));
|
|
118
|
+
if (other.length) {
|
|
119
|
+
console.log(` non-semver (not part of the version history): ${[...other].sort().join(", ")}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The ONLY writer of the global image: build + push a new immutable tag,
|
|
125
|
+
* then repoint the ConfigMap (previous=current, current=tag, maxEver=tag).
|
|
126
|
+
*/
|
|
127
|
+
export async function systemCreate(cfg, { tag } = {}) {
|
|
128
|
+
requireGlobalMode(cfg);
|
|
129
|
+
requireSemverTag(tag);
|
|
130
|
+
const ns = cfg.k8s.namespace;
|
|
131
|
+
const sys = getSystemConfig(ns);
|
|
132
|
+
if (compareSemver(tag, sys.maxEver) <= 0) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`tag ${tag} is not greater than maxEver ${sys.maxEver} — global tags are immutable and ` +
|
|
135
|
+
`a rolled-back version can never be republished. Pick a new, higher version.`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
console.log(`Checking that ${tag} does not exist in ${cfg.image.registry}/${cfg.image.name} ...`);
|
|
139
|
+
const tags = await globalTags(cfg);
|
|
140
|
+
if (tags.includes(tag)) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`tag ${tag} already exists in ${cfg.image.registry}/${cfg.image.name} — ` +
|
|
143
|
+
`tags are immutable, pick another version`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
cfg.image.tag = tag;
|
|
147
|
+
await buildImage(cfg);
|
|
148
|
+
await pushImage(cfg);
|
|
149
|
+
await patchSystemConfig(ns, {
|
|
150
|
+
current: tag,
|
|
151
|
+
previous: sys.current,
|
|
152
|
+
maxEver: tag,
|
|
153
|
+
keep: sys.keep,
|
|
154
|
+
});
|
|
155
|
+
console.log(
|
|
156
|
+
`Published ${cfg.image.registry}/${cfg.image.name}:${tag} — ` +
|
|
157
|
+
`current ${sys.current} -> ${tag}, previous -> ${sys.current}, maxEver -> ${tag}`
|
|
158
|
+
);
|
|
159
|
+
console.log("Projects now deploy " + tag + " on their next 'coding-container deploy'.");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Repoint current to an existing version (default: previous). Pure pointer
|
|
164
|
+
* change — no build, no push, no delete. maxEver never moves.
|
|
165
|
+
*/
|
|
166
|
+
export async function systemRollback(cfg, { tag } = {}) {
|
|
167
|
+
requireGlobalMode(cfg);
|
|
168
|
+
const ns = cfg.k8s.namespace;
|
|
169
|
+
const sys = getSystemConfig(ns);
|
|
170
|
+
const target = tag ?? sys.previous;
|
|
171
|
+
if (!target) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
"no rollback target: 'previous' is not set yet (nothing has been published or " +
|
|
174
|
+
"rolled back since init). Pass a version explicitly: coding-container system rollback vX.Y.Z"
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
requireSemverTag(target, "target version");
|
|
178
|
+
if (target === sys.current) {
|
|
179
|
+
throw new Error(`${target} is already current — nothing to roll back to`);
|
|
180
|
+
}
|
|
181
|
+
console.log(`Checking that ${target} exists in ${cfg.image.registry}/${cfg.image.name} ...`);
|
|
182
|
+
const tags = await globalTags(cfg);
|
|
183
|
+
if (!tags.includes(target)) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`tag ${target} does not exist in ${cfg.image.registry}/${cfg.image.name} — ` +
|
|
186
|
+
`'coding-container system list' shows what is available`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
await patchSystemConfig(ns, {
|
|
190
|
+
current: target,
|
|
191
|
+
previous: sys.current,
|
|
192
|
+
maxEver: sys.maxEver,
|
|
193
|
+
keep: sys.keep,
|
|
194
|
+
});
|
|
195
|
+
console.log(
|
|
196
|
+
`Rolled back: current ${sys.current} -> ${target}, previous -> ${sys.current} ` +
|
|
197
|
+
`(maxEver stays ${sys.maxEver})`
|
|
198
|
+
);
|
|
199
|
+
console.log(
|
|
200
|
+
"Projects now deploy " +
|
|
201
|
+
target +
|
|
202
|
+
" on their next 'coding-container deploy'. No image was built, pushed or deleted."
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Garbage-collect the global repo only. Keeps current + previous + every
|
|
208
|
+
* in-use tag + the ConfigMap's keep-many predecessors of current.
|
|
209
|
+
* `--keep` overrides the window for one run.
|
|
210
|
+
*/
|
|
211
|
+
export async function systemGc(cfg, { keep, dryRun = false } = {}) {
|
|
212
|
+
requireGlobalMode(cfg);
|
|
213
|
+
const sys = getSystemConfig(cfg.k8s.namespace);
|
|
214
|
+
await gcTags(cfg, {
|
|
215
|
+
pinned: sys.current,
|
|
216
|
+
previous: sys.previous,
|
|
217
|
+
keep: keep !== undefined ? keep : sys.keep,
|
|
218
|
+
dryRun,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { imageRef } from "../config.js";
|
|
4
|
+
import { fullManifest, secretEnvEntries } from "../utils/manifest.js";
|
|
5
|
+
import { readPublicKey } from "../utils/keys.js";
|
|
6
|
+
import { getSystemConfig, SYSTEM_CM_NAME } from "../utils/system-config.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Load-validated config, no side effects. Without --manifest prints a short
|
|
10
|
+
* summary (mode, resolved image, system state when reachable); with it,
|
|
11
|
+
* prints the full multi-doc k8s manifest to stdout (ready for
|
|
12
|
+
* `kubectl apply --dry-run=client -f -`).
|
|
13
|
+
*
|
|
14
|
+
* The coding-system ConfigMap is a cluster read: when the cluster is
|
|
15
|
+
* unreachable or the ConfigMap is missing, validate warns and falls back
|
|
16
|
+
* to the local image.tag instead of failing.
|
|
17
|
+
*/
|
|
18
|
+
export function validate(cfg, { manifest = false } = {}) {
|
|
19
|
+
let system = null;
|
|
20
|
+
if (cfg.imageMode === "global") {
|
|
21
|
+
try {
|
|
22
|
+
system = getSystemConfig(cfg.k8s.namespace);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
console.warn(
|
|
25
|
+
`warning: could not read the ${SYSTEM_CM_NAME} ConfigMap ` +
|
|
26
|
+
`(offline or not initialized): ${String(err.message).split("\n")[0]}`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const tag = cfg.image.tag || (system ? system.current : undefined);
|
|
31
|
+
if (tag) cfg.image.tag = tag;
|
|
32
|
+
|
|
33
|
+
if (manifest) {
|
|
34
|
+
// Use the real public key when the pair already exists; otherwise a
|
|
35
|
+
// placeholder (validate must never generate keys).
|
|
36
|
+
const publicPath = path.join(cfg.ssh.keyDirPath, `${cfg.ssh.keyName}.pub`);
|
|
37
|
+
const publicKey = fs.existsSync(publicPath)
|
|
38
|
+
? readPublicKey(publicPath)
|
|
39
|
+
: "ssh-ed25519 AAAAvalidate-only coding-container";
|
|
40
|
+
if (!cfg.image.tag) {
|
|
41
|
+
console.warn(
|
|
42
|
+
"warning: no image tag resolved (no coding-system ConfigMap and no image.tag) — " +
|
|
43
|
+
"rendering with a placeholder tag"
|
|
44
|
+
);
|
|
45
|
+
cfg.image.tag = "__UNRESOLVED__";
|
|
46
|
+
}
|
|
47
|
+
process.stdout.write(fullManifest(cfg, publicKey, null, { redactSecrets: true }));
|
|
48
|
+
if (secretEnvEntries(cfg).length) {
|
|
49
|
+
console.warn(
|
|
50
|
+
`warning: k8s.secretEnv values are redacted above (${secretEnvEntries(cfg).length} key(s)) — ` +
|
|
51
|
+
`deploy applies the real values`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
console.log(`OK: config is valid for project "${cfg.project}"`);
|
|
57
|
+
console.log(` mode: ${cfg.imageMode}${cfg.imageMode === "custom" ? ` (repo ${cfg.image.registry}/${cfg.image.name})` : " (shared global image)"}`);
|
|
58
|
+
if (system) {
|
|
59
|
+
console.log(
|
|
60
|
+
` system: current ${system.current}, previous ${system.previous ?? "none"}, ` +
|
|
61
|
+
`maxEver ${system.maxEver}, keep ${system.keep}`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
console.log(
|
|
65
|
+
` image: ${
|
|
66
|
+
cfg.image.tag
|
|
67
|
+
? imageRef(cfg)
|
|
68
|
+
: `${cfg.image.registry}/${cfg.image.name}:<no tag — run 'coding-container system init' or set image.tag>`
|
|
69
|
+
}`
|
|
70
|
+
);
|
|
71
|
+
console.log(` cluster: namespace ${cfg.k8s.namespace}, ${cfg.k8s.serviceType} port ${cfg.k8s.nodePort}`);
|
|
72
|
+
const secretKeys = secretEnvEntries(cfg).map(([name]) => name);
|
|
73
|
+
if (secretKeys.length) {
|
|
74
|
+
console.log(` secrets: ${secretKeys.join(", ")} (via Secret ${cfg.project}-env, values never shown)`);
|
|
75
|
+
}
|
|
76
|
+
const sidecars = cfg.k8s.sidecars || [];
|
|
77
|
+
if (sidecars.length) {
|
|
78
|
+
console.log(` sidecars: ${sidecars.map((s) => s.name).join(", ")}`);
|
|
79
|
+
}
|
|
80
|
+
for (const p of cfg.web?.ports || []) {
|
|
81
|
+
console.log(` web: container port ${p.port} -> http://${p.host}/`);
|
|
82
|
+
}
|
|
83
|
+
for (const v of cfg.nfs.volumes || []) {
|
|
84
|
+
console.log(` volume: ${v.subPath} -> ${v.mountPath}${v.readOnly ? " (read-only)" : ""}`);
|
|
85
|
+
}
|
|
86
|
+
}
|