openrod 0.1.1 → 0.1.2-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -9
- package/dist/assets/index-89TNohi_.css +1 -0
- package/dist/assets/index-DoKK2G9s.js +545 -0
- package/dist/assets/{terminal-view-Bqw3jQjI.js → terminal-view-DzqQnmh2.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/logos/SOURCES.md +0 -14
- package/package.json +1 -1
- package/server/api.js +57 -10
- package/server/cli.js +32 -11
- package/server/gateway-docker.js +499 -0
- package/server/gateway.js +8 -0
- package/server/image-templates.js +36 -12
- package/server/launch-token.js +23 -3
- package/server/openshell-cli.js +1 -1
- package/server/openshell-install.js +189 -0
- package/server/policy.js +3 -5
- package/server/remote-gateway.js +11 -4
- package/shared/openshell-release.js +13 -0
- package/src/lib/api.js +4 -1
- package/src/lib/gateway-docker-store.js +5 -0
- package/src/lib/gateway-docker.js +77 -0
- package/src/lib/sandbox-creations.js +3 -2
- package/src/lib/services.js +1 -12
- package/dist/assets/index-DbxTgFt9.css +0 -1
- package/dist/assets/index-Ddo8D1tE.js +0 -545
- package/dist/logos/anthropic.svg +0 -1
- package/dist/logos/aws-bedrock.svg +0 -1
- package/dist/logos/aws-s3.svg +0 -1
- package/dist/logos/aws.svg +0 -1
- package/dist/logos/deepinfra.svg +0 -1
- package/dist/logos/google-cloud.svg +0 -1
- package/dist/logos/google-vertex-ai.svg +0 -1
- package/dist/logos/nvidia.svg +0 -1
- package/dist/logos/pypi.svg +0 -1
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
|
|
7
|
+
// OpenShell's VM driver looks for local sandbox images only at DOCKER_HOST, or
|
|
8
|
+
// at /var/run/docker.sock when it is unset (NVIDIA/OpenShell#4155). Docker
|
|
9
|
+
// Desktop leaves that socket off by default, so the driver can't see the images
|
|
10
|
+
// OpenRod builds. This finds the driver, compares its engine with the build
|
|
11
|
+
// engine and, for the Homebrew service only, writes DOCKER_HOST to the
|
|
12
|
+
// gateway.env the service wrapper sources, then restarts the service.
|
|
13
|
+
// Everything is injected: nothing here imports the project, so tests never
|
|
14
|
+
// read the real OpenShell config.
|
|
15
|
+
|
|
16
|
+
export const FIXED_IN = null // first OpenShell release with NVIDIA/OpenShell#4155 fixed
|
|
17
|
+
export const LABEL = 'sh.brew.openshell'
|
|
18
|
+
export const MARK = "# openrod-managed: lets OpenShell's VM driver use your Docker images. Delete this line and the next to undo."
|
|
19
|
+
export const LIVE = ['ready', 'provisioning', 'starting', 'stopping', 'unknown', 'unspecified']
|
|
20
|
+
export const isVm = (drivers = []) => drivers.some((d) => d.name === 'vm' || d.capabilities?.driverName === 'openshell-driver-vm')
|
|
21
|
+
|
|
22
|
+
const fail = (message, status = 400) => Object.assign(new Error(message), { status })
|
|
23
|
+
const shQuote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`
|
|
24
|
+
const ASSIGN = /^\s*(?:export\s+)?DOCKER_HOST=(.*)$/
|
|
25
|
+
const isMark = (line) => line.startsWith('# openrod-managed')
|
|
26
|
+
const unquote = (v) => (v = v.trim(), /^'[^']*'$|^"[^"]*"$/.test(v) ? v.slice(1, -1) : v)
|
|
27
|
+
const bare = (line) => line?.replace(/\r$/, '') ?? ''
|
|
28
|
+
const argv0 = (command) => path.basename(command.trim().split(/\s+/)[0] ?? '')
|
|
29
|
+
const WRAPPER = ['xdg_gateway_env="${xdg_config_home}/openshell/gateway.env"', 'if [ -f "${xdg_gateway_env}" ]; then', 'elif [ -f "${prefix_gateway_env}" ]; then']
|
|
30
|
+
|
|
31
|
+
export function parseProcesses(listing) {
|
|
32
|
+
return String(listing ?? '').split('\n').flatMap((line) => {
|
|
33
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line)
|
|
34
|
+
return m ? [{ pid: Number(m[1]), ppid: Number(m[2]), command: m[3] }] : []
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The driver serves the gateway on --bind-socket; its --internal-run-vm
|
|
39
|
+
// children are per-sandbox launchers and never match.
|
|
40
|
+
export function findDriver(processes, { listener = null } = {}) {
|
|
41
|
+
const gateways = new Map(processes.filter((p) => argv0(p.command) === 'openshell-gateway').map((p) => [p.pid, p]))
|
|
42
|
+
const drivers = processes.filter((p) => argv0(p.command) === 'openshell-driver-vm' && /\s--bind-socket(?:[\s=]|$)/.test(p.command) && gateways.has(p.ppid))
|
|
43
|
+
const mine = listener ? drivers.filter((d) => d.ppid === listener) : drivers
|
|
44
|
+
if (mine.length === 1) return { gateway: gateways.get(mine[0].ppid), driver: mine[0] }
|
|
45
|
+
return drivers.length > 1 ? { ambiguous: true } : null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// `ps eww` prints the command then KEY=value pairs with no quoting; a value
|
|
49
|
+
// runs until the next ` KEY=` so values with spaces survive.
|
|
50
|
+
export function envValue(output, key) {
|
|
51
|
+
let value
|
|
52
|
+
for (const m of String(output ?? '').matchAll(new RegExp(`(?:^|\\s)${key}=(.*?)(?=\\s[A-Za-z_][A-Za-z0-9_]*=|\\s*$)`, 'g'))) value = m[1]
|
|
53
|
+
return value
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function procEnviron(buffer) {
|
|
57
|
+
const env = {}
|
|
58
|
+
for (const entry of String(buffer ?? '').split('\0')) {
|
|
59
|
+
const i = entry.indexOf('=')
|
|
60
|
+
if (i > 0) env[entry.slice(0, i)] = entry.slice(i + 1)
|
|
61
|
+
}
|
|
62
|
+
return env
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// bollard 0.20.2: only a unix:// DOCKER_HOST is used for a local socket.
|
|
66
|
+
export const driverSocket = (h) => h?.startsWith('unix://') ? h.slice(7) : '/var/run/docker.sock'
|
|
67
|
+
export const brewPrefix = (command) => /^(.+)\/opt\/openshell\/bin\/openshell-gateway(\s|$)/.exec(command ?? '')?.[1] ?? null
|
|
68
|
+
|
|
69
|
+
export function wrapperPrefixFile(script) {
|
|
70
|
+
const lines = new Set(String(script ?? '').split('\n').map((l) => l.trim()))
|
|
71
|
+
if (!WRAPPER.every((l) => lines.has(l))) return null
|
|
72
|
+
return /^prefix_gateway_env="([^"$`\\]+)"$/m.exec(script)?.[1] ?? null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const launchdPid = (out) => Number(/^\s*pid = (\d+)$/m.exec(out)?.[1]) || null
|
|
76
|
+
export const listensOn = (lsof, port) => lsof.split('\n').some((l) => l.startsWith('n') && l.endsWith(`:${port}`))
|
|
77
|
+
|
|
78
|
+
// Same order as the wrapper. Creating the XDG file when only the prefix file
|
|
79
|
+
// exists would hide the prefix file's settings.
|
|
80
|
+
export const chooseEnvFile = ({ xdgFile, prefixFile, xdgIsFile, prefixIsFile }) => xdgIsFile || !prefixFile || !prefixIsFile ? xdgFile : prefixFile
|
|
81
|
+
|
|
82
|
+
export function readManaged(text) {
|
|
83
|
+
const lines = String(text ?? '').split('\n').map(bare), foreign = []
|
|
84
|
+
let managed = null, effective = null
|
|
85
|
+
for (let i = 0; i < lines.length; i++) {
|
|
86
|
+
const line = lines[i], next = ASSIGN.exec(lines[i + 1] ?? '')
|
|
87
|
+
if (isMark(line) && next) { managed = effective = unquote(next[1]); i++; continue }
|
|
88
|
+
if (line.trimStart().startsWith('#') || !/\bDOCKER_HOST\b/.test(line)) continue
|
|
89
|
+
foreign.push(line)
|
|
90
|
+
const m = ASSIGN.exec(line)
|
|
91
|
+
if (m) effective = unquote(m[1])
|
|
92
|
+
else if (/^\s*unset\b/.test(line)) effective = null
|
|
93
|
+
}
|
|
94
|
+
return { managed, foreign, effective }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The block always goes last: in sh the last assignment wins, so it overrides a
|
|
98
|
+
// foreign line while leaving that line byte-for-byte in place.
|
|
99
|
+
export function writeManaged(text, value) {
|
|
100
|
+
const lines = String(text ?? '').split('\n'), kept = []
|
|
101
|
+
for (let i = 0; i < lines.length; i++) {
|
|
102
|
+
if (!isMark(lines[i])) kept.push(lines[i])
|
|
103
|
+
else if (ASSIGN.test(bare(lines[i + 1]))) i++
|
|
104
|
+
}
|
|
105
|
+
const rest = kept.join('\n')
|
|
106
|
+
if (value == null) return rest
|
|
107
|
+
return `${rest}${rest && !rest.endsWith('\n') ? '\n' : ''}${MARK}\nDOCKER_HOST=${quote(value)}\n`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function quote(value) {
|
|
111
|
+
if (typeof value !== 'string' || !value || /['\0\r\n]/.test(value)) throw fail('The Docker socket path has characters OpenRod won’t write.', 409)
|
|
112
|
+
return /^[A-Za-z0-9_/.:@%+,=-]+$/.test(value) ? value : `'${value}'`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const semver = (v) => /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v ?? '').trim())?.slice(1).map(Number)
|
|
116
|
+
export const newer = (a, b) => {
|
|
117
|
+
const x = semver(a), y = semver(b)
|
|
118
|
+
if (!x || !y) return false
|
|
119
|
+
const i = x.findIndex((n, k) => n !== y[k])
|
|
120
|
+
return i >= 0 && x[i] > y[i]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function decideFix(f) {
|
|
124
|
+
const manual = (blocked) => ({ fix: 'manual', blocked })
|
|
125
|
+
if (f.platform !== 'darwin') return manual('Add one setting to OpenShell and restart it.')
|
|
126
|
+
if (!f.service) return manual('OpenShell isn’t running as a Homebrew service.')
|
|
127
|
+
if (!f.plistOk || !f.prefixOk || !f.brew) return manual('This OpenShell setup is different from what OpenRod expects.')
|
|
128
|
+
if (!f.fileOk) return manual(`OpenRod can’t write ${f.file}.`)
|
|
129
|
+
if (!f.quotable) return manual('The Docker socket path has characters OpenRod won’t write.')
|
|
130
|
+
if (f.conflict || !f.sandboxes || f.sandboxes.length || f.stranded?.length || f.declined || f.failedBefore || f.busy) return { fix: 'confirm', blocked: null }
|
|
131
|
+
return { fix: 'auto', blocked: null }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const changed = () => fail('OpenShell’s settings file changed while OpenRod was editing it. Try again.', 409)
|
|
135
|
+
const readOrEmpty = (file) => fs.readFile(file, 'utf8').catch((e) => { if (e.code === 'ENOENT') return ''; throw e })
|
|
136
|
+
const realOrSelf = (file) => fs.realpath(file).catch((e) => { if (e.code === 'ENOENT') return file; throw e })
|
|
137
|
+
const dangling = (file) => fs.lstat(file).then((s) => s.isSymbolicLink(), () => false)
|
|
138
|
+
|
|
139
|
+
// Follows a symlink to the real file, keeps its mode, and only replaces it when
|
|
140
|
+
// it still holds what OpenRod read. A missing file reads as ''.
|
|
141
|
+
export async function writeEnvFile(file, next, expected, validate = null) {
|
|
142
|
+
const real = await realOrSelf(file)
|
|
143
|
+
// Renaming over a link OpenRod can't follow would replace it with a plain file.
|
|
144
|
+
if (real === file && await dangling(file)) throw fail(`OpenRod can’t write ${file}.`, 409)
|
|
145
|
+
let mode = 0o600, exists = true
|
|
146
|
+
try {
|
|
147
|
+
const st = await fs.stat(real)
|
|
148
|
+
if (!st.isFile()) throw fail(`OpenRod can’t write ${real}.`, 409)
|
|
149
|
+
mode = st.mode & 0o777
|
|
150
|
+
} catch (e) {
|
|
151
|
+
if (e.code !== 'ENOENT') throw e
|
|
152
|
+
exists = false
|
|
153
|
+
await fs.mkdir(path.dirname(real), { recursive: true, mode: 0o700 })
|
|
154
|
+
}
|
|
155
|
+
if (await readOrEmpty(real) !== expected) throw changed()
|
|
156
|
+
if (exists) try { await fs.copyFile(real, `${real}.openrod.bak`, constants.COPYFILE_EXCL); await fs.chmod(`${real}.openrod.bak`, mode) } catch (e) { if (e.code !== 'EEXIST') throw e }
|
|
157
|
+
const temp = `${real}.openrod-${randomUUID()}.tmp`
|
|
158
|
+
try {
|
|
159
|
+
await fs.writeFile(temp, next, { mode, flag: 'wx' })
|
|
160
|
+
await fs.chmod(temp, mode)
|
|
161
|
+
if (validate) await validate(temp)
|
|
162
|
+
if (await readOrEmpty(real) !== expected) throw changed()
|
|
163
|
+
await fs.rename(temp, real)
|
|
164
|
+
} finally { await fs.rm(temp, { force: true }) }
|
|
165
|
+
return real
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Sources the file the way the wrapper does (set -eu; set -a) with launchd's
|
|
169
|
+
// bare environment, so a line that would crash-loop the service never lands.
|
|
170
|
+
export async function validateEnvFile(run, file, { home, expect } = {}) {
|
|
171
|
+
const r = await run('/bin/sh', ['-c', 'set -eu; set -a; . "$1"; set +a; printf %s "${DOCKER_HOST-}"', 'openrod-check', file], { env: { HOME: home, PATH: '/usr/bin:/bin:/usr/sbin:/sbin' }, timeoutMs: 5000 })
|
|
172
|
+
if (r.code !== 0 || (expect !== undefined && r.stdout !== expect)) throw Object.assign(fail('OpenShell’s settings file has a line OpenRod can’t check. Use the steps instead.', 409), { unsafe: true })
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function removeEnvFile(file, expected) {
|
|
176
|
+
const real = await realOrSelf(file)
|
|
177
|
+
if (await readOrEmpty(real) !== expected) throw changed()
|
|
178
|
+
await fs.rm(real, { force: true })
|
|
179
|
+
return real
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function writable(file) {
|
|
183
|
+
let real = file
|
|
184
|
+
try { real = await fs.realpath(file); if (!(await fs.stat(real)).isFile()) return false } catch (e) { if (e.code !== 'ENOENT' || await dangling(file)) return false }
|
|
185
|
+
for (let dir = path.dirname(real); ; dir = path.dirname(dir)) {
|
|
186
|
+
try { await fs.access(dir, constants.W_OK); return true } catch (e) { if (e.code !== 'ENOENT' || dir === path.dirname(dir)) return false }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const portOf = (endpoint) => { try { const u = new URL(endpoint); return u.port || (u.protocol === 'https:' ? '443' : '80') } catch { return null } }
|
|
191
|
+
const mismatch = (message, s, fix = s.fix) => Object.assign(fail(message, 409), { code: 'GATEWAY_DOCKER_MISMATCH', fix, sandboxes: s.sandboxes ?? null })
|
|
192
|
+
|
|
193
|
+
export function createGatewayDocker({
|
|
194
|
+
platform = process.platform, home = os.userInfo().homedir, uid = process.getuid?.() ?? null,
|
|
195
|
+
target, client, forget = () => {}, sandboxes, localEngine, docker, run, busy = () => null,
|
|
196
|
+
stateFile, onJob = () => {}, now = Date.now, wait = (ms) => new Promise((r) => setTimeout(r, ms)), logger = console,
|
|
197
|
+
readProc = (pid, name) => fs.readFile(`/proc/${pid}/${name}`),
|
|
198
|
+
} = {}) {
|
|
199
|
+
const exec = (file, args, options) => run(file, args, { timeoutMs: 5000, ...options }).catch((e) => ({ code: null, stdout: '', stderr: e.message }))
|
|
200
|
+
const iso = () => new Date(now()).toISOString()
|
|
201
|
+
const tilde = (p) => p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p
|
|
202
|
+
const isFile = (p) => p ? fs.stat(p).then((s) => s.isFile(), () => false) : false
|
|
203
|
+
const keyOf = (build) => build.engineId ?? build.endpoint
|
|
204
|
+
const failed = new Set(), unsafe = new Set()
|
|
205
|
+
let state = null, loading = null, cache = null, probing = null, job = null, working = null
|
|
206
|
+
|
|
207
|
+
const load = () => loading ??= fs.readFile(stateFile, 'utf8').then(JSON.parse).catch(() => ({})).then((v) => {
|
|
208
|
+
v = v && typeof v === 'object' ? v : {}
|
|
209
|
+
state = { undo: v.undo ?? null, declined: Array.isArray(v.declined) ? v.declined : [], lastChange: v.lastChange ?? null }
|
|
210
|
+
})
|
|
211
|
+
async function save() {
|
|
212
|
+
if (!stateFile) return
|
|
213
|
+
try {
|
|
214
|
+
await fs.mkdir(path.dirname(stateFile), { recursive: true, mode: 0o700 })
|
|
215
|
+
const temp = `${stateFile}.${randomUUID()}.tmp`
|
|
216
|
+
try { await fs.writeFile(temp, `${JSON.stringify({ version: 1, ...state }, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); await fs.rename(temp, stateFile) } finally { await fs.rm(temp, { force: true }) }
|
|
217
|
+
} catch (e) { logger.warn?.(`OpenRod couldn’t save the Docker connection state: ${e.message}`) }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const blank = (patch = {}) => ({ state: 'n/a', reason: null, gateway: null, build: null, driver: null, pending: false, conflict: null, sandboxes: null, stranded: [], fix: null, blocked: null, steps: null, alternative: null, ...patch, checkedAt: iso() })
|
|
221
|
+
function view(value) {
|
|
222
|
+
const change = state?.lastChange
|
|
223
|
+
return {
|
|
224
|
+
...value, ...(job?.status === 'working' ? { state: 'working' } : {}), job: job && { ...job },
|
|
225
|
+
lastChange: change ? { ...change, undoable: Boolean(state.undo) && change.kind === 'connect' } : null,
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function engineAt(socket) {
|
|
230
|
+
try {
|
|
231
|
+
await fs.stat(socket)
|
|
232
|
+
return JSON.parse(await docker(['info', '--format', '{{json .}}'], { engine: { endpoint: `unix://${socket}` }, timeout: 10_000 }))?.ID || null
|
|
233
|
+
} catch { return null }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// full: also work out the fix when the engines match (undo needs it).
|
|
237
|
+
async function probe(full) {
|
|
238
|
+
const out = blank(), done = (patch, facts = null) => ({ value: { ...out, ...patch }, facts })
|
|
239
|
+
if (platform !== 'darwin' && platform !== 'linux') return done({})
|
|
240
|
+
const t = await target()
|
|
241
|
+
if (!t || t.remote) return done({})
|
|
242
|
+
out.gateway = t.name
|
|
243
|
+
let info, health
|
|
244
|
+
try { const c = await client(t.name); [info, health] = await Promise.all([c.raw.getGatewayInfo({}), c.health()]) } catch { return done({ state: 'unknown', reason: 'OpenShell isn’t reachable.' }) }
|
|
245
|
+
if (!isVm(info?.computeDrivers) || (FIXED_IN && !newer(FIXED_IN, health?.version))) return done({})
|
|
246
|
+
let build
|
|
247
|
+
try { const e = await localEngine(); build = { endpoint: e.endpoint, engineId: e.engineId || null, desktop: Boolean(e.desktop) } } catch (e) { return done({ state: 'no-docker', reason: e.message }) }
|
|
248
|
+
out.build = build
|
|
249
|
+
const port = portOf(t.endpoint)
|
|
250
|
+
|
|
251
|
+
const processes = parseProcesses((await exec('ps', ['-ww', '-x', '-o', 'pid=,ppid=,command='], { outputLimit: 4 << 20 })).stdout)
|
|
252
|
+
let found = findDriver(processes)
|
|
253
|
+
if (found?.ambiguous && port) {
|
|
254
|
+
const lsof = await exec('lsof', ['-nP', '-a', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fp'])
|
|
255
|
+
found = findDriver(processes, { listener: Number(/^p(\d+)$/m.exec(lsof.stdout)?.[1]) || null })
|
|
256
|
+
}
|
|
257
|
+
if (!found || found.ambiguous) {
|
|
258
|
+
const reason = 'OpenRod can’t find the OpenShell driver on this computer.'
|
|
259
|
+
return done({ state: 'unknown', reason, fix: 'manual', blocked: reason })
|
|
260
|
+
}
|
|
261
|
+
const { gateway, driver } = found
|
|
262
|
+
|
|
263
|
+
// Only these keys are kept from the driver's environment.
|
|
264
|
+
let env = null
|
|
265
|
+
if (platform === 'darwin') {
|
|
266
|
+
const r = await exec('ps', ['eww', '-o', 'command=', '-p', String(driver.pid)], { outputLimit: 1 << 20 })
|
|
267
|
+
const text = r.code === 0 ? r.stdout : ''
|
|
268
|
+
env = { DOCKER_HOST: envValue(text, 'DOCKER_HOST'), XDG_CONFIG_HOME: envValue(text, 'XDG_CONFIG_HOME'), HOME: envValue(text, 'HOME'), PATH: envValue(text, 'PATH') !== undefined }
|
|
269
|
+
} else {
|
|
270
|
+
try { const e = procEnviron(await readProc(driver.pid, 'environ')); env = { DOCKER_HOST: e.DOCKER_HOST, XDG_CONFIG_HOME: e.XDG_CONFIG_HOME, HOME: e.HOME, PATH: 'PATH' in e } } catch { /* unreadable */ }
|
|
271
|
+
}
|
|
272
|
+
if (env && env.HOME === undefined && !env.PATH) env = null
|
|
273
|
+
|
|
274
|
+
const prefix = brewPrefix(gateway.command)
|
|
275
|
+
const xdgFile = path.join(env?.XDG_CONFIG_HOME || path.join(env?.HOME || home, '.config'), 'openshell', 'gateway.env')
|
|
276
|
+
let prefixFile = null
|
|
277
|
+
if (platform === 'darwin' && prefix) prefixFile = wrapperPrefixFile(await fs.readFile(path.join(prefix, 'opt/openshell/libexec/openshell-gateway-homebrew-service'), 'utf8').catch(() => ''))
|
|
278
|
+
const prefixOk = Boolean(prefixFile)
|
|
279
|
+
if (platform === 'darwin') prefixFile ??= path.join(prefix ?? '/opt/homebrew', 'var/openshell/gateway.env')
|
|
280
|
+
const file = chooseEnvFile({ xdgFile, prefixFile, xdgIsFile: await isFile(xdgFile), prefixIsFile: await isFile(prefixFile) })
|
|
281
|
+
let text = '', existed = false, readable = true
|
|
282
|
+
try { text = await fs.readFile(file, 'utf8'); existed = true } catch (e) { readable = e.code === 'ENOENT' }
|
|
283
|
+
const m = readManaged(text)
|
|
284
|
+
|
|
285
|
+
const dockerHost = env ? env.DOCKER_HOST ?? null : m.effective
|
|
286
|
+
const socket = driverSocket(dockerHost)
|
|
287
|
+
const drv = { dockerHost, socket, source: env ? 'process' : m.effective != null ? 'file' : 'default', engineId: await engineAt(socket) }
|
|
288
|
+
const [a, b] = await Promise.all([socket, build.endpoint.slice(7)].map((p) => fs.realpath(p).catch(() => null)))
|
|
289
|
+
const same = Boolean((build.engineId && drv.engineId && build.engineId === drv.engineId) || (a && a === b))
|
|
290
|
+
const facts = { name: t.name, gateway, file, text, existed, build, env: Boolean(env), driverHost: dockerHost, managed: m.managed, pending: m.managed === build.endpoint && m.effective === build.endpoint, brew: null, decision: null }
|
|
291
|
+
if (same && !full) return done({ state: 'ok', driver: drv }, facts)
|
|
292
|
+
|
|
293
|
+
const conflict = m.foreign.length ? { dockerHost: m.effective, source: 'file' }
|
|
294
|
+
: env?.DOCKER_HOST && env.DOCKER_HOST !== m.managed && env.DOCKER_HOST !== build.endpoint ? { dockerHost: env.DOCKER_HOST, source: 'process' } : null
|
|
295
|
+
let live = null
|
|
296
|
+
try { live = (await sandboxes(t.name)).filter((s) => LIVE.includes(s.phase)) } catch { /* confirm instead */ }
|
|
297
|
+
const listed = live && live.map(({ name, workspace }) => ({ name, workspace }))
|
|
298
|
+
// A restart onto the build engine can't bring these back: their images live elsewhere.
|
|
299
|
+
const stranded = []
|
|
300
|
+
if (!same) for (const image of new Set((live ?? []).map((s) => s.image).filter((i) => i?.startsWith('openshell-template/')))) {
|
|
301
|
+
try { await docker(['image', 'inspect', '--format', '{{.Id}}', image], { engine: { endpoint: build.endpoint }, timeout: 10_000 }) }
|
|
302
|
+
catch { stranded.push(...live.filter((s) => s.image === image).map(({ name, workspace }) => ({ name, workspace, image }))) }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let service = false, plistOk = false
|
|
306
|
+
if (platform === 'darwin') {
|
|
307
|
+
const lc = await exec('/bin/launchctl', ['print', `gui/${uid}/${LABEL}`])
|
|
308
|
+
const svcPid = lc.code === 0 ? launchdPid(lc.stdout) : null
|
|
309
|
+
if (svcPid && svcPid === gateway.pid && port) service = listensOn((await exec('lsof', ['-nP', '-a', '-p', String(svcPid), '-iTCP', '-sTCP:LISTEN', '-Fn'])).stdout, port)
|
|
310
|
+
const plist = await exec('plutil', ['-extract', 'EnvironmentVariables', 'raw', '-o', '-', path.join(home, 'Library/LaunchAgents', `${LABEL}.plist`)])
|
|
311
|
+
plistOk = typeof plist.code === 'number' && plist.code !== 0
|
|
312
|
+
if (prefix) facts.brew = await fs.access(path.join(prefix, 'bin/brew'), constants.X_OK).then(() => path.join(prefix, 'bin/brew'), () => null)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let line, quotable = true
|
|
316
|
+
try { line = `DOCKER_HOST=${quote(build.endpoint)}` } catch { quotable = false; line = `DOCKER_HOST=${shQuote(build.endpoint)}` }
|
|
317
|
+
// brew services restart would load a second gateway when this one isn't the service,
|
|
318
|
+
// and rebuilds a hand-edited plist; kickstart restarts the loaded job as it is.
|
|
319
|
+
const restart = platform === 'darwin' ? (!service ? null : plistOk ? 'brew services restart openshell' : `launchctl kickstart -k gui/${uid}/${LABEL}`)
|
|
320
|
+
: String(await readProc(gateway.pid, 'cgroup').catch(() => '')).includes('openshell-gateway.service') ? 'systemctl --user restart openshell-gateway' : null
|
|
321
|
+
const steps = { file: tilde(file), line, append: `mkdir -p ${shQuote(path.dirname(file))} && printf '\\n%s\\n' ${shQuote(line)} >> ${shQuote(file)}`, restart }
|
|
322
|
+
const alternative = platform === 'darwin' && build.endpoint === `unix://${home}/.docker/run/docker.sock` ? 'docker-desktop-socket' : null
|
|
323
|
+
const decision = decideFix({
|
|
324
|
+
platform, service, plistOk, prefixOk, brew: facts.brew, fileOk: readable && !unsafe.has(file) && await writable(file), file: tilde(file), quotable,
|
|
325
|
+
conflict, sandboxes: listed, stranded, declined: state.declined.includes(keyOf(build)), failedBefore: failed.has(`${build.engineId}|${file}`), busy: Boolean(busy()),
|
|
326
|
+
})
|
|
327
|
+
facts.decision = { ...decision, sandboxes: listed }
|
|
328
|
+
if (same) return done({ state: 'ok', driver: drv }, facts)
|
|
329
|
+
return done({ state: 'mismatch', driver: drv, pending: facts.pending, conflict, sandboxes: listed, stranded, ...decision, steps, alternative }, facts)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const remember = (r) => { cache = { at: now(), ...r }; return r }
|
|
333
|
+
const safely = (full) => load().then(() => probe(full)).catch((e) => {
|
|
334
|
+
logger.warn?.(`OpenRod couldn’t check OpenShell’s Docker setting: ${e.message}`)
|
|
335
|
+
return { value: blank({ state: 'unknown', reason: 'OpenRod couldn’t check OpenShell’s Docker setting.' }), facts: null }
|
|
336
|
+
})
|
|
337
|
+
const refresh = ({ full = false } = {}) => full ? safely(true).then(remember) : probing ??= safely(false).then(remember).finally(() => { probing = null })
|
|
338
|
+
|
|
339
|
+
async function status({ fresh = false } = {}) {
|
|
340
|
+
await load()
|
|
341
|
+
if (job?.status === 'working') return view(cache?.value ?? blank())
|
|
342
|
+
if (!fresh && cache && now() - cache.at < 30_000) return view(cache.value)
|
|
343
|
+
return view((await refresh()).value)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function restartService(brew) {
|
|
347
|
+
const r = await run(brew, ['services', 'restart', 'openshell'], { timeoutMs: 120_000, env: { ...process.env, HOMEBREW_NO_AUTO_UPDATE: '1', HOMEBREW_NO_ENV_HINTS: '1', NO_COLOR: '1' } }).catch((e) => ({ code: null, stdout: '', stderr: e.message }))
|
|
348
|
+
if (r.code === 0 && !r.timedOut) return null
|
|
349
|
+
return (r.stderr || r.stdout || '').trim().split('\n').at(-1) || (r.timedOut ? 'it timed out.' : `exit ${r.code}.`)
|
|
350
|
+
}
|
|
351
|
+
async function healthy(name, ms = 60_000) {
|
|
352
|
+
const end = now() + ms
|
|
353
|
+
forget(name)
|
|
354
|
+
for (;;) {
|
|
355
|
+
try { if ((await (await client(name)).health())?.status !== 'unhealthy') return true } catch { forget(name) }
|
|
356
|
+
if (now() >= end) return false
|
|
357
|
+
await wait(1000)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const put = (file, text, expected, { created = false, check = null } = {}) => created && !text.trim() ? removeEnvFile(file, expected) : writeEnvFile(file, text, expected, check)
|
|
361
|
+
|
|
362
|
+
// Write (when the text changes), restart the service, wait for health.
|
|
363
|
+
// Failures put the file back as the rollback table in the spec says.
|
|
364
|
+
async function change(j, f, { next, created = false, expect, writeStage }) {
|
|
365
|
+
let real = f.file, wrote = false
|
|
366
|
+
const restore = async () => {
|
|
367
|
+
if (!wrote) return
|
|
368
|
+
try { await put(real, f.text, next, { created: !f.existed }) } catch (e) { logger.warn?.(`OpenRod couldn’t put back ${real}: ${e.message}`) }
|
|
369
|
+
}
|
|
370
|
+
if (next !== f.text) {
|
|
371
|
+
j.stage = writeStage
|
|
372
|
+
real = await put(f.file, next, f.text, { created, check: (temp) => validateEnvFile(run, temp, { home, expect }) })
|
|
373
|
+
wrote = true
|
|
374
|
+
// A later rewrite of a file OpenRod created still removes it on undo. The
|
|
375
|
+
// first write saved the unresolved path: the file wasn't there to resolve.
|
|
376
|
+
if (j.kind === 'connect') { state.undo = { file: real, created: !f.existed || Boolean(state.undo?.created && [real, f.file].includes(state.undo.file)) }; await save() }
|
|
377
|
+
}
|
|
378
|
+
j.stage = 'Restarting OpenShell…'
|
|
379
|
+
const lc = await exec('/bin/launchctl', ['print', `gui/${uid}/${LABEL}`])
|
|
380
|
+
if (lc.code !== 0 || launchdPid(lc.stdout) !== f.gateway.pid) { await restore(); throw fail('OpenShell changed while OpenRod was working on it. Try again.', 409) }
|
|
381
|
+
const problem = await restartService(f.brew)
|
|
382
|
+
if (problem) {
|
|
383
|
+
await restore()
|
|
384
|
+
if (!(await healthy(f.name, 0) && (await refresh()).value.driver?.dockerHost === f.driverHost)) { await restartService(f.brew); await healthy(f.name) }
|
|
385
|
+
throw fail(`OpenShell didn’t restart: ${problem}`, 502)
|
|
386
|
+
}
|
|
387
|
+
j.stage = 'Waiting for OpenShell…'
|
|
388
|
+
if (!(await healthy(f.name))) {
|
|
389
|
+
await restore(); await restartService(f.brew); await healthy(f.name)
|
|
390
|
+
throw fail('OpenShell didn’t come back within a minute. OpenRod put its settings back.', 504)
|
|
391
|
+
}
|
|
392
|
+
j.stage = 'Checking Docker…'
|
|
393
|
+
// The driver can show up a moment after the gateway answers health.
|
|
394
|
+
let after = (await refresh()).value
|
|
395
|
+
for (let i = 0; i < 10 && after.state === 'unknown'; i++) { await wait(1000); after = (await refresh()).value }
|
|
396
|
+
return { after, restore }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function connectJob(j, f) {
|
|
400
|
+
const endpoint = f.build.endpoint
|
|
401
|
+
// change() skips the write when the block is already in place and last.
|
|
402
|
+
const next = writeManaged(f.text, endpoint)
|
|
403
|
+
if (next === f.text) state.undo ??= { file: f.file, created: false }
|
|
404
|
+
const { after, restore } = await change(j, f, { next, expect: endpoint, writeStage: 'Saving OpenShell settings…' })
|
|
405
|
+
const picked = after.driver?.source === 'process' ? after.driver.dockerHost === endpoint : after.state === 'ok'
|
|
406
|
+
if (!picked) { await restore(); throw fail('OpenShell restarted but didn’t pick up the Docker setting. Use the steps instead.', 502) }
|
|
407
|
+
if (after.state !== 'ok') throw fail(`OpenShell still can’t reach Docker at ${endpoint}. Check that Docker is running.`, 502)
|
|
408
|
+
state.lastChange = { id: j.id, kind: 'connect', auto: j.auto, at: iso() }
|
|
409
|
+
state.declined = state.declined.filter((k) => k !== keyOf(f.build))
|
|
410
|
+
await save()
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function undoJob(j, f) {
|
|
414
|
+
const { after } = await change(j, f, { next: writeManaged(f.text, null), created: Boolean(state.undo?.created), writeStage: 'Undoing the Docker change…' })
|
|
415
|
+
if (after.driver?.source === 'process' && after.driver.dockerHost === f.managed) throw fail('OpenShell restarted but still uses the Docker setting. Edit the file yourself to undo.', 502)
|
|
416
|
+
const key = keyOf(f.build)
|
|
417
|
+
state.declined = [...state.declined.filter((k) => k !== key), key]
|
|
418
|
+
state.lastChange = { id: j.id, kind: 'undo', auto: false, at: iso() }
|
|
419
|
+
state.undo = null
|
|
420
|
+
await save()
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Checks busy() in the same tick that creates the job, so a template save
|
|
424
|
+
// can't start in between.
|
|
425
|
+
function start(kind, auto, f) {
|
|
426
|
+
if (job?.status === 'working') return working
|
|
427
|
+
const reason = busy()
|
|
428
|
+
if (reason) throw fail(reason === 'saving' ? 'A template is being saved. Try again in a moment.' : 'Wait for the connection change to finish.', 409)
|
|
429
|
+
const j = job = { id: randomUUID(), kind, auto, status: 'working', stage: null, error: null, startedAt: iso(), finishedAt: null }
|
|
430
|
+
working = (kind === 'connect' ? connectJob : undoJob)(j, f)
|
|
431
|
+
.then(() => { j.status = 'done' }, (e) => {
|
|
432
|
+
j.status = 'failed'; j.error = e.message
|
|
433
|
+
if (e.unsafe) unsafe.add(f.file)
|
|
434
|
+
if (auto) failed.add(`${f.build.engineId}|${f.file}`)
|
|
435
|
+
logger.warn?.(`OpenRod couldn’t ${kind === 'connect' ? 'connect OpenShell to Docker' : 'undo the Docker change'}: ${e.message}`)
|
|
436
|
+
})
|
|
437
|
+
// The verify probe ran before the outcome above was recorded.
|
|
438
|
+
.then(() => { j.finishedAt = iso(); j.stage = null; cache = null })
|
|
439
|
+
.then(() => status())
|
|
440
|
+
onJob(working)
|
|
441
|
+
return working
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function gate(s, { confirm, seen, auto = false }, restarts) {
|
|
445
|
+
if (s.fix === 'manual') throw mismatch(s.blocked, s)
|
|
446
|
+
const saw = new Set(Array.isArray(seen) ? seen : [])
|
|
447
|
+
if ((auto && s.fix !== 'auto') || (s.fix === 'confirm' && (!confirm || (s.sandboxes ?? []).some((x) => !saw.has(`${x.workspace}/${x.name}`))))) throw mismatch(restarts, s, 'confirm')
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function connect({ confirm = false, seen = [], auto = false } = {}) {
|
|
451
|
+
await load()
|
|
452
|
+
if (job?.status === 'working') return status()
|
|
453
|
+
const r = await refresh()
|
|
454
|
+
if (r.value.state !== 'mismatch') return view(r.value)
|
|
455
|
+
gate(r.value, { confirm, seen, auto }, 'Connecting restarts OpenShell and running sandboxes.')
|
|
456
|
+
start('connect', auto, r.facts)
|
|
457
|
+
return status()
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function undo({ confirm = false, seen = [] } = {}) {
|
|
461
|
+
await load()
|
|
462
|
+
if (job?.status === 'working') return status()
|
|
463
|
+
const r = await refresh({ full: true }), f = r.facts
|
|
464
|
+
if (!f?.decision) throw fail(r.value.reason ?? 'OpenShell isn’t reachable.', 409)
|
|
465
|
+
if (f.managed == null) throw fail('OpenShell’s settings changed after OpenRod edited them. Edit the file yourself to undo.', 409)
|
|
466
|
+
gate(f.decision, { confirm, seen }, 'Undoing restarts OpenShell and running sandboxes.')
|
|
467
|
+
start('undo', false, f)
|
|
468
|
+
return status()
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function ensure() {
|
|
472
|
+
try {
|
|
473
|
+
await load()
|
|
474
|
+
if (job?.status === 'working') return await working
|
|
475
|
+
const r = await refresh()
|
|
476
|
+
if (r.value.state === 'mismatch' && r.value.fix === 'auto') return await start('connect', true, r.facts)
|
|
477
|
+
return view(r.value)
|
|
478
|
+
} catch (e) { logger.warn?.(`OpenRod couldn’t check OpenShell’s Docker setting: ${e.message}`); return null }
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function settle() { while (job?.status === 'working') await working?.catch(() => {}) }
|
|
482
|
+
|
|
483
|
+
// Fails open on unknown and no-docker: only a known mismatch blocks a launch.
|
|
484
|
+
async function assertLaunch({ built = false, localOnly = false } = {}) {
|
|
485
|
+
await settle()
|
|
486
|
+
await load()
|
|
487
|
+
let r = cache && now() - cache.at < 30_000 ? cache : null
|
|
488
|
+
if (!r || r.value.state === 'mismatch') r = await refresh()
|
|
489
|
+
if (r.value.state !== 'mismatch') return
|
|
490
|
+
if (!built && !(typeof localOnly === 'function' ? await localOnly() : localOnly)) return
|
|
491
|
+
if (r.value.fix === 'auto') {
|
|
492
|
+
try { await start('connect', true, r.facts); r = await refresh() } catch { /* busy: ask the user instead */ }
|
|
493
|
+
if (r.value.state !== 'mismatch') return
|
|
494
|
+
}
|
|
495
|
+
throw mismatch('OpenShell can’t use your Docker images yet. Connect it to Docker, then try again.', r.value)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return { status, ensure, settle, assertLaunch, connect, undo }
|
|
499
|
+
}
|
package/server/gateway.js
CHANGED
|
@@ -32,6 +32,9 @@ let selected = {
|
|
|
32
32
|
let configured = Boolean(process.env.OPENSHELL_GATEWAY || saved.gateway)
|
|
33
33
|
let selectionSource = process.env.OPENSHELL_GATEWAY ? 'environment' : saved.gateway ? 'saved' : cliGateway ? 'cli' : 'default'
|
|
34
34
|
export const contextConfigured = () => configured
|
|
35
|
+
// A console nobody has pointed anywhere yet: no saved or cleared choice and no
|
|
36
|
+
// OPENSHELL_GATEWAY. Its candidate is the gateway the OpenShell CLI is set to.
|
|
37
|
+
export const unchosenCliGateway = () => !configured && !process.env.OPENSHELL_GATEWAY && cliGateway && !fs.existsSync(CONTEXT_FILE) ? cliGateway : null
|
|
35
38
|
|
|
36
39
|
const requestContext = new AsyncLocalStorage()
|
|
37
40
|
export const contextSelection = () => ({ ...(requestContext.getStore() ?? selected) })
|
|
@@ -108,6 +111,9 @@ export function listGateways({ configDir = CONFIG_DIR } = {}) {
|
|
|
108
111
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
109
112
|
}
|
|
110
113
|
|
|
114
|
+
// mTLS gateways registered on this computer, the ones "Use this computer" can pick.
|
|
115
|
+
export const localGateways = (options) => listGateways(options).filter(target => target.name !== 'aws-eks' && target.supported && !target.remote && ['localhost', '127.0.0.1', '[::1]'].includes(new URL(target.endpoint).hostname))
|
|
116
|
+
|
|
111
117
|
export const workspaceScope = (workspace = workspaceName()) => ({ selection: { case: 'workspace', value: workspace } })
|
|
112
118
|
export const workspaceName = () => contextSelection().workspace
|
|
113
119
|
|
|
@@ -132,6 +138,8 @@ async function connectRegisteredGateway(name) {
|
|
|
132
138
|
throw error
|
|
133
139
|
}
|
|
134
140
|
}
|
|
141
|
+
// A restarted gateway gets a fresh connection on the next call.
|
|
142
|
+
export const forgetGatewayConnection = (name) => { connections.delete(name) }
|
|
135
143
|
|
|
136
144
|
async function listWorkspaces(client) {
|
|
137
145
|
const workspaces = []
|
|
@@ -32,6 +32,10 @@ async function locked(name, task) {
|
|
|
32
32
|
const checkName = (name) => { if (!NAME_PATTERN.test(name || '')) throw fail('Unknown image template.', 404); return name }
|
|
33
33
|
const missing = (e) => e?.code === 'not_found'
|
|
34
34
|
export { run as runDocker }
|
|
35
|
+
export const savingTemplate = () => [...jobs.values()].some((j) => j.saving && running(j))
|
|
36
|
+
// Connects the local gateway's VM driver to the build engine (gateway-docker.js); set by api.js in local mode.
|
|
37
|
+
let gatewayDocker = null
|
|
38
|
+
export const setGatewayDocker = (value) => { gatewayDocker = value }
|
|
35
39
|
|
|
36
40
|
function run(args, { job, engine, timeout = 30_000, spawnProcess = spawn } = {}) {
|
|
37
41
|
return new Promise((resolve, reject) => {
|
|
@@ -77,7 +81,7 @@ export async function localEngine({ execute = run } = {}) {
|
|
|
77
81
|
const info = JSON.parse(await execute(['info', '--format', '{{json .}}'], { engine: { endpoint } }))
|
|
78
82
|
if (info.ServerErrors?.length) throw fail(`Docker is unavailable. Start Docker Desktop and retry. ${info.ServerErrors.join(' ')}`, 503)
|
|
79
83
|
if (info.OSType !== 'linux') throw fail('Switch local Docker to Linux containers, then retry.')
|
|
80
|
-
return { endpoint, architecture: info.Architecture === 'aarch64' ? 'arm64' : info.Architecture === 'x86_64' ? 'amd64' : info.Architecture, engineId: info.ID }
|
|
84
|
+
return { endpoint, architecture: info.Architecture === 'aarch64' ? 'arm64' : info.Architecture === 'x86_64' ? 'amd64' : info.Architecture, engineId: info.ID, desktop: info.OperatingSystem === 'Docker Desktop' }
|
|
81
85
|
} catch (e) {
|
|
82
86
|
if (e.code === 'ENOENT') throw fail('Docker isn’t installed. Install Docker Desktop (macOS: brew install --cask docker-desktop) or Docker Engine (Linux), start it, then try again.')
|
|
83
87
|
if (/failed to connect to the docker API|Cannot connect to the Docker daemon|connection refused/i.test(e.message)) throw fail('Docker isn’t running. Start Docker Desktop (or the Docker service), then try again.', 503)
|
|
@@ -129,8 +133,10 @@ export function templateView(t) {
|
|
|
129
133
|
}
|
|
130
134
|
const jobView = (job) => ({ name: job.name, image: null, recipe: job.recipe, status: job.status, logs: job.logs, error: job.error, startedAt: job.startedAt })
|
|
131
135
|
|
|
132
|
-
export async function listImageTemplates() {
|
|
133
|
-
|
|
136
|
+
export async function listImageTemplates({ connect = gateway, check = gatewayDocker, resolve = resolveGateway } = {}) {
|
|
137
|
+
// Wait out a local gateway restart (gateway-docker.js), as the save does.
|
|
138
|
+
if (check && !resolve().remote) await check.settle()
|
|
139
|
+
const { client, workspace } = await connect()
|
|
134
140
|
const templates = (await client.sandboxTemplates.listAll({ workspace })).map(templateView)
|
|
135
141
|
const byName = new Map(templates.map((t) => [t.name, t]))
|
|
136
142
|
// A rebuild in progress (or one that failed) shows on the template it replaces.
|
|
@@ -139,13 +145,21 @@ export async function listImageTemplates() {
|
|
|
139
145
|
}
|
|
140
146
|
|
|
141
147
|
// What New sandbox needs: the template name plus how the sandbox starts.
|
|
142
|
-
export async function imageTemplateForLaunch(name) {
|
|
143
|
-
const { client, target, workspace } = await
|
|
148
|
+
export async function imageTemplateForLaunch(name, { connect = gateway, engineFor = deploymentEngine, check = gatewayDocker, execute = run } = {}) {
|
|
149
|
+
const { client, target, workspace } = await connect()
|
|
144
150
|
let template
|
|
145
151
|
try { template = templateView(await client.sandboxTemplates.get(checkName(name), { workspace })) } catch (e) { if (missing(e)) throw fail('Image template not found.', 404); throw e }
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
152
|
+
const built = Boolean(template.image?.startsWith(BUILT_PREFIX))
|
|
153
|
+
if (built) {
|
|
154
|
+
const engine = await engineFor(target)
|
|
155
|
+
try { await inspect(template.image, engine, { execute }) } catch { throw fail('This template’s image is unavailable or incompatible on the selected compute. Rebuild the template before launching.') }
|
|
156
|
+
}
|
|
157
|
+
// The local VM driver must reach the engine holding built or never-pushed images.
|
|
158
|
+
if (!target.remote && check && template.image) {
|
|
159
|
+
const localOnly = async () => {
|
|
160
|
+
try { return !JSON.parse(await execute(['image', 'inspect', template.image], { engine: await engineFor(target) }))[0]?.RepoDigests?.length } catch { return false }
|
|
161
|
+
}
|
|
162
|
+
await check.assertLaunch({ built, localOnly })
|
|
149
163
|
}
|
|
150
164
|
return template
|
|
151
165
|
}
|
|
@@ -191,9 +205,15 @@ async function start(input) {
|
|
|
191
205
|
const buildEngine = target.remote && (recipe.source === 'build' || setups.length) ? await localEngine() : engine
|
|
192
206
|
const job = { name, scope: contextKey(), recipe, status: 'building', logs: '', error: null, cancelled: false, child: null, startedAt: new Date().toISOString() }
|
|
193
207
|
jobs.set(key, job)
|
|
208
|
+
// A long build hides the gateway restart that lets its VM driver see local images.
|
|
209
|
+
if (!target.remote) void gatewayDocker?.ensure()
|
|
210
|
+
const hooks = target.remote ? {} : {
|
|
211
|
+
gatewayReady: () => gatewayDocker?.settle(),
|
|
212
|
+
refreshClient: async () => (await gateway({ gateway: target.name, workspace })).client,
|
|
213
|
+
}
|
|
194
214
|
void (async () => {
|
|
195
215
|
try {
|
|
196
|
-
await publishImageTemplate({ recipe, setups, client, workspace, previous, target, engine, buildEngine, job })
|
|
216
|
+
await publishImageTemplate({ recipe, setups, client, workspace, previous, target, engine, buildEngine, job, ...hooks })
|
|
197
217
|
jobs.delete(key)
|
|
198
218
|
// Other workspaces or gateways may still reference the previous Docker image.
|
|
199
219
|
// Keep published images; removing a record must not remove a shared image.
|
|
@@ -204,7 +224,7 @@ async function start(input) {
|
|
|
204
224
|
return jobView(job)
|
|
205
225
|
}
|
|
206
226
|
|
|
207
|
-
export async function publishImageTemplate({ recipe, setups = [], client, workspace, previous, target, engine, buildEngine = engine, job }, { execute = run } = {}) {
|
|
227
|
+
export async function publishImageTemplate({ recipe, setups = [], client, workspace, previous, target, engine, buildEngine = engine, job, gatewayReady, refreshClient }, { execute = run } = {}) {
|
|
208
228
|
let temp
|
|
209
229
|
let baseTag
|
|
210
230
|
const generated = recipe.source === 'build' || setups.length > 0
|
|
@@ -268,9 +288,13 @@ export async function publishImageTemplate({ recipe, setups = [], client, worksp
|
|
|
268
288
|
// References are resolved on the deployment engine, not on local Docker.
|
|
269
289
|
await ensureImage(image, engine, { execute, job })
|
|
270
290
|
}
|
|
271
|
-
|
|
291
|
+
// Saving deletes and recreates the template, so it waits out a gateway restart.
|
|
292
|
+
active(); if (gatewayReady) { await gatewayReady(); active() }
|
|
272
293
|
job.saving = true
|
|
273
|
-
try {
|
|
294
|
+
try {
|
|
295
|
+
if (refreshClient) client = await refreshClient()
|
|
296
|
+
await saveTemplate(client, workspace, recipe, image, previous)
|
|
297
|
+
} catch (e) {
|
|
274
298
|
// Only this build's unpublished local tag is disposable. Remote images
|
|
275
299
|
// may be shared with another gateway or workspace and are retained.
|
|
276
300
|
if (generated) await execute(['image', 'rm', '--', image], { engine: buildEngine }).catch(() => {})
|