socketduct 0.1.24 → 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 (60) 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/client/multiplexed-transport-set.js +20 -2
  7. package/src/logging/compressed-log-writer.js +1 -1
  8. package/src/logging/zstd-frame-codec.js +15 -1
  9. package/src/reverse/client.js +44 -1
  10. package/src/reverse/hub.js +10 -1
  11. package/src/reverse/runtime.js +11 -2
  12. package/test/e2e/gateway-process.test.js +93 -92
  13. package/test/e2e/logger-shutdown-process.test.js +40 -37
  14. package/test/e2e/reverse-client-recovery-process.test.js +107 -103
  15. package/test/e2e/reverse-gateway-process.test.js +328 -318
  16. package/test/integration/initial-target-recovery.test.js +333 -334
  17. package/test/integration/logging-cli.test.js +121 -119
  18. package/test/integration/logging-compressed-recovery.test.js +96 -95
  19. package/test/integration/multiplexed-compatibility.test.js +178 -177
  20. package/test/integration/multiplexed-corrections.test.js +360 -359
  21. package/test/integration/multiplexed-failures.test.js +253 -251
  22. package/test/integration/multiplexed-transport-set.test.js +334 -322
  23. package/test/integration/multiplexed-transport.test.js +135 -134
  24. package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
  25. package/test/integration/reverse-gateway.test.js +2309 -2218
  26. package/test/integration/reverse-resume-continuity.test.js +219 -218
  27. package/test/integration/reverse-route-class.test.js +363 -278
  28. package/test/integration/socketduct-http-agent.test.js +314 -312
  29. package/test/integration/socketduct-https-agent.test.js +516 -485
  30. package/test/integration/stream-compression.test.js +862 -824
  31. package/test/integration/tcp-forwarding.test.js +2550 -2447
  32. package/test/integration/tls-forwarding.test.js +593 -592
  33. package/test/unit/auth/auth.test.js +43 -44
  34. package/test/unit/auth/static-token-store.test.js +9 -8
  35. package/test/unit/client/connect.test.js +41 -40
  36. package/test/unit/client/deferred-virtual-socket.test.js +95 -94
  37. package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
  38. package/test/unit/client/multiplexed-transport-set.test.js +845 -732
  39. package/test/unit/client/socketduct-socket.test.js +768 -771
  40. package/test/unit/client/virtual-socket.test.js +198 -168
  41. package/test/unit/config/docker-gateway-files.test.js +224 -224
  42. package/test/unit/config/environment.test.js +472 -492
  43. package/test/unit/index.test.js +53 -52
  44. package/test/unit/logging/compressed-log-worker.test.js +23 -22
  45. package/test/unit/logging/compressed-log-writer.test.js +155 -154
  46. package/test/unit/logging/log-retention.test.js +52 -47
  47. package/test/unit/logging/log-segment-index.test.js +84 -81
  48. package/test/unit/logging/zstd-frame-codec.test.js +28 -19
  49. package/test/unit/protocol/compression.test.js +254 -298
  50. package/test/unit/protocol/frame.test.js +280 -276
  51. package/test/unit/protocol/v3-compression.test.js +21 -23
  52. package/test/unit/protocol/v3-frame.test.js +42 -41
  53. package/test/unit/release-configuration.test.js +15 -12
  54. package/test/unit/reverse/disconnect-attribution.test.js +65 -64
  55. package/test/unit/reverse-terminal-outcomes.test.js +53 -50
  56. package/test/unit/server/control-handler.test.js +232 -231
  57. package/test/unit/server/primary.test.js +51 -49
  58. package/test/unit/server/relay-runtime.test.js +280 -294
  59. package/test/unit/sessions/session.test.js +48 -62
  60. 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
  })