@esuyo/coding-container 1.0.4 → 1.0.5
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 +54 -25
- package/config.yaml +3 -0
- package/package.json +11 -4
- package/src/commands/deploy.js +5 -8
- package/src/commands/list.js +6 -1
- package/src/commands/projects.js +81 -0
- package/src/commands/ssh.js +9 -7
- package/src/commands/system.js +6 -2
- package/src/commands/validate.js +4 -1
- package/src/config.js +37 -8
- package/src/index.js +58 -9
- package/src/utils/instance.js +85 -0
- package/src/utils/manifest.js +3 -0
- package/src/utils/node-ip.js +74 -0
- package/src/utils/projects.js +218 -0
- package/src/utils/registry-api.js +59 -4
- package/src/utils/semver.js +11 -0
- package/src/utils/ssh-config.js +23 -3
package/README.md
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
# Esuyo coding-container
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> Ready-to-code containers on your Kubernetes cluster — per project, over SSH, with workspaces that survive restarts.
|
|
4
4
|
|
|
5
|
-
`coding-container` gives every project its own consistent coding environment on Kubernetes.
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
`coding-container` gives every project its own consistent coding environment on Kubernetes. One deploy puts an SSH-accessible container per project at your fingertips: you land in `/workspace`, backed by your NAS so work survives restarts. 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.
|
|
6
|
+
|
|
7
|
+
Work your way: the terminal workflow, a browser admin UI (dashboard, project editor, terminal in the browser, job history), or the installable desktop app that wraps the same UI. All three share one project registry, so a project created in one place shows up in the others.
|
|
8
8
|
|
|
9
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.
|
|
10
10
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
13
|
-
- One
|
|
14
|
-
- Kubernetes-native:
|
|
13
|
+
- One deploy for a ready-to-code container, SSH in, and tear down when done
|
|
14
|
+
- Kubernetes-native: Deployment, Service, Ingress, ConfigMap, and Secret manifests applied with `kubectl`
|
|
15
15
|
- Persistent per-project workspace on your NAS, so files survive restarts
|
|
16
|
-
-
|
|
16
|
+
- Dedicated SSH key; VS Code Remote-SSH ready
|
|
17
17
|
- Optional browser access to dev servers and sidecars for databases and queues
|
|
18
18
|
- Shared team image with versioning, or a custom image per project. Bring your own Dockerfile for any language or toolchain
|
|
19
|
+
- Browser admin UI: dashboard with live workload status, project editor with validation, terminal in the browser, image publishing, and a job log for every operation
|
|
20
|
+
- Desktop app (Windows / macOS / Linux): the same UI in an installable window with encrypted sign-in and automatic updates
|
|
21
|
+
- Shared project registry, so the terminal, browser, and desktop views stay in sync
|
|
19
22
|
|
|
20
23
|
## Prerequisites
|
|
21
24
|
|
|
@@ -24,9 +27,12 @@ For private, internal Kubernetes clusters only. Do not expose to the public web.
|
|
|
24
27
|
- `docker login` to your container registry
|
|
25
28
|
- OpenSSH (`ssh`, `ssh-keygen`)
|
|
26
29
|
- Your NAS reachable from the cluster nodes
|
|
30
|
+
- For the browser UI / desktop app: a sign-in (email + password) configured in the checkout `.env` file
|
|
27
31
|
|
|
28
32
|
## Installation
|
|
29
33
|
|
|
34
|
+
Terminal workflow:
|
|
35
|
+
|
|
30
36
|
```sh
|
|
31
37
|
npm install -g @esuyo/coding-container
|
|
32
38
|
coding-container --help
|
|
@@ -48,6 +54,18 @@ One-time setup (creates your global config and SSH key, never overwrites):
|
|
|
48
54
|
coding-container setup
|
|
49
55
|
```
|
|
50
56
|
|
|
57
|
+
Browser UI (from source):
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
npm --prefix web/client install # first time only
|
|
61
|
+
copy .env.example .env # Windows (cp on macOS/Linux) — then set ADMIN_EMAIL + ADMIN_PASSWORD
|
|
62
|
+
node web/dev.js # pages on :3400, API on the .env PORT
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Production build: `npm --prefix web/client run build`, then `node web/server/server.js` serves the pages and the API on one port. See [Web GUI](./docs/gui.md).
|
|
66
|
+
|
|
67
|
+
Desktop app: download an installer from the releases page, or build one from source (`npm --prefix web/client run build`, `npm --prefix electron install`, `npm --prefix electron run dist:win`). See [Desktop](./docs/desktop.md).
|
|
68
|
+
|
|
51
69
|
## Configuration
|
|
52
70
|
|
|
53
71
|
Two small YAML files drive everything: a global config for your environment (`~/.config/coding-container/config.yaml`) and a per-project `config.yaml` for what differs per project.
|
|
@@ -58,17 +76,25 @@ k8s:
|
|
|
58
76
|
nodePort: 30022
|
|
59
77
|
```
|
|
60
78
|
|
|
61
|
-
|
|
79
|
+
Each checkout also carries its own identity in a `.env` file (copied from `.env.example`): separate config folders, connection-alias prefixes, and namespaces for isolated dev/prod instances, plus the browser UI sign-in and port.
|
|
80
|
+
|
|
81
|
+
See [Configuration](./docs/configuration.md) for layering, multiple projects, the shared registry, and NAS folders.
|
|
62
82
|
|
|
63
83
|
## Quick Start
|
|
64
84
|
|
|
85
|
+
Terminal workflow:
|
|
86
|
+
|
|
65
87
|
```sh
|
|
66
88
|
coding-container create # deploy this project
|
|
67
89
|
coding-container ssh # connect, you land in /workspace
|
|
68
90
|
coding-container delete # remove cluster resources (files stay safe on the NAS)
|
|
69
91
|
```
|
|
70
92
|
|
|
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.
|
|
93
|
+
Working in another folder? `cd` there and run `create`: a minimal `config.yaml` is scaffolded automatically. Each project gets its own container and workspace. Registered projects can be addressed from anywhere (`coding-container -p <name> deploy`).
|
|
94
|
+
|
|
95
|
+
Browser UI: open the pages, sign in, and use the Dashboard for live status, Projects to add/edit/deploy (including a terminal per project), Images and System for the shared image, and Jobs for the log of every operation.
|
|
96
|
+
|
|
97
|
+
Desktop app: sign in once on first launch — the dashboard opens automatically from then on, with the sign-in kept in the OS keychain.
|
|
72
98
|
|
|
73
99
|
For everything else (web URLs, databases, image versions, all commands and flags), see [CLI reference](./docs/cli-reference.md) and the guides below.
|
|
74
100
|
|
|
@@ -77,6 +103,8 @@ For everything else (web URLs, databases, image versions, all commands and flags
|
|
|
77
103
|
- **Missing registry or NAS settings?** Run `coding-container setup`.
|
|
78
104
|
- **Push auth error?** Run `docker login <your-registry>` first.
|
|
79
105
|
- **Pod stuck?** Usually NFS or image pull. See [Troubleshooting](./docs/troubleshooting.md).
|
|
106
|
+
- **Can't sign in to the browser UI?** Set `ADMIN_EMAIL` + `ADMIN_PASSWORD` in `.env` and restart the server.
|
|
107
|
+
- **Pages show "GUI not built"?** Run `npm --prefix web/client run build` first.
|
|
80
108
|
|
|
81
109
|
## License
|
|
82
110
|
|
|
@@ -84,19 +112,20 @@ MIT. See [LICENSE](./LICENSE).
|
|
|
84
112
|
|
|
85
113
|
## Developer Documentation
|
|
86
114
|
|
|
87
|
-
- [
|
|
88
|
-
- [
|
|
89
|
-
- [
|
|
90
|
-
- [
|
|
91
|
-
- [
|
|
92
|
-
- [
|
|
93
|
-
- [
|
|
94
|
-
- [
|
|
95
|
-
- [
|
|
96
|
-
- [
|
|
97
|
-
- [
|
|
98
|
-
- [
|
|
99
|
-
- [
|
|
100
|
-
- [
|
|
101
|
-
|
|
102
|
-
|
|
115
|
+
- [Web GUI](./docs/gui.md) — Pages, jobs, and server settings
|
|
116
|
+
- [Desktop app](./docs/desktop.md) — Installable Electron shell
|
|
117
|
+
- [Build & Deployment](./docs/build-and-deployment.md) — npm releases, GUI bundle, installers
|
|
118
|
+
- [Configuration](./docs/configuration.md) — Config layering, multiple projects, NAS volumes
|
|
119
|
+
- [Shared global image](./docs/global-image.md) — Team image versions, publishing, rollback, cleanup
|
|
120
|
+
- [Web access](./docs/web-access.md) — Browser access to dev servers via Traefik
|
|
121
|
+
- [Sidecars](./docs/sidecars.md) — Databases, queues, and other pod companions
|
|
122
|
+
- [CLI reference](./docs/cli-reference.md) — All commands and flags
|
|
123
|
+
- [SSH and VS Code](./docs/ssh-and-vscode.md) — Key rotation and Remote-SSH setup
|
|
124
|
+
- [SSH image paste](./docs/ssh-image-paste.md) — Clipboard images into opencode over SSH
|
|
125
|
+
- [Troubleshooting](./docs/troubleshooting.md) — Common failures and fixes
|
|
126
|
+
- [Architecture](./docs/architecture.md) — Tech stack and system design
|
|
127
|
+
- [Project Structure](./docs/project-structure.md) — Folder layout
|
|
128
|
+
- [Development Guide](./docs/development.md) — Local setup for contributors
|
|
129
|
+
- [App config & setup files](./docs/config-files.md) — Persisting tool configs via /workspace
|
|
130
|
+
- [Plan: Additional NFS volumes](./docs/build/additional-volumes.md) — Design history for extra volumes
|
|
131
|
+
- [Plan: Public prebuilt images](./docs/build/public-images.md) — Design history for public images
|
package/config.yaml
CHANGED
|
@@ -8,6 +8,9 @@ project: my-project
|
|
|
8
8
|
|
|
9
9
|
k8s:
|
|
10
10
|
nodePort: 30022
|
|
11
|
+
# Pin the pod to one named cluster node (kubectl get nodes). Empty/unset ->
|
|
12
|
+
# the scheduler picks a node.
|
|
13
|
+
# nodeName: worker-02
|
|
11
14
|
|
|
12
15
|
# Expose container ports to the browser through Traefik: one Ingress rule per
|
|
13
16
|
# host ('hosts' list, or a single 'host'), each routing to that port on this
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esuyo/coding-container",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "CLI to build, publish and deploy SSH-accessible coding containers on k3s",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,8 +9,14 @@
|
|
|
9
9
|
"main": "src/index.js",
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node src/index.js",
|
|
12
|
-
"dev": "node
|
|
13
|
-
"
|
|
12
|
+
"dev": "node web/dev.js",
|
|
13
|
+
"dev:kill": "node web/kill-dev.js",
|
|
14
|
+
"dev:cli": "node src/index.js",
|
|
15
|
+
"dev:server": "node web/server/server.js",
|
|
16
|
+
"dev:client": "npm --prefix web/client run dev",
|
|
17
|
+
"build:gui": "npm --prefix web/client run build",
|
|
18
|
+
"start:gui": "node web/server/server.js",
|
|
19
|
+
"test": "node --test test/*.test.* web/server/*.test.mjs web/client/src/**/*.test.mjs"
|
|
14
20
|
},
|
|
15
21
|
"engines": {
|
|
16
22
|
"node": ">=18"
|
|
@@ -34,6 +40,7 @@
|
|
|
34
40
|
],
|
|
35
41
|
"dependencies": {
|
|
36
42
|
"commander": "^12.1.0",
|
|
37
|
-
"js-yaml": "^4.1.0"
|
|
43
|
+
"js-yaml": "^4.1.0",
|
|
44
|
+
"ssh2": "^1.17.0"
|
|
38
45
|
}
|
|
39
46
|
}
|
package/src/commands/deploy.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { exec, run, runWithInput } from "../utils/exec.js";
|
|
3
|
+
import { resolveSshNodeIp } from "../utils/node-ip.js";
|
|
3
4
|
import { ensureKeyPair, readPublicKey } from "../utils/keys.js";
|
|
4
5
|
import { ensurePullSecret } from "../utils/registry.js";
|
|
5
6
|
import { fullManifest, webPorts, sidecarNodePorts as sidecarNodePortsList } from "../utils/manifest.js";
|
|
@@ -79,16 +80,12 @@ export async function deploy(cfg, { wait = true } = {}) {
|
|
|
79
80
|
}
|
|
80
81
|
console.log(`Deployed ${cfg.project} in ${cfg.k8s.namespace}`);
|
|
81
82
|
|
|
82
|
-
// Node IP for the NodePort hints below and the SSH alias
|
|
83
|
+
// Node IP for the NodePort hints below and the SSH alias: the node the
|
|
84
|
+
// pod actually runs on (when Ready), else the first Ready node. A NotReady
|
|
85
|
+
// node never serves NodePorts, so items[0] is not a valid default.
|
|
83
86
|
let nodeIp;
|
|
84
87
|
try {
|
|
85
|
-
nodeIp =
|
|
86
|
-
...kargs,
|
|
87
|
-
"get",
|
|
88
|
-
"nodes",
|
|
89
|
-
"-o",
|
|
90
|
-
"jsonpath={.items[0].status.addresses[?(@.type==\"InternalIP\")].address}",
|
|
91
|
-
], kopts);
|
|
88
|
+
nodeIp = resolveSshNodeIp(cfg);
|
|
92
89
|
} catch {
|
|
93
90
|
/* handled below */
|
|
94
91
|
}
|
package/src/commands/list.js
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
ephemeralKnownHostsFile,
|
|
4
4
|
listHostEntries,
|
|
5
5
|
sshConfigPath,
|
|
6
|
+
sshProjectFromAlias,
|
|
6
7
|
} from "../utils/ssh-config.js";
|
|
7
8
|
import { exec } from "../utils/exec.js";
|
|
8
9
|
import { kubectlExec } from "../utils/system-config.js";
|
|
@@ -194,7 +195,11 @@ export function listUpdateSsh({ file, dryRun = false } = {}) {
|
|
|
194
195
|
let updated = 0;
|
|
195
196
|
let current = 0;
|
|
196
197
|
for (const entry of entries) {
|
|
197
|
-
const project = entry.alias
|
|
198
|
+
const project = sshProjectFromAlias(entry.alias);
|
|
199
|
+
if (project === null) {
|
|
200
|
+
console.log(`"${entry.alias}": belongs to another copy (different SSH prefix) — leaving it untouched`);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
198
203
|
const matches = services.get(project) ?? [];
|
|
199
204
|
if (matches.length === 0) {
|
|
200
205
|
console.warn(
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
computeProjectSlugs,
|
|
4
|
+
defaultProjectsFile,
|
|
5
|
+
readProjects,
|
|
6
|
+
} from "../utils/projects.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `projects list`: tabular view of the shared registry
|
|
10
|
+
* (<globalConfigDir()>/projects.json unless PROJECTS_FILE overrides it).
|
|
11
|
+
* Every value in SLUG/NAME/ID can be passed to `-p, --project` to run any
|
|
12
|
+
* config-dependent command against that project, e.g.
|
|
13
|
+
* `coding-container --project esuyo-mapping deploy`.
|
|
14
|
+
*/
|
|
15
|
+
export function listProjects({ projectsFile, json = false } = {}) {
|
|
16
|
+
const file = projectsFile ?? defaultProjectsFile();
|
|
17
|
+
const projects = readProjects(file);
|
|
18
|
+
const entries = computeProjectSlugs(projects);
|
|
19
|
+
|
|
20
|
+
if (json) {
|
|
21
|
+
console.log(
|
|
22
|
+
JSON.stringify(
|
|
23
|
+
entries.map(({ project, slug }) => {
|
|
24
|
+
const configPath = String(project?.configPath ?? "");
|
|
25
|
+
let exists = false;
|
|
26
|
+
try {
|
|
27
|
+
exists = configPath ? fs.existsSync(configPath) : false;
|
|
28
|
+
} catch {
|
|
29
|
+
exists = false;
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
slug,
|
|
33
|
+
name: project?.name ?? "",
|
|
34
|
+
id: project?.id ?? "",
|
|
35
|
+
configPath,
|
|
36
|
+
exists,
|
|
37
|
+
};
|
|
38
|
+
}),
|
|
39
|
+
null,
|
|
40
|
+
2
|
|
41
|
+
)
|
|
42
|
+
);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (entries.length === 0) {
|
|
47
|
+
console.log(`No projects registered in ${file}.`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const rows = entries.map(({ project, slug }) => {
|
|
52
|
+
const configPath = String(project?.configPath ?? "-");
|
|
53
|
+
let display = configPath;
|
|
54
|
+
try {
|
|
55
|
+
if (configPath !== "-" && !fs.existsSync(configPath)) display = `${configPath} (missing)`;
|
|
56
|
+
} catch {
|
|
57
|
+
/* keep raw path */
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
slug,
|
|
61
|
+
name: String(project?.name ?? "-"),
|
|
62
|
+
id: String(project?.id ?? "-"),
|
|
63
|
+
config: display,
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
const widths = {
|
|
67
|
+
slug: Math.max("SLUG".length, ...rows.map((r) => r.slug.length)),
|
|
68
|
+
name: Math.max("NAME".length, ...rows.map((r) => r.name.length)),
|
|
69
|
+
id: Math.max("ID".length, ...rows.map((r) => r.id.length)),
|
|
70
|
+
};
|
|
71
|
+
const pad = (s, w) => s.padEnd(w, " ");
|
|
72
|
+
console.log(
|
|
73
|
+
`${pad("SLUG", widths.slug)} ${pad("NAME", widths.name)} ${pad("ID", widths.id)} CONFIG`
|
|
74
|
+
);
|
|
75
|
+
for (const r of rows) {
|
|
76
|
+
console.log(`${pad(r.slug, widths.slug)} ${pad(r.name, widths.name)} ${pad(r.id, widths.id)} ${r.config}`);
|
|
77
|
+
}
|
|
78
|
+
console.log(
|
|
79
|
+
`\n${entries.length} project(s) from ${file} (run: coding-container --project <slug|name|id> <command>)`
|
|
80
|
+
);
|
|
81
|
+
}
|
package/src/commands/ssh.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import net from "node:net";
|
|
2
|
-
import {
|
|
2
|
+
import { run, start } from "../utils/exec.js";
|
|
3
|
+
import { resolveSshNodeIp } from "../utils/node-ip.js";
|
|
3
4
|
import { ensureKeyPair } from "../utils/keys.js";
|
|
4
5
|
import { ephemeralKnownHostsFile, removeLegacyKnownHostsFile } from "../utils/ssh-config.js";
|
|
5
6
|
|
|
@@ -53,12 +54,13 @@ function sshCommonOptions(cfg, port, privateKey) {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
async function sshDirect(cfg, privateKey) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
// Same resolution as deploy: the pod's own (Ready) node, else the first
|
|
58
|
+
// Ready node. Never items[0] blindly — a NotReady node drops NodePort
|
|
59
|
+
// traffic even though the Service looks fine.
|
|
60
|
+
const nodeIp = resolveSshNodeIp(cfg);
|
|
61
|
+
if (!nodeIp) {
|
|
62
|
+
throw new Error("could not resolve a Ready cluster node for --direct (is any node Ready?)");
|
|
63
|
+
}
|
|
62
64
|
await run("ssh", [...sshCommonOptions(cfg, cfg.k8s.nodePort, privateKey), `${cfg.ssh.user}@${nodeIp}`]);
|
|
63
65
|
}
|
|
64
66
|
|
package/src/commands/system.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { exec } from "../utils/exec.js";
|
|
2
2
|
import { listTags } from "../utils/registry-api.js";
|
|
3
|
-
import { isSemver, compareSemver, semverSortAsc } from "../utils/semver.js";
|
|
3
|
+
import { isSemver, compareSemver, semverSortAsc, bumpPatch } from "../utils/semver.js";
|
|
4
4
|
import {
|
|
5
5
|
getSystemConfig,
|
|
6
6
|
patchSystemConfig,
|
|
@@ -126,9 +126,13 @@ export async function systemList(cfg) {
|
|
|
126
126
|
*/
|
|
127
127
|
export async function systemCreate(cfg, { tag } = {}) {
|
|
128
128
|
requireGlobalMode(cfg);
|
|
129
|
-
requireSemverTag(tag);
|
|
130
129
|
const ns = cfg.k8s.namespace;
|
|
131
130
|
const sys = getSystemConfig(ns);
|
|
131
|
+
if (!tag) {
|
|
132
|
+
tag = bumpPatch(sys.maxEver);
|
|
133
|
+
console.log(`No --tag given, auto-bumped to ${tag} (patch +1 over maxEver ${sys.maxEver})`);
|
|
134
|
+
}
|
|
135
|
+
requireSemverTag(tag);
|
|
132
136
|
if (compareSemver(tag, sys.maxEver) <= 0) {
|
|
133
137
|
throw new Error(
|
|
134
138
|
`tag ${tag} is not greater than maxEver ${sys.maxEver} — global tags are immutable and ` +
|
package/src/commands/validate.js
CHANGED
|
@@ -68,7 +68,10 @@ export function validate(cfg, { manifest = false } = {}) {
|
|
|
68
68
|
: `${cfg.image.registry}/${cfg.image.name}:<no tag — run 'coding-container system init' or set image.tag>`
|
|
69
69
|
}`
|
|
70
70
|
);
|
|
71
|
-
console.log(
|
|
71
|
+
console.log(
|
|
72
|
+
` cluster: namespace ${cfg.k8s.namespace}, ${cfg.k8s.serviceType} port ${cfg.k8s.nodePort}` +
|
|
73
|
+
(cfg.k8s.nodeName ? `, node ${cfg.k8s.nodeName}` : "")
|
|
74
|
+
);
|
|
72
75
|
const secretKeys = secretEnvEntries(cfg).map(([name]) => name);
|
|
73
76
|
if (secretKeys.length) {
|
|
74
77
|
console.log(` secrets: ${secretKeys.join(", ")} (via Secret ${cfg.project}-env, values never shown)`);
|
package/src/config.js
CHANGED
|
@@ -4,12 +4,16 @@ import path from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import yaml from "js-yaml";
|
|
6
6
|
import { expandEnvMap } from "./utils/envsubst.js";
|
|
7
|
+
import { configDirLeaf, defaultNamespace } from "./utils/instance.js";
|
|
7
8
|
|
|
8
9
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
10
|
export const PROJECT_ROOT = path.resolve(__dirname, "..");
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
|
-
* Global config directory: $XDG_CONFIG_HOME
|
|
13
|
+
* Global config directory: $XDG_CONFIG_HOME/<leaf> or ~/.config/<leaf>.
|
|
14
|
+
* Leaf defaults to "coding-container" (CODING_CONFIG_LEAF in
|
|
15
|
+
* PROJECT_FOLDER/.env or the environment overrides it), so repeated
|
|
16
|
+
* checkouts (dev vs prod) get separate config.yaml/Dockerfile/keys folders.
|
|
13
17
|
* Holds the shared settings (config.yaml) and, by default, the SSH key
|
|
14
18
|
* material (keys/).
|
|
15
19
|
*/
|
|
@@ -17,7 +21,7 @@ export function globalConfigDir() {
|
|
|
17
21
|
const base = process.env.XDG_CONFIG_HOME
|
|
18
22
|
? path.resolve(process.env.XDG_CONFIG_HOME)
|
|
19
23
|
: path.join(os.homedir(), ".config");
|
|
20
|
-
return path.join(base,
|
|
24
|
+
return path.join(base, configDirLeaf());
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export function globalConfigPath() {
|
|
@@ -50,6 +54,12 @@ export const DEFAULTS = {
|
|
|
50
54
|
replicas: 1,
|
|
51
55
|
serviceType: "NodePort",
|
|
52
56
|
nodePort: 30022,
|
|
57
|
+
// Exact cluster node the pod is pinned to (k8s podSpec.nodeName).
|
|
58
|
+
// Unset (empty) -> the scheduler picks a node. Set it to force placement,
|
|
59
|
+
// e.g. a node with the NAS mount or a GPU:
|
|
60
|
+
// k8s:
|
|
61
|
+
// nodeName: worker-02
|
|
62
|
+
nodeName: "",
|
|
53
63
|
imagePullSecret: "",
|
|
54
64
|
resources: {
|
|
55
65
|
requests: { cpu: "100m", memory: "128Mi" },
|
|
@@ -184,7 +194,7 @@ export function resolveDefaultConfigPath(explicit) {
|
|
|
184
194
|
* 1. hardcoded DEFAULTS
|
|
185
195
|
* 2. global config (~/.config/coding-container/config.yaml, if present)
|
|
186
196
|
* 3. local project config (configPath)
|
|
187
|
-
|
|
197
|
+
* 4. CLI overrides (e.g. --project-name, --tag)
|
|
188
198
|
*
|
|
189
199
|
* The cluster is a layer too, but it is read by the deploy/system commands,
|
|
190
200
|
* not here: in global mode the image tag floats on the coding-system
|
|
@@ -196,18 +206,26 @@ export function resolveDefaultConfigPath(explicit) {
|
|
|
196
206
|
*
|
|
197
207
|
* @param {string} configPath path to the local project yaml file
|
|
198
208
|
* @param {object} overrides flat-ish overrides applied on top (e.g. from CLI flags)
|
|
209
|
+
* @param {object} opts optional knobs; `opts.globalConfigPath` swaps which
|
|
210
|
+
* file backs the global layer (used to validate an unsaved global draft
|
|
211
|
+
* without touching the real file on disk).
|
|
199
212
|
*/
|
|
200
|
-
export function loadConfig(configPath, overrides = {}) {
|
|
213
|
+
export function loadConfig(configPath, overrides = {}, opts = {}) {
|
|
201
214
|
const resolved = path.resolve(configPath);
|
|
202
215
|
if (!fs.existsSync(resolved)) {
|
|
203
216
|
throw new Error(`Config file not found: ${resolved}`);
|
|
204
217
|
}
|
|
205
218
|
const localDir = path.dirname(resolved);
|
|
206
|
-
const
|
|
219
|
+
const gPath = opts.globalConfigPath ? path.resolve(opts.globalConfigPath) : globalConfigPath();
|
|
220
|
+
const gDir = path.dirname(gPath);
|
|
207
221
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
222
|
+
// Instance default namespace (CODING_NAMESPACE): base layer only, so the
|
|
223
|
+
// global/project configs still override it per project.
|
|
224
|
+
const baseDefaults = structuredClone(DEFAULTS);
|
|
225
|
+
baseDefaults.k8s.namespace = defaultNamespace();
|
|
226
|
+
let cfg = withPaths(baseDefaults, localDir);
|
|
227
|
+
if (fs.existsSync(gPath)) {
|
|
228
|
+
cfg = deepMerge(cfg, withPaths(readYaml(gPath), gDir));
|
|
211
229
|
}
|
|
212
230
|
const local = readYaml(resolved);
|
|
213
231
|
cfg = deepMerge(cfg, withPaths(local, localDir));
|
|
@@ -369,6 +387,17 @@ function validate(cfg) {
|
|
|
369
387
|
if (st !== "NodePort" && st !== "LoadBalancer") {
|
|
370
388
|
throw new Error("config: k8s.serviceType must be NodePort or LoadBalancer");
|
|
371
389
|
}
|
|
390
|
+
if (cfg.k8s.nodeName !== undefined && cfg.k8s.nodeName !== "") {
|
|
391
|
+
if (
|
|
392
|
+
typeof cfg.k8s.nodeName !== "string" ||
|
|
393
|
+
cfg.k8s.nodeName.length > 253 ||
|
|
394
|
+
!/^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$/.test(cfg.k8s.nodeName)
|
|
395
|
+
) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
`config: k8s.nodeName must be a valid node name (lowercase a-z/0-9/-/. , max 253 chars): ${JSON.stringify(cfg.k8s.nodeName)}`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
372
401
|
for (const field of ["env", "secretEnv"]) {
|
|
373
402
|
const map = cfg.k8s[field];
|
|
374
403
|
if (map == null) continue;
|
package/src/index.js
CHANGED
|
@@ -25,6 +25,13 @@ import {
|
|
|
25
25
|
import { ensureKeyPair } from "./utils/keys.js";
|
|
26
26
|
import { ensureProjectConfig } from "./utils/project.js";
|
|
27
27
|
import { resolveDeployTag } from "./utils/system-config.js";
|
|
28
|
+
import {
|
|
29
|
+
defaultProjectsFile,
|
|
30
|
+
readProjects,
|
|
31
|
+
resolveProjectRefOrThrow,
|
|
32
|
+
resolveStoredConfigPath,
|
|
33
|
+
} from "./utils/projects.js";
|
|
34
|
+
import { listProjects } from "./commands/projects.js";
|
|
28
35
|
|
|
29
36
|
const program = new Command();
|
|
30
37
|
|
|
@@ -40,15 +47,43 @@ program
|
|
|
40
47
|
// not swallowed by the global --tag.
|
|
41
48
|
.enablePositionalOptions()
|
|
42
49
|
.option("-c, --config <file>", "path to config yaml (default: ./config.yaml if present, else the repo's)")
|
|
43
|
-
.option("-p, --project <name>", "
|
|
50
|
+
.option("-p, --project <slug|name|id>", "select a project from the shared registry (see 'projects list')")
|
|
51
|
+
.option("--project-name <name>", "override the project name")
|
|
44
52
|
.option("--tag <tag>", "override the image tag");
|
|
45
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Which local config file to load: an explicit -c wins, else a -p/--project
|
|
56
|
+
* registry entry (same store the GUI uses), else ./config.yaml or the repo's.
|
|
57
|
+
* -c and -p together are an error, never a guess.
|
|
58
|
+
*/
|
|
59
|
+
function resolveSelectedConfigPath(globalOpts) {
|
|
60
|
+
if (globalOpts.config && globalOpts.project) {
|
|
61
|
+
throw new Error("Use either -c/--config or -p/--project, not both.");
|
|
62
|
+
}
|
|
63
|
+
if (globalOpts.project) {
|
|
64
|
+
const projectsFile = defaultProjectsFile();
|
|
65
|
+
const projects = readProjects(projectsFile);
|
|
66
|
+
const { project } = resolveProjectRefOrThrow(projects, globalOpts.project, projectsFile);
|
|
67
|
+
const configPath = resolveStoredConfigPath(project.configPath);
|
|
68
|
+
if (!configPath) {
|
|
69
|
+
throw new Error(`Project "${globalOpts.project}" has no configPath in ${projectsFile}.`);
|
|
70
|
+
}
|
|
71
|
+
if (!fs.existsSync(configPath)) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Project "${globalOpts.project}" config not found: ${configPath}\nRun "coding-container projects list" to see all projects.`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return configPath;
|
|
77
|
+
}
|
|
78
|
+
return resolveDefaultConfigPath(globalOpts.config);
|
|
79
|
+
}
|
|
80
|
+
|
|
46
81
|
function cfgFrom(ctx) {
|
|
47
82
|
const globalOpts = program.opts();
|
|
48
83
|
const overrides = {};
|
|
49
|
-
if (globalOpts.
|
|
84
|
+
if (globalOpts.projectName) overrides.project = globalOpts.projectName;
|
|
50
85
|
if (globalOpts.tag) overrides.image = { tag: globalOpts.tag };
|
|
51
|
-
const cfg = loadConfig(
|
|
86
|
+
const cfg = loadConfig(resolveSelectedConfigPath(globalOpts), overrides);
|
|
52
87
|
// First-run nudge: shown once, on stderr, until the global config exists.
|
|
53
88
|
if (process.argv[2] !== "setup" && !fs.existsSync(globalConfigPath())) {
|
|
54
89
|
console.error(
|
|
@@ -78,14 +113,14 @@ program
|
|
|
78
113
|
.option("--skip-push", "skip the docker push step")
|
|
79
114
|
.action(async (opts) => {
|
|
80
115
|
const globalOpts = program.opts();
|
|
81
|
-
// No explicit -c and no ./config.yaml: scaffold one (project name from
|
|
82
|
-
// the folder, first free NodePort) and proceed with it.
|
|
83
|
-
if (!globalOpts.config) {
|
|
116
|
+
// No explicit -c/-p and no ./config.yaml: scaffold one (project name from
|
|
117
|
+
// the folder or --project-name, first free NodePort) and proceed with it.
|
|
118
|
+
if (!globalOpts.config && !globalOpts.project) {
|
|
84
119
|
const cwdConfig = path.resolve(process.cwd(), "config.yaml");
|
|
85
120
|
if (!fs.existsSync(cwdConfig)) {
|
|
86
121
|
const { created, project, nodePort } = ensureProjectConfig(
|
|
87
122
|
process.cwd(),
|
|
88
|
-
globalOpts.
|
|
123
|
+
globalOpts.projectName
|
|
89
124
|
);
|
|
90
125
|
if (created) {
|
|
91
126
|
console.log(
|
|
@@ -152,6 +187,18 @@ list
|
|
|
152
187
|
await listUpdateSsh({ dryRun: opts.dryRun });
|
|
153
188
|
});
|
|
154
189
|
|
|
190
|
+
const projects = program
|
|
191
|
+
.command("projects")
|
|
192
|
+
.description("work with the shared project registry (same store the GUI uses)");
|
|
193
|
+
|
|
194
|
+
projects
|
|
195
|
+
.command("list")
|
|
196
|
+
.description("list registered projects (any SLUG, NAME or ID works with -p/--project)")
|
|
197
|
+
.option("--json", "print the registry as JSON (includes slugs and file-exists flags)")
|
|
198
|
+
.action((opts) => {
|
|
199
|
+
listProjects({ json: opts.json });
|
|
200
|
+
});
|
|
201
|
+
|
|
155
202
|
program
|
|
156
203
|
.command("delete")
|
|
157
204
|
.alias("destroy")
|
|
@@ -264,10 +311,12 @@ system
|
|
|
264
311
|
.description(
|
|
265
312
|
"build + push a new global version and repoint current (the only writer of the global image)"
|
|
266
313
|
)
|
|
267
|
-
.
|
|
314
|
+
.option("--tag <tag>", "new version (semver vX.Y.Z, must not exist and must be > maxEver; default: maxEver patch +1)")
|
|
268
315
|
.action(async (opts) => {
|
|
269
316
|
const cfg = cfgFrom();
|
|
270
|
-
|
|
317
|
+
// Global --tag (before `system`) also works: `coding-container --tag vX.Y.Z system create`.
|
|
318
|
+
const tag = opts.tag ?? program.opts().tag;
|
|
319
|
+
await systemCreate(cfg, { tag });
|
|
271
320
|
});
|
|
272
321
|
|
|
273
322
|
system
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
// Checkout root (PROJECT_FOLDER): src/utils -> src -> root.
|
|
7
|
+
export const INSTANCE_ROOT = path.resolve(__dirname, "..", "..");
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_CONFIG_LEAF = "coding-container";
|
|
10
|
+
export const DEFAULT_SSH_PREFIX = "coding-";
|
|
11
|
+
export const DEFAULT_NAMESPACE = "coding";
|
|
12
|
+
|
|
13
|
+
let loaded = false;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Minimal .env parser (no dependency): KEY=VALUE lines, optional `export`
|
|
17
|
+
* prefix, `#` comments, single/double-quoted values. Real environment wins:
|
|
18
|
+
* keys already in process.env are never overwritten. Loads once from
|
|
19
|
+
* PROJECT_FOLDER/.env (the checkout root), so each repeated folder carries
|
|
20
|
+
* its own dev/prod identity no matter where commands run from.
|
|
21
|
+
*/
|
|
22
|
+
export function loadInstanceEnv(root = INSTANCE_ROOT) {
|
|
23
|
+
if (loaded) return;
|
|
24
|
+
loaded = true;
|
|
25
|
+
let text;
|
|
26
|
+
try {
|
|
27
|
+
text = fs.readFileSync(path.join(root, ".env"), "utf8");
|
|
28
|
+
} catch {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
32
|
+
const line = rawLine.trim();
|
|
33
|
+
if (!line || line.startsWith("#")) continue;
|
|
34
|
+
const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
|
35
|
+
if (!m) continue;
|
|
36
|
+
const [, key, rest] = m;
|
|
37
|
+
if (key in process.env) continue;
|
|
38
|
+
process.env[key] = unquote(rest.trim());
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Test-only: allow re-loading after env swaps.
|
|
43
|
+
export function resetInstanceEnvForTests() {
|
|
44
|
+
loaded = false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function unquote(v) {
|
|
48
|
+
if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
|
|
49
|
+
const inner = v.slice(1, -1);
|
|
50
|
+
// Double-quoted: honor common escapes; single-quoted: literal.
|
|
51
|
+
return v.startsWith('"') ? inner.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t").replace(/\\"/g, '"').replace(/\\\\/g, "\\") : inner;
|
|
52
|
+
}
|
|
53
|
+
const comment = v.search(/(^|\s)#/);
|
|
54
|
+
return (comment === -1 ? v : v.slice(0, comment).trim());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Global config dir leaf: CODING_CONFIG_LEAF, default "coding-container". */
|
|
58
|
+
export function configDirLeaf() {
|
|
59
|
+
loadInstanceEnv();
|
|
60
|
+
const v = (process.env.CODING_CONFIG_LEAF ?? "").trim() || DEFAULT_CONFIG_LEAF;
|
|
61
|
+
if (!/^[A-Za-z0-9._-]+$/.test(v) || v === "." || v === "..") {
|
|
62
|
+
throw new Error(`config: CODING_CONFIG_LEAF must be a plain folder name (no slashes): ${JSON.stringify(v)}`);
|
|
63
|
+
}
|
|
64
|
+
return v;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** SSH alias prefix: CODING_SSH_PREFIX, default "coding-". */
|
|
68
|
+
export function sshPrefix() {
|
|
69
|
+
loadInstanceEnv();
|
|
70
|
+
const v = (process.env.CODING_SSH_PREFIX ?? "").trim() || DEFAULT_SSH_PREFIX;
|
|
71
|
+
if (!/^[a-z0-9-]+$/.test(v) || !v.endsWith("-") || v.length > 32) {
|
|
72
|
+
throw new Error(`config: CODING_SSH_PREFIX must be lowercase alnum/dashes ending in '-': ${JSON.stringify(v)}`);
|
|
73
|
+
}
|
|
74
|
+
return v;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Default k8s namespace: CODING_NAMESPACE, default "coding". Config files still override it. */
|
|
78
|
+
export function defaultNamespace() {
|
|
79
|
+
loadInstanceEnv();
|
|
80
|
+
const v = (process.env.CODING_NAMESPACE ?? "").trim() || DEFAULT_NAMESPACE;
|
|
81
|
+
if (v.length > 63 || !/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(v)) {
|
|
82
|
+
throw new Error(`config: CODING_NAMESPACE must be a valid k8s name (lowercase alphanum and '-'): ${v}`);
|
|
83
|
+
}
|
|
84
|
+
return v;
|
|
85
|
+
}
|
package/src/utils/manifest.js
CHANGED
|
@@ -227,6 +227,9 @@ export function deploymentManifest(cfg, pullSecret = null) {
|
|
|
227
227
|
},
|
|
228
228
|
spec: {
|
|
229
229
|
...(pullSecret ? { imagePullSecrets: [{ name: pullSecret }] } : {}),
|
|
230
|
+
// Pin the pod to one named cluster node when k8s.nodeName is set.
|
|
231
|
+
// Empty/unset -> the scheduler picks a node as before.
|
|
232
|
+
...(cfg.k8s?.nodeName ? { nodeName: cfg.k8s.nodeName } : {}),
|
|
230
233
|
containers: [container, ...sidecarContainers(cfg)],
|
|
231
234
|
volumes: [
|
|
232
235
|
{
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { exec } from "./exec.js";
|
|
2
|
+
import { kubectlExec } from "./system-config.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse `kubectl get nodes -o json` into [{ name, internalIP, ready }].
|
|
6
|
+
* Unknown/missing fields stay undefined/false (never throws on shape).
|
|
7
|
+
*/
|
|
8
|
+
export function parseNodesJson(jsonStr) {
|
|
9
|
+
const { items = [] } = JSON.parse(jsonStr);
|
|
10
|
+
return items.map((n) => {
|
|
11
|
+
const addresses = n.status?.addresses || [];
|
|
12
|
+
const internalIP = (addresses.find((a) => a.type === "InternalIP") || {}).address;
|
|
13
|
+
const readyCond = (n.status?.conditions || []).find((c) => c.type === "Ready");
|
|
14
|
+
return {
|
|
15
|
+
name: n.metadata?.name,
|
|
16
|
+
internalIP,
|
|
17
|
+
ready: readyCond?.status === "True",
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse `kubectl get pods -o json` into [{ name, phase, hostIP, nodeName }].
|
|
24
|
+
*/
|
|
25
|
+
export function parsePodsJson(jsonStr) {
|
|
26
|
+
const { items = [] } = JSON.parse(jsonStr);
|
|
27
|
+
return items.map((p) => ({
|
|
28
|
+
name: p.metadata?.name,
|
|
29
|
+
phase: p.status?.phase,
|
|
30
|
+
hostIP: p.status?.hostIP,
|
|
31
|
+
nodeName: p.spec?.nodeName,
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Pick the SSH NodePort IP: the pod's own hostIP when that node is Ready,
|
|
37
|
+
* else the first Ready node with an InternalIP. Returns null when no Ready
|
|
38
|
+
* node exists (caller warns instead of writing a dead alias).
|
|
39
|
+
*/
|
|
40
|
+
export function pickNodeIp(nodes, podHostIP = null) {
|
|
41
|
+
const ready = nodes.filter((n) => n.ready && n.internalIP);
|
|
42
|
+
if (!ready.length) return null;
|
|
43
|
+
if (podHostIP && ready.some((n) => n.internalIP === podHostIP)) return podHostIP;
|
|
44
|
+
return ready[0].internalIP;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve where the project actually runs: ask the cluster for this
|
|
49
|
+
* project's pods, then map the Running pod's hostIP onto the Ready nodes.
|
|
50
|
+
* Falls back to the first Ready node when the pod list is empty/pending;
|
|
51
|
+
* returns null when the cluster is unreachable or nothing is Ready (so
|
|
52
|
+
* deploy can warn and skip the SSH alias instead of writing a dead IP).
|
|
53
|
+
*
|
|
54
|
+
* Uses `-o json` (not jsonpath) so Windows script stubs don't hit shell
|
|
55
|
+
* quoting issues.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveSshNodeIp(cfg) {
|
|
58
|
+
const { cmd, args, opts } = kubectlExec();
|
|
59
|
+
let podHostIP = null;
|
|
60
|
+
try {
|
|
61
|
+
const out = exec(cmd, [...args, "get", "pods", "-n", cfg.k8s.namespace, "-l", `app=${cfg.project}`, "-o", "json"], opts);
|
|
62
|
+
const pods = parsePodsJson(out);
|
|
63
|
+
const running = pods.find((p) => p.phase === "Running" && p.hostIP) || pods.find((p) => p.hostIP);
|
|
64
|
+
podHostIP = running?.hostIP ?? null;
|
|
65
|
+
} catch {
|
|
66
|
+
/* pods unreadable -> Ready-node fallback below */
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const out = exec(cmd, [...args, "get", "nodes", "-o", "json"], opts);
|
|
70
|
+
return pickNodeIp(parseNodesJson(out), podHostIP);
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { PROJECT_ROOT, globalConfigDir } from "../config.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared projects registry (the GUI datastore), reused by the CLI.
|
|
7
|
+
*
|
|
8
|
+
* Store shape: JSON array of { id, name, configPath } at
|
|
9
|
+
* <globalConfigDir()>/projects.json
|
|
10
|
+
* unless PROJECTS_FILE overrides it (absolute, or relative to the repo root).
|
|
11
|
+
* Managed per-project configs live under <globalConfigDir()>/projects/<slug>/.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Resolve an explicit PROJECTS_FILE value to an absolute path. */
|
|
15
|
+
export function resolveProjectsFile(raw) {
|
|
16
|
+
const value = raw && String(raw).trim() ? String(raw).trim() : "";
|
|
17
|
+
if (!value) return path.join(globalConfigDir(), "projects.json");
|
|
18
|
+
return path.isAbsolute(value) ? value : path.join(PROJECT_ROOT, value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Default registry path: PROJECTS_FILE env wins, else next to global config. */
|
|
22
|
+
export function defaultProjectsFile() {
|
|
23
|
+
// Touch the global dir first so a PROJECTS_FILE set in the checkout's .env
|
|
24
|
+
// is loaded into process.env before reading it — matching the GUI server,
|
|
25
|
+
// which reads the same .env file directly.
|
|
26
|
+
globalConfigDir();
|
|
27
|
+
return resolveProjectsFile(process.env.PROJECTS_FILE ?? "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readProjects(projectsFile) {
|
|
31
|
+
const file = projectsFile ?? defaultProjectsFile();
|
|
32
|
+
try {
|
|
33
|
+
if (!fs.existsSync(file)) return [];
|
|
34
|
+
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
35
|
+
return Array.isArray(data) ? data : [];
|
|
36
|
+
} catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function writeProjects(projectsFile, projects) {
|
|
42
|
+
fs.mkdirSync(path.dirname(projectsFile), { recursive: true });
|
|
43
|
+
fs.writeFileSync(projectsFile, `${JSON.stringify(projects, null, 2)}\n`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveStoredConfigPath(configPath) {
|
|
47
|
+
const raw = String(configPath ?? "").trim();
|
|
48
|
+
if (!raw) return null;
|
|
49
|
+
return path.isAbsolute(raw) ? raw : path.join(PROJECT_ROOT, raw);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Filesystem + k8s-safe slug from a display name (DNS-1123 label style).
|
|
54
|
+
* Mirrors deriveProjectName(): lowercase, non [a-z0-9-] -> "-", collapse,
|
|
55
|
+
* trim dashes, cap at 63 chars, fallback to "project".
|
|
56
|
+
*/
|
|
57
|
+
export function slugifyProjectName(name) {
|
|
58
|
+
let slug = String(name ?? "")
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
61
|
+
.replace(/-+/g, "-")
|
|
62
|
+
.replace(/^-+|-+$/g, "");
|
|
63
|
+
if (!slug) slug = "project";
|
|
64
|
+
if (slug.length > 63) slug = slug.slice(0, 63).replace(/-+$/g, "") || "project";
|
|
65
|
+
return slug;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Display slugs for every registry entry, in registry order. The slug tracks
|
|
70
|
+
* the current name; collisions get -2, -3, ... suffixes.
|
|
71
|
+
* Returns [{ project, slug }].
|
|
72
|
+
*/
|
|
73
|
+
export function computeProjectSlugs(projects) {
|
|
74
|
+
const used = new Set();
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const project of projects ?? []) {
|
|
77
|
+
const base = slugifyProjectName(project?.name);
|
|
78
|
+
let slug = base;
|
|
79
|
+
let n = 1;
|
|
80
|
+
while (used.has(slug)) {
|
|
81
|
+
n += 1;
|
|
82
|
+
slug = `${base}-${n}`;
|
|
83
|
+
}
|
|
84
|
+
used.add(slug);
|
|
85
|
+
out.push({ project, slug });
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Folder holding auto-managed per-project configs: <globalDir>/projects. */
|
|
91
|
+
export function managedProjectsDir() {
|
|
92
|
+
return path.join(globalConfigDir(), "projects");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Default config file for an auto-managed project slug. */
|
|
96
|
+
export function defaultProjectConfigPath(slug) {
|
|
97
|
+
return path.join(managedProjectsDir(), slug, "config.yaml");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* True when configPath lives inside the managed projects dir (i.e. it was
|
|
102
|
+
* auto-assigned, not a user-supplied custom path). Such paths follow renames.
|
|
103
|
+
*/
|
|
104
|
+
export function isManagedConfigPath(configPath) {
|
|
105
|
+
const resolved = path.resolve(String(configPath ?? ""));
|
|
106
|
+
const base = path.resolve(managedProjectsDir());
|
|
107
|
+
const rel = path.relative(base, resolved);
|
|
108
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Unique managed config path for a base slug. Suffixes -2, -3, ... while the
|
|
113
|
+
* candidate is taken by another registry entry (excluding excludeId) or an
|
|
114
|
+
* existing file on disk (e.g. leftovers from a removed entry).
|
|
115
|
+
*/
|
|
116
|
+
export function uniqueManagedConfigPath(projects, baseSlug, excludeId = null) {
|
|
117
|
+
const takenPaths = new Set(
|
|
118
|
+
(projects ?? [])
|
|
119
|
+
.filter((p) => p.id !== excludeId)
|
|
120
|
+
.map((p) => path.resolve(String(p.configPath ?? "")))
|
|
121
|
+
);
|
|
122
|
+
let slug = baseSlug;
|
|
123
|
+
let n = 1;
|
|
124
|
+
while (true) {
|
|
125
|
+
const candidate = defaultProjectConfigPath(slug);
|
|
126
|
+
const resolved = path.resolve(candidate);
|
|
127
|
+
if (!takenPaths.has(resolved) && !fs.existsSync(candidate)) return { slug, configPath: candidate };
|
|
128
|
+
n += 1;
|
|
129
|
+
slug = `${baseSlug}-${n}`;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One-line summary used in "unknown project" errors.
|
|
135
|
+
* `entries` is the result of computeProjectSlugs().
|
|
136
|
+
*/
|
|
137
|
+
function availableProjectsHint(entries, projectsFile) {
|
|
138
|
+
if (!entries || entries.length === 0) {
|
|
139
|
+
return `No projects registered in ${projectsFile}.`;
|
|
140
|
+
}
|
|
141
|
+
const lines = entries.map(({ project, slug }) => ` ${slug} ("${project?.name ?? ""}")`);
|
|
142
|
+
return `Available projects (slug, name or id):\n${lines.join("\n")}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Match a user-supplied ref against the registry. Ref may be a display slug,
|
|
147
|
+
* a name, or an id (exact or unique prefix >= 4 chars).
|
|
148
|
+
* Returns { project, slug } or null when nothing matches.
|
|
149
|
+
* Throws on ambiguous names/prefixes.
|
|
150
|
+
*/
|
|
151
|
+
export function findProjectByRef(projects, ref) {
|
|
152
|
+
const needle = String(ref ?? "").trim();
|
|
153
|
+
if (!needle) return null;
|
|
154
|
+
const list = projects ?? [];
|
|
155
|
+
const entries = computeProjectSlugs(list);
|
|
156
|
+
|
|
157
|
+
const byId = list.find((p) => p?.id === needle);
|
|
158
|
+
if (byId) {
|
|
159
|
+
const slug = entries.find((e) => e.project === byId)?.slug ?? slugifyProjectName(byId.name);
|
|
160
|
+
return { project: byId, slug };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const lowered = needle.toLowerCase();
|
|
164
|
+
const slugHits = entries.filter((e) => e.slug.toLowerCase() === lowered);
|
|
165
|
+
if (slugHits.length === 1) return slugHits[0];
|
|
166
|
+
if (slugHits.length > 1) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Ambiguous project "${needle}" matches several slugs: ${slugHits.map((e) => e.slug).join(", ")}`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const exactName = entries.filter((e) => String(e.project?.name ?? "") === needle);
|
|
173
|
+
if (exactName.length === 1) return exactName[0];
|
|
174
|
+
if (exactName.length > 1) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Ambiguous project name "${needle}" matches ${exactName.length} projects; use the slug or id instead.`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const ciName = entries.filter(
|
|
181
|
+
(e) => String(e.project?.name ?? "").toLowerCase() === lowered
|
|
182
|
+
);
|
|
183
|
+
if (ciName.length === 1) return ciName[0];
|
|
184
|
+
if (ciName.length > 1) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`Ambiguous project name "${needle}" matches ${ciName.length} projects; use the slug or id instead.`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (needle.length >= 4) {
|
|
191
|
+
const prefix = list.filter((p) => typeof p?.id === "string" && p.id.startsWith(needle));
|
|
192
|
+
if (prefix.length === 1) {
|
|
193
|
+
const project = prefix[0];
|
|
194
|
+
const slug = entries.find((e) => e.project === project)?.slug ?? slugifyProjectName(project.name);
|
|
195
|
+
return { project, slug };
|
|
196
|
+
}
|
|
197
|
+
if (prefix.length > 1) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`Ambiguous project id prefix "${needle}" matches ${prefix.length} projects; use the full id.`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Like findProjectByRef, but throws a copy-paste friendly error naming the
|
|
209
|
+
* registry file and every available slug when nothing matches.
|
|
210
|
+
*/
|
|
211
|
+
export function resolveProjectRefOrThrow(projects, ref, projectsFile) {
|
|
212
|
+
const match = findProjectByRef(projects, ref);
|
|
213
|
+
if (match) return match;
|
|
214
|
+
const entries = computeProjectSlugs(projects ?? []);
|
|
215
|
+
throw new Error(
|
|
216
|
+
`Unknown project "${String(ref ?? "").trim()}".\n${availableProjectsHint(entries, projectsFile)}\nRun "coding-container projects list" to see all projects.`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
@@ -186,7 +186,7 @@ async function send(cfg, method, path, authHeader, extraHeaders = {}) {
|
|
|
186
186
|
* Authorization header, and on 401 follow the WWW-Authenticate challenge
|
|
187
187
|
* (Bearer token exchange or plain Basic) with the local docker credentials.
|
|
188
188
|
*/
|
|
189
|
-
async function apiRequest(cfg, method, path, extraHeaders = {}) {
|
|
189
|
+
async function apiRequest(cfg, method, path, extraHeaders = {}, scopeOverride = null) {
|
|
190
190
|
let res = await send(cfg, method, path, state.authHeader, extraHeaders);
|
|
191
191
|
if (res.status !== 401) return res;
|
|
192
192
|
|
|
@@ -214,7 +214,7 @@ async function apiRequest(cfg, method, path, extraHeaders = {}) {
|
|
|
214
214
|
authHeader = basic;
|
|
215
215
|
} else if (challenge.type === "bearer") {
|
|
216
216
|
const repo = registryRepoName(cfg);
|
|
217
|
-
const scope = challenge.scope || `repository:${repo}:pull,push`;
|
|
217
|
+
const scope = scopeOverride || challenge.scope || `repository:${repo}:pull,push`;
|
|
218
218
|
const key = `${challenge.realm}|${challenge.service || ""}|${scope}`;
|
|
219
219
|
let token = state.tokens.get(key);
|
|
220
220
|
if (!token) {
|
|
@@ -287,12 +287,67 @@ export async function listTags(cfg) {
|
|
|
287
287
|
}
|
|
288
288
|
|
|
289
289
|
/**
|
|
290
|
-
* Delete one tag.
|
|
290
|
+
* Delete one tag. Uses the standard Registry V2 / OCI flow:
|
|
291
|
+
* GET /v2/<repo>/manifests/<tag> (with a manifest Accept header) to
|
|
292
|
+
* resolve the Docker-Content-Digest, then
|
|
293
|
+
* DELETE /v2/<repo>/manifests/<digest>.
|
|
294
|
+
* Gitea/Forgejo also accepts DELETE .../manifests/<tag> directly, so when
|
|
295
|
+
* the digest lookup fails we fall back to deleting by tag reference.
|
|
296
|
+
* Returns normally on success (2xx); throws otherwise.
|
|
291
297
|
*/
|
|
292
298
|
export async function deleteTag(cfg, tag) {
|
|
293
299
|
const repo = registryRepoName(cfg);
|
|
294
300
|
const host = registryHost(cfg.image.registry);
|
|
295
|
-
const
|
|
301
|
+
const ref = encodeURIComponent(tag);
|
|
302
|
+
const deleteScope = `repository:${repo}:pull,push,delete`;
|
|
303
|
+
const manifestAccept = [
|
|
304
|
+
"application/vnd.docker.distribution.manifest.v2+json",
|
|
305
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
306
|
+
"application/vnd.oci.image.manifest.v1+json",
|
|
307
|
+
"application/vnd.oci.image.index.v1+json",
|
|
308
|
+
].join(", ");
|
|
309
|
+
|
|
310
|
+
// Resolve the manifest digest for the tag. A 404 here means the tag is
|
|
311
|
+
// already gone.
|
|
312
|
+
const manifestRes = await apiRequest(
|
|
313
|
+
cfg,
|
|
314
|
+
"GET",
|
|
315
|
+
`/v2/${repo}/manifests/${ref}`,
|
|
316
|
+
{ Accept: manifestAccept },
|
|
317
|
+
deleteScope
|
|
318
|
+
);
|
|
319
|
+
let digest = manifestRes.headers.get("docker-content-digest");
|
|
320
|
+
if (manifestRes.status === 404) {
|
|
321
|
+
throw new Error(`tag ${tag} not found (already deleted?)`);
|
|
322
|
+
}
|
|
323
|
+
if (!manifestRes.ok || !digest) {
|
|
324
|
+
// Registry did not return a digest (e.g. it rejects GET manifests by
|
|
325
|
+
// tag): try deleting the tag reference directly (Gitea supports
|
|
326
|
+
// DELETE .../manifests/<tag>).
|
|
327
|
+
const direct = await apiRequest(
|
|
328
|
+
cfg,
|
|
329
|
+
"DELETE",
|
|
330
|
+
`/v2/${repo}/manifests/${ref}`,
|
|
331
|
+
{},
|
|
332
|
+
deleteScope
|
|
333
|
+
);
|
|
334
|
+
if (direct.status === 404) {
|
|
335
|
+
throw new Error(`tag ${tag} not found (already deleted?)`);
|
|
336
|
+
}
|
|
337
|
+
if (direct.status === 405) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`registry ${host} does not support deleting tags through the registry API (405) — ` +
|
|
340
|
+
`e.g. AWS ECR requires the AWS BatchDeleteImage API instead`
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
if (!direct.ok) {
|
|
344
|
+
throw new Error(`deleting tag ${tag} failed with ${direct.status} ${direct.statusText}`.trim());
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
digest = digest.trim();
|
|
350
|
+
const res = await apiRequest(cfg, "DELETE", `/v2/${repo}/manifests/${digest}`, {}, deleteScope);
|
|
296
351
|
if (res.status === 404) {
|
|
297
352
|
throw new Error(`tag ${tag} not found (already deleted?)`);
|
|
298
353
|
}
|
package/src/utils/semver.js
CHANGED
|
@@ -19,6 +19,17 @@ export function compareSemver(a, b) {
|
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Patch-bump a semver tag (vX.Y.Z -> vX.Y.Z+1). Assumes the input is semver.
|
|
24
|
+
*/
|
|
25
|
+
export function bumpPatch(tag) {
|
|
26
|
+
const m = typeof tag === "string" && tag.match(SEMVER_RE);
|
|
27
|
+
if (!m) {
|
|
28
|
+
throw new Error(`cannot bump patch: not a semver tag (vX.Y.Z), got: ${tag}`);
|
|
29
|
+
}
|
|
30
|
+
return `v${m[1]}.${m[2]}.${Number(m[3]) + 1}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
/**
|
|
23
34
|
* The semver tags of a list, sorted ascending (oldest first). Non-semver
|
|
24
35
|
* tags (e.g. legacy timestamp tags) are excluded.
|
package/src/utils/ssh-config.js
CHANGED
|
@@ -11,11 +11,29 @@ export function sshConfigPath() {
|
|
|
11
11
|
return path.join(os.homedir(), ".ssh", "config");
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
import { sshPrefix } from "./instance.js";
|
|
15
|
+
|
|
14
16
|
/**
|
|
15
|
-
* Alias convention for container hosts:
|
|
17
|
+
* Alias convention for container hosts: <prefix><project> (default
|
|
18
|
+
* "coding-<project>"; CODING_SSH_PREFIX in PROJECT_FOLDER/.env overrides
|
|
19
|
+
* the prefix so dev/prod copies don't overwrite each other's aliases).
|
|
16
20
|
*/
|
|
17
21
|
export function sshAlias(project) {
|
|
18
|
-
return
|
|
22
|
+
return `${sshPrefix()}${project}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Inverse of sshAlias(): strip this instance's prefix. Returns null when the
|
|
27
|
+
* alias belongs to a sibling instance sharing the same managed block, so
|
|
28
|
+
* `list update` leaves other copies' aliases untouched (each copy manages
|
|
29
|
+
* only its own entries; the sibling's own runs update those).
|
|
30
|
+
*/
|
|
31
|
+
export function sshProjectFromAlias(alias) {
|
|
32
|
+
const prefix = sshPrefix();
|
|
33
|
+
if (alias.startsWith(prefix) && alias.length > prefix.length) {
|
|
34
|
+
return alias.slice(prefix.length);
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
19
37
|
}
|
|
20
38
|
|
|
21
39
|
/**
|
|
@@ -64,7 +82,9 @@ function readManaged(file) {
|
|
|
64
82
|
const entries = {};
|
|
65
83
|
let current = null;
|
|
66
84
|
for (const line of lines.slice(bi + 1, ei)) {
|
|
67
|
-
|
|
85
|
+
// Shared managed block: accept any Host alias inside the markers so
|
|
86
|
+
// sibling instances (different CODING_SSH_PREFIX) coexist.
|
|
87
|
+
const m = line.match(/^Host ([A-Za-z0-9._-]+)\s*$/);
|
|
68
88
|
if (m) {
|
|
69
89
|
current = m[1];
|
|
70
90
|
entries[current] = [line];
|