@spunto/build 0.3.1 → 0.4.0
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 +46 -0
- package/package.json +22 -5
- package/src/agent-stream/agent-stream.ts +561 -0
- package/src/agent-stream/index.ts +42 -0
- package/src/agent-stream/model-window.ts +189 -0
- package/src/catalogs/index.ts +216 -0
- package/src/index.ts +4 -0
- package/src/script/index.ts +30 -0
- package/src/script/local-features.ts +39 -0
- package/src/script/setup-script.ts +1932 -0
- package/src/types/index.ts +68 -0
|
@@ -0,0 +1,1932 @@
|
|
|
1
|
+
import { AVAILABLE_FEATURES } from "../catalogs/index"
|
|
2
|
+
import { codeServerGallery, parseGallery, registryInfo } from "../extensions/extension-registry"
|
|
3
|
+
import {
|
|
4
|
+
EXTENSIONS_DIR,
|
|
5
|
+
FEATURE_ENTRYPOINTS_FILE,
|
|
6
|
+
FEATURE_ENTRYPOINTS_STAGING_FILE,
|
|
7
|
+
workerStatusPath,
|
|
8
|
+
} from "../naming/index"
|
|
9
|
+
import { stepEndLine, stepStartLine, type BuildStep, type BuildStepKind } from "../steps/build-steps"
|
|
10
|
+
import type { SetupStatus } from "../types/index"
|
|
11
|
+
import { localFeatureScript } from "./local-features"
|
|
12
|
+
|
|
13
|
+
// ─── Internal helpers ──────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Base64 of a string's UTF-8 bytes — what `b64(s)` produced before this
|
|
17
|
+
* file left Node behind.
|
|
18
|
+
*
|
|
19
|
+
* The two-step dance is not decoration: `btoa` takes a *binary string*, one code unit per byte, and
|
|
20
|
+
* throws above U+00FF. Handed a JavaScript string directly it would reject an `é` in a postCreate
|
|
21
|
+
* command, and a surrogate pair would encode to bytes no decoder reads back. These payloads are
|
|
22
|
+
* `base64 -d`'d inside the container, so a wrong byte is a broken worker, not a mojibake.
|
|
23
|
+
*/
|
|
24
|
+
function b64(value: string): string {
|
|
25
|
+
const bytes = new TextEncoder().encode(value)
|
|
26
|
+
let binary = ""
|
|
27
|
+
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
28
|
+
return btoa(binary)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
/** Single-quotes a value for safe embedding in a shell command. Survives being
|
|
33
|
+
* nested inside a double-quoted `su -c "..."` context, unlike double quotes. */
|
|
34
|
+
function shQuote(v: string): string {
|
|
35
|
+
return `'${v.replace(/'/g, `'\\''`)}'`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `export EXTENSIONS_GALLERY=…`, or nothing when the org is on the default (Open VSX) registry.
|
|
40
|
+
*
|
|
41
|
+
* Emitted into the generated scripts rather than relied on from the ambient environment: a
|
|
42
|
+
* `docker build` doesn't inherit the daemon's env at all, and the code-server loop runs under `su`,
|
|
43
|
+
* which is free to strip it. A blob we couldn't parse emits nothing — see lib/extension-registry.ts:
|
|
44
|
+
* handing code-server a gallery the picker didn't search would recreate the split-brain the org
|
|
45
|
+
* setting exists to close.
|
|
46
|
+
*/
|
|
47
|
+
function galleryExport(extensionsGallery?: string | null): string[] {
|
|
48
|
+
const forward = codeServerGallery(extensionsGallery)
|
|
49
|
+
return forward ? [`export EXTENSIONS_GALLERY=${shQuote(forward)}`] : []
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Registry name, flattened to something safe to echo from a shell script. */
|
|
53
|
+
function registryLabel(extensionsGallery?: string | null): string {
|
|
54
|
+
return registryInfo(parseGallery(extensionsGallery)).name.replace(/[^\w .:/-]/g, "") || "the configured registry"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Produces an `env KEY=val ...` prefix for injecting secrets into a single command. */
|
|
58
|
+
function envPrefix(secrets?: Record<string, string>): string {
|
|
59
|
+
if (!secrets || Object.keys(secrets).length === 0) return ""
|
|
60
|
+
const pairs = Object.entries(secrets).map(([k, v]) => `${k}=${shQuote(v)}`).join(" ")
|
|
61
|
+
return `env ${pairs} `
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A project repository, reduced to what the clone step needs. */
|
|
65
|
+
type CloneRepo = {
|
|
66
|
+
provider: string
|
|
67
|
+
project: string
|
|
68
|
+
workspacePath: string
|
|
69
|
+
cloneUrl?: string
|
|
70
|
+
/** Project-level default branch for this repo (optional). */
|
|
71
|
+
branch?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Branch actually checked out for a repo: the worker-level choice (made at creation, applies to
|
|
76
|
+
* every repo of the project) wins over the project-level default carried by the repository itself.
|
|
77
|
+
* Empty/blank on both sides → undefined = clone the remote's default branch (HEAD), the historical
|
|
78
|
+
* behaviour.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveRepoBranch(repo: { branch?: string | null }, workerBranch?: string | null): string | undefined {
|
|
81
|
+
return workerBranch?.trim() || repo.branch?.trim() || undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Clone block for one repository — shared by the legacy script and the orchestrated plan (RFC 0017)
|
|
86
|
+
* so the two generators can't drift apart. Returns the `--- Cloning … ---` header line separately
|
|
87
|
+
* because the legacy path stamps its phase status between the header and the block.
|
|
88
|
+
*
|
|
89
|
+
* Clone strategy, in order of preference:
|
|
90
|
+
* 0. Generic "git" repo (RFC 0013): clone the raw cloneUrl using the per-project deploy key.
|
|
91
|
+
* Works for any SSH host (StrictHostKeyChecking off); GIT_SSH_COMMAND is a no-op for HTTPS.
|
|
92
|
+
* 1. Org GitHub App installation token (RFC 0012): clone over HTTPS x-access-token for
|
|
93
|
+
* org-scoped read access, then rewrite the remote to SSH so push/pull use the user's
|
|
94
|
+
* personal key — and the token never persists in .git/config.
|
|
95
|
+
* 2. User personal SSH key (legacy / fallback when org has no installation).
|
|
96
|
+
* 3. Plain HTTPS (public repos only).
|
|
97
|
+
*
|
|
98
|
+
* When a branch is requested, `git clone --branch` posts it directly (it also accepts a tag). A ref
|
|
99
|
+
* that doesn't exist makes git fail immediately ("Remote branch … not found in upstream origin")
|
|
100
|
+
* without leaving a half-cloned workspace: we turn that into an explicit message + `exit 1` so the
|
|
101
|
+
* setup stops in `error` instead of silently landing on the default branch.
|
|
102
|
+
*/
|
|
103
|
+
function cloneRepoBlock(params: {
|
|
104
|
+
repo: CloneRepo
|
|
105
|
+
index: number
|
|
106
|
+
total: number
|
|
107
|
+
homeDir: string
|
|
108
|
+
branch?: string
|
|
109
|
+
githubInstallationTokens?: Record<string, string>
|
|
110
|
+
userSshPrivateKey?: string
|
|
111
|
+
}): { header: string; lines: string[] } {
|
|
112
|
+
const { repo: r, index, total, homeDir, branch, githubInstallationTokens, userSshPrivateKey } = params
|
|
113
|
+
// Pick the installation token for this repo's owner. A GitHub App installation only grants
|
|
114
|
+
// access to repos owned by its account, so the owner login (== installation accountLogin)
|
|
115
|
+
// uniquely selects the right token among the org's connected installations. Covers legacy
|
|
116
|
+
// repos with no stored installationId too, since resolution is by owner, not by id.
|
|
117
|
+
const owner = (r.project.split("/")[0] ?? "").toLowerCase()
|
|
118
|
+
const repoToken = githubInstallationTokens?.[owner]
|
|
119
|
+
const b = branch ? ` --branch ${shQuote(branch)}` : ""
|
|
120
|
+
const cloneCmd = r.provider === "git" && r.cloneUrl
|
|
121
|
+
? `GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ${homeDir}/.ssh/mp_deploy_key" git clone${b} ${shQuote(r.cloneUrl)} /workspace/${r.workspacePath}`
|
|
122
|
+
: r.provider === "github" && repoToken
|
|
123
|
+
? `git clone${b} https://x-access-token:${repoToken}@github.com/${r.project}.git /workspace/${r.workspacePath} && git -C /workspace/${r.workspacePath} remote set-url origin git@github.com:${r.project}.git`
|
|
124
|
+
: r.provider === "github" && userSshPrivateKey
|
|
125
|
+
? `GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ${homeDir}/.ssh/mp_user_key" git clone${b} git@github.com:${r.project}.git /workspace/${r.workspacePath}`
|
|
126
|
+
: `git clone${b} https://github.com/${r.project} /workspace/${r.workspacePath}`
|
|
127
|
+
|
|
128
|
+
// Single-quoted only when a (user-provided) branch is interpolated; the plain form is kept
|
|
129
|
+
// verbatim for the no-branch case so the generated script is unchanged there.
|
|
130
|
+
const header = branch
|
|
131
|
+
? `echo ${shQuote(`--- Cloning ${r.project} (branch ${branch}) (${index + 1}/${total}) ---`)}`
|
|
132
|
+
: `echo "--- Cloning ${r.project} (${index + 1}/${total}) ---"`
|
|
133
|
+
// Single quotes only: this message ends up inside the JSON status written by the failure trap.
|
|
134
|
+
const failMsg = `Clone of ${r.project} failed — branch '${branch}' does not exist on the remote (or the repo is unreachable)`
|
|
135
|
+
const lines = [
|
|
136
|
+
`if [ ! -d "/workspace/${r.workspacePath}/.git" ]; then`,
|
|
137
|
+
...(branch
|
|
138
|
+
? [
|
|
139
|
+
// set +e around the clone so the message below is emitted before the script aborts —
|
|
140
|
+
// `set -e` alone would kill the shell on the failing clone with only git's own output.
|
|
141
|
+
` set +e`,
|
|
142
|
+
` ${cloneCmd}`,
|
|
143
|
+
` _CLONE_EXIT=$?`,
|
|
144
|
+
` set -e`,
|
|
145
|
+
` if [ $_CLONE_EXIT -ne 0 ]; then`,
|
|
146
|
+
// Two readers for the same message, so both setup paths show it instead of the generic
|
|
147
|
+
// "phase failed": the legacy failure trap reads MP_FAIL_MSG, the agent (RFC 0017) reads
|
|
148
|
+
// the file after a non-zero phase (the phase's own output goes to PID 1, not to the exec).
|
|
149
|
+
` MP_FAIL_MSG=${shQuote(failMsg)}`,
|
|
150
|
+
` printf '%s' "$MP_FAIL_MSG" > ${homeDir}/.mp-fail-msg 2>/dev/null || true`,
|
|
151
|
+
` echo "[setup] ✗ $MP_FAIL_MSG" >&2`,
|
|
152
|
+
` exit 1`,
|
|
153
|
+
` fi`,
|
|
154
|
+
]
|
|
155
|
+
: [` ${cloneCmd}`]),
|
|
156
|
+
`else`,
|
|
157
|
+
` echo "${r.project}: already present, skipping clone"`,
|
|
158
|
+
`fi`,
|
|
159
|
+
]
|
|
160
|
+
return { header, lines }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Git identity + SSH credentials written into the worker's home — shared by the legacy script and
|
|
165
|
+
* the orchestrated plan (RFC 0017) so the two generators can't drift apart.
|
|
166
|
+
*
|
|
167
|
+
* Everything here runs as root (both setup paths do), so every file it creates lands root-owned.
|
|
168
|
+
* The block therefore ends by handing `~/.ssh` back to the worker's user: ssh SILENTLY IGNORES a
|
|
169
|
+
* `~/.ssh/config` it can't read (no error, exit 0 — it just falls back to the default identity
|
|
170
|
+
* files), so a root-owned config doesn't break loudly, it makes every `git pull`/`push` over SSH
|
|
171
|
+
* fail authentication for no visible reason. The legacy path used to be saved by its final
|
|
172
|
+
* `chown -R` (step 8), which the orchestrated plan has no equivalent of; chowning right here fixes
|
|
173
|
+
* both, and also makes the credentials usable by everything that runs as the user *during* setup
|
|
174
|
+
* (postCreateCommand, dotfiles install script).
|
|
175
|
+
*/
|
|
176
|
+
function credentialsBlock(params: {
|
|
177
|
+
homeDir: string
|
|
178
|
+
username: string
|
|
179
|
+
userInfo?: { name: string; email?: string | null }
|
|
180
|
+
userSshPrivateKey?: string
|
|
181
|
+
projectDeployKey?: string
|
|
182
|
+
}): string[] {
|
|
183
|
+
const { homeDir, username, userInfo, userSshPrivateKey, projectDeployKey } = params
|
|
184
|
+
const lines: string[] = []
|
|
185
|
+
|
|
186
|
+
if (userInfo) {
|
|
187
|
+
lines.push(`git config --global user.name ${JSON.stringify(userInfo.name)}`)
|
|
188
|
+
if (userInfo.email) lines.push(`git config --global user.email ${JSON.stringify(userInfo.email)}`)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (userSshPrivateKey) {
|
|
192
|
+
lines.push(
|
|
193
|
+
'echo "Configuring user SSH key..."',
|
|
194
|
+
`mkdir -p ${homeDir}/.ssh`,
|
|
195
|
+
`printf '%b' ${JSON.stringify(userSshPrivateKey)} > ${homeDir}/.ssh/mp_user_key`,
|
|
196
|
+
`chmod 600 ${homeDir}/.ssh/mp_user_key`,
|
|
197
|
+
`grep -qF "mp_user_key" ${homeDir}/.ssh/config 2>/dev/null || printf 'Host *\\n IdentityFile ${homeDir}/.ssh/mp_user_key\\n StrictHostKeyChecking no\\n IdentitiesOnly yes\\n' >> ${homeDir}/.ssh/config`,
|
|
198
|
+
`chmod 600 ${homeDir}/.ssh/config`,
|
|
199
|
+
'echo "User SSH key configured"',
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Per-project deploy key (RFC 0013) — used only for generic "git" repos, referenced per-clone
|
|
204
|
+
// via GIT_SSH_COMMAND (not added to ~/.ssh/config, so it never shadows the user's own key).
|
|
205
|
+
if (projectDeployKey) {
|
|
206
|
+
lines.push(
|
|
207
|
+
'echo "Configuring project deploy key..."',
|
|
208
|
+
`mkdir -p ${homeDir}/.ssh`,
|
|
209
|
+
`printf '%b' ${JSON.stringify(projectDeployKey)} > ${homeDir}/.ssh/mp_deploy_key`,
|
|
210
|
+
`chmod 600 ${homeDir}/.ssh/mp_deploy_key`,
|
|
211
|
+
'echo "Project deploy key configured"',
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// See the block comment: root-written credentials are worthless to the user they're for.
|
|
216
|
+
if (userSshPrivateKey || projectDeployKey) {
|
|
217
|
+
lines.push(
|
|
218
|
+
`chmod 700 ${homeDir}/.ssh`,
|
|
219
|
+
`chown -R ${username}:${username} ${homeDir}/.ssh`,
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return lines
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
type RS = { name: string; state: "pending" | "cloning" | "done" | "error" }
|
|
227
|
+
type LS = "pending" | "running" | "done" | "error" | null
|
|
228
|
+
|
|
229
|
+
function mkStatus(
|
|
230
|
+
phase: SetupStatus["phase"],
|
|
231
|
+
repos: RS[],
|
|
232
|
+
postCreate: LS,
|
|
233
|
+
postStart: LS,
|
|
234
|
+
): string {
|
|
235
|
+
return JSON.stringify({ phase, repos, postCreate, postStart } satisfies SetupStatus)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function banner(title: string): string[] {
|
|
239
|
+
return [
|
|
240
|
+
"",
|
|
241
|
+
`echo "###########################################################"`,
|
|
242
|
+
`echo "### ${title}"`,
|
|
243
|
+
`echo "###########################################################"`,
|
|
244
|
+
]
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function buildFeatureInstallScript(ociRef: string, options?: Record<string, string>): string {
|
|
248
|
+
const match = ociRef.match(/^([^/]+)\/(.+):(.+)$/)
|
|
249
|
+
if (!match) return `echo "[feature] Invalid OCI ref: ${ociRef}"`
|
|
250
|
+
const [, registry, repo, tag] = match
|
|
251
|
+
const featureId = repo.split("/").pop()
|
|
252
|
+
|
|
253
|
+
const lines = [
|
|
254
|
+
`echo "[feature] Installing ${featureId}..."`,
|
|
255
|
+
`_FEAT_DIR=$(mktemp -d)`,
|
|
256
|
+
`echo "[feature] ${featureId}: fetching OCI token..."`,
|
|
257
|
+
`_FEAT_TOKEN=$(curl -fsSL "https://${registry}/token?scope=repository:${repo}:pull" | grep -o '"token":"[^"]*"' | cut -d'"' -f4)`,
|
|
258
|
+
`echo "[feature] ${featureId}: fetching manifest..."`,
|
|
259
|
+
`_FEAT_MANIFEST=$(curl -fsSL -H "Authorization: Bearer $_FEAT_TOKEN" -H "Accept: application/vnd.oci.image.manifest.v1+json" "https://${registry}/v2/${repo}/manifests/${tag}")`,
|
|
260
|
+
`_FEAT_DIGEST=$(echo "$_FEAT_MANIFEST" | grep -o '"digest":"sha256:[^"]*"' | tail -1 | cut -d'"' -f4)`,
|
|
261
|
+
`echo "[feature] ${featureId}: downloading layer $_FEAT_DIGEST..."`,
|
|
262
|
+
`curl -fsSL -H "Authorization: Bearer $_FEAT_TOKEN" "https://${registry}/v2/${repo}/blobs/$_FEAT_DIGEST" -o "$_FEAT_DIR/feature.tar"`,
|
|
263
|
+
`echo "[feature] ${featureId}: extracting $(wc -c < "$_FEAT_DIR/feature.tar") bytes..."`,
|
|
264
|
+
`(tar xzf "$_FEAT_DIR/feature.tar" -C "$_FEAT_DIR" 2>/dev/null || tar xf "$_FEAT_DIR/feature.tar" -C "$_FEAT_DIR")`,
|
|
265
|
+
`rm -f "$_FEAT_DIR/feature.tar"`,
|
|
266
|
+
`echo "[feature] ${featureId}: files extracted: $(ls "$_FEAT_DIR" | tr '\\n' ' ')"`,
|
|
267
|
+
`cd "$_FEAT_DIR"`,
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
if (options) {
|
|
271
|
+
for (const [k, v] of Object.entries(options)) {
|
|
272
|
+
lines.push(`export ${k.toUpperCase()}=${JSON.stringify(v)}`)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
lines.push(
|
|
277
|
+
`echo "[feature] ${featureId}: running install.sh..."`,
|
|
278
|
+
`chmod +x install.sh`,
|
|
279
|
+
`set +e`,
|
|
280
|
+
`./install.sh`,
|
|
281
|
+
`_FEAT_EXIT=$?`,
|
|
282
|
+
`set -e`,
|
|
283
|
+
`if [ -f "$_FEAT_DIR/devcontainer-feature.json" ]; then`,
|
|
284
|
+
` _EP=$(grep -o '"entrypoint"[[:space:]]*:[[:space:]]*"[^"]*"' "$_FEAT_DIR/devcontainer-feature.json" | head -1 | cut -d'"' -f4)`,
|
|
285
|
+
` if [ -n "$_EP" ]; then`,
|
|
286
|
+
` echo "$_EP" >> " + FEATURE_ENTRYPOINTS_STAGING_FILE + "`,
|
|
287
|
+
` echo "[feature] ${featureId}: registered entrypoint $_EP"`,
|
|
288
|
+
` fi`,
|
|
289
|
+
`fi`,
|
|
290
|
+
`cd /`,
|
|
291
|
+
`rm -rf "$_FEAT_DIR"`,
|
|
292
|
+
`if [ $_FEAT_EXIT -ne 0 ]; then`,
|
|
293
|
+
` echo "[feature] ${featureId} ✗ FAILED (exit $_FEAT_EXIT)"`,
|
|
294
|
+
` exit $_FEAT_EXIT`,
|
|
295
|
+
`fi`,
|
|
296
|
+
`echo "[feature] ${featureId} ✓ installed"`,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
return lines.join("\n")
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function buildLocalFeatureInstallScript(featureId: string, scriptFile: string): string | null {
|
|
303
|
+
const scriptContent = localFeatureScript(scriptFile)
|
|
304
|
+
if (!scriptContent) return null
|
|
305
|
+
const encoded = b64(scriptContent)
|
|
306
|
+
return [
|
|
307
|
+
`echo "[feature] ${featureId}: running local install script..."`,
|
|
308
|
+
`echo "${encoded}" | base64 -d > /tmp/_mp_feature.sh`,
|
|
309
|
+
`chmod +x /tmp/_mp_feature.sh`,
|
|
310
|
+
`set +e`,
|
|
311
|
+
`/tmp/_mp_feature.sh`,
|
|
312
|
+
`_LOCAL_EXIT=$?`,
|
|
313
|
+
`set -e`,
|
|
314
|
+
`rm -f /tmp/_mp_feature.sh`,
|
|
315
|
+
`if [ $_LOCAL_EXIT -ne 0 ]; then`,
|
|
316
|
+
` echo "[feature] ${featureId} ✗ FAILED (exit $_LOCAL_EXIT)"`,
|
|
317
|
+
` exit $_LOCAL_EXIT`,
|
|
318
|
+
`fi`,
|
|
319
|
+
`echo "[feature] ${featureId} ✓ installed"`,
|
|
320
|
+
].join("\n")
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export type ResolvedFeature = { id: string; script: string; ociRef?: string }
|
|
324
|
+
|
|
325
|
+
function resolveFeatures(
|
|
326
|
+
features: { id: string; ociRef?: string; options?: Record<string, string> }[],
|
|
327
|
+
warnId?: string,
|
|
328
|
+
): ResolvedFeature[] {
|
|
329
|
+
const resolved: ResolvedFeature[] = []
|
|
330
|
+
for (const f of features) {
|
|
331
|
+
const known = AVAILABLE_FEATURES.find((af) => af.id === f.id)
|
|
332
|
+
if (known?.localScript) {
|
|
333
|
+
const script = buildLocalFeatureInstallScript(f.id, known.localScript)
|
|
334
|
+
if (script) {
|
|
335
|
+
resolved.push({ id: f.id, script })
|
|
336
|
+
continue
|
|
337
|
+
}
|
|
338
|
+
// A catalog entry naming a script this build doesn't ship: fall through to the OCI ref if the
|
|
339
|
+
// feature has one, rather than silently installing nothing.
|
|
340
|
+
if (warnId) console.warn(`[worker:${warnId}] Feature "${f.id}" names a local script we don't ship`)
|
|
341
|
+
}
|
|
342
|
+
const ociRef = f.ociRef ?? known?.ociRef
|
|
343
|
+
if (!ociRef) {
|
|
344
|
+
if (warnId) console.warn(`[worker:${warnId}] Unknown feature "${f.id}" (no ociRef or localScript), skipping`)
|
|
345
|
+
continue
|
|
346
|
+
}
|
|
347
|
+
const options = { ...known?.defaultOptions, ...f.options }
|
|
348
|
+
resolved.push({
|
|
349
|
+
id: f.id,
|
|
350
|
+
ociRef,
|
|
351
|
+
script: buildFeatureInstallScript(ociRef, Object.keys(options).length > 0 ? options : undefined),
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
return resolved
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ─── The two features every worker image is made of ───────────────────────────
|
|
358
|
+
//
|
|
359
|
+
// What a worker needs on top of its base image — a non-root user with sudo, an in-browser VS Code,
|
|
360
|
+
// a terminal that survives a disconnect — used to be four hand-written blocks of this file, and the
|
|
361
|
+
// same four blocks, forked and already drifting, in spunto-lite. Both sides speak the devcontainer
|
|
362
|
+
// feature spec already (`buildFeatureInstallScript` below), so that spec is the seam: the shared
|
|
363
|
+
// half is published as features and consumed identically, and nothing private has to be released to
|
|
364
|
+
// share it.
|
|
365
|
+
//
|
|
366
|
+
// common-utils (upstream) → spunto-pack (ours) → the project's own features
|
|
367
|
+
//
|
|
368
|
+
// `spunto-pack` is one feature rather than four (code-server / tmux / dtach / sshd) because the
|
|
369
|
+
// fetcher below reads nothing but `entrypoint` out of a devcontainer-feature.json: `installsAfter`
|
|
370
|
+
// and `dependsOn` are ignored, so features run in the order of the array and nothing resolves an
|
|
371
|
+
// ordering for us. Inside a single install.sh the order is guaranteed by construction — and it
|
|
372
|
+
// costs one OCI round-trip instead of four.
|
|
373
|
+
const COMMON_UTILS_REF = "ghcr.io/devcontainers/features/common-utils:2"
|
|
374
|
+
const SPUNTO_PACK_REF = "ghcr.io/coderhammer/features/spunto-pack:1"
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* common-utils replaces the old "System & user" block: it creates `vscode`, drops a
|
|
378
|
+
* `/etc/sudoers.d/vscode` at 0440 (rather than appending to /etc/sudoers), and lays down the base
|
|
379
|
+
* toolchain — on Debian, RedHat, Alpine and azurelinux alike.
|
|
380
|
+
*
|
|
381
|
+
* None of these four options can be left at its default:
|
|
382
|
+
* - `username` pinned instead of `automatic`, whose candidate list starts with users the base image
|
|
383
|
+
* may already have (`node` on a node:* base) — we'd configure sudo for the wrong user.
|
|
384
|
+
* - `upgradePackages` off: on by default, it runs a full distro upgrade in every image build.
|
|
385
|
+
* - `configureZshAsDefaultShell` on: the block it replaces made zsh the login shell, and every
|
|
386
|
+
* worker terminal opens on it.
|
|
387
|
+
* - `installOhMyZsh` off, deliberately: oh-my-zsh lives in a *home directory*, which is per-worker
|
|
388
|
+
* and per-user, and the setup script already installs it there (theme, plugins, aliases) at a
|
|
389
|
+
* point where it knows whether the user has dotfiles of their own. Baking a `.zshrc` into the
|
|
390
|
+
* image would put a file in the way of a dotfiles install script that expects to own it.
|
|
391
|
+
*/
|
|
392
|
+
function commonUtilsOptions(): Record<string, string> {
|
|
393
|
+
return {
|
|
394
|
+
username: "vscode",
|
|
395
|
+
upgradePackages: "false",
|
|
396
|
+
installZsh: "true",
|
|
397
|
+
configureZshAsDefaultShell: "true",
|
|
398
|
+
installOhMyZsh: "false",
|
|
399
|
+
installOhMyZshConfig: "false",
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* spunto-pack replaces the code-server / tmux / dtach / SSH-server blocks. Its defaults are already
|
|
405
|
+
* what a worker wants (code-server + both terminal backends + the system-wide tmux.conf), so only
|
|
406
|
+
* the SSH server is decided here: the package is worth its size only when a gateway key exists to
|
|
407
|
+
* actually reach it. The pack installs the package and stops there — host keys, sshd_config and who
|
|
408
|
+
* starts the daemon stay ours (docs/architecture-deep-dive.md § SSH gateway).
|
|
409
|
+
*
|
|
410
|
+
* Not strict: a base image where tmux won't install must still produce a usable worker, exactly as
|
|
411
|
+
* the best-effort blocks it replaces did.
|
|
412
|
+
*/
|
|
413
|
+
function spuntoPackOptions(params: { sshGatewayPublicKey?: string }): Record<string, string> {
|
|
414
|
+
return { installSshd: params.sshGatewayPublicKey ? "true" : "false" }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// The system-wide tmux configuration (mouse, OSC 52 clipboard, 50k scrollback, warm status bar)
|
|
418
|
+
// now lives in spunto-pack's install.sh, where it is written to /etc/tmux.conf — same file, same
|
|
419
|
+
// content, plus the version guard this copy never had: `set -as terminal-features` only exists from
|
|
420
|
+
// tmux 3.2, and on an older base (Debian bullseye ships 3.1c) every client attaching to a worker
|
|
421
|
+
// terminal got an "invalid option" banner to dismiss. The frontend's half of the clipboard story is
|
|
422
|
+
// unchanged — see apps/next/lib/terminal-clipboard.ts.
|
|
423
|
+
|
|
424
|
+
// ─── code-server user settings ───────────────────────────────────────────────
|
|
425
|
+
//
|
|
426
|
+
// Seeded into every worker at start (see buildStartScript §4b), no-clobber so they
|
|
427
|
+
// stay user-overridable. Two of these make parallel worker tabs tellable apart:
|
|
428
|
+
// - window.title leads with the Spunto project name (injected as a literal — VS Code's
|
|
429
|
+
// `${rootName}` only knows the opened folder), so the project survives a narrow tab;
|
|
430
|
+
// the file name (`${activeEditorShort}`) trails.
|
|
431
|
+
// - workbench.colorCustomizations tints the window chrome (title/activity/status
|
|
432
|
+
// bars) with a per-project hue, Peacock-style, so the window itself is distinct.
|
|
433
|
+
// NOTE: code-server's web server only forwards a hardcoded product.json whitelist to
|
|
434
|
+
// the browser — "configurationDefaults" is NOT in that list, so patching product.json
|
|
435
|
+
// is silently ignored client-side. These have to be seeded as real user settings.
|
|
436
|
+
|
|
437
|
+
/** Deterministic 32-bit hash (djb2 xor) — a given project name always maps to the same hue. */
|
|
438
|
+
function hashString(s: string): number {
|
|
439
|
+
let h = 5381
|
|
440
|
+
for (let i = 0; i < s.length; i++) h = ((h * 33) ^ s.charCodeAt(i)) >>> 0
|
|
441
|
+
return h
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** HSL (h∈[0,360), s,l∈[0,1]) → #rrggbb. */
|
|
445
|
+
function hslToHex(h: number, s: number, l: number): string {
|
|
446
|
+
const c = (1 - Math.abs(2 * l - 1)) * s
|
|
447
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
|
|
448
|
+
const m = l - c / 2
|
|
449
|
+
const [r, g, b] =
|
|
450
|
+
h < 60 ? [c, x, 0]
|
|
451
|
+
: h < 120 ? [x, c, 0]
|
|
452
|
+
: h < 180 ? [0, c, x]
|
|
453
|
+
: h < 240 ? [0, x, c]
|
|
454
|
+
: h < 300 ? [x, 0, c]
|
|
455
|
+
: [c, 0, x]
|
|
456
|
+
const to = (v: number) => Math.round((v + m) * 255).toString(16).padStart(2, "0")
|
|
457
|
+
return `#${to(r)}${to(g)}${to(b)}`
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Per-project window chrome colors, derived from the project name so every worker of a
|
|
461
|
+
* given project shares one recognizable hue on its title/activity/status bars. Backgrounds
|
|
462
|
+
* are dark enough that white foregrounds stay readable across the whole hue circle. */
|
|
463
|
+
function deriveProjectChromeColors(projectName: string): Record<string, string> {
|
|
464
|
+
const hue = hashString(projectName) % 360
|
|
465
|
+
const bg = hslToHex(hue, 0.6, 0.3)
|
|
466
|
+
const bgInactive = hslToHex(hue, 0.4, 0.24)
|
|
467
|
+
const fg = "#ffffff"
|
|
468
|
+
const fgMuted = "#ffffff99"
|
|
469
|
+
return {
|
|
470
|
+
"titleBar.activeBackground": bg,
|
|
471
|
+
"titleBar.activeForeground": fg,
|
|
472
|
+
"titleBar.inactiveBackground": bgInactive,
|
|
473
|
+
"titleBar.inactiveForeground": fgMuted,
|
|
474
|
+
"activityBar.background": bg,
|
|
475
|
+
"activityBar.foreground": fg,
|
|
476
|
+
"activityBar.inactiveForeground": fgMuted,
|
|
477
|
+
"statusBar.background": bg,
|
|
478
|
+
"statusBar.foreground": fg,
|
|
479
|
+
"statusBar.noFolderBackground": bg,
|
|
480
|
+
"statusBar.debuggingBackground": bg,
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Default code-server user settings seeded into every worker. Kills dead Copilot UI, the
|
|
485
|
+
* workspace-trust prompt and the secondary sidebar; makes the tab identifiable via window.title;
|
|
486
|
+
* and, when `colorByProject` is on (org opt-in), tints the window chrome per project. See the
|
|
487
|
+
* block comment above. */
|
|
488
|
+
export function defaultVscodeUserSettings(opts?: {
|
|
489
|
+
projectName?: string
|
|
490
|
+
colorByProject?: boolean
|
|
491
|
+
}): Record<string, unknown> {
|
|
492
|
+
const { projectName, colorByProject } = opts ?? {}
|
|
493
|
+
const settings: Record<string, unknown> = {
|
|
494
|
+
"chat.disableAIFeatures": true,
|
|
495
|
+
// Workers are ephemeral/single-purpose — the trust prompt only adds friction.
|
|
496
|
+
"security.workspace.trust.enabled": false,
|
|
497
|
+
// Ephemeral workers never have a persisted layout, so this always applies.
|
|
498
|
+
"workbench.secondarySideBar.defaultVisibility": "hidden",
|
|
499
|
+
// Spunto project name first so the project stays identifiable even in a narrow browser
|
|
500
|
+
// tab — VS Code's ${rootName} only knows the opened *folder* (e.g. "spunto"), not the
|
|
501
|
+
// Spunto project. Injected as a literal here (we have the name); the file (${activeEditorShort})
|
|
502
|
+
// trails. Falls back to ${rootName} when no project name is available.
|
|
503
|
+
"window.title": projectName
|
|
504
|
+
? `${projectName}\${separator}\${activeEditorShort}`
|
|
505
|
+
: "${rootName}${separator}${activeEditorShort}",
|
|
506
|
+
}
|
|
507
|
+
// Per-project window color is opt-in at the org level (workerTabColors).
|
|
508
|
+
if (projectName && colorByProject) {
|
|
509
|
+
settings["workbench.colorCustomizations"] = deriveProjectChromeColors(projectName)
|
|
510
|
+
}
|
|
511
|
+
return settings
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ─── 1. buildImageScript ──────────────────────────────────────────────────────
|
|
515
|
+
//
|
|
516
|
+
// Runs once during prebuild (docker build / mp-build container).
|
|
517
|
+
// Bakes in: curl, the two base features (common-utils → vscode user; spunto-pack → code-server,
|
|
518
|
+
// tmux, dtach, SSH server), the project's own devcontainer features, then its
|
|
519
|
+
// VS Code extensions (/opt/mp-extensions/).
|
|
520
|
+
//
|
|
521
|
+
// Prewarm images (RFC 0014) are no longer baked here: `docker build`'s `RUN` steps have no daemon
|
|
522
|
+
// available, so RFC 0006 used to pull them into `docker load`-able tarballs via `crane`. That's now
|
|
523
|
+
// done agent-side, after this script runs and the image exists — see
|
|
524
|
+
// `apps/agent/src/docker.ts` (`bakeDindSeed`), which spins up a privileged container from the
|
|
525
|
+
// freshly-built image (real dockerd available) and commits a pre-populated `/var/lib/docker` graph
|
|
526
|
+
// back onto it. Docker's native volume pre-population then seeds each worker's DinD volume from
|
|
527
|
+
// that graph at container creation — no `docker load` anywhere anymore.
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* What the baked layer is made of, as a version.
|
|
531
|
+
*
|
|
532
|
+
* A project image (`mp-proj-…:vN`) is keyed by the project's *version*, which only moves when the
|
|
533
|
+
* project's own config does — so a change to this file alone leaves every already-built image in
|
|
534
|
+
* place, and new workers keep spawning on the old base. That is invisible until someone wonders why
|
|
535
|
+
* a fix shipped last week isn't in their container.
|
|
536
|
+
*
|
|
537
|
+
* So the recipe carries a version of its own, stamped on each build row and compared at spawn: a
|
|
538
|
+
* build made by an older recipe is stale, and the image is rebuilt exactly as if the project had
|
|
539
|
+
* been bumped. Bump this whenever a change here must reach existing projects.
|
|
540
|
+
*
|
|
541
|
+
* 1 → the hand-written blocks (vscode user, code-server, tmux, dtach, sshd).
|
|
542
|
+
* 2 → common-utils + spunto-pack.
|
|
543
|
+
*/
|
|
544
|
+
export const IMAGE_RECIPE_VERSION = 2
|
|
545
|
+
|
|
546
|
+
export function buildImageScript(params: {
|
|
547
|
+
features: { id: string; ociRef?: string; options?: Record<string, string> }[]
|
|
548
|
+
vscodeExtensions?: string[]
|
|
549
|
+
sshGatewayPublicKey?: string
|
|
550
|
+
/** Project-level DinD opt-in (base image bakes Docker itself, no feature). See projects.dind. */
|
|
551
|
+
dind?: boolean
|
|
552
|
+
/**
|
|
553
|
+
* Org setting (organizations.extensionsGallery): raw code-server gallery blob the
|
|
554
|
+
* `--install-extension` calls below resolve ids against. Absent/unusable = Open VSX.
|
|
555
|
+
*/
|
|
556
|
+
extensionsGallery?: string | null
|
|
557
|
+
}): { script: string; hasDinD: boolean; resolvedFeatures: ResolvedFeature[]; steps: BuildStep[] } {
|
|
558
|
+
const lines: string[] = ["set -e"]
|
|
559
|
+
|
|
560
|
+
// Every block of the build declares itself, so the UI can show the whole plan upfront and light
|
|
561
|
+
// each block up as it runs (lib/build-steps.ts). The plan is emitted from the *same* call that
|
|
562
|
+
// emits the script, so the two can never list different blocks.
|
|
563
|
+
const steps: BuildStep[] = []
|
|
564
|
+
function block(
|
|
565
|
+
id: string,
|
|
566
|
+
label: string,
|
|
567
|
+
kind: BuildStepKind,
|
|
568
|
+
detail: string | undefined,
|
|
569
|
+
body: string[],
|
|
570
|
+
): void {
|
|
571
|
+
steps.push({ id, label, kind, detail, state: "pending" })
|
|
572
|
+
lines.push("", stepStartLine(id), ...body, stepEndLine(id))
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Feature env vars (devcontainer spec — features use these to target the right user). They come
|
|
576
|
+
// *first*, before any feature runs: common-utils reads them to decide which user it creates and
|
|
577
|
+
// configures, so exporting them later (as this script used to) would have it fall back to its own
|
|
578
|
+
// `automatic` detection — which on a node base image picks the pre-existing `node` user, not ours.
|
|
579
|
+
// Outside any block: they have to stay set for everything that follows, and exporting them isn't
|
|
580
|
+
// a step.
|
|
581
|
+
lines.push(
|
|
582
|
+
"",
|
|
583
|
+
"export HOME=/root",
|
|
584
|
+
"export _REMOTE_USER=vscode",
|
|
585
|
+
"export _REMOTE_USER_HOME=/home/vscode",
|
|
586
|
+
"export _CONTAINER_USER=vscode",
|
|
587
|
+
"export _CONTAINER_USER_HOME=/home/vscode",
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
// curl stays inline, and can't become a feature: `buildFeatureInstallScript` *is* a curl script
|
|
591
|
+
// (OCI token, manifest, blob), so a base image without curl can't fetch the very feature that
|
|
592
|
+
// would install it. Chicken and egg. images/worker-base bakes curl in, but a project is free to
|
|
593
|
+
// point at any base image.
|
|
594
|
+
block("bootstrap", "Bootstrap", "runtime", "curl — what fetches every feature below", [
|
|
595
|
+
"if ! command -v curl >/dev/null 2>&1; then",
|
|
596
|
+
' echo "[build] Installing curl (the feature fetcher\'s own dependency)..."',
|
|
597
|
+
" (",
|
|
598
|
+
" set +e",
|
|
599
|
+
" if command -v apt-get >/dev/null 2>&1; then",
|
|
600
|
+
" DEBIAN_FRONTEND=noninteractive apt-get update -qq 2>&1 && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends curl ca-certificates 2>&1",
|
|
601
|
+
" elif command -v apk >/dev/null 2>&1; then",
|
|
602
|
+
" apk add --no-cache curl ca-certificates 2>&1",
|
|
603
|
+
" elif command -v dnf >/dev/null 2>&1; then",
|
|
604
|
+
" dnf install -y curl ca-certificates 2>&1",
|
|
605
|
+
" elif command -v yum >/dev/null 2>&1; then",
|
|
606
|
+
" yum install -y curl ca-certificates 2>&1",
|
|
607
|
+
" fi",
|
|
608
|
+
" set -e",
|
|
609
|
+
" )",
|
|
610
|
+
"fi",
|
|
611
|
+
'command -v curl >/dev/null 2>&1 || echo "[build] WARNING: curl unavailable — feature installs will fail"',
|
|
612
|
+
])
|
|
613
|
+
|
|
614
|
+
const hasDinD = params.features.some((f) => f.id === "docker-in-docker") || !!params.dind
|
|
615
|
+
// The two features every worker image is made of, ahead of whatever the project picked. The user
|
|
616
|
+
// can select common-utils in the picker too — its options are then merged over ours rather than
|
|
617
|
+
// installed a second time.
|
|
618
|
+
const userCommonUtils = params.features.find((f) => f.id === "common-utils")
|
|
619
|
+
const baseFeatures = [
|
|
620
|
+
{
|
|
621
|
+
id: "common-utils",
|
|
622
|
+
ociRef: userCommonUtils?.ociRef ?? COMMON_UTILS_REF,
|
|
623
|
+
options: { ...commonUtilsOptions(), ...userCommonUtils?.options },
|
|
624
|
+
},
|
|
625
|
+
{ id: "spunto-pack", ociRef: SPUNTO_PACK_REF, options: spuntoPackOptions(params) },
|
|
626
|
+
]
|
|
627
|
+
const resolvedFeatures = resolveFeatures([
|
|
628
|
+
...baseFeatures,
|
|
629
|
+
...params.features.filter((f) => f.id !== "common-utils"),
|
|
630
|
+
])
|
|
631
|
+
|
|
632
|
+
// Install features — one block each, so a feature that takes four minutes is visibly *the* thing
|
|
633
|
+
// the build is doing rather than an anonymous stretch of log.
|
|
634
|
+
for (const { id, script, ociRef } of resolvedFeatures) {
|
|
635
|
+
block(`feature:${id}`, id, "feature", ociRef, [`echo "[build] Installing feature: ${id}..."`, script])
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Save entrypoints to a durable location. Append (not overwrite) + dedup so an entrypoint
|
|
639
|
+
// already baked into the base image (e.g. a dockerd starter for projects.dind, see
|
|
640
|
+
// images/worker-base) survives alongside any registered by features installed here.
|
|
641
|
+
const finalizeBody = [
|
|
642
|
+
"if [ -f " + FEATURE_ENTRYPOINTS_STAGING_FILE + " ]; then",
|
|
643
|
+
" touch " + FEATURE_ENTRYPOINTS_FILE,
|
|
644
|
+
" cat " + FEATURE_ENTRYPOINTS_STAGING_FILE + " >> " + FEATURE_ENTRYPOINTS_FILE,
|
|
645
|
+
" sort -u " + FEATURE_ENTRYPOINTS_FILE + " -o " + FEATURE_ENTRYPOINTS_FILE,
|
|
646
|
+
" rm -f " + FEATURE_ENTRYPOINTS_STAGING_FILE,
|
|
647
|
+
' echo "[build] Entrypoints saved to ' + FEATURE_ENTRYPOINTS_FILE + '"',
|
|
648
|
+
"fi",
|
|
649
|
+
]
|
|
650
|
+
|
|
651
|
+
// Install VS Code extensions to a system path (not in /home, which is per-worker)
|
|
652
|
+
if (params.vscodeExtensions && params.vscodeExtensions.length > 0) {
|
|
653
|
+
const extLines = [
|
|
654
|
+
'echo "[build] Installing VS Code extensions to ' + EXTENSIONS_DIR + '..."',
|
|
655
|
+
"mkdir -p " + EXTENSIONS_DIR,
|
|
656
|
+
// Must precede the first install: this is what makes `--install-extension` resolve ids
|
|
657
|
+
// against the org's gallery instead of Open VSX.
|
|
658
|
+
...galleryExport(params.extensionsGallery),
|
|
659
|
+
]
|
|
660
|
+
const registry = registryLabel(params.extensionsGallery)
|
|
661
|
+
for (const ext of params.vscodeExtensions) {
|
|
662
|
+
extLines.push(
|
|
663
|
+
`echo "[build] Installing extension: ${ext}..."`,
|
|
664
|
+
`set +e`,
|
|
665
|
+
`code-server --extensions-dir ${EXTENSIONS_DIR} --install-extension ${JSON.stringify(ext)} 2>&1`,
|
|
666
|
+
`_EXT_EXIT=$?`,
|
|
667
|
+
`set -e`,
|
|
668
|
+
`if [ $_EXT_EXIT -ne 0 ]; then echo "[build] Extension ${ext} failed (exit $_EXT_EXIT) — ids are resolved against ${registry}; an id that isn't published there can't be installed. Continuing"; fi`,
|
|
669
|
+
)
|
|
670
|
+
}
|
|
671
|
+
extLines.push('echo "[build] Extensions installed"')
|
|
672
|
+
const n = params.vscodeExtensions.length
|
|
673
|
+
block("extensions", "VS Code extensions", "extensions", `${n} extension${n > 1 ? "s" : ""} · ${registry}`, extLines)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Fix ownership (features may have created files in /home/vscode as root)
|
|
677
|
+
block("finalize", "Finalizing image", "finalize", "entrypoints, sudo, ownership", [
|
|
678
|
+
...finalizeBody,
|
|
679
|
+
// common-utils writes /etc/sudoers.d/<user> only when it *creates* the user: a base image
|
|
680
|
+
// that already ships `vscode` is assumed to have configured sudo for it, which the
|
|
681
|
+
// devcontainer bases do and an arbitrary base may not. The block this replaced appended the
|
|
682
|
+
// rule unconditionally, and setup runs as `vscode` (dotfiles, postCreate) — so the guarantee
|
|
683
|
+
// is kept, in the file-per-user form rather than by editing /etc/sudoers.
|
|
684
|
+
// The check has to be the real thing: `sudo -l -U vscode` exits 0 even for a user with no
|
|
685
|
+
// rights at all (it prints the default env settings), so it answers a different question.
|
|
686
|
+
"if ! su vscode -c 'sudo -n true' >/dev/null 2>&1; then",
|
|
687
|
+
" echo 'vscode ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/vscode",
|
|
688
|
+
" chmod 0440 /etc/sudoers.d/vscode",
|
|
689
|
+
' echo "[build] sudo NOPASSWD configured for vscode"',
|
|
690
|
+
"fi",
|
|
691
|
+
"chown -R vscode:vscode /home/vscode 2>/dev/null || true",
|
|
692
|
+
'echo "[build] Image build complete"',
|
|
693
|
+
])
|
|
694
|
+
|
|
695
|
+
return { script: lines.join("\n"), hasDinD, resolvedFeatures, steps }
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ─── 2. buildSetupScript ──────────────────────────────────────────────────────
|
|
699
|
+
//
|
|
700
|
+
// Runs once on the first container start (marker: /home/vscode/.mp-setup-done).
|
|
701
|
+
// Handles: ownership, credentials, shell customization, dotfiles, repo cloning,
|
|
702
|
+
// postCreateCommand, gcloud wrapper.
|
|
703
|
+
// Does NOT run on subsequent starts — those only run buildStartScript.
|
|
704
|
+
|
|
705
|
+
export type SetupScriptParams = {
|
|
706
|
+
project: {
|
|
707
|
+
repositories: { id: string; provider: string; project: string; workspacePath: string; installationId?: number; cloneUrl?: string; branch?: string }[]
|
|
708
|
+
features: { id: string; ociRef?: string; options?: Record<string, string> }[] | null
|
|
709
|
+
vscodeExtensions?: string[] | null
|
|
710
|
+
dind?: boolean | null
|
|
711
|
+
postCreateCommand: string | null
|
|
712
|
+
postStartCommand?: string | null
|
|
713
|
+
}
|
|
714
|
+
workerId: string
|
|
715
|
+
userInfo?: { name: string; email?: string | null }
|
|
716
|
+
userSshPrivateKey?: string
|
|
717
|
+
sshGatewayPublicKey?: string
|
|
718
|
+
skipFeatures?: boolean
|
|
719
|
+
dotfilesRepo?: string | null
|
|
720
|
+
userEnvSecrets?: Record<string, string>
|
|
721
|
+
// Short-lived org GitHub App installation tokens (RFC 0012), one per connected GitHub
|
|
722
|
+
// account keyed by lowercased owner login. When a repo's owner has a token, it's cloned via
|
|
723
|
+
// HTTPS x-access-token (org-scoped read access) and the remote is then rewritten to SSH so
|
|
724
|
+
// push/pull keep the user's personal git identity.
|
|
725
|
+
githubInstallationTokens?: Record<string, string>
|
|
726
|
+
// Decrypted per-project SSH deploy key (RFC 0013). Used to clone generic ("git") repos from a
|
|
727
|
+
// raw SSH URL. Written to ~/.ssh/mp_deploy_key and referenced per-clone via GIT_SSH_COMMAND.
|
|
728
|
+
projectDeployKey?: string
|
|
729
|
+
// Git branch chosen when this worker was created (workers.branch): checked out for every repo
|
|
730
|
+
// of the project, overriding each repository's own default branch. Null/absent → default branch.
|
|
731
|
+
branch?: string | null
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
export function buildSetupScript(params: SetupScriptParams): { script: string } {
|
|
735
|
+
const { project, workerId, userInfo, userSshPrivateKey, dotfilesRepo, userEnvSecrets, githubInstallationTokens, projectDeployKey, branch } = params
|
|
736
|
+
const homeDir = "/home/vscode"
|
|
737
|
+
const username = "vscode"
|
|
738
|
+
|
|
739
|
+
const repoNames = project.repositories.map((r) => r.project)
|
|
740
|
+
const hasPostCreate = !!project.postCreateCommand
|
|
741
|
+
const pc0: LS = hasPostCreate ? "pending" : null
|
|
742
|
+
|
|
743
|
+
const allReposPending: RS[] = repoNames.map((n) => ({ name: n, state: "pending" }))
|
|
744
|
+
const allReposDone: RS[] = repoNames.map((n) => ({ name: n, state: "done" }))
|
|
745
|
+
|
|
746
|
+
function reposAtClone(i: number, cur: "cloning" | "done"): RS[] {
|
|
747
|
+
return repoNames.map((n, j) => ({ name: n, state: j < i ? "done" : j === i ? cur : "pending" }))
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const lines: string[] = []
|
|
751
|
+
const push = (...l: string[]) => lines.push(...l)
|
|
752
|
+
const mp = (json: string) => lines.push(`_mp ${JSON.stringify(json)}`)
|
|
753
|
+
// Stamp a phase-start timestamp (container-side) then write the status. `key` is a
|
|
754
|
+
// fixed identifier, never user input — safe to pass unquoted to _mp_stamp.
|
|
755
|
+
const mpAt = (json: string, key: string) => lines.push(`_mp_stamp ${key}`, `_mp ${JSON.stringify(json)}`)
|
|
756
|
+
|
|
757
|
+
// ── 1. Ownership ─────────────────────────────────────────────────────────
|
|
758
|
+
push(...banner("SETUP: OWNERSHIP"))
|
|
759
|
+
push(
|
|
760
|
+
`chown -R ${username}:${username} /workspace`,
|
|
761
|
+
`chown -R ${username}:${username} ${homeDir}`,
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
// ── 2. Credentials ────────────────────────────────────────────────────────
|
|
765
|
+
const hasCredentials = !!(userInfo || userSshPrivateKey || projectDeployKey)
|
|
766
|
+
if (hasCredentials) {
|
|
767
|
+
push(...banner("SETUP: CREDENTIALS"))
|
|
768
|
+
mpAt(mkStatus("credentials", allReposPending, pc0, null), "credentials")
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
push(...credentialsBlock({ homeDir, username, userInfo, userSshPrivateKey, projectDeployKey }))
|
|
772
|
+
|
|
773
|
+
// ── 3. Shell setup ────────────────────────────────────────────────────────
|
|
774
|
+
// Land in the workspace on a fresh interactive shell (code-server terminal +
|
|
775
|
+
// SSH login). If there is exactly one repo dir under /workspace, cd into it;
|
|
776
|
+
// otherwise cd into /workspace. Guarded by `$PWD == $HOME` so it only fires on
|
|
777
|
+
// a fresh login and never fights a user who has navigated elsewhere.
|
|
778
|
+
// `find` does its own globbing, so this stays safe under zsh's default
|
|
779
|
+
// `nomatch` (a bare `/workspace/*/` would error when the dir is empty).
|
|
780
|
+
const landInWorkspace = [
|
|
781
|
+
``,
|
|
782
|
+
`# ── Spunto: land in workspace on fresh login ─────────────────────`,
|
|
783
|
+
`if [ "$PWD" = "$HOME" ] && [ -d /workspace ]; then`,
|
|
784
|
+
` __mp_dirs=$(find /workspace -mindepth 1 -maxdepth 1 -type d -not -name '.*' 2>/dev/null)`,
|
|
785
|
+
` __mp_n=$(printf '%s\\n' "$__mp_dirs" | grep -c .)`,
|
|
786
|
+
` if [ "$__mp_n" = 1 ]; then cd "$__mp_dirs" 2>/dev/null; else cd /workspace 2>/dev/null; fi`,
|
|
787
|
+
` unset __mp_dirs __mp_n`,
|
|
788
|
+
`fi`,
|
|
789
|
+
].join("\n")
|
|
790
|
+
|
|
791
|
+
const bashrcSnippet = [
|
|
792
|
+
``,
|
|
793
|
+
`# ── Spunto shell config ──────────────────────────────────────────`,
|
|
794
|
+
`__mp_ps1_git() {`,
|
|
795
|
+
` local b`,
|
|
796
|
+
` b=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null) || return`,
|
|
797
|
+
` printf ' \\e[0;33m(%s)\\e[0m' "$b"`,
|
|
798
|
+
`}`,
|
|
799
|
+
``,
|
|
800
|
+
`PS1='\\n\\[\\e[0;2m\\]\\u@\\h\\[\\e[0m\\] \\[\\e[1;34m\\]\\w\\[\\e[0m\\]$(__mp_ps1_git)\\n\\[\\e[1;32m\\]❯\\[\\e[0m\\] '`,
|
|
801
|
+
``,
|
|
802
|
+
`alias ll='ls -lah --color=auto'`,
|
|
803
|
+
`alias la='ls -A --color=auto'`,
|
|
804
|
+
`alias l='ls -CF --color=auto'`,
|
|
805
|
+
`alias gs='git status'`,
|
|
806
|
+
`alias gd='git diff'`,
|
|
807
|
+
`alias gl='git log --oneline --graph --decorate -20'`,
|
|
808
|
+
landInWorkspace,
|
|
809
|
+
].join("\n")
|
|
810
|
+
const bashrcEncoded = b64(bashrcSnippet)
|
|
811
|
+
|
|
812
|
+
const zshrcAliasSnippet = [
|
|
813
|
+
``,
|
|
814
|
+
`# ── Spunto config ────────────────────────────────────────────────`,
|
|
815
|
+
`setopt NO_BANG_HIST`,
|
|
816
|
+
`alias ll='ls -lah --color=auto'`,
|
|
817
|
+
`alias la='ls -A --color=auto'`,
|
|
818
|
+
`alias l='ls -CF --color=auto'`,
|
|
819
|
+
`alias gs='git status'`,
|
|
820
|
+
`alias gd='git diff'`,
|
|
821
|
+
`alias gl='git log --oneline --graph --decorate -20'`,
|
|
822
|
+
landInWorkspace,
|
|
823
|
+
].join("\n")
|
|
824
|
+
const zshrcAliasEncoded = b64(zshrcAliasSnippet)
|
|
825
|
+
|
|
826
|
+
push(...banner("SETUP: SHELL"))
|
|
827
|
+
// Shell setup (oh-my-zsh install is network-bound and can dominate startup), so
|
|
828
|
+
// give it its own timing segment. Phase is unchanged — re-stated so the merged
|
|
829
|
+
// status keeps the phase the UI already shows while recording the shell start.
|
|
830
|
+
mpAt(mkStatus(hasCredentials ? "credentials" : "initializing", allReposPending, pc0, null), "shell")
|
|
831
|
+
push(
|
|
832
|
+
`echo ${JSON.stringify(bashrcEncoded)} | base64 -d >> ${homeDir}/.bashrc`,
|
|
833
|
+
`if command -v zsh >/dev/null 2>&1; then`,
|
|
834
|
+
` if [ ! -d "${homeDir}/.oh-my-zsh" ]; then`,
|
|
835
|
+
` echo "Installing oh-my-zsh..."`,
|
|
836
|
+
` _OMZ_TMP=$(mktemp)`,
|
|
837
|
+
` curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh -o "$_OMZ_TMP" 2>&1`,
|
|
838
|
+
` chmod 644 "$_OMZ_TMP"`,
|
|
839
|
+
` su ${username} -c "HOME=${homeDir} RUNZSH=no CHSH=no KEEP_ZSHRC=no bash $_OMZ_TMP" 2>&1 || true`,
|
|
840
|
+
` rm -f "$_OMZ_TMP"`,
|
|
841
|
+
` else`,
|
|
842
|
+
` echo "oh-my-zsh already installed"`,
|
|
843
|
+
` fi`,
|
|
844
|
+
` sed -i 's/ZSH_THEME="robbyrussell"/ZSH_THEME="af-magic"/' ${homeDir}/.zshrc 2>/dev/null || true`,
|
|
845
|
+
` sed -i 's/^plugins=(git)$/plugins=(git z)/' ${homeDir}/.zshrc 2>/dev/null || true`,
|
|
846
|
+
` echo ${JSON.stringify(zshrcAliasEncoded)} | base64 -d >> ${homeDir}/.zshrc`,
|
|
847
|
+
`fi`,
|
|
848
|
+
// bash login shells (e.g. SSH when zsh is absent) read .profile, not .bashrc
|
|
849
|
+
`echo ${JSON.stringify(b64(landInWorkspace))} | base64 -d >> ${homeDir}/.profile`,
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
// ── 4. Dotfiles ───────────────────────────────────────────────────────────
|
|
853
|
+
if (dotfilesRepo) {
|
|
854
|
+
const dotfilesUrl = dotfilesRepo.startsWith("http") || dotfilesRepo.startsWith("git@")
|
|
855
|
+
? dotfilesRepo
|
|
856
|
+
: `https://github.com/${dotfilesRepo}`
|
|
857
|
+
push(...banner("SETUP: DOTFILES"))
|
|
858
|
+
mpAt(mkStatus("dotfiles", allReposPending, pc0, null), "dotfiles")
|
|
859
|
+
push(
|
|
860
|
+
`echo "Cloning dotfiles from ${dotfilesUrl}..."`,
|
|
861
|
+
`set +e`,
|
|
862
|
+
`git clone ${JSON.stringify(dotfilesUrl)} ${homeDir}/dotfiles 2>&1`,
|
|
863
|
+
`_DOTS_EXIT=$?`,
|
|
864
|
+
`set -e`,
|
|
865
|
+
`if [ $_DOTS_EXIT -ne 0 ]; then`,
|
|
866
|
+
` echo "Dotfiles clone failed (exit $_DOTS_EXIT) — continuing without dotfiles"`,
|
|
867
|
+
`else`,
|
|
868
|
+
` echo "Dotfiles cloned"`,
|
|
869
|
+
// Cloned as root, but the install script below runs as the user (and so does whoever edits
|
|
870
|
+
// these files later) — hand the clone over before touching it.
|
|
871
|
+
` chown -R ${username}:${username} ${homeDir}/dotfiles`,
|
|
872
|
+
` _INSTALL_SCRIPT=""`,
|
|
873
|
+
` for _candidate in install.sh bootstrap.sh setup.sh script/setup; do`,
|
|
874
|
+
` if [ -f "${homeDir}/dotfiles/$_candidate" ]; then`,
|
|
875
|
+
` _INSTALL_SCRIPT="$_candidate"`,
|
|
876
|
+
` break`,
|
|
877
|
+
` fi`,
|
|
878
|
+
` done`,
|
|
879
|
+
` if [ -n "$_INSTALL_SCRIPT" ]; then`,
|
|
880
|
+
` echo "Running dotfiles install script: $_INSTALL_SCRIPT"`,
|
|
881
|
+
` chmod +x "${homeDir}/dotfiles/$_INSTALL_SCRIPT"`,
|
|
882
|
+
` set +e`,
|
|
883
|
+
` su ${username} -c "cd ${homeDir}/dotfiles && ${envPrefix(userEnvSecrets)}./$_INSTALL_SCRIPT"`,
|
|
884
|
+
` _DOTS_INST_EXIT=$?`,
|
|
885
|
+
` set -e`,
|
|
886
|
+
` if [ $_DOTS_INST_EXIT -ne 0 ]; then`,
|
|
887
|
+
` echo "Dotfiles install script exited with $_DOTS_INST_EXIT — continuing"`,
|
|
888
|
+
` else`,
|
|
889
|
+
` echo "Dotfiles installed"`,
|
|
890
|
+
` fi`,
|
|
891
|
+
` else`,
|
|
892
|
+
` echo "No install script found — symlinking dotfiles..."`,
|
|
893
|
+
` for _f in "${homeDir}/dotfiles"/.*; do`,
|
|
894
|
+
` _name=$(basename "$_f")`,
|
|
895
|
+
` case "$_name" in .|..|.git|.gitignore|.gitmodules) continue ;; esac`,
|
|
896
|
+
` ln -sf "$_f" "${homeDir}/$_name" && echo " linked $_name"`,
|
|
897
|
+
` done`,
|
|
898
|
+
` echo "Dotfiles symlinked"`,
|
|
899
|
+
` fi`,
|
|
900
|
+
`fi`,
|
|
901
|
+
)
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ── 5. Clone repos ────────────────────────────────────────────────────────
|
|
905
|
+
if (project.repositories.length > 0) {
|
|
906
|
+
push(...banner(`SETUP: CLONE REPOSITORIES (${project.repositories.length})`))
|
|
907
|
+
}
|
|
908
|
+
project.repositories.forEach((r, i) => {
|
|
909
|
+
const { header, lines: cloneLines } = cloneRepoBlock({
|
|
910
|
+
repo: r,
|
|
911
|
+
index: i,
|
|
912
|
+
total: project.repositories.length,
|
|
913
|
+
homeDir,
|
|
914
|
+
branch: resolveRepoBranch(r, branch),
|
|
915
|
+
githubInstallationTokens,
|
|
916
|
+
userSshPrivateKey,
|
|
917
|
+
})
|
|
918
|
+
push("")
|
|
919
|
+
push(header)
|
|
920
|
+
// Stamp the clone phase start once (first repo); _mp_stamp is first-write-wins.
|
|
921
|
+
mpAt(mkStatus("cloning", reposAtClone(i, "cloning"), pc0, null), "cloning")
|
|
922
|
+
push(...cloneLines)
|
|
923
|
+
mp(mkStatus("cloning", reposAtClone(i, "done"), pc0, null))
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
// ── 5b. Re-own /workspace after cloning ───────────────────────────────────
|
|
927
|
+
// Repos are cloned as root (the setup script runs as root), so the cloned
|
|
928
|
+
// directories end up root-owned. The initial chown (step 1) ran *before* the
|
|
929
|
+
// clone, so it didn't cover them. postCreateCommand runs as vscode, so without
|
|
930
|
+
// this the user can't write into the repo (e.g. `npm install` → EACCES on
|
|
931
|
+
// node_modules). Must run before postCreate, not just in the final step.
|
|
932
|
+
if (project.repositories.length > 0) {
|
|
933
|
+
push("", `chown -R ${username}:${username} /workspace`)
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// ── 6. postCreateCommand ──────────────────────────────────────────────────
|
|
937
|
+
// Feature entrypoints (e.g. dockerd) are normally started in buildStartScript,
|
|
938
|
+
// but postCreateCommand runs before that. Start them here so commands like
|
|
939
|
+
// `docker run` work during postCreate. Also wait for the Docker socket if
|
|
940
|
+
// docker-in-docker is present.
|
|
941
|
+
// `project.dind` makes DinD available without the docker-in-docker feature (baked into the base
|
|
942
|
+
// image, see images/worker-base) — treat it like the feature for entrypoint start + docker wait.
|
|
943
|
+
const hasDinDFeature = (project.features?.some((f) => f.id === "docker-in-docker") ?? false) || !!project.dind
|
|
944
|
+
if (project.postCreateCommand && ((project.features && project.features.length > 0) || project.dind)) {
|
|
945
|
+
push(
|
|
946
|
+
"",
|
|
947
|
+
"if [ -f " + FEATURE_ENTRYPOINTS_FILE + " ]; then",
|
|
948
|
+
' echo "--- Starting feature entrypoints for postCreate ---"',
|
|
949
|
+
" while IFS= read -r _ep; do",
|
|
950
|
+
' if [ -f "$_ep" ]; then',
|
|
951
|
+
' echo "Starting entrypoint: $_ep"',
|
|
952
|
+
' "$_ep" &',
|
|
953
|
+
" fi",
|
|
954
|
+
" done < " + FEATURE_ENTRYPOINTS_FILE,
|
|
955
|
+
"fi",
|
|
956
|
+
)
|
|
957
|
+
if (hasDinDFeature) {
|
|
958
|
+
push(
|
|
959
|
+
'echo "Waiting for Docker daemon..."',
|
|
960
|
+
"for _i in $(seq 1 30); do",
|
|
961
|
+
" docker info >/dev/null 2>&1 && break",
|
|
962
|
+
" sleep 1",
|
|
963
|
+
"done",
|
|
964
|
+
'docker info >/dev/null 2>&1 && echo "Docker daemon ready" || echo "Docker daemon not ready after 30s, continuing anyway"',
|
|
965
|
+
)
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
if (project.postCreateCommand) {
|
|
970
|
+
const postCreateDir = project.repositories.length === 1
|
|
971
|
+
? `/workspace/${project.repositories[0].workspacePath}`
|
|
972
|
+
: "/workspace"
|
|
973
|
+
push(...banner("SETUP: POST CREATE"))
|
|
974
|
+
mpAt(mkStatus("lifecycle", allReposDone, "running", null), "postCreate")
|
|
975
|
+
const cmdEncoded = b64(project.postCreateCommand)
|
|
976
|
+
push(
|
|
977
|
+
`if [ ! -f "${homeDir}/.mp-post-create-done" ]; then`,
|
|
978
|
+
` echo "--- Running postCreateCommand ---"`,
|
|
979
|
+
` echo ${JSON.stringify(cmdEncoded)} | base64 -d > /tmp/mp_postcreate.sh`,
|
|
980
|
+
` chmod +x /tmp/mp_postcreate.sh`,
|
|
981
|
+
` su ${username} -c "cd ${postCreateDir} && bash /tmp/mp_postcreate.sh" 2>&1`,
|
|
982
|
+
` rm -f /tmp/mp_postcreate.sh`,
|
|
983
|
+
` touch "${homeDir}/.mp-post-create-done"`,
|
|
984
|
+
` echo "postCreateCommand done"`,
|
|
985
|
+
`fi`,
|
|
986
|
+
)
|
|
987
|
+
mp(mkStatus("lifecycle", allReposDone, "done", null))
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ── 8. Final ownership + marker ───────────────────────────────────────────
|
|
991
|
+
push(
|
|
992
|
+
"",
|
|
993
|
+
`chown -R ${username}:${username} ${homeDir}`,
|
|
994
|
+
`chown -R ${username}:${username} /workspace`,
|
|
995
|
+
`touch ${homeDir}/.mp-setup-done`,
|
|
996
|
+
'echo "[setup] First-start setup complete"',
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
return { script: lines.join("\n") }
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// ─── 3. buildStartScript ──────────────────────────────────────────────────────
|
|
1003
|
+
//
|
|
1004
|
+
// Runs on every container start (first and subsequent).
|
|
1005
|
+
// Handles: SSH server, feature entrypoints, worker env, extension copy,
|
|
1006
|
+
// code-server start (as vscode), postStartCommand.
|
|
1007
|
+
|
|
1008
|
+
export type StartScriptParams = {
|
|
1009
|
+
project: {
|
|
1010
|
+
name?: string
|
|
1011
|
+
features: { id: string; ociRef?: string; options?: Record<string, string> }[] | null
|
|
1012
|
+
vscodeExtensions?: string[] | null
|
|
1013
|
+
dind?: boolean | null
|
|
1014
|
+
postCreateCommand: string | null
|
|
1015
|
+
postStartCommand: string | null
|
|
1016
|
+
repositories: { project: string; workspacePath: string }[]
|
|
1017
|
+
}
|
|
1018
|
+
sshGatewayPublicKey?: string
|
|
1019
|
+
/** Org opt-in (organizations.workerTabColors): tint the worker window chrome per project. */
|
|
1020
|
+
colorTabsByProject?: boolean
|
|
1021
|
+
/**
|
|
1022
|
+
* Org setting (organizations.extensionsGallery): raw code-server gallery blob, exported into the
|
|
1023
|
+
* code-server loop so the editor's own Extensions view searches the same registry the picker and
|
|
1024
|
+
* the image build used. Absent/unusable = Open VSX.
|
|
1025
|
+
*/
|
|
1026
|
+
extensionsGallery?: string | null
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
export function buildStartScript(params: StartScriptParams): { script: string; hasDinD: boolean } {
|
|
1030
|
+
const { project, sshGatewayPublicKey, colorTabsByProject, extensionsGallery } = params
|
|
1031
|
+
const homeDir = "/home/vscode"
|
|
1032
|
+
const username = "vscode"
|
|
1033
|
+
|
|
1034
|
+
const features = project.features ?? []
|
|
1035
|
+
const hasPostCreate = !!project.postCreateCommand
|
|
1036
|
+
const hasPostStart = !!project.postStartCommand
|
|
1037
|
+
const hasDinD = features.some((f) => f.id === "docker-in-docker") || !!project.dind
|
|
1038
|
+
|
|
1039
|
+
const allReposDone: RS[] = project.repositories.map((r) => ({ name: r.project, state: "done" }))
|
|
1040
|
+
|
|
1041
|
+
const lines: string[] = []
|
|
1042
|
+
const push = (...l: string[]) => lines.push(...l)
|
|
1043
|
+
const mp = (json: string) => lines.push(`_mp ${JSON.stringify(json)}`)
|
|
1044
|
+
const mpAt = (json: string, key: string) => lines.push(`_mp_stamp ${key}`, `_mp ${JSON.stringify(json)}`)
|
|
1045
|
+
|
|
1046
|
+
// ── Session separator (visible in logs on every start/restart) ────────────
|
|
1047
|
+
push(
|
|
1048
|
+
`printf '\\033[2m%s\\033[0m\\n' "$(printf '─%.0s' {1..42})"`,
|
|
1049
|
+
`printf '\\033[2m ↺ Session · %s\\033[0m\\n' "$(date +%H:%M:%S)"`,
|
|
1050
|
+
`printf '\\033[2m%s\\033[0m\\n' "$(printf '─%.0s' {1..42})"`,
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
// ── Reset status on restart so postStart stays blocking ───────────────────
|
|
1054
|
+
// .mp-status.json lives on the persistent home volume, so on a *restart* it
|
|
1055
|
+
// still holds the previous run's "ready" status. Emit a non-ready status at
|
|
1056
|
+
// the very top of every start (before code-server / postStart) so a watcher
|
|
1057
|
+
// waiting on postStart doesn't observe that stale "ready" before this start's
|
|
1058
|
+
// postStartCommand has even begun. Only relevant when there is a postStart to
|
|
1059
|
+
// wait for; on first start setup has just emitted lifecycle/postCreate=done,
|
|
1060
|
+
// so this is a harmless no-op-equivalent restatement.
|
|
1061
|
+
if (hasPostStart) {
|
|
1062
|
+
mp(mkStatus("lifecycle", allReposDone, hasPostCreate ? "done" : null, "pending"))
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// ── 1. SSH server ─────────────────────────────────────────────────────────
|
|
1066
|
+
if (sshGatewayPublicKey) {
|
|
1067
|
+
push(...banner("START: SSH SERVER"))
|
|
1068
|
+
push(
|
|
1069
|
+
"(",
|
|
1070
|
+
" set +e",
|
|
1071
|
+
" mkdir -p /run/sshd /var/run/sshd",
|
|
1072
|
+
// Authorize the gateway key for the `vscode` user so SSH lands as the same
|
|
1073
|
+
// user as code-server (not root), with their env, git creds and shell.
|
|
1074
|
+
` mkdir -p ${homeDir}/.ssh`,
|
|
1075
|
+
` chmod 700 ${homeDir}/.ssh`,
|
|
1076
|
+
` _GW_KEY=${JSON.stringify(sshGatewayPublicKey)}`,
|
|
1077
|
+
` grep -qF "$_GW_KEY" ${homeDir}/.ssh/authorized_keys 2>/dev/null || printf '%s\\n' "$_GW_KEY" >> ${homeDir}/.ssh/authorized_keys`,
|
|
1078
|
+
` chmod 600 ${homeDir}/.ssh/authorized_keys`,
|
|
1079
|
+
` chown -R ${username}:${username} ${homeDir}/.ssh`,
|
|
1080
|
+
" printf '\\nPubkeyAuthentication yes\\nPasswordAuthentication no\\nChallengeResponseAuthentication no\\nUsePAM no\\n' >> /etc/ssh/sshd_config",
|
|
1081
|
+
" ssh-keygen -A 2>&1",
|
|
1082
|
+
" if command -v /usr/sbin/sshd >/dev/null 2>&1; then",
|
|
1083
|
+
" /usr/sbin/sshd 2>&1 &",
|
|
1084
|
+
" elif command -v /usr/bin/sshd >/dev/null 2>&1; then",
|
|
1085
|
+
" /usr/bin/sshd 2>&1 &",
|
|
1086
|
+
" else",
|
|
1087
|
+
' echo "SSH server: sshd binary not found"',
|
|
1088
|
+
" fi",
|
|
1089
|
+
' echo "SSH server started"',
|
|
1090
|
+
" set -e",
|
|
1091
|
+
")",
|
|
1092
|
+
)
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// ── 2. Feature entrypoints ────────────────────────────────────────────────
|
|
1096
|
+
push(
|
|
1097
|
+
"",
|
|
1098
|
+
"if [ -f " + FEATURE_ENTRYPOINTS_FILE + " ]; then",
|
|
1099
|
+
' echo "--- Starting feature entrypoints ---"',
|
|
1100
|
+
" while IFS= read -r _ep; do",
|
|
1101
|
+
' if [ -f "$_ep" ]; then',
|
|
1102
|
+
' echo "Starting entrypoint: $_ep"',
|
|
1103
|
+
' "$_ep" &',
|
|
1104
|
+
" fi",
|
|
1105
|
+
" done < " + FEATURE_ENTRYPOINTS_FILE,
|
|
1106
|
+
"fi",
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
// ── 3. Persist env vars for VS Code terminal login shells ─────────────────
|
|
1110
|
+
push(
|
|
1111
|
+
"",
|
|
1112
|
+
"# Persist worker env for login shells (VS Code integrated terminals read /etc/environment)",
|
|
1113
|
+
"for _var in WORKER_SLUG BASE_DOMAIN PUBLIC_PROTOCOL GITHUB_TOKEN GITHUB_USER GOOGLE_APPLICATION_CREDENTIALS; do",
|
|
1114
|
+
' _val=$(printenv "$_var" 2>/dev/null || true)',
|
|
1115
|
+
' [ -n "$_val" ] && echo "${_var}=${_val}" >> /etc/environment || true',
|
|
1116
|
+
"done",
|
|
1117
|
+
)
|
|
1118
|
+
|
|
1119
|
+
// ── 4. Copy prebuilt extensions ───────────────────────────────────────────
|
|
1120
|
+
push(
|
|
1121
|
+
"",
|
|
1122
|
+
'if [ -d ' + EXTENSIONS_DIR + ' ] && [ "$(ls -A ' + EXTENSIONS_DIR + ' 2>/dev/null)" ]; then',
|
|
1123
|
+
` mkdir -p ${homeDir}/.local/share/code-server/extensions`,
|
|
1124
|
+
` cp -rn ${EXTENSIONS_DIR}/. ${homeDir}/.local/share/code-server/extensions/ 2>/dev/null || true`,
|
|
1125
|
+
` chown -R ${username}:${username} ${homeDir}/.local`,
|
|
1126
|
+
"fi",
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
// ── 4b. Seed code-server user settings (no-clobber, stays user-overridable) ─
|
|
1130
|
+
// Reordered window.title + per-project window color make parallel worker tabs
|
|
1131
|
+
// tellable apart at a glance (see defaultVscodeUserSettings). Written no-clobber
|
|
1132
|
+
// (guarded on the file, not `cp -n`) so a user's own settings.json survives restarts.
|
|
1133
|
+
const vscodeSettings = JSON.stringify(
|
|
1134
|
+
defaultVscodeUserSettings({ projectName: project.name, colorByProject: colorTabsByProject }),
|
|
1135
|
+
null,
|
|
1136
|
+
2,
|
|
1137
|
+
)
|
|
1138
|
+
push(
|
|
1139
|
+
"",
|
|
1140
|
+
`mkdir -p ${homeDir}/.local/share/code-server/User`,
|
|
1141
|
+
`if [ ! -f ${homeDir}/.local/share/code-server/User/settings.json ]; then`,
|
|
1142
|
+
` echo ${JSON.stringify(b64(vscodeSettings))} | base64 -d > ${homeDir}/.local/share/code-server/User/settings.json`,
|
|
1143
|
+
` chown -R ${username}:${username} ${homeDir}/.local`,
|
|
1144
|
+
"fi",
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
// ── 4c. Restore the user's PORT secret for code-server's own terminal panel ─
|
|
1148
|
+
// code-server reads $PORT to override --bind-addr (see § 5 below), so its
|
|
1149
|
+
// whole process tree — including the integrated terminal it spawns — inherits
|
|
1150
|
+
// PORT=8080 unless corrected. `terminal.integrated.env.linux` lets VS Code
|
|
1151
|
+
// inject env overrides into terminals it spawns without touching the server
|
|
1152
|
+
// process itself, so a `PORT` project secret still reaches the user's shell.
|
|
1153
|
+
// Uses code-server's own bundled node (guaranteed present regardless of the
|
|
1154
|
+
// devcontainer base image) since jq/node aren't guaranteed there.
|
|
1155
|
+
const seedTerminalPortEnv = [
|
|
1156
|
+
"const fs = require('fs')",
|
|
1157
|
+
"const path = process.env.MP_SETTINGS_PATH",
|
|
1158
|
+
"let settings = {}",
|
|
1159
|
+
"try { settings = JSON.parse(fs.readFileSync(path, 'utf8')) } catch {}",
|
|
1160
|
+
"settings['terminal.integrated.env.linux'] = { ...settings['terminal.integrated.env.linux'], PORT: process.env.MP_ORIGINAL_PORT }",
|
|
1161
|
+
"fs.writeFileSync(path, JSON.stringify(settings, null, 2))",
|
|
1162
|
+
].join("\n")
|
|
1163
|
+
const seedTerminalPortEnvEncoded = b64(seedTerminalPortEnv)
|
|
1164
|
+
|
|
1165
|
+
push(
|
|
1166
|
+
"",
|
|
1167
|
+
'if [ -n "${PORT:-}" ]; then',
|
|
1168
|
+
" _mp_cs_bin=$(command -v code-server 2>/dev/null || true)",
|
|
1169
|
+
' if [ -n "$_mp_cs_bin" ]; then',
|
|
1170
|
+
' _mp_cs_bin=$(readlink -f "$_mp_cs_bin" 2>/dev/null || echo "$_mp_cs_bin")',
|
|
1171
|
+
' _mp_cs_node="$(dirname "$(dirname "$_mp_cs_bin")")/lib/node"',
|
|
1172
|
+
' if [ -x "$_mp_cs_node" ]; then',
|
|
1173
|
+
` mkdir -p ${homeDir}/.local/share/code-server/User`,
|
|
1174
|
+
` echo ${JSON.stringify(seedTerminalPortEnvEncoded)} | base64 -d > ${homeDir}/.mp_seed_terminal_port.js`,
|
|
1175
|
+
` MP_ORIGINAL_PORT="$PORT" MP_SETTINGS_PATH="${homeDir}/.local/share/code-server/User/settings.json" "$_mp_cs_node" ${homeDir}/.mp_seed_terminal_port.js`,
|
|
1176
|
+
` rm -f ${homeDir}/.mp_seed_terminal_port.js`,
|
|
1177
|
+
` chown -R ${username}:${username} ${homeDir}/.local`,
|
|
1178
|
+
" fi",
|
|
1179
|
+
" fi",
|
|
1180
|
+
"fi",
|
|
1181
|
+
)
|
|
1182
|
+
|
|
1183
|
+
// ── 5. Start code-server as vscode ────────────────────────────────────────
|
|
1184
|
+
// Write the restart-loop to a temp script so quoting stays clean.
|
|
1185
|
+
// Single-quoted heredoc: $VARS are NOT expanded here, they expand when the script runs
|
|
1186
|
+
// (env is inherited from the start script via `su vscode` without -l).
|
|
1187
|
+
// Write the restart-loop using base64 to avoid heredoc issues in DinD contexts
|
|
1188
|
+
// where dockerd startup can affect /tmp mount visibility for child processes.
|
|
1189
|
+
// Script lives in home dir (persistent volume) rather than /tmp.
|
|
1190
|
+
const codeServerLoop = [
|
|
1191
|
+
"#!/bin/bash",
|
|
1192
|
+
"export SHELL=$(command -v zsh 2>/dev/null || echo /bin/bash)",
|
|
1193
|
+
// Makes the editor's own Extensions view search the same registry the picker and the image
|
|
1194
|
+
// build used (org setting — see lib/extension-registry.ts). Nothing when the org is on Open VSX.
|
|
1195
|
+
...galleryExport(extensionsGallery),
|
|
1196
|
+
"while true; do",
|
|
1197
|
+
' VSCODE_PROXY_URI="${PUBLIC_PROTOCOL}://${WORKER_SLUG}-{{port}}.${BASE_DOMAIN}" \\',
|
|
1198
|
+
// PORT is overridden here (not unset globally) so a project secret named
|
|
1199
|
+
// PORT can't make code-server bind elsewhere. This also leaks PORT=8080
|
|
1200
|
+
// into code-server's own process tree (its integrated terminal included) —
|
|
1201
|
+
// § 4c above restores the original value for that terminal specifically.
|
|
1202
|
+
// apps/agent/src/terminal.ts (dashboard's separate terminal feature) is a
|
|
1203
|
+
// different process tree entirely and was never affected.
|
|
1204
|
+
" PORT=8080 code-server --bind-addr 0.0.0.0:8080 --auth none /workspace 2>&1",
|
|
1205
|
+
' echo "[start] code-server exited ($?), restarting in 3s..."',
|
|
1206
|
+
" sleep 3",
|
|
1207
|
+
"done",
|
|
1208
|
+
].join("\n")
|
|
1209
|
+
const codeServerEncoded = b64(codeServerLoop)
|
|
1210
|
+
|
|
1211
|
+
push(
|
|
1212
|
+
...banner("START: CODE-SERVER"),
|
|
1213
|
+
`echo ${JSON.stringify(codeServerEncoded)} | base64 -d > ${homeDir}/.mp_codeserver.sh`,
|
|
1214
|
+
`chmod +x ${homeDir}/.mp_codeserver.sh`,
|
|
1215
|
+
`su ${username} -s /bin/bash -c "${homeDir}/.mp_codeserver.sh" &`,
|
|
1216
|
+
)
|
|
1217
|
+
|
|
1218
|
+
// ── 6. postStartCommand ───────────────────────────────────────────────────
|
|
1219
|
+
// Wait for Docker if feature entrypoints were started — regardless of whether
|
|
1220
|
+
// the project explicitly lists docker-in-docker. Some images have Docker in
|
|
1221
|
+
// the base image or via a differently-named feature. Strategy: give the socket
|
|
1222
|
+
// up to 5s to appear; if it shows up, wait up to 30s for the daemon to be ready.
|
|
1223
|
+
if (project.postStartCommand) {
|
|
1224
|
+
push(
|
|
1225
|
+
"",
|
|
1226
|
+
"if [ -f " + FEATURE_ENTRYPOINTS_FILE + " ]; then",
|
|
1227
|
+
" for _i in 1 2 3 4 5; do [ -S /var/run/docker.sock ] && break || sleep 1; done",
|
|
1228
|
+
" if [ -S /var/run/docker.sock ]; then",
|
|
1229
|
+
' echo "Waiting for Docker daemon..."',
|
|
1230
|
+
" for _i in $(seq 1 30); do docker info >/dev/null 2>&1 && break; sleep 1; done",
|
|
1231
|
+
' docker info >/dev/null 2>&1 && echo "Docker daemon ready" || echo "Docker daemon not ready, continuing"',
|
|
1232
|
+
" fi",
|
|
1233
|
+
"fi",
|
|
1234
|
+
)
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
if (project.postStartCommand) {
|
|
1238
|
+
const postStartDir = project.repositories.length === 1
|
|
1239
|
+
? `/workspace/${project.repositories[0].workspacePath}`
|
|
1240
|
+
: "/workspace"
|
|
1241
|
+
push(...banner("START: POST START"))
|
|
1242
|
+
mpAt(mkStatus("lifecycle", allReposDone, hasPostCreate ? "done" : null, "running"), "postStart")
|
|
1243
|
+
const cmdEncoded = b64(project.postStartCommand)
|
|
1244
|
+
push(
|
|
1245
|
+
`echo "--- Running postStartCommand ---"`,
|
|
1246
|
+
`echo ${JSON.stringify(cmdEncoded)} | base64 -d > /tmp/mp_poststart.sh`,
|
|
1247
|
+
`chmod +x /tmp/mp_poststart.sh`,
|
|
1248
|
+
`su ${username} -c "cd ${postStartDir} && bash /tmp/mp_poststart.sh" 2>&1`,
|
|
1249
|
+
`rm -f /tmp/mp_poststart.sh`,
|
|
1250
|
+
`echo "postStartCommand done"`,
|
|
1251
|
+
)
|
|
1252
|
+
mp(mkStatus("lifecycle", allReposDone, hasPostCreate ? "done" : null, "done"))
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// ── 7. Ready ─────────────────────────────────────────────────────────────
|
|
1256
|
+
push(...banner("READY"))
|
|
1257
|
+
mpAt(mkStatus("ready", allReposDone, hasPostCreate ? "done" : null, hasPostStart ? "done" : null), "ready")
|
|
1258
|
+
// Setup + start succeeded — disarm the failure trap (see buildWorkerScript _mp_fail) so a later
|
|
1259
|
+
// container stop or a code-server restart-loop blip never gets reported as a setup failure.
|
|
1260
|
+
push("_MP_DONE=1")
|
|
1261
|
+
push("", "wait")
|
|
1262
|
+
|
|
1263
|
+
return { script: lines.join("\n"), hasDinD }
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// ─── buildWorkerScript ────────────────────────────────────────────────────────
|
|
1267
|
+
//
|
|
1268
|
+
// Assembles the full container CMD by combining setup + start.
|
|
1269
|
+
// Setup runs only on first start (marker file guards it).
|
|
1270
|
+
// Start runs on every start.
|
|
1271
|
+
|
|
1272
|
+
// ─── RFC 0017: agent-orchestrated setup (behind WORKER_SETUP_ORCHESTRATOR=agent) ──
|
|
1273
|
+
//
|
|
1274
|
+
// The functions below power the *new* model where the agent (not the container) drives setup:
|
|
1275
|
+
// `buildContainerScript` is the passive CMD (serve code-server + stay alive, no setup, no status
|
|
1276
|
+
// machinery), and `buildSetupPlan` returns the ordered phases the agent execs one by one, each
|
|
1277
|
+
// wrapped in a native OTel span. The legacy `buildWorkerScript`/`buildSetupScript`/`buildStartScript`
|
|
1278
|
+
// above are left untouched — the runner picks the path by flag, defaulting to legacy. See RFC 0017.
|
|
1279
|
+
|
|
1280
|
+
/** One setup phase the agent execs via `docker exec`, wrapped in a native OTel span. */
|
|
1281
|
+
export type SetupPhase = {
|
|
1282
|
+
/** Timing-key / span-suffix (`setup.<id>`) and the phase's short identity. */
|
|
1283
|
+
id: "credentials" | "shell" | "dotfiles" | "clone" | "postCreate" | "postStart"
|
|
1284
|
+
/** Human label for logs. */
|
|
1285
|
+
label: string
|
|
1286
|
+
/** Self-contained bash (runs as root via `docker exec`; `su vscode` inside where needed). */
|
|
1287
|
+
script: string
|
|
1288
|
+
/** Re-run on every start (restart included). Only postStart; the rest is first-start-only. */
|
|
1289
|
+
runsOnRestart: boolean
|
|
1290
|
+
/** Full SetupStatus to persist when this phase starts / finishes (mirrors legacy `mkStatus`). */
|
|
1291
|
+
statusStart: SetupStatus
|
|
1292
|
+
statusEnd: SetupStatus
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// The passive container CMD. Same "serve + stay alive" blocks as buildStartScript (SSH, feature
|
|
1296
|
+
// entrypoints, env persistence, extensions, code-server settings + restart loop) — MINUS the setup
|
|
1297
|
+
// status machinery, the lifecycle commands and any OTLP/timing emission (the agent owns all of that
|
|
1298
|
+
// now). Ends by signalling readiness (`.mp-container-up`) then `wait`. Blocks are duplicated from
|
|
1299
|
+
// buildStartScript on purpose: the legacy path stays byte-identical (prod default) until it's
|
|
1300
|
+
// deleted once WORKER_SETUP_ORCHESTRATOR=agent becomes the default. See RFC 0017 §2.1.
|
|
1301
|
+
export function buildContainerScript(params: StartScriptParams): { script: string; hasDinD: boolean } {
|
|
1302
|
+
const { project, sshGatewayPublicKey, colorTabsByProject, extensionsGallery } = params
|
|
1303
|
+
const homeDir = "/home/vscode"
|
|
1304
|
+
const username = "vscode"
|
|
1305
|
+
|
|
1306
|
+
const features = project.features ?? []
|
|
1307
|
+
const hasDinD = features.some((f) => f.id === "docker-in-docker") || !!project.dind
|
|
1308
|
+
|
|
1309
|
+
const lines: string[] = ["set -e", "export HOME=/home/vscode"]
|
|
1310
|
+
const push = (...l: string[]) => lines.push(...l)
|
|
1311
|
+
|
|
1312
|
+
// ── Session separator (visible in logs on every start/restart) ────────────
|
|
1313
|
+
push(
|
|
1314
|
+
`printf '\\033[2m%s\\033[0m\\n' "$(printf '─%.0s' {1..42})"`,
|
|
1315
|
+
`printf '\\033[2m ↺ Session · %s\\033[0m\\n' "$(date +%H:%M:%S)"`,
|
|
1316
|
+
`printf '\\033[2m%s\\033[0m\\n' "$(printf '─%.0s' {1..42})"`,
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
// ── 1. SSH server ─────────────────────────────────────────────────────────
|
|
1320
|
+
if (sshGatewayPublicKey) {
|
|
1321
|
+
push(...banner("START: SSH SERVER"))
|
|
1322
|
+
push(
|
|
1323
|
+
"(",
|
|
1324
|
+
" set +e",
|
|
1325
|
+
" mkdir -p /run/sshd /var/run/sshd",
|
|
1326
|
+
` mkdir -p ${homeDir}/.ssh`,
|
|
1327
|
+
` chmod 700 ${homeDir}/.ssh`,
|
|
1328
|
+
` _GW_KEY=${JSON.stringify(sshGatewayPublicKey)}`,
|
|
1329
|
+
` grep -qF "$_GW_KEY" ${homeDir}/.ssh/authorized_keys 2>/dev/null || printf '%s\\n' "$_GW_KEY" >> ${homeDir}/.ssh/authorized_keys`,
|
|
1330
|
+
` chmod 600 ${homeDir}/.ssh/authorized_keys`,
|
|
1331
|
+
` chown -R ${username}:${username} ${homeDir}/.ssh`,
|
|
1332
|
+
" printf '\\nPubkeyAuthentication yes\\nPasswordAuthentication no\\nChallengeResponseAuthentication no\\nUsePAM no\\n' >> /etc/ssh/sshd_config",
|
|
1333
|
+
" ssh-keygen -A 2>&1",
|
|
1334
|
+
" if command -v /usr/sbin/sshd >/dev/null 2>&1; then",
|
|
1335
|
+
" /usr/sbin/sshd 2>&1 &",
|
|
1336
|
+
" elif command -v /usr/bin/sshd >/dev/null 2>&1; then",
|
|
1337
|
+
" /usr/bin/sshd 2>&1 &",
|
|
1338
|
+
" else",
|
|
1339
|
+
' echo "SSH server: sshd binary not found"',
|
|
1340
|
+
" fi",
|
|
1341
|
+
' echo "SSH server started"',
|
|
1342
|
+
" set -e",
|
|
1343
|
+
")",
|
|
1344
|
+
)
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// ── 2. Feature entrypoints ────────────────────────────────────────────────
|
|
1348
|
+
push(
|
|
1349
|
+
"",
|
|
1350
|
+
"if [ -f " + FEATURE_ENTRYPOINTS_FILE + " ]; then",
|
|
1351
|
+
' echo "--- Starting feature entrypoints ---"',
|
|
1352
|
+
" while IFS= read -r _ep; do",
|
|
1353
|
+
' if [ -f "$_ep" ]; then',
|
|
1354
|
+
' echo "Starting entrypoint: $_ep"',
|
|
1355
|
+
' "$_ep" &',
|
|
1356
|
+
" fi",
|
|
1357
|
+
" done < " + FEATURE_ENTRYPOINTS_FILE,
|
|
1358
|
+
"fi",
|
|
1359
|
+
)
|
|
1360
|
+
|
|
1361
|
+
// ── 3. Persist env vars for VS Code terminal login shells ─────────────────
|
|
1362
|
+
push(
|
|
1363
|
+
"",
|
|
1364
|
+
"# Persist worker env for login shells (VS Code integrated terminals read /etc/environment)",
|
|
1365
|
+
"for _var in WORKER_SLUG BASE_DOMAIN PUBLIC_PROTOCOL GITHUB_TOKEN GITHUB_USER GOOGLE_APPLICATION_CREDENTIALS; do",
|
|
1366
|
+
' _val=$(printenv "$_var" 2>/dev/null || true)',
|
|
1367
|
+
' [ -n "$_val" ] && echo "${_var}=${_val}" >> /etc/environment || true',
|
|
1368
|
+
"done",
|
|
1369
|
+
)
|
|
1370
|
+
|
|
1371
|
+
// ── 4. Copy prebuilt extensions ───────────────────────────────────────────
|
|
1372
|
+
push(
|
|
1373
|
+
"",
|
|
1374
|
+
'if [ -d ' + EXTENSIONS_DIR + ' ] && [ "$(ls -A ' + EXTENSIONS_DIR + ' 2>/dev/null)" ]; then',
|
|
1375
|
+
` mkdir -p ${homeDir}/.local/share/code-server/extensions`,
|
|
1376
|
+
` cp -rn ${EXTENSIONS_DIR}/. ${homeDir}/.local/share/code-server/extensions/ 2>/dev/null || true`,
|
|
1377
|
+
` chown -R ${username}:${username} ${homeDir}/.local`,
|
|
1378
|
+
"fi",
|
|
1379
|
+
)
|
|
1380
|
+
|
|
1381
|
+
// ── 4b. Seed code-server user settings (no-clobber) ───────────────────────
|
|
1382
|
+
const vscodeSettings = JSON.stringify(
|
|
1383
|
+
defaultVscodeUserSettings({ projectName: project.name, colorByProject: colorTabsByProject }),
|
|
1384
|
+
null,
|
|
1385
|
+
2,
|
|
1386
|
+
)
|
|
1387
|
+
push(
|
|
1388
|
+
"",
|
|
1389
|
+
`mkdir -p ${homeDir}/.local/share/code-server/User`,
|
|
1390
|
+
`if [ ! -f ${homeDir}/.local/share/code-server/User/settings.json ]; then`,
|
|
1391
|
+
` echo ${JSON.stringify(b64(vscodeSettings))} | base64 -d > ${homeDir}/.local/share/code-server/User/settings.json`,
|
|
1392
|
+
` chown -R ${username}:${username} ${homeDir}/.local`,
|
|
1393
|
+
"fi",
|
|
1394
|
+
)
|
|
1395
|
+
|
|
1396
|
+
// ── 4c. Restore the user's PORT secret for code-server's own terminal panel ─
|
|
1397
|
+
const seedTerminalPortEnv = [
|
|
1398
|
+
"const fs = require('fs')",
|
|
1399
|
+
"const path = process.env.MP_SETTINGS_PATH",
|
|
1400
|
+
"let settings = {}",
|
|
1401
|
+
"try { settings = JSON.parse(fs.readFileSync(path, 'utf8')) } catch {}",
|
|
1402
|
+
"settings['terminal.integrated.env.linux'] = { ...settings['terminal.integrated.env.linux'], PORT: process.env.MP_ORIGINAL_PORT }",
|
|
1403
|
+
"fs.writeFileSync(path, JSON.stringify(settings, null, 2))",
|
|
1404
|
+
].join("\n")
|
|
1405
|
+
const seedTerminalPortEnvEncoded = b64(seedTerminalPortEnv)
|
|
1406
|
+
push(
|
|
1407
|
+
"",
|
|
1408
|
+
'if [ -n "${PORT:-}" ]; then',
|
|
1409
|
+
" _mp_cs_bin=$(command -v code-server 2>/dev/null || true)",
|
|
1410
|
+
' if [ -n "$_mp_cs_bin" ]; then',
|
|
1411
|
+
' _mp_cs_bin=$(readlink -f "$_mp_cs_bin" 2>/dev/null || echo "$_mp_cs_bin")',
|
|
1412
|
+
' _mp_cs_node="$(dirname "$(dirname "$_mp_cs_bin")")/lib/node"',
|
|
1413
|
+
' if [ -x "$_mp_cs_node" ]; then',
|
|
1414
|
+
` mkdir -p ${homeDir}/.local/share/code-server/User`,
|
|
1415
|
+
` echo ${JSON.stringify(seedTerminalPortEnvEncoded)} | base64 -d > ${homeDir}/.mp_seed_terminal_port.js`,
|
|
1416
|
+
` MP_ORIGINAL_PORT="$PORT" MP_SETTINGS_PATH="${homeDir}/.local/share/code-server/User/settings.json" "$_mp_cs_node" ${homeDir}/.mp_seed_terminal_port.js`,
|
|
1417
|
+
` rm -f ${homeDir}/.mp_seed_terminal_port.js`,
|
|
1418
|
+
` chown -R ${username}:${username} ${homeDir}/.local`,
|
|
1419
|
+
" fi",
|
|
1420
|
+
" fi",
|
|
1421
|
+
"fi",
|
|
1422
|
+
)
|
|
1423
|
+
|
|
1424
|
+
// ── 5. Start code-server as vscode ────────────────────────────────────────
|
|
1425
|
+
const codeServerLoop = [
|
|
1426
|
+
"#!/bin/bash",
|
|
1427
|
+
"export SHELL=$(command -v zsh 2>/dev/null || echo /bin/bash)",
|
|
1428
|
+
// Same org-level gallery export as the legacy loop above — the two blocks are duplicated on
|
|
1429
|
+
// purpose (RFC 0017 §2.1), so this one has to be kept in step by hand.
|
|
1430
|
+
...galleryExport(extensionsGallery),
|
|
1431
|
+
"while true; do",
|
|
1432
|
+
' VSCODE_PROXY_URI="${PUBLIC_PROTOCOL}://${WORKER_SLUG}-{{port}}.${BASE_DOMAIN}" \\',
|
|
1433
|
+
" PORT=8080 code-server --bind-addr 0.0.0.0:8080 --auth none /workspace 2>&1",
|
|
1434
|
+
' echo "[start] code-server exited ($?), restarting in 3s..."',
|
|
1435
|
+
" sleep 3",
|
|
1436
|
+
"done",
|
|
1437
|
+
].join("\n")
|
|
1438
|
+
const codeServerEncoded = b64(codeServerLoop)
|
|
1439
|
+
push(
|
|
1440
|
+
...banner("START: CODE-SERVER"),
|
|
1441
|
+
`echo ${JSON.stringify(codeServerEncoded)} | base64 -d > ${homeDir}/.mp_codeserver.sh`,
|
|
1442
|
+
`chmod +x ${homeDir}/.mp_codeserver.sh`,
|
|
1443
|
+
`su ${username} -s /bin/bash -c "${homeDir}/.mp_codeserver.sh" &`,
|
|
1444
|
+
)
|
|
1445
|
+
|
|
1446
|
+
// ── 6. Signal "container infra up" then stay alive ────────────────────────
|
|
1447
|
+
// The agent waits for this marker before running setup phases that touch Docker (postCreate),
|
|
1448
|
+
// guaranteeing feature entrypoints (dockerd) and env are in place. See RFC 0017 §2.3.
|
|
1449
|
+
push(
|
|
1450
|
+
"",
|
|
1451
|
+
`touch ${homeDir}/.mp-container-up`,
|
|
1452
|
+
'echo "[container] ready — setup orchestrated by the agent"',
|
|
1453
|
+
"",
|
|
1454
|
+
"wait",
|
|
1455
|
+
)
|
|
1456
|
+
|
|
1457
|
+
return { script: lines.join("\n"), hasDinD }
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
export function buildWorkerScript(params: {
|
|
1461
|
+
setupScript: string
|
|
1462
|
+
startScript: string
|
|
1463
|
+
project: {
|
|
1464
|
+
postCreateCommand: string | null
|
|
1465
|
+
postStartCommand: string | null
|
|
1466
|
+
repositories: { project: string }[]
|
|
1467
|
+
}
|
|
1468
|
+
}): string {
|
|
1469
|
+
const { setupScript, startScript, project } = params
|
|
1470
|
+
const hasPostCreate = !!project.postCreateCommand
|
|
1471
|
+
const hasPostStart = !!project.postStartCommand
|
|
1472
|
+
|
|
1473
|
+
const allReposPending: RS[] = project.repositories.map((r) => ({ name: r.project, state: "pending" }))
|
|
1474
|
+
|
|
1475
|
+
const initialStatus = mkStatus(
|
|
1476
|
+
"initializing",
|
|
1477
|
+
allReposPending,
|
|
1478
|
+
hasPostCreate ? "pending" : null,
|
|
1479
|
+
hasPostStart ? "pending" : null,
|
|
1480
|
+
)
|
|
1481
|
+
|
|
1482
|
+
return [
|
|
1483
|
+
"set -e",
|
|
1484
|
+
"export HOME=/home/vscode",
|
|
1485
|
+
"",
|
|
1486
|
+
// ── Per-phase start timings (worker start-time visibility) ────────────────
|
|
1487
|
+
// Each phase transition stamps a millisecond-epoch timestamp *container-side*
|
|
1488
|
+
// (one clock, unbiased by the backend's 1.5s poll) into a persistent
|
|
1489
|
+
// accumulator file, which `_mp` then merges into .mp-status.json under a
|
|
1490
|
+
// `timings` key on every write. First-write-wins per key, so a restart — which
|
|
1491
|
+
// re-runs only the start script, not setup — never overwrites the original
|
|
1492
|
+
// spawn timings. See docs/worker-states.md § Setup timings.
|
|
1493
|
+
"_MP_TFILE=/home/vscode/.mp-timings",
|
|
1494
|
+
"_mp_now() {",
|
|
1495
|
+
" local n",
|
|
1496
|
+
// Milliseconds epoch. `date +%s%N` = seconds + 9-digit nanoseconds on both GNU and
|
|
1497
|
+
// uutils coreutils (the latter, shipped by some base images, ignores the `%3N`
|
|
1498
|
+
// width and would emit raw nanoseconds — 19 digits that overflow JS safe integers);
|
|
1499
|
+
// dividing by 1e6 in bash 64-bit arithmetic yields a safe 13-digit ms value. Falls
|
|
1500
|
+
// back to seconds×1000 when `%N` is unsupported (busybox → non-numeric output).
|
|
1501
|
+
" n=$(date +%s%N 2>/dev/null || true)",
|
|
1502
|
+
" case \"$n\" in ''|*[!0-9]*) printf '%s' \"$(date +%s 2>/dev/null || echo 0)000\"; return ;; esac",
|
|
1503
|
+
" printf '%s' \"$(( n / 1000000 ))\"",
|
|
1504
|
+
"}",
|
|
1505
|
+
"_mp_stamp() {",
|
|
1506
|
+
" if grep -q \"\\\"$1\\\":\" \"$_MP_TFILE\" 2>/dev/null; then return 0; fi",
|
|
1507
|
+
" local now; now=\"$(_mp_now)\"",
|
|
1508
|
+
" if [ -s \"$_MP_TFILE\" ]; then printf ',' >> \"$_MP_TFILE\"; fi",
|
|
1509
|
+
" printf '\"%s\":%s' \"$1\" \"$now\" >> \"$_MP_TFILE\" || true",
|
|
1510
|
+
// Native OTel: emit this phase transition as a real OTLP span through the node
|
|
1511
|
+
// collector (|| true + suspends set -e inside the fn — telemetry never breaks setup).
|
|
1512
|
+
" _mp_span_on_stamp \"$1\" \"$now\" || true",
|
|
1513
|
+
"}",
|
|
1514
|
+
// mp-status.json lives in the home dir (persistent volume), not /tmp — when a
|
|
1515
|
+
// DinD feature starts dockerd in the background, /tmp's mount visibility can
|
|
1516
|
+
// change for sibling processes (same issue that affected the code-server
|
|
1517
|
+
// restart script, see buildStartScript), silently dropping status writes.
|
|
1518
|
+
"_mp() {",
|
|
1519
|
+
" local j=\"$1\" t=''",
|
|
1520
|
+
" if [ -s \"$_MP_TFILE\" ]; then t=\"$(cat \"$_MP_TFILE\" 2>/dev/null || true)\"; fi",
|
|
1521
|
+
// Splice `,"timings":{…}` in before the status object's closing brace.
|
|
1522
|
+
" if [ -n \"$t\" ]; then j=\"${j%?},\\\"timings\\\":{$t}}\"; fi",
|
|
1523
|
+
// Remember the last status written so the failure trap (_mp_fail) can re-emit it with
|
|
1524
|
+
// phase=error, preserving repos/postCreate/postStart/timings the UI already shows.
|
|
1525
|
+
" _MP_LAST=\"$j\"",
|
|
1526
|
+
" printf '%s' \"$j\" > " + workerStatusPath("/home/vscode") + " || true",
|
|
1527
|
+
"}",
|
|
1528
|
+
"",
|
|
1529
|
+
// ── Native OTel setup spans (emitted by this script, not backdated in the API) ──
|
|
1530
|
+
// Each phase is emitted as a real OTLP span to the node agent's collector (the
|
|
1531
|
+
// auto-injected OTEL_EXPORTER_OTLP_ENDPOINT); the agent forwards to /otlp where org.id
|
|
1532
|
+
// /node.id are stamped server-side. MP_SETUP_TRACEPARENT (the job.worker.spawn span,
|
|
1533
|
+
// injected by the runner) gives the traceId + parent span, so the whole `worker.setup`
|
|
1534
|
+
// subtree nests under the spawn trace. No trace context (or no curl) → silently skipped.
|
|
1535
|
+
"MP_TRACE_ID=''; MP_JOB_SPAN=''",
|
|
1536
|
+
"if [ -n \"${MP_SETUP_TRACEPARENT:-}\" ]; then",
|
|
1537
|
+
" MP_TRACE_ID=$(printf '%s' \"$MP_SETUP_TRACEPARENT\" | cut -d- -f2)",
|
|
1538
|
+
" MP_JOB_SPAN=$(printf '%s' \"$MP_SETUP_TRACEPARENT\" | cut -d- -f3)",
|
|
1539
|
+
"fi",
|
|
1540
|
+
"MP_SETUP_SPAN=''; MP_SETUP_START=''; MP_PREV_PHASE=''; MP_PREV_TS=''",
|
|
1541
|
+
// Failure-trap state. _MP_DONE flips to 1 once setup+start reach `ready` (see buildStartScript),
|
|
1542
|
+
// disarming _mp_fail so a normal container stop / code-server hiccup after ready never looks like
|
|
1543
|
+
// a setup failure. _MP_LAST holds the most recent status JSON (set by _mp) for the error re-emit.
|
|
1544
|
+
"_MP_DONE=0; _MP_LAST=''",
|
|
1545
|
+
"_mp_rand_id() { od -An -tx1 -N8 /dev/urandom 2>/dev/null | tr -d ' \\n'; }",
|
|
1546
|
+
"_mp_phase_span_name() {",
|
|
1547
|
+
" case \"$1\" in",
|
|
1548
|
+
" initializing) echo 'setup.initialize' ;;",
|
|
1549
|
+
" credentials) echo 'setup.credentials' ;;",
|
|
1550
|
+
" shell) echo 'setup.shell' ;;",
|
|
1551
|
+
" cloning) echo 'setup.clone' ;;",
|
|
1552
|
+
" postCreate) echo 'setup.postCreate' ;;",
|
|
1553
|
+
" postStart) echo 'setup.postStart' ;;",
|
|
1554
|
+
" *) echo \"setup.$1\" ;;",
|
|
1555
|
+
" esac",
|
|
1556
|
+
"}",
|
|
1557
|
+
// Emit one OTLP/HTTP JSON span, backgrounded + short timeout so it never adds latency
|
|
1558
|
+
// to (or blocks) the setup path. Args: name start_ms end_ms span_id parent_span_id phase.
|
|
1559
|
+
"_mp_span_emit() {",
|
|
1560
|
+
" if [ -z \"$MP_TRACE_ID\" ] || [ -z \"${OTEL_EXPORTER_OTLP_ENDPOINT:-}\" ]; then return 0; fi",
|
|
1561
|
+
" if ! command -v curl >/dev/null 2>&1; then return 0; fi",
|
|
1562
|
+
// Optional $7 = error message and $8 = exit code turn this into a native ERROR span
|
|
1563
|
+
// (OTel status code 2 + a setup.exit_code attribute) — that's what makes a failed
|
|
1564
|
+
// clone/postCreate/postStart show up red in the waterfall instead of as a hole.
|
|
1565
|
+
" local name=\"$1\" s_ns=$(( $2 * 1000000 )) e_ns=$(( $3 * 1000000 )) sid=\"$4\" pid=\"$5\" phase=\"$6\" errmsg=\"${7:-}\" ecode=\"${8:-}\" dur=$(( $3 - $2 )) attrs='' status=''",
|
|
1566
|
+
" if [ -n \"$phase\" ]; then attrs=\"{\\\"key\\\":\\\"setup.phase\\\",\\\"value\\\":{\\\"stringValue\\\":\\\"$phase\\\"}},{\\\"key\\\":\\\"setup.duration_ms\\\",\\\"value\\\":{\\\"intValue\\\":\\\"$dur\\\"}}\"; fi",
|
|
1567
|
+
" if [ -n \"$ecode\" ]; then [ -n \"$attrs\" ] && attrs=\"$attrs,\"; attrs=\"${attrs}{\\\"key\\\":\\\"setup.exit_code\\\",\\\"value\\\":{\\\"intValue\\\":\\\"$ecode\\\"}}\"; fi",
|
|
1568
|
+
" if [ -n \"$errmsg\" ]; then status=\",\\\"status\\\":{\\\"code\\\":2,\\\"message\\\":\\\"$errmsg\\\"}\"; fi",
|
|
1569
|
+
" local body=\"{\\\"resourceSpans\\\":[{\\\"resource\\\":{\\\"attributes\\\":[{\\\"key\\\":\\\"service.name\\\",\\\"value\\\":{\\\"stringValue\\\":\\\"spunto-worker\\\"}},{\\\"key\\\":\\\"spunto.worker.slug\\\",\\\"value\\\":{\\\"stringValue\\\":\\\"${WORKER_SLUG:-}\\\"}}]},\\\"scopeSpans\\\":[{\\\"scope\\\":{\\\"name\\\":\\\"spunto.setup\\\"},\\\"spans\\\":[{\\\"traceId\\\":\\\"$MP_TRACE_ID\\\",\\\"spanId\\\":\\\"$sid\\\",\\\"parentSpanId\\\":\\\"$pid\\\",\\\"name\\\":\\\"$name\\\",\\\"kind\\\":1,\\\"startTimeUnixNano\\\":\\\"$s_ns\\\",\\\"endTimeUnixNano\\\":\\\"$e_ns\\\",\\\"attributes\\\":[$attrs]$status}]}]}]}\"",
|
|
1570
|
+
" ( curl -s -m 3 -o /dev/null -X POST \"$OTEL_EXPORTER_OTLP_ENDPOINT/v1/traces\" -H 'Content-Type: application/json' -d \"$body\" || true ) &",
|
|
1571
|
+
"}",
|
|
1572
|
+
// Called on each new phase stamp. The first stamp opens the `worker.setup` window; every
|
|
1573
|
+
// later stamp closes the previous phase as a child span; `ready` also emits the parent.
|
|
1574
|
+
"_mp_span_on_stamp() {",
|
|
1575
|
+
" if [ -z \"$MP_TRACE_ID\" ]; then return 0; fi",
|
|
1576
|
+
" local key=\"$1\" now=\"$2\"",
|
|
1577
|
+
" if [ -z \"$MP_SETUP_SPAN\" ]; then",
|
|
1578
|
+
" MP_SETUP_SPAN=$(_mp_rand_id); MP_SETUP_START=\"$now\"; MP_PREV_PHASE=\"$key\"; MP_PREV_TS=\"$now\"; return 0",
|
|
1579
|
+
" fi",
|
|
1580
|
+
" _mp_span_emit \"$(_mp_phase_span_name \"$MP_PREV_PHASE\")\" \"$MP_PREV_TS\" \"$now\" \"$(_mp_rand_id)\" \"$MP_SETUP_SPAN\" \"$MP_PREV_PHASE\"",
|
|
1581
|
+
" if [ \"$key\" = ready ]; then",
|
|
1582
|
+
" _mp_span_emit 'worker.setup' \"$MP_SETUP_START\" \"$now\" \"$MP_SETUP_SPAN\" \"$MP_JOB_SPAN\" ''",
|
|
1583
|
+
" else",
|
|
1584
|
+
" MP_PREV_PHASE=\"$key\"; MP_PREV_TS=\"$now\"",
|
|
1585
|
+
" fi",
|
|
1586
|
+
"}",
|
|
1587
|
+
// ── Native error spans on setup failure (the direct goal of this refactor) ──
|
|
1588
|
+
// set -e makes any uncaught failure abort the CMD; before PR #115 that just left the worker
|
|
1589
|
+
// stuck at its last status with a hole in the trace. This EXIT trap turns that same failure
|
|
1590
|
+
// into: (1) a real OTLP span in ERROR (status code 2 + exit code + the in-flight phase), so the
|
|
1591
|
+
// failing clone/postCreate/postStart shows up red under worker.setup, and (2) a phase=error
|
|
1592
|
+
// status write so the API poller flips the worker to `error` with a message. It fires only on a
|
|
1593
|
+
// non-zero exit and only before `ready` (_MP_DONE guards the normal path); the short sleep lets
|
|
1594
|
+
// the 1.5s poll read the error status before the container exits.
|
|
1595
|
+
"_mp_fail() {",
|
|
1596
|
+
" local code=$?",
|
|
1597
|
+
// We're already exiting — never let errexit abort the trap before it writes the error status.
|
|
1598
|
+
" set +e",
|
|
1599
|
+
" [ \"$_MP_DONE\" = 1 ] && return 0",
|
|
1600
|
+
" [ \"$code\" = 0 ] && return 0",
|
|
1601
|
+
" _MP_DONE=1",
|
|
1602
|
+
" local phase now msg j",
|
|
1603
|
+
" phase=\"$MP_PREV_PHASE\"",
|
|
1604
|
+
" [ -z \"$phase\" ] && phase=$(printf '%s' \"$_MP_LAST\" | sed -n 's/.*\"phase\":\"\\([^\"]*\\)\".*/\\1/p')",
|
|
1605
|
+
" [ -z \"$phase\" ] && phase=initializing",
|
|
1606
|
+
" now=\"$(_mp_now)\"",
|
|
1607
|
+
// A phase that knows *why* it failed (e.g. a clone on a branch that doesn't exist) sets
|
|
1608
|
+
// MP_FAIL_MSG before exiting, so the UI shows that instead of the generic phase message.
|
|
1609
|
+
" msg=\"${MP_FAIL_MSG:-setup phase $phase failed (exit $code)}\"",
|
|
1610
|
+
" echo \"[setup] ✗ $msg\" >&2",
|
|
1611
|
+
// Best-effort error spans: close the failing phase (child of worker.setup) then worker.setup
|
|
1612
|
+
// itself in ERROR. On a restart (postStart failure) the setup window was never opened, so emit
|
|
1613
|
+
// a single worker.setup error span under the job span instead.
|
|
1614
|
+
" if [ -n \"$MP_TRACE_ID\" ]; then",
|
|
1615
|
+
" if [ -n \"$MP_SETUP_SPAN\" ]; then",
|
|
1616
|
+
" _mp_span_emit \"$(_mp_phase_span_name \"$phase\")\" \"${MP_PREV_TS:-$now}\" \"$now\" \"$(_mp_rand_id)\" \"$MP_SETUP_SPAN\" \"$phase\" \"$msg\" \"$code\"",
|
|
1617
|
+
" _mp_span_emit 'worker.setup' \"${MP_SETUP_START:-$now}\" \"$now\" \"$MP_SETUP_SPAN\" \"$MP_JOB_SPAN\" '' \"$msg\" \"$code\"",
|
|
1618
|
+
" else",
|
|
1619
|
+
" _mp_span_emit 'worker.setup' \"${MP_PREV_TS:-$now}\" \"$now\" \"$(_mp_rand_id)\" \"$MP_JOB_SPAN\" \"$phase\" \"$msg\" \"$code\"",
|
|
1620
|
+
" fi",
|
|
1621
|
+
" fi",
|
|
1622
|
+
// Flip .mp-status.json to phase=error (preserving the rest) so monitorSetup surfaces it.
|
|
1623
|
+
" j=\"$_MP_LAST\"",
|
|
1624
|
+
" if [ -n \"$j\" ]; then",
|
|
1625
|
+
" j=$(printf '%s' \"$j\" | sed 's/\"phase\":\"[^\"]*\"/\"phase\":\"error\"/')",
|
|
1626
|
+
" j=\"${j%?},\\\"error\\\":\\\"$msg\\\"}\"",
|
|
1627
|
+
" else",
|
|
1628
|
+
" j=\"{\\\"phase\\\":\\\"error\\\",\\\"repos\\\":[],\\\"postCreate\\\":null,\\\"postStart\\\":null,\\\"error\\\":\\\"$msg\\\"}\"",
|
|
1629
|
+
" fi",
|
|
1630
|
+
" printf '%s' \"$j\" > " + workerStatusPath("/home/vscode") + " || true",
|
|
1631
|
+
// Bridge the API's 1.5s poll interval before the container actually exits.
|
|
1632
|
+
" sleep 5 || true",
|
|
1633
|
+
"}",
|
|
1634
|
+
"trap _mp_fail EXIT",
|
|
1635
|
+
"",
|
|
1636
|
+
"# ─── First-start setup (runs once) ───────────────────────────────────────",
|
|
1637
|
+
"if [ ! -f /home/vscode/.mp-setup-done ]; then",
|
|
1638
|
+
" touch " + workerStatusPath("/home/vscode") + " && chmod 666 " + workerStatusPath("/home/vscode"),
|
|
1639
|
+
" _mp_stamp initializing",
|
|
1640
|
+
` _mp ${JSON.stringify(initialStatus)}`,
|
|
1641
|
+
setupScript,
|
|
1642
|
+
"fi",
|
|
1643
|
+
"",
|
|
1644
|
+
"# ─── Every-start ─────────────────────────────────────────────────────────",
|
|
1645
|
+
startScript,
|
|
1646
|
+
].join("\n")
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// ─── buildSetupPlan (RFC 0017) ─────────────────────────────────────────────────
|
|
1650
|
+
//
|
|
1651
|
+
// The ordered phases the agent execs one by one (each in its own `docker exec` + OTel span) when
|
|
1652
|
+
// WORKER_SETUP_ORCHESTRATOR=agent. Reuses the exact bash blocks from buildSetupScript/buildStartScript
|
|
1653
|
+
// (clone strategies, oh-my-zsh, dotfiles, lifecycle) MINUS the `_mp`/`_mp_stamp`/OTLP machinery — the
|
|
1654
|
+
// agent owns status + spans now. Each phase carries the SetupStatus to persist at its start/end
|
|
1655
|
+
// (mirrors the legacy `mkStatus(...)` writes) so the DB/UI progression is identical. See RFC 0017 §2.2.
|
|
1656
|
+
|
|
1657
|
+
export function buildSetupPlan(params: SetupScriptParams): {
|
|
1658
|
+
phases: SetupPhase[]
|
|
1659
|
+
initialStatus: SetupStatus
|
|
1660
|
+
readyStatus: SetupStatus
|
|
1661
|
+
} {
|
|
1662
|
+
const { project, userInfo, userSshPrivateKey, dotfilesRepo, userEnvSecrets, githubInstallationTokens, projectDeployKey, branch } = params
|
|
1663
|
+
const homeDir = "/home/vscode"
|
|
1664
|
+
const username = "vscode"
|
|
1665
|
+
|
|
1666
|
+
const repoNames = project.repositories.map((r) => r.project)
|
|
1667
|
+
const hasPostCreate = !!project.postCreateCommand
|
|
1668
|
+
const hasPostStart = !!project.postStartCommand
|
|
1669
|
+
const pc0: LS = hasPostCreate ? "pending" : null
|
|
1670
|
+
const ps0: LS = hasPostStart ? "pending" : null
|
|
1671
|
+
const pcDone: LS = hasPostCreate ? "done" : null
|
|
1672
|
+
const psDone: LS = hasPostStart ? "done" : null
|
|
1673
|
+
|
|
1674
|
+
const allReposPending: RS[] = repoNames.map((n) => ({ name: n, state: "pending" }))
|
|
1675
|
+
const allReposCloning: RS[] = repoNames.map((n) => ({ name: n, state: "cloning" }))
|
|
1676
|
+
const allReposDone: RS[] = repoNames.map((n) => ({ name: n, state: "done" }))
|
|
1677
|
+
|
|
1678
|
+
const status = (
|
|
1679
|
+
phase: SetupStatus["phase"],
|
|
1680
|
+
repos: RS[],
|
|
1681
|
+
postCreate: LS,
|
|
1682
|
+
postStart: LS,
|
|
1683
|
+
): SetupStatus => ({ phase, repos, postCreate, postStart })
|
|
1684
|
+
|
|
1685
|
+
const initialStatus = status("initializing", allReposPending, pc0, ps0)
|
|
1686
|
+
const readyStatus = status("ready", allReposDone, pcDone, psDone)
|
|
1687
|
+
|
|
1688
|
+
const phases: SetupPhase[] = []
|
|
1689
|
+
const hasCredentials = !!(userInfo || userSshPrivateKey || projectDeployKey)
|
|
1690
|
+
|
|
1691
|
+
// ── credentials (+ ownership) ─────────────────────────────────────────────
|
|
1692
|
+
{
|
|
1693
|
+
const l: string[] = ["set -e", ...banner("SETUP: OWNERSHIP"),
|
|
1694
|
+
`chown -R ${username}:${username} /workspace`,
|
|
1695
|
+
`chown -R ${username}:${username} ${homeDir}`,
|
|
1696
|
+
]
|
|
1697
|
+
if (hasCredentials) l.push(...banner("SETUP: CREDENTIALS"))
|
|
1698
|
+
l.push(...credentialsBlock({ homeDir, username, userInfo, userSshPrivateKey, projectDeployKey }))
|
|
1699
|
+
const st = status(hasCredentials ? "credentials" : "initializing", allReposPending, pc0, ps0)
|
|
1700
|
+
phases.push({ id: "credentials", label: "Credentials & ownership", script: l.join("\n"), runsOnRestart: false, statusStart: st, statusEnd: st })
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// ── shell (bashrc/zsh/oh-my-zsh, land-in-workspace) ───────────────────────
|
|
1704
|
+
{
|
|
1705
|
+
const landInWorkspace = [
|
|
1706
|
+
``,
|
|
1707
|
+
`# ── Spunto: land in workspace on fresh login ─────────────────────`,
|
|
1708
|
+
`if [ "$PWD" = "$HOME" ] && [ -d /workspace ]; then`,
|
|
1709
|
+
` __mp_dirs=$(find /workspace -mindepth 1 -maxdepth 1 -type d -not -name '.*' 2>/dev/null)`,
|
|
1710
|
+
` __mp_n=$(printf '%s\\n' "$__mp_dirs" | grep -c .)`,
|
|
1711
|
+
` if [ "$__mp_n" = 1 ]; then cd "$__mp_dirs" 2>/dev/null; else cd /workspace 2>/dev/null; fi`,
|
|
1712
|
+
` unset __mp_dirs __mp_n`,
|
|
1713
|
+
`fi`,
|
|
1714
|
+
].join("\n")
|
|
1715
|
+
const bashrcSnippet = [
|
|
1716
|
+
``,
|
|
1717
|
+
`# ── Spunto shell config ──────────────────────────────────────────`,
|
|
1718
|
+
`__mp_ps1_git() {`,
|
|
1719
|
+
` local b`,
|
|
1720
|
+
` b=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null) || return`,
|
|
1721
|
+
` printf ' \\e[0;33m(%s)\\e[0m' "$b"`,
|
|
1722
|
+
`}`,
|
|
1723
|
+
``,
|
|
1724
|
+
`PS1='\\n\\[\\e[0;2m\\]\\u@\\h\\[\\e[0m\\] \\[\\e[1;34m\\]\\w\\[\\e[0m\\]$(__mp_ps1_git)\\n\\[\\e[1;32m\\]❯\\[\\e[0m\\] '`,
|
|
1725
|
+
``,
|
|
1726
|
+
`alias ll='ls -lah --color=auto'`,
|
|
1727
|
+
`alias la='ls -A --color=auto'`,
|
|
1728
|
+
`alias l='ls -CF --color=auto'`,
|
|
1729
|
+
`alias gs='git status'`,
|
|
1730
|
+
`alias gd='git diff'`,
|
|
1731
|
+
`alias gl='git log --oneline --graph --decorate -20'`,
|
|
1732
|
+
landInWorkspace,
|
|
1733
|
+
].join("\n")
|
|
1734
|
+
const bashrcEncoded = b64(bashrcSnippet)
|
|
1735
|
+
const zshrcAliasSnippet = [
|
|
1736
|
+
``,
|
|
1737
|
+
`# ── Spunto config ────────────────────────────────────────────────`,
|
|
1738
|
+
`setopt NO_BANG_HIST`,
|
|
1739
|
+
`alias ll='ls -lah --color=auto'`,
|
|
1740
|
+
`alias la='ls -A --color=auto'`,
|
|
1741
|
+
`alias l='ls -CF --color=auto'`,
|
|
1742
|
+
`alias gs='git status'`,
|
|
1743
|
+
`alias gd='git diff'`,
|
|
1744
|
+
`alias gl='git log --oneline --graph --decorate -20'`,
|
|
1745
|
+
landInWorkspace,
|
|
1746
|
+
].join("\n")
|
|
1747
|
+
const zshrcAliasEncoded = b64(zshrcAliasSnippet)
|
|
1748
|
+
const l: string[] = ["set -e", ...banner("SETUP: SHELL"),
|
|
1749
|
+
`echo ${JSON.stringify(bashrcEncoded)} | base64 -d >> ${homeDir}/.bashrc`,
|
|
1750
|
+
`if command -v zsh >/dev/null 2>&1; then`,
|
|
1751
|
+
` if [ ! -d "${homeDir}/.oh-my-zsh" ]; then`,
|
|
1752
|
+
` echo "Installing oh-my-zsh..."`,
|
|
1753
|
+
` _OMZ_TMP=$(mktemp)`,
|
|
1754
|
+
` curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh -o "$_OMZ_TMP" 2>&1`,
|
|
1755
|
+
` chmod 644 "$_OMZ_TMP"`,
|
|
1756
|
+
` su ${username} -c "HOME=${homeDir} RUNZSH=no CHSH=no KEEP_ZSHRC=no bash $_OMZ_TMP" 2>&1 || true`,
|
|
1757
|
+
` rm -f "$_OMZ_TMP"`,
|
|
1758
|
+
` else`,
|
|
1759
|
+
` echo "oh-my-zsh already installed"`,
|
|
1760
|
+
` fi`,
|
|
1761
|
+
` sed -i 's/ZSH_THEME="robbyrussell"/ZSH_THEME="af-magic"/' ${homeDir}/.zshrc 2>/dev/null || true`,
|
|
1762
|
+
` sed -i 's/^plugins=(git)$/plugins=(git z)/' ${homeDir}/.zshrc 2>/dev/null || true`,
|
|
1763
|
+
` echo ${JSON.stringify(zshrcAliasEncoded)} | base64 -d >> ${homeDir}/.zshrc`,
|
|
1764
|
+
`fi`,
|
|
1765
|
+
`echo ${JSON.stringify(b64(landInWorkspace))} | base64 -d >> ${homeDir}/.profile`,
|
|
1766
|
+
// Same reason as in credentialsBlock: this phase runs as root, so any rc file it had to
|
|
1767
|
+
// *create* (.bashrc/.zshrc/.profile absent from the image) is root-owned. The legacy script's
|
|
1768
|
+
// final `chown -R` covered that; here each phase leaves the home consistent on its own.
|
|
1769
|
+
`chown -R ${username}:${username} ${homeDir}`,
|
|
1770
|
+
]
|
|
1771
|
+
const st = status(hasCredentials ? "credentials" : "initializing", allReposPending, pc0, ps0)
|
|
1772
|
+
phases.push({ id: "shell", label: "Shell setup", script: l.join("\n"), runsOnRestart: false, statusStart: st, statusEnd: st })
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// ── dotfiles ──────────────────────────────────────────────────────────────
|
|
1776
|
+
if (dotfilesRepo) {
|
|
1777
|
+
const dotfilesUrl = dotfilesRepo.startsWith("http") || dotfilesRepo.startsWith("git@")
|
|
1778
|
+
? dotfilesRepo
|
|
1779
|
+
: `https://github.com/${dotfilesRepo}`
|
|
1780
|
+
const l: string[] = ["set -e", ...banner("SETUP: DOTFILES"),
|
|
1781
|
+
`echo "Cloning dotfiles from ${dotfilesUrl}..."`,
|
|
1782
|
+
`set +e`,
|
|
1783
|
+
`git clone ${JSON.stringify(dotfilesUrl)} ${homeDir}/dotfiles 2>&1`,
|
|
1784
|
+
`_DOTS_EXIT=$?`,
|
|
1785
|
+
`set -e`,
|
|
1786
|
+
`if [ $_DOTS_EXIT -ne 0 ]; then`,
|
|
1787
|
+
` echo "Dotfiles clone failed (exit $_DOTS_EXIT) — continuing without dotfiles"`,
|
|
1788
|
+
`else`,
|
|
1789
|
+
` echo "Dotfiles cloned"`,
|
|
1790
|
+
// Cloned as root, but the install script below runs as the user (and so does whoever edits
|
|
1791
|
+
// these files later) — hand the clone over before touching it.
|
|
1792
|
+
` chown -R ${username}:${username} ${homeDir}/dotfiles`,
|
|
1793
|
+
` _INSTALL_SCRIPT=""`,
|
|
1794
|
+
` for _candidate in install.sh bootstrap.sh setup.sh script/setup; do`,
|
|
1795
|
+
` if [ -f "${homeDir}/dotfiles/$_candidate" ]; then`,
|
|
1796
|
+
` _INSTALL_SCRIPT="$_candidate"`,
|
|
1797
|
+
` break`,
|
|
1798
|
+
` fi`,
|
|
1799
|
+
` done`,
|
|
1800
|
+
` if [ -n "$_INSTALL_SCRIPT" ]; then`,
|
|
1801
|
+
` echo "Running dotfiles install script: $_INSTALL_SCRIPT"`,
|
|
1802
|
+
` chmod +x "${homeDir}/dotfiles/$_INSTALL_SCRIPT"`,
|
|
1803
|
+
` set +e`,
|
|
1804
|
+
` su ${username} -c "cd ${homeDir}/dotfiles && ${envPrefix(userEnvSecrets)}./$_INSTALL_SCRIPT"`,
|
|
1805
|
+
` _DOTS_INST_EXIT=$?`,
|
|
1806
|
+
` set -e`,
|
|
1807
|
+
` if [ $_DOTS_INST_EXIT -ne 0 ]; then`,
|
|
1808
|
+
` echo "Dotfiles install script exited with $_DOTS_INST_EXIT — continuing"`,
|
|
1809
|
+
` else`,
|
|
1810
|
+
` echo "Dotfiles installed"`,
|
|
1811
|
+
` fi`,
|
|
1812
|
+
` else`,
|
|
1813
|
+
` echo "No install script found — symlinking dotfiles..."`,
|
|
1814
|
+
` for _f in "${homeDir}/dotfiles"/.*; do`,
|
|
1815
|
+
` _name=$(basename "$_f")`,
|
|
1816
|
+
` case "$_name" in .|..|.git|.gitignore|.gitmodules) continue ;; esac`,
|
|
1817
|
+
` ln -sf "$_f" "${homeDir}/$_name" && echo " linked $_name"`,
|
|
1818
|
+
` done`,
|
|
1819
|
+
` echo "Dotfiles symlinked"`,
|
|
1820
|
+
` fi`,
|
|
1821
|
+
`fi`,
|
|
1822
|
+
]
|
|
1823
|
+
const st = status("dotfiles", allReposPending, pc0, ps0)
|
|
1824
|
+
phases.push({ id: "dotfiles", label: "Dotfiles", script: l.join("\n"), runsOnRestart: false, statusStart: st, statusEnd: st })
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// ── clone repos ────────────────────────────────────────────────────────────
|
|
1828
|
+
if (project.repositories.length > 0) {
|
|
1829
|
+
const l: string[] = ["set -e", ...banner(`SETUP: CLONE REPOSITORIES (${project.repositories.length})`)]
|
|
1830
|
+
project.repositories.forEach((r, i) => {
|
|
1831
|
+
const { header, lines: cloneLines } = cloneRepoBlock({
|
|
1832
|
+
repo: r,
|
|
1833
|
+
index: i,
|
|
1834
|
+
total: project.repositories.length,
|
|
1835
|
+
homeDir,
|
|
1836
|
+
branch: resolveRepoBranch(r, branch),
|
|
1837
|
+
githubInstallationTokens,
|
|
1838
|
+
userSshPrivateKey,
|
|
1839
|
+
})
|
|
1840
|
+
l.push("", header, ...cloneLines)
|
|
1841
|
+
})
|
|
1842
|
+
// Re-own after cloning (repos cloned as root; postCreate runs as vscode).
|
|
1843
|
+
l.push("", `chown -R ${username}:${username} /workspace`)
|
|
1844
|
+
phases.push({
|
|
1845
|
+
id: "clone",
|
|
1846
|
+
label: "Clone repositories",
|
|
1847
|
+
script: l.join("\n"),
|
|
1848
|
+
runsOnRestart: false,
|
|
1849
|
+
statusStart: status("cloning", allReposCloning, pc0, ps0),
|
|
1850
|
+
statusEnd: status("cloning", allReposDone, pc0, ps0),
|
|
1851
|
+
})
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
// ── postCreate ──────────────────────────────────────────────────────────────
|
|
1855
|
+
if (project.postCreateCommand) {
|
|
1856
|
+
const hasDinDFeature = (project.features?.some((f) => f.id === "docker-in-docker") ?? false) || !!project.dind
|
|
1857
|
+
const postCreateDir = project.repositories.length === 1
|
|
1858
|
+
? `/workspace/${project.repositories[0].workspacePath}`
|
|
1859
|
+
: "/workspace"
|
|
1860
|
+
const l: string[] = ["set -e"]
|
|
1861
|
+
// Feature entrypoints are started by the passive CMD; just wait for the Docker daemon here.
|
|
1862
|
+
if (hasDinDFeature) {
|
|
1863
|
+
l.push(
|
|
1864
|
+
'echo "Waiting for Docker daemon..."',
|
|
1865
|
+
"for _i in $(seq 1 30); do",
|
|
1866
|
+
" docker info >/dev/null 2>&1 && break",
|
|
1867
|
+
" sleep 1",
|
|
1868
|
+
"done",
|
|
1869
|
+
'docker info >/dev/null 2>&1 && echo "Docker daemon ready" || echo "Docker daemon not ready after 30s, continuing anyway"',
|
|
1870
|
+
)
|
|
1871
|
+
}
|
|
1872
|
+
l.push(...banner("SETUP: POST CREATE"))
|
|
1873
|
+
const cmdEncoded = b64(project.postCreateCommand)
|
|
1874
|
+
l.push(
|
|
1875
|
+
`if [ ! -f "${homeDir}/.mp-post-create-done" ]; then`,
|
|
1876
|
+
` echo "--- Running postCreateCommand ---"`,
|
|
1877
|
+
` echo ${JSON.stringify(cmdEncoded)} | base64 -d > /tmp/mp_postcreate.sh`,
|
|
1878
|
+
` chmod +x /tmp/mp_postcreate.sh`,
|
|
1879
|
+
` su ${username} -c "cd ${postCreateDir} && bash /tmp/mp_postcreate.sh" 2>&1`,
|
|
1880
|
+
` rm -f /tmp/mp_postcreate.sh`,
|
|
1881
|
+
` touch "${homeDir}/.mp-post-create-done"`,
|
|
1882
|
+
` echo "postCreateCommand done"`,
|
|
1883
|
+
`fi`,
|
|
1884
|
+
)
|
|
1885
|
+
phases.push({
|
|
1886
|
+
id: "postCreate",
|
|
1887
|
+
label: "postCreateCommand",
|
|
1888
|
+
script: l.join("\n"),
|
|
1889
|
+
runsOnRestart: false,
|
|
1890
|
+
statusStart: status("lifecycle", allReposDone, "running", ps0),
|
|
1891
|
+
statusEnd: status("lifecycle", allReposDone, "done", ps0),
|
|
1892
|
+
})
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
// ── postStart (runs on every start, restart included) ─────────────────────
|
|
1896
|
+
if (project.postStartCommand) {
|
|
1897
|
+
const postStartDir = project.repositories.length === 1
|
|
1898
|
+
? `/workspace/${project.repositories[0].workspacePath}`
|
|
1899
|
+
: "/workspace"
|
|
1900
|
+
const l: string[] = ["set -e",
|
|
1901
|
+
// Same defensive Docker wait as legacy: some images ship Docker under a different name.
|
|
1902
|
+
"if [ -f " + FEATURE_ENTRYPOINTS_FILE + " ]; then",
|
|
1903
|
+
" for _i in 1 2 3 4 5; do [ -S /var/run/docker.sock ] && break || sleep 1; done",
|
|
1904
|
+
" if [ -S /var/run/docker.sock ]; then",
|
|
1905
|
+
' echo "Waiting for Docker daemon..."',
|
|
1906
|
+
" for _i in $(seq 1 30); do docker info >/dev/null 2>&1 && break; sleep 1; done",
|
|
1907
|
+
' docker info >/dev/null 2>&1 && echo "Docker daemon ready" || echo "Docker daemon not ready, continuing"',
|
|
1908
|
+
" fi",
|
|
1909
|
+
"fi",
|
|
1910
|
+
...banner("START: POST START"),
|
|
1911
|
+
]
|
|
1912
|
+
const cmdEncoded = b64(project.postStartCommand)
|
|
1913
|
+
l.push(
|
|
1914
|
+
`echo "--- Running postStartCommand ---"`,
|
|
1915
|
+
`echo ${JSON.stringify(cmdEncoded)} | base64 -d > /tmp/mp_poststart.sh`,
|
|
1916
|
+
`chmod +x /tmp/mp_poststart.sh`,
|
|
1917
|
+
`su ${username} -c "cd ${postStartDir} && bash /tmp/mp_poststart.sh" 2>&1`,
|
|
1918
|
+
`rm -f /tmp/mp_poststart.sh`,
|
|
1919
|
+
`echo "postStartCommand done"`,
|
|
1920
|
+
)
|
|
1921
|
+
phases.push({
|
|
1922
|
+
id: "postStart",
|
|
1923
|
+
label: "postStartCommand",
|
|
1924
|
+
script: l.join("\n"),
|
|
1925
|
+
runsOnRestart: true,
|
|
1926
|
+
statusStart: status("lifecycle", allReposDone, pcDone, "running"),
|
|
1927
|
+
statusEnd: status("lifecycle", allReposDone, pcDone, "done"),
|
|
1928
|
+
})
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
return { phases, initialStatus, readyStatus }
|
|
1932
|
+
}
|