mirab 0.0.0-stage → 0.2.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/LICENSE +201 -0
- package/README.md +226 -2
- package/package.json +58 -4
- package/src/agent/approved.ts +261 -0
- package/src/agent/define.ts +235 -0
- package/src/agent/guest.ts +138 -0
- package/src/agent/init.ts +150 -0
- package/src/agent/manifest.ts +141 -0
- package/src/agent/modules.ts +23 -0
- package/src/agent/runtime.ts +239 -0
- package/src/agent-profiles.ts +327 -0
- package/src/api/bootstrap-contract.ts +95 -0
- package/src/api/channel-contract.ts +19 -0
- package/src/api/client.ts +100 -0
- package/src/api/contract.ts +2454 -0
- package/src/api/events-contract.ts +103 -0
- package/src/api/import-contract.ts +22 -0
- package/src/api/orchestrator-contract.ts +73 -0
- package/src/api/runner-contract.ts +25 -0
- package/src/api/scopes.ts +41 -0
- package/src/api/sessions-contract.ts +71 -0
- package/src/api/settings-contract.ts +50 -0
- package/src/channel/bridge.ts +97 -0
- package/src/channel/local.ts +154 -0
- package/src/channel/state.ts +11 -0
- package/src/channel/wire.ts +104 -0
- package/src/dev-sync.ts +206 -0
- package/src/dev.ts +453 -0
- package/src/events-tail.ts +75 -0
- package/src/images.ts +498 -0
- package/src/index.ts +21 -0
- package/src/invocation.ts +34 -0
- package/src/isolation/anchored-fs.ts +284 -0
- package/src/isolation/colima.ts +159 -0
- package/src/isolation/container.ts +189 -0
- package/src/isolation/firecracker.ts +414 -0
- package/src/isolation/guest-protocol.json +1 -0
- package/src/isolation/host-user.ts +247 -0
- package/src/isolation/image.ts +336 -0
- package/src/isolation/index.ts +477 -0
- package/src/isolation/multipass.ts +541 -0
- package/src/isolation/network.ts +167 -0
- package/src/isolation/policy.ts +86 -0
- package/src/isolation/session.ts +385 -0
- package/src/isolation/sync.ts +329 -0
- package/src/isolation/vz-network-setup.ts +247 -0
- package/src/isolation/vz.ts +312 -0
- package/src/mirab-main.ts +536 -0
- package/src/mirab.ts +13 -0
- package/src/runner/define.ts +187 -0
- package/src/runner/image.ts +239 -0
- package/src/runner/index.ts +37 -0
- package/src/runner/serve.ts +491 -0
- package/src/runner/toolsets.ts +205 -0
- package/src/spawn-adapters/claude.ts +112 -0
- package/src/spawn-adapters/codex-logs.ts +130 -0
- package/src/spawn-adapters/codex-notify.ts +73 -0
- package/src/spawn-adapters/codex.ts +89 -0
- package/src/spawn-adapters/index.ts +40 -0
- package/src/spawn-adapters/types.ts +55 -0
- package/src/spawn-attach.ts +243 -0
- package/src/spawn-binary.ts +90 -0
- package/src/spawn-bridge.ts +504 -0
- package/src/spawn-bundle.ts +16 -0
- package/src/spawn-detach.ts +301 -0
- package/src/spawn-events.ts +35 -0
- package/src/spawn-hook.ts +86 -0
- package/src/spawn-input.ts +107 -0
- package/src/spawn-native.ts +49 -0
- package/src/spawn-nudge-worker.ts +61 -0
- package/src/spawn-nudge.ts +446 -0
- package/src/spawn-output.ts +45 -0
- package/src/spawn-queue.ts +135 -0
- package/src/spawn-quiet.ts +69 -0
- package/src/spawn-report.ts +114 -0
- package/src/spawn-sessions.ts +166 -0
- package/src/spawn-vm.ts +356 -0
- package/src/spawn-worker.ts +766 -0
- package/src/spawn.ts +468 -0
- package/src/station/config.ts +89 -0
- package/src/station/gateway.ts +94 -0
- package/src/station/vault.ts +96 -0
- package/src/test-cli.ts +90 -0
- package/src/tools.ts +3 -0
- package/types/agent/define.d.ts +76 -0
- package/types/agent/manifest.d.ts +53 -0
- package/types/agent/modules.d.ts +1 -0
- package/types/images.d.ts +124 -0
- package/types/index.d.ts +6 -0
- package/types/isolation/colima.d.ts +37 -0
- package/types/isolation/container.d.ts +25 -0
- package/types/isolation/firecracker.d.ts +55 -0
- package/types/isolation/host-user.d.ts +47 -0
- package/types/isolation/image.d.ts +158 -0
- package/types/isolation/index.d.ts +128 -0
- package/types/isolation/network.d.ts +33 -0
- package/types/isolation/policy.d.ts +29 -0
- package/types/isolation/session.d.ts +99 -0
- package/types/isolation/vz.d.ts +63 -0
- package/types/runner/define.d.ts +101 -0
- package/types/runner/image.d.ts +52 -0
- package/types/runner/index.d.ts +13 -0
- package/types/runner/serve.d.ts +69 -0
- package/types/runner/toolsets.d.ts +35 -0
- package/types/tools.d.ts +2 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// What a CI runner declares: `defineRunner(...)`. mirab runs each job in a
|
|
2
|
+
// fresh VM and throws it away; a forge adapter (woreda's `github()`, later
|
|
3
|
+
// gild, GitLab, Forgejo) says how to register for one job and what to run in
|
|
4
|
+
// the VM. Nothing here knows any particular forge.
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
|
|
7
|
+
/** The guests a runner can run. */
|
|
8
|
+
export const RUNNER_PLATFORMS = ['linux-x64', 'linux-arm64'] as const
|
|
9
|
+
export type RunnerPlatform = (typeof RUNNER_PLATFORMS)[number]
|
|
10
|
+
|
|
11
|
+
/** Every runner carries `mirab-<os>-<arch>` (e.g. `mirab-linux-arm64`), so a
|
|
12
|
+
* job asks for a fresh VM of a platform with one label. */
|
|
13
|
+
export const platformLabel = (platform: RunnerPlatform) => `mirab-${platform}`
|
|
14
|
+
|
|
15
|
+
/** One command in the VM. Steps run as the unprivileged `runner` user unless
|
|
16
|
+
* `asRoot` (then the step is responsible for dropping to it, with RUNNER_SETPRIV). */
|
|
17
|
+
export interface RunnerStep {
|
|
18
|
+
argv: string[]
|
|
19
|
+
env?: Record<string, string>
|
|
20
|
+
asRoot?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A file put into the VM before the job's steps. `secret` files are never
|
|
24
|
+
* logged; they should live on tmpfs (/run) and be removed once read. */
|
|
25
|
+
export interface RunnerFile {
|
|
26
|
+
path: string
|
|
27
|
+
mode: number
|
|
28
|
+
content: string
|
|
29
|
+
secret?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A one-time credential for exactly one job. Every value in `secret` is
|
|
33
|
+
* redacted from mirab's logs and never written to the host's disk. */
|
|
34
|
+
export interface RunnerTicket {
|
|
35
|
+
name: string
|
|
36
|
+
secret: Record<string, string>
|
|
37
|
+
meta?: Record<string, string>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What a forge adapter provides. */
|
|
41
|
+
export interface Forge {
|
|
42
|
+
name: string
|
|
43
|
+
/** Host side: register one runner called `name` for one job. */
|
|
44
|
+
ticket(req: {
|
|
45
|
+
name: string
|
|
46
|
+
labels: string[]
|
|
47
|
+
platform: RunnerPlatform
|
|
48
|
+
}): Promise<RunnerTicket>
|
|
49
|
+
guest: {
|
|
50
|
+
/** Baked into the runner image as root, in the build VM. `key` names
|
|
51
|
+
* exactly what is installed (e.g. a version and checksum): a new key is
|
|
52
|
+
* a new image. Called again at most daily, to notice new releases. */
|
|
53
|
+
install(
|
|
54
|
+
platform: RunnerPlatform,
|
|
55
|
+
): Promise<{ key: string; steps: RunnerStep[] }>
|
|
56
|
+
/** Hosts the image build may reach for `install`. */
|
|
57
|
+
buildEgress: string[]
|
|
58
|
+
/** One job: files put first, then the steps in order; the last one is
|
|
59
|
+
* the runner, which takes one job and exits. */
|
|
60
|
+
job(ticket: RunnerTicket): {
|
|
61
|
+
files: RunnerFile[]
|
|
62
|
+
steps: RunnerStep[]
|
|
63
|
+
env: Record<string, string>
|
|
64
|
+
}
|
|
65
|
+
/** True for the runner's output line that says a job has started: the
|
|
66
|
+
* job timer (timeoutMinutes) starts there, not at boot. */
|
|
67
|
+
started(line: string): boolean
|
|
68
|
+
}
|
|
69
|
+
/** Delete this host's registrations named `prefix*` that aren't in `live`. */
|
|
70
|
+
reconcile(prefix: string, live: string[]): Promise<void>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The toolsets a runner image can carry. */
|
|
74
|
+
export const TOOLSETS = ['rust', 'node', 'go'] as const
|
|
75
|
+
|
|
76
|
+
const NAME = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/
|
|
77
|
+
const LABEL = /^[A-Za-z0-9._-]{1,64}$/
|
|
78
|
+
const PACKAGE = /^[a-z0-9][a-z0-9+.-]*$/
|
|
79
|
+
|
|
80
|
+
const forgeSchema = z.custom<Forge>(
|
|
81
|
+
(f) =>
|
|
82
|
+
!!f &&
|
|
83
|
+
typeof f === 'object' &&
|
|
84
|
+
typeof (f as Forge).name === 'string' &&
|
|
85
|
+
typeof (f as Forge).ticket === 'function' &&
|
|
86
|
+
typeof (f as Forge).reconcile === 'function' &&
|
|
87
|
+
typeof (f as Forge).guest?.job === 'function' &&
|
|
88
|
+
typeof (f as Forge).guest?.install === 'function' &&
|
|
89
|
+
typeof (f as Forge).guest?.started === 'function' &&
|
|
90
|
+
Array.isArray((f as Forge).guest?.buildEgress),
|
|
91
|
+
'forge is an adapter object (ticket, reconcile, guest.install/job/started/buildEgress)',
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
export const runnerSchema = z.strictObject({
|
|
95
|
+
/** Unique on its host; registrations are `<host>-<name>-<random>`. */
|
|
96
|
+
name: z
|
|
97
|
+
.string()
|
|
98
|
+
.regex(
|
|
99
|
+
NAME,
|
|
100
|
+
'name is [a-z0-9-], up to 40 characters, no leading or trailing -',
|
|
101
|
+
),
|
|
102
|
+
forge: forgeSchema,
|
|
103
|
+
/** Extra labels, after `platformLabel`'s, passed to the adapter as they are. */
|
|
104
|
+
labels: z.array(z.string().regex(LABEL)).max(32).default([]),
|
|
105
|
+
image: z
|
|
106
|
+
.strictObject({
|
|
107
|
+
/** Pinned versions: rust `1.91.0` or `1.91`; node `24` or `24.21.0`; go `1.25` or `1.25.3`. */
|
|
108
|
+
tools: z
|
|
109
|
+
.strictObject({
|
|
110
|
+
rust: z
|
|
111
|
+
.string()
|
|
112
|
+
.regex(/^\d+\.\d+(\.\d+)?$/)
|
|
113
|
+
.optional(),
|
|
114
|
+
node: z
|
|
115
|
+
.string()
|
|
116
|
+
.regex(/^\d+(\.\d+){0,2}$/)
|
|
117
|
+
.optional(),
|
|
118
|
+
go: z
|
|
119
|
+
.string()
|
|
120
|
+
.regex(/^\d+\.\d+(\.\d+)?$/)
|
|
121
|
+
.optional(),
|
|
122
|
+
})
|
|
123
|
+
.default({}),
|
|
124
|
+
/** apt package names. */
|
|
125
|
+
packages: z
|
|
126
|
+
.array(z.string().regex(PACKAGE, 'an apt package name'))
|
|
127
|
+
.default([]),
|
|
128
|
+
})
|
|
129
|
+
.default({ tools: {}, packages: [] }),
|
|
130
|
+
vm: z
|
|
131
|
+
.strictObject({
|
|
132
|
+
vcpus: z.number().int().min(1).max(64).default(2),
|
|
133
|
+
memoryMiB: z.number().int().min(1024).default(3072),
|
|
134
|
+
/** The job's disk; caps what one job can use of the host's. */
|
|
135
|
+
diskGiB: z.number().int().min(8).max(1024).default(30),
|
|
136
|
+
})
|
|
137
|
+
.default({ vcpus: 2, memoryMiB: 3072, diskGiB: 30 }),
|
|
138
|
+
pool: z
|
|
139
|
+
.strictObject({
|
|
140
|
+
/** VMs kept booted and registered, waiting for a job (0 or 1 in phase 1). */
|
|
141
|
+
warm: z.number().int().min(0).max(1).default(1),
|
|
142
|
+
/** VMs at once on this host (1 until VM-to-VM isolation is proven). */
|
|
143
|
+
max: z.number().int().min(1).max(1).default(1),
|
|
144
|
+
/** A slot nobody gave a job ends after this, and registers again. */
|
|
145
|
+
idleHours: z.number().positive().max(72).default(12),
|
|
146
|
+
})
|
|
147
|
+
.default({ warm: 1, max: 1, idleHours: 12 }),
|
|
148
|
+
/** One job's hard limit, from the moment it starts; the VM is killed after. */
|
|
149
|
+
timeoutMinutes: z
|
|
150
|
+
.number()
|
|
151
|
+
.int()
|
|
152
|
+
.min(1)
|
|
153
|
+
.max(24 * 60)
|
|
154
|
+
.default(90),
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
export type RunnerDefinition = z.infer<typeof runnerSchema>
|
|
158
|
+
export type RunnerInput = z.input<typeof runnerSchema>
|
|
159
|
+
|
|
160
|
+
export function defineRunner(input: RunnerInput): RunnerDefinition {
|
|
161
|
+
const parsed = runnerSchema.safeParse(input)
|
|
162
|
+
if (!parsed.success)
|
|
163
|
+
throw new Error(
|
|
164
|
+
`defineRunner: ${parsed.error.issues.map((i) => `${i.path.join('.') || 'runner'}: ${i.message}`).join('; ')}`,
|
|
165
|
+
)
|
|
166
|
+
return Object.freeze(parsed.data)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The unprivileged user every job step runs as (created in the runner image). */
|
|
170
|
+
export const RUNNER_USER = { name: 'runner', uid: 1001, home: '/home/runner' }
|
|
171
|
+
|
|
172
|
+
/** Prefix that drops a root command to the runner user, keeping its env. */
|
|
173
|
+
export const RUNNER_SETPRIV = [
|
|
174
|
+
'setpriv',
|
|
175
|
+
`--reuid=${RUNNER_USER.uid}`,
|
|
176
|
+
`--regid=${RUNNER_USER.uid}`,
|
|
177
|
+
'--init-groups',
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
/** The guest platform a VM on this host runs. */
|
|
181
|
+
export function hostRunnerPlatform(
|
|
182
|
+
arch: string = process.arch,
|
|
183
|
+
): RunnerPlatform {
|
|
184
|
+
if (arch === 'arm64') return 'linux-arm64'
|
|
185
|
+
if (arch === 'x64') return 'linux-x64'
|
|
186
|
+
throw new Error(`no runner guest for ${arch} hosts`)
|
|
187
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// A runner's image: the base, grown to the job's disk size, plus the runner
|
|
2
|
+
// user, apt packages, toolsets and the forge's runner, built once in a VM
|
|
3
|
+
// whose only way out is the station (to the hosts the recipe needs) and cached
|
|
4
|
+
// under the hash of all of it. Every job boots a copy-on-write clone of it.
|
|
5
|
+
import { mkdir, readFile, rm, utimes } from 'node:fs/promises'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import {
|
|
8
|
+
BUILD_ENV,
|
|
9
|
+
GROW_ROOT_FS,
|
|
10
|
+
UBUNTU_MIRRORS,
|
|
11
|
+
baseId,
|
|
12
|
+
bootForBuild,
|
|
13
|
+
keep,
|
|
14
|
+
recipeKey,
|
|
15
|
+
run,
|
|
16
|
+
} from '../images'
|
|
17
|
+
import type { VmConfig } from '../isolation'
|
|
18
|
+
import {
|
|
19
|
+
RUNNER_USER,
|
|
20
|
+
hostRunnerPlatform,
|
|
21
|
+
type RunnerDefinition,
|
|
22
|
+
type RunnerPlatform,
|
|
23
|
+
type RunnerStep,
|
|
24
|
+
} from './define'
|
|
25
|
+
import {
|
|
26
|
+
RUST_HOME,
|
|
27
|
+
TOOL_CACHE,
|
|
28
|
+
resolveTools,
|
|
29
|
+
toolEnv,
|
|
30
|
+
toolSteps,
|
|
31
|
+
type Fetcher,
|
|
32
|
+
type ResolvedTool,
|
|
33
|
+
} from './toolsets'
|
|
34
|
+
|
|
35
|
+
/** Bump when the build recipe below changes in a way that needs new images. */
|
|
36
|
+
const RECIPE_VERSION = 1
|
|
37
|
+
|
|
38
|
+
export const runnerImagesDir = (configDir: string) =>
|
|
39
|
+
join(configDir, 'runner-images')
|
|
40
|
+
|
|
41
|
+
export interface RunnerImageMeta {
|
|
42
|
+
key: string
|
|
43
|
+
platform: RunnerPlatform
|
|
44
|
+
forge: { name: string; key: string }
|
|
45
|
+
tools: ResolvedTool[]
|
|
46
|
+
packages: string[]
|
|
47
|
+
diskGiB: number
|
|
48
|
+
/** The toolsets' env, and PATH entries to put first, for every job. */
|
|
49
|
+
env: Record<string, string>
|
|
50
|
+
path: string[]
|
|
51
|
+
builtAt: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RunnerImage {
|
|
55
|
+
rootfs: string
|
|
56
|
+
meta: RunnerImageMeta
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface RunnerRecipe {
|
|
60
|
+
platform: RunnerPlatform
|
|
61
|
+
forge: { name: string; key: string; steps: RunnerStep[]; egress: string[] }
|
|
62
|
+
tools: ResolvedTool[]
|
|
63
|
+
packages: string[]
|
|
64
|
+
diskGiB: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Everything that decides an image, resolved: the key is the hash of it. */
|
|
68
|
+
export async function runnerRecipe(
|
|
69
|
+
def: RunnerDefinition,
|
|
70
|
+
o: { platform?: RunnerPlatform; fetch?: Fetcher } = {},
|
|
71
|
+
): Promise<RunnerRecipe> {
|
|
72
|
+
const platform = o.platform ?? hostRunnerPlatform()
|
|
73
|
+
const install = await def.forge.guest.install(platform)
|
|
74
|
+
return {
|
|
75
|
+
platform,
|
|
76
|
+
forge: {
|
|
77
|
+
name: def.forge.name,
|
|
78
|
+
key: install.key,
|
|
79
|
+
steps: install.steps,
|
|
80
|
+
egress: def.forge.guest.buildEgress,
|
|
81
|
+
},
|
|
82
|
+
tools: await resolveTools(def.image.tools, platform, o.fetch),
|
|
83
|
+
packages: [...new Set(def.image.packages)].sort(),
|
|
84
|
+
diskGiB: def.vm.diskGiB,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function runnerImageKey(base: string, r: RunnerRecipe) {
|
|
89
|
+
return recipeKey(base, {
|
|
90
|
+
v: RECIPE_VERSION,
|
|
91
|
+
platform: r.platform,
|
|
92
|
+
forge: { name: r.forge.name, key: r.forge.key },
|
|
93
|
+
tools: r.tools.map((t) => ({
|
|
94
|
+
tool: t.tool,
|
|
95
|
+
version: t.version,
|
|
96
|
+
sha256: t.sha256,
|
|
97
|
+
})),
|
|
98
|
+
packages: r.packages,
|
|
99
|
+
diskGiB: r.diskGiB,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The build, as root in the build VM, in order. */
|
|
104
|
+
export function buildSteps(r: RunnerRecipe): RunnerStep[] {
|
|
105
|
+
const u = RUNNER_USER
|
|
106
|
+
return [
|
|
107
|
+
{ argv: ['sh', '-c', GROW_ROOT_FS] },
|
|
108
|
+
{ argv: ['apt-get', 'update'] },
|
|
109
|
+
{
|
|
110
|
+
argv: [
|
|
111
|
+
'apt-get',
|
|
112
|
+
'install',
|
|
113
|
+
'-y',
|
|
114
|
+
'--no-install-recommends',
|
|
115
|
+
...new Set(['sudo', ...r.packages]),
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
// The base writes /etc/passwd and /etc/group itself; useradd adds the
|
|
119
|
+
// runner (and its group, and a shadow entry sudo's PAM wants). sudo by a
|
|
120
|
+
// sudoers file, not a sudo group the base doesn't have.
|
|
121
|
+
{
|
|
122
|
+
argv: [
|
|
123
|
+
'sh',
|
|
124
|
+
'-euc',
|
|
125
|
+
[
|
|
126
|
+
`useradd -m -d ${u.home} -u ${u.uid} -U -s /bin/bash ${u.name}`,
|
|
127
|
+
`printf '%s\\n' '${u.name} ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/${u.name}`,
|
|
128
|
+
`chmod 0440 /etc/sudoers.d/${u.name}`,
|
|
129
|
+
`mkdir -p ${u.home}/tmp ${TOOL_CACHE}`,
|
|
130
|
+
].join(' && '),
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
...toolSteps(r.tools, r.platform),
|
|
134
|
+
...r.forge.steps,
|
|
135
|
+
{
|
|
136
|
+
argv: [
|
|
137
|
+
'sh',
|
|
138
|
+
'-euc',
|
|
139
|
+
[
|
|
140
|
+
`chown -R ${u.uid}:${u.uid} ${u.home} ${TOOL_CACHE}`,
|
|
141
|
+
r.tools.some((t) => t.tool === 'rust')
|
|
142
|
+
? `chown -R ${u.uid}:${u.uid} ${RUST_HOME}`
|
|
143
|
+
: 'true',
|
|
144
|
+
'apt-get clean',
|
|
145
|
+
'rm -rf /var/lib/apt/lists/*',
|
|
146
|
+
].join(' && '),
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The cached image for `r`, if it was built on this base. */
|
|
153
|
+
export async function cachedRunnerImage(
|
|
154
|
+
configDir: string,
|
|
155
|
+
key: string,
|
|
156
|
+
): Promise<RunnerImage | undefined> {
|
|
157
|
+
const dir = join(runnerImagesDir(configDir), key)
|
|
158
|
+
try {
|
|
159
|
+
const meta = JSON.parse(
|
|
160
|
+
await readFile(join(dir, 'meta.json'), 'utf8'),
|
|
161
|
+
) as RunnerImageMeta
|
|
162
|
+
if (meta.key !== key) return undefined
|
|
163
|
+
return { rootfs: join(dir, 'rootfs.ext4'), meta }
|
|
164
|
+
} catch {
|
|
165
|
+
return undefined
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The runner's image: from the cache, or built now (a few minutes). */
|
|
170
|
+
export async function runnerImage(o: {
|
|
171
|
+
configDir: string
|
|
172
|
+
vm: VmConfig
|
|
173
|
+
recipe: RunnerRecipe
|
|
174
|
+
log?: (line: string) => void
|
|
175
|
+
}): Promise<RunnerImage> {
|
|
176
|
+
const r = o.recipe
|
|
177
|
+
const base = await baseId(o.vm.rootfs)
|
|
178
|
+
const key = runnerImageKey(base, r)
|
|
179
|
+
const hit = await cachedRunnerImage(o.configDir, key)
|
|
180
|
+
if (hit) {
|
|
181
|
+
const now = new Date()
|
|
182
|
+
await utimes(
|
|
183
|
+
join(runnerImagesDir(o.configDir), key, 'meta.json'),
|
|
184
|
+
now,
|
|
185
|
+
now,
|
|
186
|
+
).catch(() => {})
|
|
187
|
+
return hit
|
|
188
|
+
}
|
|
189
|
+
o.log?.(
|
|
190
|
+
`building runner image ${key.slice(0, 12)}: ${r.forge.name} ${r.forge.key}` +
|
|
191
|
+
(r.tools.length
|
|
192
|
+
? `, ${r.tools.map((t) => `${t.tool} ${t.version}`).join(', ')}`
|
|
193
|
+
: '') +
|
|
194
|
+
(r.packages.length ? `, apt: ${r.packages.join(' ')}` : ''),
|
|
195
|
+
)
|
|
196
|
+
const dir = join(runnerImagesDir(o.configDir), key)
|
|
197
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
198
|
+
const allow = [
|
|
199
|
+
...new Set([
|
|
200
|
+
...UBUNTU_MIRRORS,
|
|
201
|
+
...r.tools.flatMap((t) => t.hosts),
|
|
202
|
+
...r.forge.egress,
|
|
203
|
+
]),
|
|
204
|
+
]
|
|
205
|
+
const { iso, cleanup } = await bootForBuild({
|
|
206
|
+
configDir: o.configDir,
|
|
207
|
+
// A Rust toolchain unpacks in memory-hungry steps; the build VM is short-lived.
|
|
208
|
+
vm: { ...o.vm, memoryMiB: Math.max(o.vm.memoryMiB, 2048) },
|
|
209
|
+
rootfs: o.vm.rootfs,
|
|
210
|
+
key,
|
|
211
|
+
allow,
|
|
212
|
+
rootGiB: r.diskGiB,
|
|
213
|
+
log: o.log,
|
|
214
|
+
})
|
|
215
|
+
try {
|
|
216
|
+
for (const step of buildSteps(r))
|
|
217
|
+
await run(iso, step.argv, o.log, { ...BUILD_ENV, ...step.env })
|
|
218
|
+
const { env, path } = toolEnv(r.tools, r.platform)
|
|
219
|
+
const meta: RunnerImageMeta = {
|
|
220
|
+
key,
|
|
221
|
+
platform: r.platform,
|
|
222
|
+
forge: { name: r.forge.name, key: r.forge.key },
|
|
223
|
+
tools: r.tools,
|
|
224
|
+
packages: r.packages,
|
|
225
|
+
diskGiB: r.diskGiB,
|
|
226
|
+
env,
|
|
227
|
+
path,
|
|
228
|
+
builtAt: new Date().toISOString(),
|
|
229
|
+
}
|
|
230
|
+
await keep(iso, dir, meta, { previous: false })
|
|
231
|
+
return { rootfs: join(dir, 'rootfs.ext4'), meta }
|
|
232
|
+
} catch (e) {
|
|
233
|
+
await iso.close().catch(() => {})
|
|
234
|
+
await rm(dir, { recursive: true, force: true })
|
|
235
|
+
throw e
|
|
236
|
+
} finally {
|
|
237
|
+
await cleanup()
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// What a host that serves runners imports (woreda): the VM side of runners.
|
|
2
|
+
// A runner definition itself imports only `defineRunner` from 'mirab'.
|
|
3
|
+
export * from './define'
|
|
4
|
+
export {
|
|
5
|
+
buildSteps,
|
|
6
|
+
cachedRunnerImage,
|
|
7
|
+
runnerImage,
|
|
8
|
+
runnerImageKey,
|
|
9
|
+
runnerImagesDir,
|
|
10
|
+
runnerRecipe,
|
|
11
|
+
} from './image'
|
|
12
|
+
export type { RunnerImage, RunnerImageMeta, RunnerRecipe } from './image'
|
|
13
|
+
export {
|
|
14
|
+
admission,
|
|
15
|
+
hostLabel,
|
|
16
|
+
jobEnv,
|
|
17
|
+
redact,
|
|
18
|
+
runnersDir,
|
|
19
|
+
serveRunners,
|
|
20
|
+
startRunnerVm,
|
|
21
|
+
stepArgv,
|
|
22
|
+
} from './serve'
|
|
23
|
+
export type {
|
|
24
|
+
RunnerState,
|
|
25
|
+
ServeDeps,
|
|
26
|
+
ServeOptions,
|
|
27
|
+
SlotPhase,
|
|
28
|
+
SlotState,
|
|
29
|
+
} from './serve'
|
|
30
|
+
export { resolveTools, TOOL_CACHE } from './toolsets'
|
|
31
|
+
/** The VM this host runs runners on: the published guest image (fetched on
|
|
32
|
+
* first use) and the backend's settings from <config dir>/isolation.json. */
|
|
33
|
+
export { requireVm as runnerHostVm } from '../isolation'
|
|
34
|
+
/** This copy's package version: with the commit a package pins mirab at, it
|
|
35
|
+
* names that commit's canary (`<version>-canary-<sha7>`) for runnerHostVm. */
|
|
36
|
+
export { MIRAB_VERSION } from '../isolation/image'
|
|
37
|
+
export type { ResolvedTool } from './toolsets'
|