socketduct 0.1.25 → 0.1.26

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.
Files changed (56) hide show
  1. package/AGENTS.md +2 -2
  2. package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
  3. package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
  4. package/package.json +2 -2
  5. package/scripts/test.js +9 -14
  6. package/src/logging/compressed-log-writer.js +1 -1
  7. package/src/logging/zstd-frame-codec.js +15 -1
  8. package/test/e2e/gateway-process.test.js +93 -92
  9. package/test/e2e/logger-shutdown-process.test.js +40 -37
  10. package/test/e2e/reverse-client-recovery-process.test.js +107 -103
  11. package/test/e2e/reverse-gateway-process.test.js +328 -318
  12. package/test/integration/initial-target-recovery.test.js +333 -334
  13. package/test/integration/logging-cli.test.js +121 -119
  14. package/test/integration/logging-compressed-recovery.test.js +96 -95
  15. package/test/integration/multiplexed-compatibility.test.js +178 -177
  16. package/test/integration/multiplexed-corrections.test.js +360 -359
  17. package/test/integration/multiplexed-failures.test.js +253 -251
  18. package/test/integration/multiplexed-transport-set.test.js +334 -322
  19. package/test/integration/multiplexed-transport.test.js +135 -134
  20. package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
  21. package/test/integration/reverse-gateway.test.js +2309 -2218
  22. package/test/integration/reverse-resume-continuity.test.js +219 -218
  23. package/test/integration/reverse-route-class.test.js +358 -341
  24. package/test/integration/socketduct-http-agent.test.js +314 -312
  25. package/test/integration/socketduct-https-agent.test.js +516 -485
  26. package/test/integration/stream-compression.test.js +862 -824
  27. package/test/integration/tcp-forwarding.test.js +2550 -2447
  28. package/test/integration/tls-forwarding.test.js +593 -592
  29. package/test/unit/auth/auth.test.js +43 -44
  30. package/test/unit/auth/static-token-store.test.js +9 -8
  31. package/test/unit/client/connect.test.js +41 -40
  32. package/test/unit/client/deferred-virtual-socket.test.js +95 -94
  33. package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
  34. package/test/unit/client/multiplexed-transport-set.test.js +830 -748
  35. package/test/unit/client/socketduct-socket.test.js +768 -771
  36. package/test/unit/client/virtual-socket.test.js +198 -168
  37. package/test/unit/config/docker-gateway-files.test.js +224 -224
  38. package/test/unit/config/environment.test.js +472 -492
  39. package/test/unit/index.test.js +53 -52
  40. package/test/unit/logging/compressed-log-worker.test.js +23 -22
  41. package/test/unit/logging/compressed-log-writer.test.js +155 -154
  42. package/test/unit/logging/log-retention.test.js +52 -47
  43. package/test/unit/logging/log-segment-index.test.js +84 -81
  44. package/test/unit/logging/zstd-frame-codec.test.js +28 -19
  45. package/test/unit/protocol/compression.test.js +254 -298
  46. package/test/unit/protocol/frame.test.js +280 -276
  47. package/test/unit/protocol/v3-compression.test.js +21 -23
  48. package/test/unit/protocol/v3-frame.test.js +42 -41
  49. package/test/unit/release-configuration.test.js +15 -12
  50. package/test/unit/reverse/disconnect-attribution.test.js +65 -64
  51. package/test/unit/reverse-terminal-outcomes.test.js +53 -50
  52. package/test/unit/server/control-handler.test.js +232 -231
  53. package/test/unit/server/primary.test.js +51 -49
  54. package/test/unit/server/relay-runtime.test.js +280 -294
  55. package/test/unit/sessions/session.test.js +48 -62
  56. package/test/unit/spool/spool-file.test.js +401 -385
@@ -1,9 +1,8 @@
1
- import assert from "node:assert/strict"
1
+ import {describe, expect, it} from "@velocious/testing"
2
2
  import {spawnSync} from "node:child_process"
3
3
  import {copyFile, mkdtemp, readFile, readdir, rm} from "node:fs/promises"
