@emulates/docker 0.0.0-stage → 1.0.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/package.json CHANGED
@@ -1,6 +1,123 @@
1
1
  {
2
2
  "name": "@emulates/docker",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Stateful Docker Engine API v1.52 emulator with portable controls and Node transport.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "files": [
9
+ "dist",
10
+ "README.md",
11
+ "CHANGELOG.md",
12
+ "API_EVIDENCE.md",
13
+ "SUPPORT.md",
14
+ "ORACLE.md",
15
+ "evidence",
16
+ "scripts",
17
+ "DISCOVERY.md",
18
+ "openapi.yaml"
19
+ ],
20
+ "main": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "./server": {
28
+ "types": "./dist/server.d.ts",
29
+ "default": "./dist/server.js"
30
+ }
31
+ },
32
+ "bin": {
33
+ "emulates-docker": "./dist/cli.js"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public",
37
+ "provenance": true
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/crvouga/emulates.git",
42
+ "directory": "packages/service/docker"
43
+ },
44
+ "homepage": "https://github.com/crvouga/emulates/tree/main/packages/service/docker#readme",
45
+ "keywords": [
46
+ "emulates",
47
+ "service",
48
+ "docker",
49
+ "containers"
50
+ ],
51
+ "emulates": {
52
+ "runtime": "portable",
53
+ "entries": {
54
+ "server": "node",
55
+ "cli": "node"
56
+ },
57
+ "layer": "service",
58
+ "category": "infrastructure",
59
+ "displayName": "Docker Engine",
60
+ "vendor": {
61
+ "website": "https://www.docker.com",
62
+ "docs": "https://docs.docker.com/reference/api/engine/version/v1.52/",
63
+ "description": "Docker Engine provides a public API for container lifecycle and attached streams.",
64
+ "name": "Docker",
65
+ "icon": "docker"
66
+ },
67
+ "status": "wip",
68
+ "playground": {
69
+ "operation": "SystemVersion"
70
+ },
71
+ "parity": "Docker Engine container lifecycle, logs and streams",
72
+ "discovery": {
73
+ "guide": "DISCOVERY.md",
74
+ "behavior": "README.md",
75
+ "capabilities": "SUPPORT.md",
76
+ "contract": "openapi.yaml",
77
+ "types": "dist/index.d.ts",
78
+ "oracle": {
79
+ "kind": "Live vendor API or sandbox",
80
+ "command": "bun run parity:service -- docker",
81
+ "note": "Run from an Emulates checkout; credentials come only from .env.local or GitHub Actions secrets. Missing credentials exit 2."
82
+ },
83
+ "introspection": [
84
+ "GET /__admin/health",
85
+ "GET /__admin",
86
+ "GET /__admin/state",
87
+ "GET /__admin/requests",
88
+ "GET /__admin/metrics",
89
+ "GET /__admin/faults/presets",
90
+ "GET /__admin/ui"
91
+ ],
92
+ "reporting": "https://github.com/crvouga/emulates/blob/main/docs/REPORTING_ISSUES.md"
93
+ }
94
+ },
95
+ "scripts": {
96
+ "generate": "bun ../../openapi/codegen/dist/cli.js",
97
+ "generate:check": "bun ../../openapi/codegen/dist/cli.js --check",
98
+ "openapi:check": "bun ../../openapi/codegen/dist/cli.js --validate",
99
+ "build": "bun ../../../scripts/bundle-service.ts",
100
+ "typecheck": "tsc -p tsconfig.json --noEmit",
101
+ "lint": "biome check .",
102
+ "test": "bun test",
103
+ "portability": "bun ../../../scripts/portability.ts",
104
+ "pack:check": "bun ../../../scripts/pack-check.ts"
105
+ },
106
+ "dependencies": {
107
+ "@emulates/sqlite": "3.0.0",
108
+ "hono": "4.11.9"
109
+ },
110
+ "devDependencies": {
111
+ "fast-check": "4.9.0",
112
+ "@emulates/testing": "0.1.0",
113
+ "@emulates/sqlite": "0.0.0-development",
114
+ "@emulates/parity": "0.0.0-development",
115
+ "@emulates/openapi-codegen": "0.1.0",
116
+ "@emulates/openapi-metadata": "0.0.0-development",
117
+ "@emulates/core": "0.0.0-development",
118
+ "@emulates/sqlite-client": "0.0.0-development",
119
+ "@emulates/openapi": "0.0.0-development",
120
+ "@emulates/service": "0.0.0-development",
121
+ "@emulates/adapter-node": "0.0.0-development"
122
+ }
123
+ }
@@ -0,0 +1,276 @@
1
+ import { pathToFileURL } from "node:url"
2
+ import { isDeepStrictEqual } from "node:util"
3
+ import { createServer } from "../dist/server.js"
4
+ import {
5
+ attach,
6
+ execution,
7
+ imageDefaults,
8
+ jsonRequest,
9
+ owned,
10
+ parseOptions,
11
+ supportsApi,
12
+ } from "./oracle-client.mjs"
13
+
14
+ export const runOracle = async (options) => {
15
+ const report = {
16
+ oracle: "Docker Engine",
17
+ expectedVersion: options.engineVersion,
18
+ comparisonApi: "1.52",
19
+ runId: options.runId,
20
+ image: options.image,
21
+ comparisons: [],
22
+ cleanup: [],
23
+ gaps: [
24
+ "No daemon restart or live-restore scenario executed",
25
+ "Transport response-loss evidence is synthetic and recorded separately in US-008/US-012",
26
+ "No host enforcement, SDK or external consumer policy verification",
27
+ ],
28
+ passed: false,
29
+ }
30
+ // No default Docker context, environment variable, CLI or host socket is consulted.
31
+ const real = options.endpoint
32
+ const read = (endpoint, path) => jsonRequest(endpoint, "GET", path)
33
+ const compare = (label, actual, modeled) => {
34
+ const equal = isDeepStrictEqual(actual, modeled)
35
+ report.comparisons.push({ label, actual, modeled, equal })
36
+ if (!equal) throw new Error(`Comparison failed: ${label}`)
37
+ }
38
+ const requireStatus = (response, expected, label) => {
39
+ if (response.status !== expected)
40
+ throw new Error(`${label}: expected ${expected}, received ${response.status}`)
41
+ return response.body
42
+ }
43
+ const intents = []
44
+ let server
45
+ const attachments = []
46
+ try {
47
+ const version = requireStatus(await read(real, "/version"), 200, "Engine version")
48
+ report.version = {
49
+ version: version.Version,
50
+ api: version.ApiVersion,
51
+ minimumApi: version.MinAPIVersion,
52
+ os: version.Os,
53
+ arch: version.Arch,
54
+ gitCommit: version.GitCommit,
55
+ }
56
+ if (
57
+ version.Version !== options.engineVersion ||
58
+ !supportsApi(version) ||
59
+ version.Os !== "linux"
60
+ )
61
+ throw new Error(
62
+ "Oracle requires the explicitly selected Linux Engine version and API 1.52 support",
63
+ )
64
+ const image = requireStatus(
65
+ await read(real, `/v1.52/images/${options.image}/json`),
66
+ 200,
67
+ "Existing image",
68
+ )
69
+ if (image.Id !== options.image || image.Os !== "linux")
70
+ throw new Error("Oracle image identity or OS mismatch")
71
+ const defaults = imageDefaults(image)
72
+ report.omittedImageMetadata = defaults.omitted
73
+ report.imagePlatform = { os: image.Os, architecture: image.Architecture }
74
+ // Reject collisions before any real mutation; never adopt preexisting resources.
75
+ for (const scenario of ["attach", "stop", "kill"]) {
76
+ const name = `${options.runId}-${scenario}`
77
+ requireStatus(await read(real, `/v1.52/containers/${name}/json`), 404, "Ownership preflight")
78
+ }
79
+ server = await createServer()
80
+ const mock = { host: server.host, port: server.port }
81
+ requireStatus(
82
+ await jsonRequest(mock, "POST", "/__admin/docker/seed", {
83
+ images: [
84
+ {
85
+ id: image.Id,
86
+ tags: [],
87
+ config: defaults.config,
88
+ platform: `${image.Os}/${image.Architecture ?? "amd64"}`,
89
+ },
90
+ ],
91
+ }),
92
+ 201,
93
+ "Mock image seed",
94
+ )
95
+ for (const scenario of ["attach", "stop", "kill"]) {
96
+ const name = `${options.runId}-${scenario}`
97
+ const intent = { name }
98
+ // Track the unique create intent before I/O so an accepted-but-lost create is recoverable.
99
+ intents.push(intent)
100
+ const body = {
101
+ Image: options.image,
102
+ Cmd: [
103
+ "/bin/sh",
104
+ "-c",
105
+ scenario === "attach"
106
+ ? "read line; printf '%s\\n' \"$line\"; printf 'oracle-stderr\\n' >&2; exit 7"
107
+ : "while :; do sleep 1; done",
108
+ ],
109
+ Entrypoint: [""],
110
+ WorkingDir: "/",
111
+ OpenStdin: true,
112
+ Tty: false,
113
+ Labels: { "emulates.oracle": options.runId },
114
+ HostConfig: { NetworkMode: "none" },
115
+ }
116
+ const created = await jsonRequest(real, "POST", `/v1.52/containers/create?name=${name}`, body)
117
+ if (created.status === 201 && /^[a-f0-9]{64}$/.test(created.body?.Id ?? ""))
118
+ intent.id = created.body.Id
119
+ if (created.status !== 201) intents.pop() // A definitive rejected create conveys no ownership.
120
+ requireStatus(created, 201, "Real create")
121
+ if (!intent.id) throw new Error("Real create returned invalid container ID")
122
+ const modeled = await jsonRequest(mock, "POST", `/v1.52/containers/create?name=${name}`, body)
123
+ compare(`${scenario}: create status`, created.status, modeled.status)
124
+ const mockId = requireStatus(modeled, 201, "Mock create").Id
125
+ const realPath = `/v1.52/containers/${intent.id}`
126
+ const mockPath = `/v1.52/containers/${mockId}`
127
+ const inspect = async () => {
128
+ const actual = requireStatus(await read(real, `${realPath}/json`), 200, "Real inspect")
129
+ if (!owned(actual, intent, options)) throw new Error("Container ownership changed")
130
+ const simulated = requireStatus(await read(mock, `${mockPath}/json`), 200, "Mock inspect")
131
+ compare(`${scenario}: execution`, execution(actual), execution(simulated))
132
+ }
133
+ await inspect()
134
+ const started = await jsonRequest(real, "POST", `${realPath}/start`)
135
+ const modeledStart = await jsonRequest(mock, "POST", `${mockPath}/start`)
136
+ compare(`${scenario}: start status`, started.status, modeledStart.status)
137
+ requireStatus(started, 204, "Real start")
138
+ requireStatus(modeledStart, 204, "Mock start")
139
+ await inspect()
140
+ if (scenario === "attach") {
141
+ const actual = await attach(real, intent.id)
142
+ attachments.push(actual)
143
+ const simulated = await attach(mock, mockId)
144
+ attachments.push(simulated)
145
+ compare("attach: upgrade", actual.headers, simulated.headers)
146
+ actual.socket.write("oracle-probe\n")
147
+ simulated.socket.write("oracle-probe\n")
148
+ const [handle] = server.attachments()
149
+ if (!handle) throw new Error("Missing synthetic attachment")
150
+ let input = Buffer.alloc(0)
151
+ const deadline = performance.now() + 2000
152
+ while (!input.includes("\n")) {
153
+ input = Buffer.concat([input, Buffer.from(handle.takeStdin())])
154
+ if (performance.now() > deadline) throw new Error("Synthetic stdin timeout")
155
+ await new Promise((resolve) => setImmediate(resolve))
156
+ }
157
+ compare("attach: stdin", "oracle-probe\n", input.toString())
158
+ await handle.write("stdout", Buffer.from("oracle-probe\n"))
159
+ await handle.write("stderr", Buffer.from("oracle-stderr\n"))
160
+ requireStatus(
161
+ await jsonRequest(mock, "POST", `/__admin/docker/containers/${mockId}/complete`, {
162
+ exitCode: 7,
163
+ }),
164
+ 200,
165
+ "Mock completion",
166
+ )
167
+ compare(
168
+ "attach: normalized channel bytes (base64)",
169
+ await actual.done,
170
+ await simulated.done,
171
+ )
172
+ } else {
173
+ const suffix = scenario === "stop" ? "/stop?t=0" : "/kill?signal=KILL"
174
+ const simulated = jsonRequest(mock, "POST", `${mockPath}${suffix}`)
175
+ void simulated.catch(() => {})
176
+ const deadline = performance.now() + 2000
177
+ while (server.runtime.instance().lifecycle.pending === 0) {
178
+ if (performance.now() > deadline)
179
+ throw new Error("Synthetic termination was not accepted")
180
+ await new Promise((resolve) => setImmediate(resolve))
181
+ }
182
+ const actual = await jsonRequest(real, "POST", `${realPath}${suffix}`)
183
+ requireStatus(actual, 204, "Real termination")
184
+ requireStatus(
185
+ await jsonRequest(mock, "POST", `/__admin/docker/containers/${mockId}/complete`, {
186
+ exitCode: 137,
187
+ }),
188
+ 200,
189
+ "Mock termination",
190
+ )
191
+ compare(`${scenario}: termination status`, actual.status, (await simulated).status)
192
+ }
193
+ const actualWait = requireStatus(
194
+ await jsonRequest(real, "POST", `${realPath}/wait`),
195
+ 200,
196
+ "Real wait",
197
+ )
198
+ const mockWait = requireStatus(
199
+ await jsonRequest(mock, "POST", `${mockPath}/wait`),
200
+ 200,
201
+ "Mock wait",
202
+ )
203
+ compare(`${scenario}: wait status code`, actualWait.StatusCode, mockWait.StatusCode)
204
+ await inspect()
205
+ // Recheck exact identity immediately before deleting this run's known stopped container.
206
+ const beforeRemove = requireStatus(
207
+ await read(real, `${realPath}/json`),
208
+ 200,
209
+ "Removal ownership",
210
+ )
211
+ if (!owned(beforeRemove, intent, options)) throw new Error("Removal ownership mismatch")
212
+ const removed = await jsonRequest(real, "DELETE", realPath)
213
+ const modeledRemove = await jsonRequest(mock, "DELETE", mockPath)
214
+ compare(`${scenario}: remove status`, removed.status, modeledRemove.status)
215
+ requireStatus(removed, 204, "Real remove")
216
+ requireStatus(modeledRemove, 204, "Mock remove")
217
+ const absent = await read(real, `${realPath}/json`)
218
+ const modeledAbsent = await read(mock, `${mockPath}/json`)
219
+ compare(`${scenario}: removed inspection`, absent.status, modeledAbsent.status)
220
+ requireStatus(absent, 404, "Real removed inspection")
221
+ requireStatus(modeledAbsent, 404, "Mock removed inspection")
222
+ }
223
+ report.passed = true
224
+ } catch (error) {
225
+ // Record our own diagnostics; remote response bodies and credentials are never logged.
226
+ report.error = error instanceof Error ? error.message : "Oracle execution failed"
227
+ } finally {
228
+ for (const attachment of attachments) attachment.socket.destroy()
229
+ for (const intent of intents) {
230
+ try {
231
+ const found = await read(real, `/v1.52/containers/${intent.id ?? intent.name}/json`)
232
+ if (found.status === 404) {
233
+ report.cleanup.push({ name: intent.name, status: "absent" })
234
+ continue
235
+ }
236
+ if (found.status !== 200 || !owned(found.body, intent, options)) {
237
+ report.passed = false
238
+ report.cleanup.push({
239
+ name: intent.name,
240
+ status: "failed",
241
+ reason: "Ownership not proven; cleanup refused",
242
+ })
243
+ continue
244
+ }
245
+ const removed = await jsonRequest(
246
+ real,
247
+ "DELETE",
248
+ `/v1.52/containers/${found.body.Id}?force=true`,
249
+ )
250
+ requireStatus(removed, 204, "Owned cleanup")
251
+ report.cleanup.push({ name: intent.name, status: "removed" })
252
+ } catch (error) {
253
+ report.passed = false
254
+ report.cleanup.push({
255
+ name: intent.name,
256
+ status: "failed",
257
+ reason: error instanceof Error ? error.message : "Cleanup failed",
258
+ })
259
+ }
260
+ }
261
+ await server?.close()
262
+ }
263
+ return report
264
+ }
265
+
266
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
267
+ try {
268
+ const options = parseOptions(process.argv.slice(2))
269
+ const report = await runOracle(options)
270
+ console.log(JSON.stringify(report, null, 2))
271
+ process.exitCode = report.passed ? 0 : 1
272
+ } catch (error) {
273
+ console.error(error instanceof Error ? error.message : "Invalid oracle invocation")
274
+ process.exitCode = 1
275
+ }
276
+ }
@@ -0,0 +1,229 @@
1
+ import { request } from "node:http"
2
+
3
+ export const parseOptions = (args) => {
4
+ const options = {}
5
+ const names = new Set(["endpoint", "engine-version", "image", "run-id"])
6
+ for (let i = 0; i < args.length; i++) {
7
+ const key = args[i].replace(/^--/, "")
8
+ if (args[i] === "--allow-lifecycle") {
9
+ if (options.allowLifecycle) throw new Error("Duplicate lifecycle authorization")
10
+ options.allowLifecycle = true
11
+ } else {
12
+ if (!args[i].startsWith("--") || !names.has(key) || options[key] !== undefined)
13
+ throw new Error("Unknown or duplicate oracle option")
14
+ const value = args[++i]
15
+ if (!value || value.startsWith("--")) throw new Error(`Missing ${key}`)
16
+ options[key] = value
17
+ }
18
+ }
19
+ if (!options.allowLifecycle) throw new Error("Explicit --allow-lifecycle is required")
20
+ if (!/^\d+\.\d+\.\d+$/.test(options["engine-version"] ?? ""))
21
+ throw new Error("Explicit numeric --engine-version is required")
22
+ if (!/^sha256:[a-f0-9]{64}$/.test(options.image ?? ""))
23
+ throw new Error("Existing immutable --image sha256 ID is required")
24
+ if (!/^mb-oracle-[a-f0-9]{32}$/.test(options["run-id"] ?? ""))
25
+ throw new Error("Unique --run-id mb-oracle-<32 lowercase hex characters> is required")
26
+ return {
27
+ endpoint: parseEndpoint(options.endpoint),
28
+ engineVersion: options["engine-version"],
29
+ image: options.image,
30
+ runId: options["run-id"],
31
+ }
32
+ }
33
+
34
+ export const parseEndpoint = (value) => {
35
+ if (typeof value !== "string" || !value)
36
+ throw new Error("Explicit --endpoint is required; no default daemon is used")
37
+ let url
38
+ try {
39
+ url = new URL(value)
40
+ } catch {
41
+ throw new Error("Invalid oracle endpoint")
42
+ }
43
+ if (url.username || url.password || url.search || url.hash)
44
+ throw new Error("Endpoint credentials, query and fragment are not supported")
45
+ if (url.protocol === "unix:" && !url.host && url.pathname.startsWith("/") && url.pathname !== "/")
46
+ return { socketPath: decodeURIComponent(url.pathname) }
47
+ if (
48
+ url.protocol === "http:" &&
49
+ ["127.0.0.1", "[::1]", "localhost"].includes(url.hostname) &&
50
+ url.pathname === "/"
51
+ )
52
+ return { host: url.hostname.replace(/^\[|\]$/g, ""), port: Number(url.port || 80) }
53
+ throw new Error("Use an explicit unix:///absolute/socket or loopback http://host:port endpoint")
54
+ }
55
+
56
+ export const jsonRequest = (endpoint, method, path, body) =>
57
+ new Promise((resolve, reject) => {
58
+ const bytes = body === undefined ? Buffer.alloc(0) : Buffer.from(JSON.stringify(body))
59
+ const req = request(
60
+ {
61
+ ...endpoint,
62
+ method,
63
+ path,
64
+ agent: false,
65
+ headers: {
66
+ "content-type": "application/json",
67
+ "content-length": bytes.length,
68
+ },
69
+ },
70
+ (res) => {
71
+ const chunks = []
72
+ let size = 0
73
+ res.on("data", (chunk) => {
74
+ size += chunk.length
75
+ if (size > 1024 * 1024) res.destroy(new Error("Oracle response exceeds 1 MiB"))
76
+ else chunks.push(chunk)
77
+ })
78
+ res.once("error", reject)
79
+ res.once("aborted", () => reject(new Error("Oracle response aborted")))
80
+ res.once("end", () => {
81
+ try {
82
+ const text = Buffer.concat(chunks).toString()
83
+ resolve({ status: res.statusCode, body: text ? JSON.parse(text) : null })
84
+ } catch {
85
+ reject(new Error("Oracle response is not JSON"))
86
+ }
87
+ })
88
+ },
89
+ )
90
+ req.once("error", reject)
91
+ req.setTimeout(10_000, () => req.destroy(new Error("Oracle HTTP timeout")))
92
+ req.end(bytes)
93
+ })
94
+
95
+ export const attach = (endpoint, id) =>
96
+ new Promise((resolve, reject) => {
97
+ const req = request({
98
+ ...endpoint,
99
+ method: "POST",
100
+ agent: false,
101
+ path: `/v1.52/containers/${id}/attach?stream=1&stdin=1&stdout=1&stderr=1`,
102
+ headers: { connection: "Upgrade", upgrade: "tcp", "content-length": "0" },
103
+ })
104
+ req.once("error", reject)
105
+ req.setTimeout(10_000, () => req.destroy(new Error("Oracle upgrade timeout")))
106
+ req.once("response", (res) => {
107
+ res.resume()
108
+ reject(new Error(`Attach did not upgrade: ${res.statusCode}`))
109
+ })
110
+ req.once("upgrade", (res, socket, head) => {
111
+ let pending = Buffer.alloc(0)
112
+ const output = { stdout: [], stderr: [] }
113
+ let total = 0
114
+ const done = new Promise((finish, fail) => {
115
+ let ended = false
116
+ const receive = (bytes) => {
117
+ total += bytes.length
118
+ if (total > 1024 * 1024) {
119
+ socket.destroy(new Error("Oracle stream exceeds 1 MiB"))
120
+ return
121
+ }
122
+ pending = Buffer.concat([pending, bytes])
123
+ while (pending.length >= 8) {
124
+ const channel = pending[0]
125
+ const size = pending.readUInt32BE(4)
126
+ if (
127
+ ![1, 2].includes(channel) ||
128
+ pending.subarray(1, 4).some((byte) => byte !== 0) ||
129
+ size > 1024 * 1024
130
+ ) {
131
+ socket.destroy(new Error("Invalid Docker multiplexed frame"))
132
+ return
133
+ }
134
+ if (pending.length < 8 + size) return
135
+ output[channel === 1 ? "stdout" : "stderr"].push(pending.subarray(8, 8 + size))
136
+ pending = pending.subarray(8 + size)
137
+ }
138
+ }
139
+ socket.on("data", receive)
140
+ socket.once("error", fail)
141
+ socket.once("end", () => {
142
+ ended = true
143
+ socket.end()
144
+ if (pending.length) fail(new Error("Truncated Docker frame"))
145
+ else
146
+ finish({
147
+ stdout: Buffer.concat(output.stdout).toString("base64"),
148
+ stderr: Buffer.concat(output.stderr).toString("base64"),
149
+ })
150
+ })
151
+ socket.once("close", () => {
152
+ if (!ended) fail(new Error("Attach closed without EOF"))
153
+ })
154
+ socket.setTimeout(10_000, () => socket.destroy(new Error("Oracle stream timeout")))
155
+ if (head.length) receive(head)
156
+ })
157
+ // Attach may fail before the caller reaches its comparison; retain rejection for await.
158
+ void done.catch(() => {})
159
+ resolve({
160
+ socket,
161
+ done,
162
+ headers: {
163
+ status: res.statusCode,
164
+ type: res.headers["content-type"],
165
+ upgrade: res.headers.upgrade,
166
+ },
167
+ })
168
+ })
169
+ req.end()
170
+ })
171
+
172
+ export const owned = (record, intent, options) =>
173
+ record?.Name === `/${intent.name}` &&
174
+ record.Image === options.image &&
175
+ record.Config?.Labels?.["emulates.oracle"] === options.runId &&
176
+ /^[a-f0-9]{64}$/.test(record.Id ?? "") &&
177
+ (!intent.id || record.Id === intent.id)
178
+
179
+ export const execution = (record) => ({
180
+ status: record.State.Status,
181
+ running: record.State.Running,
182
+ exitCode: record.State.ExitCode,
183
+ })
184
+
185
+ export const supportsApi = (version, target = "1.52") => {
186
+ const parse = (value) =>
187
+ typeof value === "string" && /^\d+\.\d+$/.test(value) ? value.split(".").map(Number) : null
188
+ const min = parse(version.MinAPIVersion),
189
+ max = parse(version.ApiVersion),
190
+ selected = parse(target)
191
+ const compare = (a, b) => a[0] - b[0] || a[1] - b[1]
192
+ return Boolean(
193
+ min && max && selected && compare(min, selected) <= 0 && compare(selected, max) <= 0,
194
+ )
195
+ }
196
+
197
+ export const imageDefaults = (image) => {
198
+ const config = image.Config ?? {}
199
+ if (Object.keys(config.Volumes ?? {}).length)
200
+ throw new Error("Oracle image must not declare volumes")
201
+ if (config.Healthcheck?.Test?.length && config.Healthcheck.Test[0] !== "NONE")
202
+ throw new Error("Oracle image must not declare an active healthcheck")
203
+ const fields = [
204
+ "Cmd",
205
+ "Entrypoint",
206
+ "Env",
207
+ "WorkingDir",
208
+ "User",
209
+ "Labels",
210
+ "StopSignal",
211
+ "StopTimeout",
212
+ "AttachStdin",
213
+ "AttachStdout",
214
+ "AttachStderr",
215
+ "OpenStdin",
216
+ "StdinOnce",
217
+ "Tty",
218
+ "NetworkDisabled",
219
+ "Hostname",
220
+ "Domainname",
221
+ "Image",
222
+ ]
223
+ return {
224
+ config: Object.fromEntries(Object.entries(config).filter(([key]) => fields.includes(key))),
225
+ omitted: Object.keys(config)
226
+ .filter((key) => !fields.includes(key))
227
+ .sort(),
228
+ }
229
+ }