4
4
  import {tmpdir} from "node:os"
5
5
  import {delimiter, join} from "node:path"
6
- import test from "node:test"
7
6
 
8
7
  const composePath = new URL("../../../docker-compose.yml", import.meta.url)
9
8
  const developmentComposePath = new URL("../../../docker-compose.dev.yml", import.meta.url)
@@ -27,252 +26,253 @@ async function githubWorkflowNames() {
27
26
  }
28
27
  }
29
28
 
30
- test("TensorBuzz exclusively owns verification and container build validation", async () => {
31
- const [packageJson, tensorbuzz, workflowNames] = await Promise.all([
32
- readFile(packagePath, "utf8"),
33
- readFile(tensorbuzzPath, "utf8"),
34
- githubWorkflowNames()
35
- ])
36
- const packageMetadata = JSON.parse(packageJson)
29
+ describe("docker gateway files", () => {
30
+ it("TensorBuzz exclusively owns verification and container build validation", async () => {
31
+ const [packageJson, tensorbuzz, workflowNames] = await Promise.all([
32
+ readFile(packagePath, "utf8"),
33
+ readFile(tensorbuzzPath, "utf8"),
34
+ githubWorkflowNames()
35
+ ])
36
+ const packageMetadata = JSON.parse(packageJson)
37
37
 
38
- assert.deepEqual(workflowNames, [])
39
- assert.match(tensorbuzz, /^dependency_roots:\n {2}- path: \.$/m)
40
- assert.doesNotMatch(tensorbuzz, /^\s*- npm ci$/m)
41
- assert.match(tensorbuzz, /DOCKER_HOST: "tcp:\/\/docker_server:2375"/)
42
- assert.match(tensorbuzz, /docker_server:\n\s+image: "docker:[^"]+-dind"/)
43
- assert.match(tensorbuzz, /privileged: true/)
44
- assert.match(tensorbuzz, /docker build --tag socketduct-ci \./)
45
- assert.doesNotMatch(tensorbuzz, /docker (?:login|push)/)
46
- // release-patch refuses to publish a package marked "private": true, so the
47
- // published UNLICENSED npm release requires the flag to be absent or false.
48
- assert.notEqual(packageMetadata.private, true)
49
- assert.equal(packageMetadata.license, "UNLICENSED")
50
- })
51
-
52
- test("Docker gateway files define a private Docker socket shim target", async () => {
53
- const [compose, envExample, docs] = await Promise.all([
54
- readFile(composePath, "utf8"),
55
- readFile(envExamplePath, "utf8"),
56
- readFile(docsPath, "utf8")
57
- ])
58
-
59
- assert.match(compose, /docker-socket-shim:/)
60
- assert.match(compose, /profiles:\n\s+- docker-host/)
61
- assert.match(compose, /\/var\/run\/docker\.sock:\/var\/run\/docker\.sock/)
62
- assert.match(compose, /expose:\n\s+- "2375"/)
63
- assert.doesNotMatch(compose, /2375:2375/)
64
- assert.match(envExample, /SOCKETDUCT_TARGET_ALLOWLIST=docker-socket-shim:2375/)
65
- assert.match(docs, /Docker socket shim/)
66
- })
38
+ expect(workflowNames).toEqual([])
39
+ expect(tensorbuzz).toMatch(/^dependency_roots:\n {2}- path: \.$/m)
40
+ expect(tensorbuzz).not.toMatch(/^\s*- npm ci$/m)
41
+ expect(tensorbuzz).toMatch(/DOCKER_HOST: "tcp:\/\/docker_server:2375"/)
42
+ expect(tensorbuzz).toMatch(/docker_server:\n\s+image: "docker:[^"]+-dind"/)
43
+ expect(tensorbuzz).toMatch(/privileged: true/)
44
+ expect(tensorbuzz).toMatch(/docker build --tag socketduct-ci \./)
45
+ expect(tensorbuzz).not.toMatch(/docker (?:login|push)/)
46
+ // release-patch refuses to publish a package marked "private": true, so the
47
+ // published UNLICENSED npm release requires the flag to be absent or false.
48
+ expect(packageMetadata.private).not.toBe(true)
49
+ expect(packageMetadata.license).toBe("UNLICENSED")
50
+ })
67
51
 
68
- test("native reverse gateway Compose has outbound-only private Docker access", async () => {
69
- const compose = await readFile(reverseComposePath, "utf8")
70
- assert.match(compose, /SOCKETDUCT_MODE: reverse-gateway/)
71
- assert.match(compose, /SOCKETDUCT_TARGET_ALLOWLIST: docker-server:2375/)
72
- assert.match(compose, /docker-control:/)
73
- assert.match(compose, /wan-egress:/)
74
- assert.doesNotMatch(compose, /\bports:/)
75
- assert.doesNotMatch(compose, /socat|socket-shim|2375:2375/)
76
- })
52
+ it("Docker gateway files define a private Docker socket shim target", async () => {
53
+ const [compose, envExample, docs] = await Promise.all([
54
+ readFile(composePath, "utf8"),
55
+ readFile(envExamplePath, "utf8"),
56
+ readFile(docsPath, "utf8")
57
+ ])
77
58
 
78
- test("production Compose roles expose the opt-in persistent log mount contract", async () => {
79
- const [compose, reverseCompose, envExample, docs] = await Promise.all([
80
- readFile(composePath, "utf8"),
81
- readFile(reverseComposePath, "utf8"),
82
- readFile(envExamplePath, "utf8"),
83
- readFile(docsPath, "utf8")
84
- ])
59
+ expect(compose).toMatch(/docker-socket-shim:/)
60
+ expect(compose).toMatch(/profiles:\n\s+- docker-host/)
61
+ expect(compose).toMatch(/\/var\/run\/docker\.sock:\/var\/run\/docker\.sock/)
62
+ expect(compose).toMatch(/expose:\n\s+- "2375"/)
63
+ expect(compose).not.toMatch(/2375:2375/)
64
+ expect(envExample).toMatch(/SOCKETDUCT_TARGET_ALLOWLIST=docker-socket-shim:2375/)
65
+ expect(docs).toMatch(/Docker socket shim/)
66
+ })
85
67
 
86
- for (const model of [compose, reverseCompose]) {
87
- assert.match(model, /\$\{SOCKETDUCT_LOG_HOST_DIRECTORY:-\.\/logs\}:\/var\/log\/socketduct/)
88
- }
89
- assert.match(envExample, /SOCKETDUCT_LOG_DIRECTORY=\/var\/log\/socketduct/)
90
- assert.match(envExample, /SOCKETDUCT_LOG_HOST_DIRECTORY=\/srv\/socketduct\/logs/)
91
- assert.match(docs, /SOCKETDUCT_LOG_HOST_DIRECTORY/)
92
- assert.match(docs, /\/var\/log\/socketduct/)
93
- })
68
+ it("native reverse gateway Compose has outbound-only private Docker access", async () => {
69
+ const compose = await readFile(reverseComposePath, "utf8")
70
+ expect(compose).toMatch(/SOCKETDUCT_MODE: reverse-gateway/)
71
+ expect(compose).toMatch(/SOCKETDUCT_TARGET_ALLOWLIST: docker-server:2375/)
72
+ expect(compose).toMatch(/docker-control:/)
73
+ expect(compose).toMatch(/wan-egress:/)
74
+ expect(compose).not.toMatch(/\bports:/)
75
+ expect(compose).not.toMatch(/socat|socket-shim|2375:2375/)
76
+ })
94
77
 
95
- test("production gateway Compose requires an immutable Socketduct GHCR image", async () => {
96
- const [compose, developmentCompose, envExample] = await Promise.all([
97
- readFile(composePath, "utf8"),
98
- readFile(developmentComposePath, "utf8"),
99
- readFile(envExamplePath, "utf8")
100
- ])
78
+ it("production Compose roles expose the opt-in persistent log mount contract", async () => {
79
+ const [compose, reverseCompose, envExample, docs] = await Promise.all([
80
+ readFile(composePath, "utf8"),
81
+ readFile(reverseComposePath, "utf8"),
82
+ readFile(envExamplePath, "utf8"),
83
+ readFile(docsPath, "utf8")
84
+ ])
101
85
 
102
- assert.match(compose, /image: \$\{SOCKETDUCT_IMAGE:\?[^}]+\}/)
103
- assert.doesNotMatch(compose, /\bbuild:/)
104
- assert.doesNotMatch(compose, /socketduct:latest/)
105
- assert.match(developmentCompose, /\bbuild:/)
106
- assert.match(envExample, /SOCKETDUCT_IMAGE=ghcr\.io\/kaspernj\/socketduct@sha256:/)
107
- })
86
+ for (const model of [compose, reverseCompose]) {
87
+ expect(model).toMatch(/\$\{SOCKETDUCT_LOG_HOST_DIRECTORY:-\.\/logs\}:\/var\/log\/socketduct/)
88
+ }
89
+ expect(envExample).toMatch(/SOCKETDUCT_LOG_DIRECTORY=\/var\/log\/socketduct/)
90
+ expect(envExample).toMatch(/SOCKETDUCT_LOG_HOST_DIRECTORY=\/srv\/socketduct\/logs/)
91
+ expect(docs).toMatch(/SOCKETDUCT_LOG_HOST_DIRECTORY/)
92
+ expect(docs).toMatch(/\/var\/log\/socketduct/)
93
+ })
108
94
 
109
- test("gateway deployment rejects mutable images before running Docker", async () => {
110
- const {deployGateway} = await import("../../../scripts/deploy-gateway.js")
111
- /** @type {Array<[string, string[]]>} */
112
- const calls = []
95
+ it("production gateway Compose requires an immutable Socketduct GHCR image", async () => {
96
+ const [compose, developmentCompose, envExample] = await Promise.all([
97
+ readFile(composePath, "utf8"),
98
+ readFile(developmentComposePath, "utf8"),
99
+ readFile(envExamplePath, "utf8")
100
+ ])
113
101
 
114
- for (const image of [
115
- undefined,
116
- "",
117
- "socketduct:latest",
118
- "ghcr.io/kaspernj/socketduct:latest",
119
- "ghcr.io/kaspernj/socketduct:main",
120
- "ghcr.io/kaspernj/socketduct:40cc261",
121
- `ghcr.io/kaspernj/socketduct:release@sha256:${"a".repeat(64)}`,
122
- `ghcr.io/kaspernj/socketduct@sha256:${"A".repeat(64)}`
123
- ]) {
124
- await assert.rejects(
125
- deployGateway({image, run: (command, args) => { calls.push([command, args]); return "" }}),
126
- /immutable GHCR image/
127
- )
128
- }
129
- assert.deepEqual(calls, [])
130
- })
102
+ expect(compose).toMatch(/image: \$\{SOCKETDUCT_IMAGE:\?[^}]+\}/)
103
+ expect(compose).not.toMatch(/\bbuild:/)
104
+ expect(compose).not.toMatch(/socketduct:latest/)
105
+ expect(developmentCompose).toMatch(/\bbuild:/)
106
+ expect(envExample).toMatch(/SOCKETDUCT_IMAGE=ghcr\.io\/kaspernj\/socketduct@sha256:/)
107
+ })
131
108
 
132
- test("gateway deployment fixes Compose inputs, neutralizes ambient selectors, then verifies and applies", async () => {
133
- const {deployGateway} = await import("../../../scripts/deploy-gateway.js")
134
- /** @type {Array<[string, string[], {captureStdout?: boolean, env: Record<string, string | undefined>}]>} */
135
- const calls = []
136
- const digest = "a".repeat(64)
137
- const image = `ghcr.io/kaspernj/socketduct@sha256:${digest}`
138
- const environment = {
139
- PATH: "/usr/bin",
140
- DOCKER_HOST: "ssh://gateway.example.test",
141
- DOCKER_CONTEXT: "production-gateway",
142
- COMPOSE_FILE: "/tmp/mutable-compose.yml",
143
- COMPOSE_ENV_FILES: "/tmp/mutable.env",
144
- COMPOSE_DISABLE_ENV_FILE: "1",
145
- COMPOSE_IGNORE_ORPHANS: "1",
146
- COMPOSE_REMOVE_ORPHANS: "1",
147
- COMPOSE_PATH_SEPARATOR: ";",
148
- COMPOSE_PROFILES: "docker-host",
149
- COMPOSE_PROJECT_NAME: "ambient-project"
150
- }
109
+ it("gateway deployment rejects mutable images before running Docker", async () => {
110
+ const {deployGateway} = await import("../../../scripts/deploy-gateway.js")
111
+ /** @type {Array<[string, string[]]>} */
112
+ const calls = []
151
113
 
152
- await deployGateway({
153
- environment,
154
- image,
155
- projectDirectory: "/srv/socketduct release #1",
156
- run: (command, args, options) => {
157
- calls.push([command, args, options])
158
- return args.includes("config")
159
- ? JSON.stringify({services: {"socketduct-relay": {image}}})
160
- : ""
114
+ for (const image of [
115
+ undefined,
116
+ "",
117
+ "socketduct:latest",
118
+ "ghcr.io/kaspernj/socketduct:latest",
119
+ "ghcr.io/kaspernj/socketduct:main",
120
+ "ghcr.io/kaspernj/socketduct:40cc261",
121
+ `ghcr.io/kaspernj/socketduct:release@sha256:${"a".repeat(64)}`,
122
+ `ghcr.io/kaspernj/socketduct@sha256:${"A".repeat(64)}`
123
+ ]) {
124
+ await expect(
125
+ deployGateway({image, run: (command, args) => { calls.push([command, args]); return "" }})
126
+ ).rejects.toThrow(/immutable GHCR image/)
161
127
  }
128
+ expect(calls).toEqual([])
162
129
  })
163
130
 
164
- const compose = [
165
- "compose",
166
- "--file", "/srv/socketduct release #1/docker-compose.yml",
167
- "--env-file", "/srv/socketduct release #1/.env",
168
- "--project-directory", "/srv/socketduct release #1",
169
- "--project-name", "socketduct-gateway"
170
- ]
171
- assert.deepEqual(calls.map(([command, args, options]) => [command, args, options.captureStdout]), [
172
- ["docker", [...compose, "config", "--format", "json"], true],
173
- ["docker", [...compose, "pull"], undefined],
174
- ["docker", [...compose, "up", "-d", "--wait", "--no-build"], undefined]
175
- ])
176
- for (const [, , options] of calls) {
177
- assert.equal(options.env.DOCKER_HOST, environment.DOCKER_HOST)
178
- assert.equal(options.env.DOCKER_CONTEXT, environment.DOCKER_CONTEXT)
179
- assert.equal(options.env.COMPOSE_FILE, "/srv/socketduct release #1/docker-compose.yml")
180
- assert.equal(options.env.COMPOSE_ENV_FILES, "/srv/socketduct release #1/.env")
181
- assert.equal(options.env.COMPOSE_PATH_SEPARATOR, delimiter)
182
- assert.equal(options.env.COMPOSE_DISABLE_ENV_FILE, "false")
183
- assert.equal(options.env.COMPOSE_IGNORE_ORPHANS, "false")
184
- assert.equal(options.env.COMPOSE_REMOVE_ORPHANS, "false")
185
- assert.equal(options.env.COMPOSE_PROFILES, "")
186
- assert.equal(options.env.COMPOSE_PROJECT_NAME, "socketduct-gateway")
187
- assert.equal(options.env.SOCKETDUCT_IMAGE, image)
188
- }
189
- })
131
+ it("gateway deployment fixes Compose inputs, neutralizes ambient selectors, then verifies and applies", async () => {
132
+ const {deployGateway} = await import("../../../scripts/deploy-gateway.js")
133
+ /** @type {Array<[string, string[], {captureStdout?: boolean, env: Record<string, string | undefined>}]>} */
134
+ const calls = []
135
+ const digest = "a".repeat(64)
136
+ const image = `ghcr.io/kaspernj/socketduct@sha256:${digest}`
137
+ const environment = {
138
+ PATH: "/usr/bin",
139
+ DOCKER_HOST: "ssh://gateway.example.test",
140
+ DOCKER_CONTEXT: "production-gateway",
141
+ COMPOSE_FILE: "/tmp/mutable-compose.yml",
142
+ COMPOSE_ENV_FILES: "/tmp/mutable.env",
143
+ COMPOSE_DISABLE_ENV_FILE: "1",
144
+ COMPOSE_IGNORE_ORPHANS: "1",
145
+ COMPOSE_REMOVE_ORPHANS: "1",
146
+ COMPOSE_PATH_SEPARATOR: ";",
147
+ COMPOSE_PROFILES: "docker-host",
148
+ COMPOSE_PROJECT_NAME: "ambient-project"
149
+ }
190
150
 
191
- test("gateway deployment enables only the explicit docker-host profile", async () => {
192
- const {deployGateway} = await import("../../../scripts/deploy-gateway.js")
193
- /** @type {Array<{args: string[], environment: Record<string, string | undefined>}>} */
194
- const calls = []
195
- const image = `ghcr.io/kaspernj/socketduct@sha256:${"b".repeat(64)}`
151
+ await deployGateway({
152
+ environment,
153
+ image,
154
+ projectDirectory: "/srv/socketduct release #1",
155
+ run: (command, args, options) => {
156
+ calls.push([command, args, options])
157
+ return args.includes("config")
158
+ ? JSON.stringify({services: {"socketduct-relay": {image}}})
159
+ : ""
160
+ }
161
+ })
196
162
 
197
- await deployGateway({
198
- dockerHost: true,
199
- environment: {COMPOSE_PROFILES: "unexpected"},
200
- image,
201
- projectDirectory: "/srv/socketduct",
202
- run: (_command, args, options) => {
203
- calls.push({args, environment: options.env})
204
- return args.includes("config")
205
- ? JSON.stringify({services: {"socketduct-relay": {image}}})
206
- : ""
163
+ const compose = [
164
+ "compose",
165
+ "--file", "/srv/socketduct release #1/docker-compose.yml",
166
+ "--env-file", "/srv/socketduct release #1/.env",
167
+ "--project-directory", "/srv/socketduct release #1",
168
+ "--project-name", "socketduct-gateway"
169
+ ]
170
+ expect(calls.map(([command, args, options]) => [command, args, options.captureStdout])).toEqual([
171
+ ["docker", [...compose, "config", "--format", "json"], true],
172
+ ["docker", [...compose, "pull"], undefined],
173
+ ["docker", [...compose, "up", "-d", "--wait", "--no-build"], undefined]
174
+ ])
175
+ for (const [, , options] of calls) {
176
+ expect(options.env.DOCKER_HOST).toBe(environment.DOCKER_HOST)
177
+ expect(options.env.DOCKER_CONTEXT).toBe(environment.DOCKER_CONTEXT)
178
+ expect(options.env.COMPOSE_FILE).toBe("/srv/socketduct release #1/docker-compose.yml")
179
+ expect(options.env.COMPOSE_ENV_FILES).toBe("/srv/socketduct release #1/.env")
180
+ expect(options.env.COMPOSE_PATH_SEPARATOR).toBe(delimiter)
181
+ expect(options.env.COMPOSE_DISABLE_ENV_FILE).toBe("false")
182
+ expect(options.env.COMPOSE_IGNORE_ORPHANS).toBe("false")
183
+ expect(options.env.COMPOSE_REMOVE_ORPHANS).toBe("false")
184
+ expect(options.env.COMPOSE_PROFILES).toBe("")
185
+ expect(options.env.COMPOSE_PROJECT_NAME).toBe("socketduct-gateway")
186
+ expect(options.env.SOCKETDUCT_IMAGE).toBe(image)
207
187
  }
208
188
  })
209
189
 
210
- assert.equal(calls.length, 3)
211
- for (const {args, environment: childEnvironment} of calls) {
212
- assert.equal(args.filter((argument) => argument === "--profile").length, 1)
213
- assert.equal(args[args.indexOf("--profile") + 1], "docker-host")
214
- assert.equal(childEnvironment.COMPOSE_PROFILES, "")
215
- }
216
- })
217
-
218
- test("gateway deployment fails closed on a rendered image mismatch or build source", async () => {
219
- const {deployGateway} = await import("../../../scripts/deploy-gateway.js")
220
- const image = `ghcr.io/kaspernj/socketduct@sha256:${"c".repeat(64)}`
221
- const renderedModels = [
222
- {services: {"socketduct-relay": {image: "ghcr.io/kaspernj/socketduct:latest"}}},
223
- {services: {"socketduct-relay": {build: {context: "."}, image}}},
224
- {services: {}}
225
- ]
190
+ it("gateway deployment enables only the explicit docker-host profile", async () => {
191
+ const {deployGateway} = await import("../../../scripts/deploy-gateway.js")
192
+ /** @type {Array<{args: string[], environment: Record<string, string | undefined>}>} */
193
+ const calls = []
194
+ const image = `ghcr.io/kaspernj/socketduct@sha256:${"b".repeat(64)}`
226
195
 
227
- for (const rendered of renderedModels) {
228
- let calls = 0
229
- await assert.rejects(deployGateway({
230
- environment: {},
196
+ await deployGateway({
197
+ dockerHost: true,
198
+ environment: {COMPOSE_PROFILES: "unexpected"},
231
199
  image,
232
200
  projectDirectory: "/srv/socketduct",
233
- run: () => {
234
- calls += 1
235
- return JSON.stringify(rendered)
201
+ run: (_command, args, options) => {
202
+ calls.push({args, environment: options.env})
203
+ return args.includes("config")
204
+ ? JSON.stringify({services: {"socketduct-relay": {image}}})
205
+ : ""
236
206
  }
237
- }), /rendered.*socketduct-relay|build source/i)
238
- assert.equal(calls, 1)
239
- }
240
- })
241
-
242
- test("gateway deployment CLI runs from paths containing spaces and hash characters", async () => {
243
- const root = await mkdtemp(join(tmpdir(), "socketduct gateway #"))
244
- const escapedScriptPath = join(root, "deploy gateway #.mjs")
245
- try {
246
- await copyFile(scriptPath, escapedScriptPath)
247
- const result = spawnSync(process.execPath, [escapedScriptPath], {
248
- encoding: "utf8",
249
- env: {...process.env, SOCKETDUCT_IMAGE: "ghcr.io/kaspernj/socketduct:latest"}
250
207
  })
251
208
 
252
- assert.equal(result.status, 1)
253
- assert.match(result.stderr, /immutable GHCR image/)
254
- } finally {
255
- await rm(root, {recursive: true, force: true})
256
- }
257
- })
209
+ expect(calls.length).toBe(3)
210
+ for (const {args, environment: childEnvironment} of calls) {
211
+ expect(args.filter((argument) => argument === "--profile").length).toBe(1)
212
+ expect(args[args.indexOf("--profile") + 1]).toBe("docker-host")
213
+ expect(childEnvironment.COMPOSE_PROFILES).toBe("")
214
+ }
215
+ })
216
+
217
+ it("gateway deployment fails closed on a rendered image mismatch or build source", async () => {
218
+ const {deployGateway} = await import("../../../scripts/deploy-gateway.js")
219
+ const image = `ghcr.io/kaspernj/socketduct@sha256:${"c".repeat(64)}`
220
+ const renderedModels = [
221
+ {services: {"socketduct-relay": {image: "ghcr.io/kaspernj/socketduct:latest"}}},
222
+ {services: {"socketduct-relay": {build: {context: "."}, image}}},
223
+ {services: {}}
224
+ ]
225
+
226
+ for (const rendered of renderedModels) {
227
+ let calls = 0
228
+ await expect(deployGateway({
229
+ environment: {},
230
+ image,
231
+ projectDirectory: "/srv/socketduct",
232
+ run: () => {
233
+ calls += 1
234
+ return JSON.stringify(rendered)
235
+ }
236
+ })).rejects.toThrow(/rendered.*socketduct-relay|build source/i)
237
+ expect(calls).toBe(1)
238
+ }
239
+ })
258
240
 
259
- test("gateway documentation describes pinning, private GHCR login, and digest rollback", async () => {
260
- const [dockerDocs, packageJson, runningDocs, securityDocs] = await Promise.all([
261
- readFile(docsPath, "utf8"),
262
- readFile(packagePath, "utf8"),
263
- readFile(runningDocsPath, "utf8"),
264
- readFile(securityDocsPath, "utf8")
265
- ])
266
- const publicDocs = `${dockerDocs}\n${runningDocs}\n${securityDocs}`
241
+ it("gateway deployment CLI runs from paths containing spaces and hash characters", async () => {
242
+ const root = await mkdtemp(join(tmpdir(), "socketduct gateway #"))
243
+ const escapedScriptPath = join(root, "deploy gateway #.mjs")
244
+ try {
245
+ await copyFile(scriptPath, escapedScriptPath)
246
+ const result = spawnSync(process.execPath, [escapedScriptPath], {
247
+ encoding: "utf8",
248
+ env: {...process.env, SOCKETDUCT_IMAGE: "ghcr.io/kaspernj/socketduct:latest"}
249
+ })
267
250
 
268
- assert.equal(JSON.parse(packageJson).scripts["gateway:deploy"], "node scripts/deploy-gateway.js")
269
- assert.match(dockerDocs, /Node\.js 24/i)
270
- assert.match(dockerDocs, /node --version/)
271
- assert.match(dockerDocs, /npm --version/)
272
- assert.match(dockerDocs, /npm run gateway:deploy/)
273
- assert.match(dockerDocs, /--project-name socketduct-gateway/)
274
- assert.match(dockerDocs, /docker login ghcr\.io/)
275
- assert.match(dockerDocs, /rollback/i)
276
- assert.match(dockerDocs, /change.*digest/i)
277
- assert.doesNotMatch(publicDocs, /docker compose[^\n]*up[^\n]*--build/)
251
+ expect(result.status).toBe(1)
252
+ expect(result.stderr).toMatch(/immutable GHCR image/)
253
+ } finally {
254
+ await rm(root, {recursive: true, force: true})
255
+ }
256
+ })
257
+
258
+ it("gateway documentation describes pinning, private GHCR login, and digest rollback", async () => {
259
+ const [dockerDocs, packageJson, runningDocs, securityDocs] = await Promise.all([
260
+ readFile(docsPath, "utf8"),
261
+ readFile(packagePath, "utf8"),
262
+ readFile(runningDocsPath, "utf8"),
263
+ readFile(securityDocsPath, "utf8")
264
+ ])
265
+ const publicDocs = `${dockerDocs}\n${runningDocs}\n${securityDocs}`
266
+
267
+ expect(JSON.parse(packageJson).scripts["gateway:deploy"]).toBe("node scripts/deploy-gateway.js")
268
+ expect(dockerDocs).toMatch(/Node\.js 24/i)
269
+ expect(dockerDocs).toMatch(/node --version/)
270
+ expect(dockerDocs).toMatch(/npm --version/)
271
+ expect(dockerDocs).toMatch(/npm run gateway:deploy/)
272
+ expect(dockerDocs).toMatch(/--project-name socketduct-gateway/)
273
+ expect(dockerDocs).toMatch(/docker login ghcr\.io/)
274
+ expect(dockerDocs).toMatch(/rollback/i)
275
+ expect(dockerDocs).toMatch(/change.*digest/i)
276
+ expect(publicDocs).not.toMatch(/docker compose[^\n]*up[^\n]*--build/)
277
+ })
278
278
  })