@vantran-se/openchamber-web 1.24.4 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -8
- package/bin/lib/commands-connect-url.js +3 -8
- package/bin/lib/commands-tunnel.js +3 -0
- package/dist/assets/{CodeMirrorCommentWidgets-B4a0JkUz.js → CodeMirrorCommentWidgets-B82Oye3V.js} +1 -1
- package/dist/assets/DiffView-DJLmWn6Z.js +4 -0
- package/dist/assets/FilesView-DjksmZRo.js +17 -0
- package/dist/assets/{GitView-Byu2DY7s.js → GitView-DiUsUIgr.js} +4 -4
- package/dist/assets/{GuestAttachDialog-B808E0-d.js → GuestAttachDialog-CYwWYLr3.js} +1 -1
- package/dist/assets/GuestSurfacePane-CmctyRI4.js +1 -0
- package/dist/assets/{JsonTreeView-VW_7czo3.js → JsonTreeView-DOHHcbgJ.js} +1 -1
- package/dist/assets/LinearIssuesView-DaqnylcV.js +1 -0
- package/dist/assets/MarkdownImageGallery-HJNvWt7L.js +1 -0
- package/dist/assets/MarkdownRendererImpl-nDZRGg2I.js +12 -0
- package/dist/assets/OnboardingScreen-C2B5Bsof.js +1 -0
- package/dist/assets/{PatchDiff-DCjGhlYV.js → PatchDiff-C4FLBjpt.js} +1 -1
- package/dist/assets/{PierreDiffViewer-CEDZNkU2.js → PierreDiffViewer-C_1S9c9n.js} +1 -1
- package/dist/assets/PlanView-_eZvb-G5.js +5 -0
- package/dist/assets/PluginPane-sM8fcZIV.js +3 -0
- package/dist/assets/SettingsView-BRfr46hb.js +29 -0
- package/dist/assets/SettingsWindow-CcUDwQHg.js +1 -0
- package/dist/assets/SubmoduleDiffSummary-CrN1ZGwl.js +1 -0
- package/dist/assets/ToolOutputDialog-Cij4jI09.js +15 -0
- package/dist/assets/ToolPartDiffPreview-BXzwQ-JY.js +8 -0
- package/dist/assets/{VirtualizedFileDiff-NU0FHFhT.js → VirtualizedFileDiff-DSWTLJNH.js} +4 -4
- package/dist/assets/{WalkthroughView-BZyjhqHv.js → WalkthroughView-CpUUgY8H.js} +2 -2
- package/dist/assets/appThemeRegistry-B5tHTkR8.js +1 -0
- package/dist/assets/{commitAndPush-BDftrjr6.js → commitAndPush-D7D8qV8p.js} +1 -1
- package/dist/assets/{computeVirtualFileMetrics-DlWlEM3x.js → computeVirtualFileMetrics-cq9uQ30i.js} +1 -1
- package/dist/assets/de-CUO1-KqF.js +10 -0
- package/dist/assets/es-BlbC6sGJ.js +16 -0
- package/dist/assets/fr-D2nwe_0W.js +14 -0
- package/dist/assets/index-BQOoYIOu.js +1 -0
- package/dist/assets/index-CLcM8HKx.css +1 -0
- package/dist/assets/index-CvLgnuNI.js +41 -0
- package/dist/assets/{isDiffPlainText-CO4e5J_S.js → isDiffPlainText-D0obJ2p8.js} +1 -1
- package/dist/assets/{isFilePlainText-ue6kCou7.js → isFilePlainText-DB3_rjyW.js} +1 -1
- package/dist/assets/ja-CA7vnDsZ.js +16 -0
- package/dist/assets/ko-Chy6a9l-.js +16 -0
- package/dist/assets/main-B9JFK3vF.js +896 -0
- package/dist/assets/main-Bhiqnrhd.js +2 -0
- package/dist/assets/{markdownCore-CK48LG0z.js → markdownCore-tfqOFaBd.js} +4 -4
- package/dist/assets/miniChat-B0UOL5zA.js +2 -0
- package/dist/assets/mobile-BJppFIEo.js +2 -0
- package/dist/assets/{patchFileDiff-D7QVlovD.js → patchFileDiff-Db6KuwCP.js} +3 -3
- package/dist/assets/pl-CTR5A_1P.js +16 -0
- package/dist/assets/pt-BR-BE1O1KEx.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-Yz5sfoFh.js +2 -0
- package/dist/assets/renderMobileApp-QQ-yB8aY.js +25 -0
- package/dist/assets/runtimeEndpointReset-DKDdzRzm.js +27 -0
- package/dist/assets/scheduledTasksApi-C5sCuNqf.js +1 -0
- package/dist/assets/{shikiHighlight-BPu3JiQs.js → shikiHighlight-xURoo5t_.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-DWqRPo9A.js → textMateThemeFromAppTheme-BZLGuYTU.js} +1 -1
- package/dist/assets/tr-Bh5vK6cW.js +7 -0
- package/dist/assets/uk-CLDkZFy_.js +16 -0
- package/dist/assets/useAppFontEffects-BxrtCnfR.js +7337 -0
- package/dist/assets/{useInlineCommentController-CgS0fnw_.js → useInlineCommentController-CeXuR-SR.js} +2 -2
- package/dist/assets/useRootScrollLock-DgWxezc1.js +2 -0
- package/dist/assets/{vendor-codemirror-language-data-Bx2EHc1O.js → vendor-codemirror-language-data-DOuaQUZ0.js} +2 -2
- package/dist/assets/vendor-cron-parser-CjvYVigk.js +2 -0
- package/dist/assets/vendor-opencode-client-_BAHQo4R.js +12 -0
- package/dist/assets/{vendor-remixicon-react-BDmGmFCA.js → vendor-remixicon-react-D6dZ0UwF.js} +1 -1
- package/dist/assets/{vendor-zod-CgAdXWZO.js → vendor-zod-Gea9uBlY.js} +1 -1
- package/dist/assets/{web-components-DxX8bZ1o.js → web-components-63H1h21j.js} +2 -2
- package/dist/assets/zh-CN-DODgcmYj.js +16 -0
- package/dist/assets/zh-TW-ByNfIwLA.js +16 -0
- package/dist/index.html +13 -7
- package/dist/mini-chat.html +5 -5
- package/dist/mobile.html +5 -5
- package/package.json +6 -5
- package/server/index.d.ts +1 -0
- package/server/index.js +246 -91
- package/server/lib/agent-tool/DOCUMENTATION.md +32 -15
- package/server/lib/agent-tool/runtime.js +135 -54
- package/server/lib/agent-tool/runtime.test.js +155 -87
- package/server/lib/browser-control/DOCUMENTATION.md +37 -2
- package/server/lib/browser-control/provider.js +217 -0
- package/server/lib/browser-control/provider.test.js +251 -0
- package/server/lib/context-obligatory/runtime.js +61 -50
- package/server/lib/context-obligatory/runtime.test.js +62 -162
- package/server/lib/event-stream/DOCUMENTATION.md +12 -1
- package/server/lib/event-stream/delta-coalescer.js +0 -0
- package/server/lib/event-stream/delta-coalescer.test.js +68 -57
- package/server/lib/event-stream/directory-ws-bridge.js +1 -1
- package/server/lib/event-stream/global-hub.js +10 -1
- package/server/lib/event-stream/global-hub.test.js +27 -26
- package/server/lib/event-stream/index.js +6 -0
- package/server/lib/event-stream/protocol.js +26 -19
- package/server/lib/event-stream/protocol.test.js +18 -7
- package/server/lib/event-stream/rebind.test.js +5 -5
- package/server/lib/event-stream/runtime.test.js +17 -15
- package/server/lib/event-stream/translate-v2.js +313 -0
- package/server/lib/event-stream/translate-v2.test.js +209 -0
- package/server/lib/event-stream/upstream-reader.test.js +16 -16
- package/server/lib/fs/DOCUMENTATION.md +4 -2
- package/server/lib/fs/byte-range.js +35 -0
- package/server/lib/fs/byte-range.test.js +31 -0
- package/server/lib/fs/routes.js +47 -42
- package/server/lib/fs/routes.test.js +123 -18
- package/server/lib/git/service.js +12 -0
- package/server/lib/github/DOCUMENTATION.md +1 -1
- package/server/lib/github/pr-status.js +22 -2
- package/server/lib/github/pr-status.test.js +27 -14
- package/server/lib/guests/DOCUMENTATION.md +13 -0
- package/server/lib/guests/catalog.js +12 -0
- package/server/lib/guests/catalog.test.js +33 -0
- package/server/lib/guests/routes.js +10 -1
- package/server/lib/guests/service.js +192 -14
- package/server/lib/guests/service.test.js +201 -1
- package/server/lib/guests/surface.js +630 -0
- package/server/lib/guests/surface.test.js +486 -0
- package/server/lib/markdown-image-grants/routes.js +18 -5
- package/server/lib/markdown-image-grants/routes.test.js +9 -6
- package/server/lib/message-queue/DOCUMENTATION.md +33 -22
- package/server/lib/message-queue/runtime.js +155 -100
- package/server/lib/message-queue/runtime.test.js +168 -82
- package/server/lib/notifications/DOCUMENTATION.md +28 -0
- package/server/lib/notifications/control-stream.http.test.js +68 -0
- package/server/lib/notifications/emitter-runtime.js +15 -0
- package/server/lib/notifications/emitter-runtime.test.js +32 -0
- package/server/lib/notifications/push-runtime.js +5 -1
- package/server/lib/notifications/push-runtime.test.js +2 -2
- package/server/lib/notifications/routes.js +1 -1
- package/server/lib/notifications/runtime.js +115 -26
- package/server/lib/notifications/template-runtime.js +13 -15
- package/server/lib/notifications/template-runtime.test.js +16 -9
- package/server/lib/notifications/trigger-runtime.test.js +187 -0
- package/server/lib/openchamber-control/DOCUMENTATION.md +10 -0
- package/server/lib/openchamber-control/actions.js +3 -2
- package/server/lib/openchamber-control/error.js +1 -1
- package/server/lib/openchamber-control/file-open.js +59 -0
- package/server/lib/openchamber-control/file-open.test.js +57 -0
- package/server/lib/openchamber-control/routes.test.js +12 -0
- package/server/lib/openchamber-control/service.js +102 -49
- package/server/lib/openchamber-control/service.test.js +84 -44
- package/server/lib/openchamber-sessions/archive-store.js +224 -0
- package/server/lib/openchamber-sessions/archive-store.test.js +187 -0
- package/server/lib/openchamber-sessions/opencode-client.js +22 -0
- package/server/lib/openchamber-sessions/routes.js +398 -328
- package/server/lib/openchamber-sessions/routes.test.js +777 -740
- package/server/lib/openchamber-sessions/session-metadata-store.js +285 -0
- package/server/lib/openchamber-sessions/session-metadata-store.test.js +259 -0
- package/server/lib/opencode/DOCUMENTATION.md +449 -29
- package/server/lib/opencode/agents.js +233 -327
- package/server/lib/opencode/auth.js +41 -44
- package/server/lib/opencode/auth.test.js +92 -0
- package/server/lib/opencode/cli-upgrade.d.ts +4 -0
- package/server/lib/opencode/cli-upgrade.js +18 -0
- package/server/lib/opencode/cli-upgrade.test.js +36 -0
- package/server/lib/opencode/commands.js +164 -145
- package/server/lib/opencode/compatibility.d.ts +16 -0
- package/server/lib/opencode/compatibility.js +88 -0
- package/server/lib/opencode/compatibility.test.js +62 -0
- package/server/lib/opencode/config-entity-routes.js +58 -29
- package/server/lib/opencode/config-entity-v2.test.js +290 -0
- package/server/lib/opencode/config-mutation-response.js +17 -7
- package/server/lib/opencode/config-mutation-response.test.js +4 -7
- package/server/lib/opencode/config-paths.test.js +2 -2
- package/server/lib/opencode/config-v2.d.ts +183 -0
- package/server/lib/opencode/config-v2.js +801 -0
- package/server/lib/opencode/config-v2.test.js +466 -0
- package/server/lib/opencode/credential-db.js +144 -0
- package/server/lib/opencode/credential-db.test.js +104 -0
- package/server/lib/opencode/env-runtime.js +48 -9
- package/server/lib/opencode/env-runtime.test.js +45 -0
- package/server/lib/opencode/feature-routes-runtime.js +15 -5
- package/server/lib/opencode/lifecycle-process.test.js +7 -4
- package/server/lib/opencode/lifecycle.js +85 -30
- package/server/lib/opencode/lifecycle.test.js +80 -8
- package/server/lib/opencode/login-shell-env.js +27 -0
- package/server/lib/opencode/managed-config-file.js +119 -0
- package/server/lib/opencode/managed-config-file.test.js +152 -0
- package/server/lib/opencode/managed-plugin-config.js +33 -13
- package/server/lib/opencode/mcp.js +43 -149
- package/server/lib/opencode/network-runtime.js +6 -7
- package/server/lib/opencode/openchamber-routes.test.js +2 -5
- package/server/lib/opencode/plugin-routes.js +3 -3
- package/server/lib/opencode/plugin-routes.test.js +6 -24
- package/server/lib/opencode/plugins.js +91 -40
- package/server/lib/opencode/plugins.test.js +28 -15
- package/server/lib/opencode/providers.js +63 -83
- package/server/lib/opencode/providers.test.js +128 -59
- package/server/lib/opencode/proxy-agent-wiring.test.js +4 -2
- package/server/lib/opencode/proxy.js +179 -129
- package/server/lib/opencode/pwa-manifest-routes.js +4 -2
- package/server/lib/opencode/pwa-manifest-routes.test.js +3 -2
- package/server/lib/opencode/response-envelope.js +14 -0
- package/server/lib/opencode/response-envelope.test.js +23 -0
- package/server/lib/opencode/routes-upgrade.test.js +73 -116
- package/server/lib/opencode/routes.js +121 -428
- package/server/lib/opencode/server-utils-runtime.js +10 -4
- package/server/lib/opencode/session-activity.js +78 -0
- package/server/lib/opencode/session-runtime.js +21 -10
- package/server/lib/opencode/session-runtime.test.js +9 -9
- package/server/lib/opencode/settings-helpers.js +9 -9
- package/server/lib/opencode/settings-helpers.test.js +7 -12
- package/server/lib/opencode/settings-registry.json +6 -8
- package/server/lib/opencode/settings-runtime.js +18 -1
- package/server/lib/opencode/settings-runtime.test.js +31 -2
- package/server/lib/opencode/shared.js +54 -22
- package/server/lib/opencode/shared.test.js +8 -4
- package/server/lib/opencode/shutdown-runtime.js +54 -7
- package/server/lib/opencode/shutdown-runtime.test.js +224 -30
- package/server/lib/opencode/skill-routes.js +23 -24
- package/server/lib/opencode/skills.js +7 -5
- package/server/lib/opencode/tunnel-wiring-runtime.js +2 -1
- package/server/lib/opencode/upgrade-capability.test.js +1 -1
- package/server/lib/opencode/v1-migration-topup.js +288 -0
- package/server/lib/opencode/v1-migration-topup.test.js +266 -0
- package/server/lib/opencode/v2-install.d.ts +8 -0
- package/server/lib/opencode/v2-install.js +158 -0
- package/server/lib/opencode/v2-install.test.js +124 -0
- package/server/lib/opencode/watcher.js +10 -3
- package/server/lib/opencode/watcher.test.js +17 -21
- package/server/lib/opencode/websearch-config.js +66 -0
- package/server/lib/package-manager.js +55 -68
- package/server/lib/package-manager.test.js +241 -75
- package/server/lib/permission-auto-accept/runtime.js +38 -22
- package/server/lib/permission-auto-accept/runtime.test.js +39 -17
- package/server/lib/quota/DOCUMENTATION.md +4 -0
- package/server/lib/quota/providers/xai.js +4 -4
- package/server/lib/relay/tunnel-host.js +4 -1
- package/server/lib/relay/tunnel-host.test.js +7 -0
- package/server/lib/routing/DOCUMENTATION.md +52 -21
- package/server/lib/routing/defaults.js +23 -6
- package/server/lib/routing/jev.js +17 -7
- package/server/lib/routing/routes.http.test.js +68 -33
- package/server/lib/routing/routes.js +65 -22
- package/server/lib/routing/runtime.js +145 -48
- package/server/lib/routing/runtime.test.js +79 -55
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +4 -0
- package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +51 -46
- package/server/lib/scheduled-tasks/runtime.js +81 -74
- package/server/lib/scheduled-tasks/runtime.test.js +53 -1
- package/server/lib/session-assist/DOCUMENTATION.md +8 -3
- package/server/lib/session-assist/context.js +122 -46
- package/server/lib/session-assist/context.test.js +117 -49
- package/server/lib/session-assist/runtime.js +52 -22
- package/server/lib/session-assist/runtime.test.js +145 -173
- package/server/lib/session-goal/DOCUMENTATION.md +23 -15
- package/server/lib/session-goal/create.js +9 -12
- package/server/lib/session-goal/create.test.js +27 -28
- package/server/lib/session-goal/runtime.js +164 -77
- package/server/lib/session-goal/runtime.test.js +271 -519
- package/server/lib/session-knowledge/DOCUMENTATION.md +5 -0
- package/server/lib/session-knowledge/runtime.js +59 -41
- package/server/lib/session-knowledge/runtime.test.js +90 -20
- package/server/lib/small-model/DOCUMENTATION.md +154 -208
- package/server/lib/small-model/client.js +113 -0
- package/server/lib/small-model/index.js +296 -158
- package/server/lib/small-model/index.test.js +423 -256
- package/server/lib/small-model/routes.js +1 -1
- package/server/lib/spaces/DOCUMENTATION.md +807 -0
- package/server/lib/spaces/code-in-bait.js +185 -0
- package/server/lib/spaces/code-in.js +641 -0
- package/server/lib/spaces/code-in.test.js +1048 -0
- package/server/lib/spaces/errors.js +8 -0
- package/server/lib/spaces/exec-http.js +94 -0
- package/server/lib/spaces/gatekeeper-channel.js +202 -0
- package/server/lib/spaces/gatekeeper-channel.test.js +215 -0
- package/server/lib/spaces/gatekeeper-program.cjs +914 -0
- package/server/lib/spaces/gatekeeper-program.test.js +1391 -0
- package/server/lib/spaces/hardening.js +457 -0
- package/server/lib/spaces/hardening.test.js +368 -0
- package/server/lib/spaces/host-git.js +127 -0
- package/server/lib/spaces/host-git.test.js +137 -0
- package/server/lib/spaces/labels.js +234 -0
- package/server/lib/spaces/labels.test.js +219 -0
- package/server/lib/spaces/layout.js +184 -0
- package/server/lib/spaces/layout.test.js +30 -0
- package/server/lib/spaces/manager.js +82 -0
- package/server/lib/spaces/manager.test.js +114 -0
- package/server/lib/spaces/places/code-in.docker.live.test.js +216 -0
- package/server/lib/spaces/places/contract-suite.js +156 -0
- package/server/lib/spaces/places/contract.docker.live.test.js +7 -0
- package/server/lib/spaces/places/contract.test.js +6 -0
- package/server/lib/spaces/places/docker-engine.js +126 -0
- package/server/lib/spaces/places/docker-live-support.js +202 -0
- package/server/lib/spaces/places/docker-tools.js +186 -0
- package/server/lib/spaces/places/docker.js +587 -0
- package/server/lib/spaces/places/docker.test.js +1610 -0
- package/server/lib/spaces/places/escape-suite.js +1018 -0
- package/server/lib/spaces/places/escape.docker.live.test.js +7 -0
- package/server/lib/spaces/places/fake-docker.js +375 -0
- package/server/lib/spaces/places/memory-place.js +103 -0
- package/server/lib/spaces/places/packed.docker.live.test.js +77 -0
- package/server/lib/spaces/places/registry.js +43 -0
- package/server/lib/spaces/places/registry.test.js +50 -0
- package/server/lib/spaces/places/server.docker.live.test.js +225 -0
- package/server/lib/spaces/run-command.js +261 -0
- package/server/lib/spaces/run-command.test.js +396 -0
- package/server/lib/spaces/space-server.js +190 -0
- package/server/lib/spaces/space-server.test.js +335 -0
- package/server/lib/spaces/tools-filler.js +82 -0
- package/server/lib/spaces/tools-filler.test.js +148 -0
- package/server/lib/spaces/tools-pack.js +49 -0
- package/server/lib/spaces/tools-pack.test.js +57 -0
- package/server/lib/spaces/tools.js +138 -0
- package/server/lib/spaces/tools.test.js +153 -0
- package/server/lib/tts/openai-sdk.js +11 -0
- package/server/lib/tts/service.js +7 -5
- package/server/lib/tts/stt.js +3 -2
- package/server/lib/tunnels/DOCUMENTATION.md +2 -0
- package/server/lib/tunnels/routes.js +7 -0
- package/server/lib/tunnels/routes.test.js +55 -0
- package/server/lib/ui-auth/ui-auth.js +38 -2
- package/server/lib/ui-auth/ui-auth.test.js +42 -0
- package/server/lib/ui-auth/ui-passkeys.js +12 -10
- package/server/lib/walkthrough/DOCUMENTATION.md +10 -1
- package/server/lib/walkthrough/pull-request.js +61 -8
- package/server/lib/walkthrough/pull-request.test.js +54 -1
- package/server/lib/walkthrough/routes.js +20 -0
- package/server/lib/walkthrough/routes.test.js +19 -0
- package/server/opencode-proxy.test.js +190 -166
- package/server/opencode-worktree-readiness.test.js +5 -4
- package/dist/assets/DiffView-B2CD9wC8.js +0 -4
- package/dist/assets/FilesView-C4vMFclc.js +0 -13
- package/dist/assets/LinearIssuesView-EzGNCGrd.js +0 -1
- package/dist/assets/MarkdownImageGallery-B2YUDFaB.js +0 -1
- package/dist/assets/MarkdownRendererImpl-B4ikIzR3.js +0 -12
- package/dist/assets/OnboardingScreen-CQHBSTEi.js +0 -1
- package/dist/assets/PlanView-D3x-pTkZ.js +0 -5
- package/dist/assets/PluginPane-DEcgDQ9h.js +0 -3
- package/dist/assets/SettingsView-CSinuH-1.js +0 -29
- package/dist/assets/SettingsWindow-B5CHoJFZ.js +0 -1
- package/dist/assets/SubmoduleDiffSummary-oxeACCWs.js +0 -1
- package/dist/assets/ToolOutputDialog-tLNWmfcW.js +0 -15
- package/dist/assets/ToolPartDiffPreview-CflzmK-x.js +0 -8
- package/dist/assets/appThemeRegistry-DLicw4dH.js +0 -1
- package/dist/assets/de-Bro6_zyl.js +0 -10
- package/dist/assets/es-Bb9XQZvd.js +0 -16
- package/dist/assets/fr-Dgfzn-IL.js +0 -14
- package/dist/assets/index-CtQNmqUL.js +0 -1
- package/dist/assets/index-DqaMWNrC.css +0 -1
- package/dist/assets/index-Dqc6USl3.js +0 -40
- package/dist/assets/ja-BYsakGFh.js +0 -16
- package/dist/assets/ko-BSaQz9aN.js +0 -16
- package/dist/assets/main-B62_pjL5.js +0 -2
- package/dist/assets/main-BEdcOI2P.js +0 -896
- package/dist/assets/miniChat-LT8Rqofr.js +0 -2
- package/dist/assets/mobile-CqarEynh.js +0 -2
- package/dist/assets/pl-OIviPdoj.js +0 -16
- package/dist/assets/pt-BR-Cltg-E6s.js +0 -16
- package/dist/assets/renderElectronMiniChatApp-COyLTtZx.js +0 -2
- package/dist/assets/renderMobileApp-BC1MHg6g.js +0 -25
- package/dist/assets/runtimeEndpointReset-DdrazOVH.js +0 -25
- package/dist/assets/scheduledTasksApi-B2SWMGEU.js +0 -1
- package/dist/assets/tr-CQI-KPre.js +0 -7
- package/dist/assets/uk-CqW7R4DA.js +0 -16
- package/dist/assets/useAppFontEffects-ne84JBVL.js +0 -7333
- package/dist/assets/useRootScrollLock-Cpf1qoGp.js +0 -2
- package/dist/assets/vendor-cron-parser-CUcP7OfX.js +0 -2
- package/dist/assets/vendor-opencode-sdk-BJ73u-bH.js +0 -7
- package/dist/assets/zh-CN-CVZG_o_J.js +0 -16
- package/dist/assets/zh-TW-D5ZxzCZp.js +0 -16
- package/server/lib/opencode/mcp-oauth-callback.test.js +0 -111
- package/server/lib/routing/feature-flag.js +0 -11
- package/server/lib/small-model/call.js +0 -851
- package/server/lib/small-model/call.test.js +0 -1292
- package/server/lib/small-model/catalog.js +0 -13
- package/server/lib/small-model/resolve.js +0 -131
- package/server/lib/small-model/resolve.test.js +0 -197
- package/server/lib/small-model/runtime-providers.js +0 -164
- package/server/lib/small-model/runtime-providers.test.js +0 -118
- package/server/lib/system-prompt/DOCUMENTATION.md +0 -36
- package/server/lib/system-prompt/runtime.js +0 -49
- package/server/lib/system-prompt/runtime.test.js +0 -76
- package/server/lib/walkthrough/reproduce-2607.test.js +0 -148
|
@@ -0,0 +1,1610 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { SpaceError } from '../errors.js';
|
|
4
|
+
import { GATEKEEPER_PROGRAM } from '../gatekeeper-channel.js';
|
|
5
|
+
import { buildSpaceLabels, buildToolsLabels, hashProjectDirectory } from '../labels.js';
|
|
6
|
+
import { SPACE_ENVIRONMENT, SPACE_SERVER_COMMAND } from '../layout.js';
|
|
7
|
+
import { createRegistryToolsSource, toolsContentKey } from '../tools.js';
|
|
8
|
+
import { SPACE_BASE_IMAGE, createDockerPlace } from './docker.js';
|
|
9
|
+
import { bridgeNetworkEntry, createFakeDocker, hardenedContainerEntry, internalNetworkEntry } from './fake-docker.js';
|
|
10
|
+
|
|
11
|
+
const OWNER = 'install-a';
|
|
12
|
+
const ID = 'a1b2c3d4e5f6';
|
|
13
|
+
const SPEC = { id: ID, name: 'Fix a=b, then c', project: hashProjectDirectory('/home/me/project'), created: '2026-09-19T10:00:00.000Z', memoryBytes: 4294967296 };
|
|
14
|
+
|
|
15
|
+
const CONTAINER = `openchamber-space-${ID}-space`;
|
|
16
|
+
const GATEKEEPER = `openchamber-space-${ID}-gatekeeper`;
|
|
17
|
+
const NETWORK = `openchamber-space-${ID}-network`;
|
|
18
|
+
const OUTER_NETWORK = `openchamber-space-${ID}-outer-network`;
|
|
19
|
+
const WORK = `openchamber-space-${ID}-volume-work`;
|
|
20
|
+
const HOME = `openchamber-space-${ID}-volume-home`;
|
|
21
|
+
|
|
22
|
+
const SOURCE = createRegistryToolsSource({ webVersion: '1.24.2', openCodeVersion: '1.18.31' });
|
|
23
|
+
const KEY = toolsContentKey(SOURCE, SPACE_BASE_IMAGE);
|
|
24
|
+
const TOOLS = `openchamber-tools-${OWNER}-${KEY}`;
|
|
25
|
+
const NOW = new Date('2026-09-20T08:00:00.000Z');
|
|
26
|
+
|
|
27
|
+
/** A place on a plain runner, for the tests that wrap or replace the fake. */
|
|
28
|
+
const placeOn = (runCommand, options = {}) => createDockerPlace({ runCommand, dockerPath: 'docker', owner: OWNER, toolsSource: SOURCE, now: () => NOW, ...options });
|
|
29
|
+
|
|
30
|
+
const makePlace = (fake, owner = OWNER, toolsSource = SOURCE) => placeOn(fake.runCommand, { dockerPath: '/usr/bin/docker', owner, toolsSource, wait: fake.wait, now: fake.now });
|
|
31
|
+
|
|
32
|
+
const labelsFor = (role, { id = ID, owner = OWNER } = {}) => buildSpaceLabels({ ...SPEC, id, role, owner });
|
|
33
|
+
|
|
34
|
+
const toolsLabels = ({ owner = OWNER, key = KEY, role = 'tools' } = {}) => buildToolsLabels({ role, owner, key, description: SOURCE.description, created: NOW.toISOString() });
|
|
35
|
+
|
|
36
|
+
/** Seed for a tools volume, filled unless the test says otherwise. */
|
|
37
|
+
const toolsResource = ({ owner = OWNER, key = KEY, filled = true, labels = toolsLabels({ owner, key }) } = {}) => {
|
|
38
|
+
const name = `openchamber-tools-${owner}-${key}`;
|
|
39
|
+
return { kind: 'volume', name, entry: { Name: name, Labels: labels }, filled };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Seeds for a complete space, as the fake docker stores them. It runs on `tools`, its server has
|
|
44
|
+
* a token, and its gatekeeper runs whenever it does.
|
|
45
|
+
*/
|
|
46
|
+
const spaceResources = ({ id = ID, owner = OWNER, running = true, tools = `openchamber-tools-${owner}-${KEY}` } = {}) => {
|
|
47
|
+
const prefix = `openchamber-space-${id}-`;
|
|
48
|
+
const mounts = [
|
|
49
|
+
{ volume: `${prefix}volume-work`, destination: `/spaces/${id}` },
|
|
50
|
+
{ volume: `${prefix}volume-home`, destination: '/home/space' },
|
|
51
|
+
{ volume: tools, destination: '/opt/openchamber-tools', readOnly: true },
|
|
52
|
+
];
|
|
53
|
+
const gatekeeper = hardenedContainerEntry({
|
|
54
|
+
name: `${prefix}gatekeeper`,
|
|
55
|
+
labels: labelsFor('gatekeeper', { id, owner }),
|
|
56
|
+
network: `${prefix}network`,
|
|
57
|
+
mounts: [],
|
|
58
|
+
env: ['HOME=/tmp'],
|
|
59
|
+
running,
|
|
60
|
+
memoryBytes: 268435456,
|
|
61
|
+
tmpfs: '/tmp:rw,noexec,nosuid,size=16m',
|
|
62
|
+
aliases: ['gatekeeper'],
|
|
63
|
+
});
|
|
64
|
+
gatekeeper.NetworkSettings.Networks[`${prefix}outer-network`] = {};
|
|
65
|
+
return [
|
|
66
|
+
{ kind: 'container', name: `${prefix}gatekeeper`, entry: gatekeeper },
|
|
67
|
+
{
|
|
68
|
+
kind: 'container',
|
|
69
|
+
name: `${prefix}space`,
|
|
70
|
+
entry: hardenedContainerEntry({ name: `${prefix}space`, labels: labelsFor('space', { id, owner }), network: `${prefix}network`, mounts, running }),
|
|
71
|
+
},
|
|
72
|
+
{ kind: 'volume', name: `${prefix}volume-work`, entry: { Name: `${prefix}volume-work`, Labels: labelsFor('volume', { id, owner }) } },
|
|
73
|
+
{ kind: 'volume', name: `${prefix}volume-home`, entry: { Name: `${prefix}volume-home`, Labels: labelsFor('volume', { id, owner }) }, token: 'seeded-token' },
|
|
74
|
+
{ kind: 'network', name: `${prefix}network`, entry: internalNetworkEntry({ name: `${prefix}network`, labels: labelsFor('network', { id, owner }) }) },
|
|
75
|
+
{ kind: 'network', name: `${prefix}outer-network`, entry: bridgeNetworkEntry({ name: `${prefix}outer-network`, labels: labelsFor('outer-network', { id, owner }) }) },
|
|
76
|
+
];
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const roleOf = (args) => args.find((arg) => arg.startsWith('openchamber.space.role='))?.split('=')[1];
|
|
80
|
+
const isRun = (role) => (args) => args[0] === 'run' && roleOf(args) === role;
|
|
81
|
+
const isExec = (word) => (args) => args[0] === 'exec' && args.slice(5).join(' ').includes(word);
|
|
82
|
+
|
|
83
|
+
const CONTAINER_ID = /^[0-9a-f]{64}$/;
|
|
84
|
+
|
|
85
|
+
const describeCall = (args) => {
|
|
86
|
+
if (args[0] === 'run') return `run ${roleOf(args)}`;
|
|
87
|
+
// A container that is addressed by the id `docker create` printed, not by a name.
|
|
88
|
+
if (CONTAINER_ID.test(args[args.length - 1])) return `${args[0]} <id>`;
|
|
89
|
+
if (args[0] === 'create') return args.includes('--network-alias') ? 'create gatekeeper' : 'create space';
|
|
90
|
+
if (args[0] === 'rename') return `rename ${args[1]} ${args[2]}`;
|
|
91
|
+
if (args[0] === 'network' && args[1] === 'connect') return `network connect ${args[2]}`;
|
|
92
|
+
if (isExec('ln -sfn')(args)) return 'exec link plugin';
|
|
93
|
+
if (isExec('token.new')(args)) return 'exec write token';
|
|
94
|
+
if (isExec('gatekeeper.cjs.new')(args)) return 'exec write program';
|
|
95
|
+
if (args[0] === 'exec') return `exec ${args[4].endsWith('-gatekeeper') ? 'gatekeeper ' : ''}${args[5].split('/').pop()}`;
|
|
96
|
+
const verb = ['network', 'volume'].includes(args[0]) ? `${args[0]} ${args[1]}` : args[0];
|
|
97
|
+
return `${verb} ${args[args.length - 1]}`;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** The calls that change something, in order, as short lines. */
|
|
101
|
+
const changes = (fake) => fake.calls
|
|
102
|
+
.map((call) => call.args)
|
|
103
|
+
.filter((args) => ['run', 'create', 'rm', 'pull', 'stop', 'start', 'exec', 'rename'].includes(args[0]) || ['create', 'rm', 'connect'].includes(args[1]))
|
|
104
|
+
.map(describeCall);
|
|
105
|
+
|
|
106
|
+
/** What the gatekeeper costs at every start: its program is written into a tmpfs that a stop empties. */
|
|
107
|
+
const GATEKEEPER_START_STEPS = ['exec write program', 'exec gatekeeper curl'];
|
|
108
|
+
|
|
109
|
+
/** What `create` does after the tools volume is there. */
|
|
110
|
+
const SPACE_STEPS = [
|
|
111
|
+
`network create ${NETWORK}`,
|
|
112
|
+
`network create ${OUTER_NETWORK}`,
|
|
113
|
+
`volume create ${WORK}`,
|
|
114
|
+
`volume create ${HOME}`,
|
|
115
|
+
'run setup',
|
|
116
|
+
'create gatekeeper',
|
|
117
|
+
`network connect ${OUTER_NETWORK}`,
|
|
118
|
+
`start ${GATEKEEPER}`,
|
|
119
|
+
...GATEKEEPER_START_STEPS,
|
|
120
|
+
'create space',
|
|
121
|
+
`start ${CONTAINER}`,
|
|
122
|
+
'exec link plugin',
|
|
123
|
+
'exec write token',
|
|
124
|
+
'exec curl',
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
const removals = (fake) => changes(fake).filter((line) => /(^| )rm /.test(line));
|
|
128
|
+
|
|
129
|
+
describe('docker place: check', () => {
|
|
130
|
+
const placeWith = (runCommand) => placeOn(runCommand, { dockerPath: '/opt/bin/docker' });
|
|
131
|
+
const SECCOMP = ['name=apparmor', 'name=seccomp,profile=builtin', 'name=cgroupns'];
|
|
132
|
+
const engine = ({ version = '29.2.1', securityOptions = SECCOMP } = {}) => async (file, args) => {
|
|
133
|
+
const answer = args[0] === 'version' ? { Client: { Version: '29.3.0' }, Server: { Version: version, Os: 'linux', Arch: 'arm64' } } : securityOptions;
|
|
134
|
+
return { code: 0, stdout: JSON.stringify(answer), stderr: '' };
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
it('reports the server version, os and arch, and that it can isolate from the host', async () => {
|
|
138
|
+
expect(await placeWith(engine()).check()).toEqual({ available: true, version: '29.2.1', os: 'linux', arch: 'arm64', hostIsolation: true });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('flags an engine older than 28 as unable to isolate from the host', async () => {
|
|
142
|
+
expect(await placeWith(engine({ version: '27.5.1' })).check()).toMatchObject({ available: true, hostIsolation: false });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it.each([
|
|
146
|
+
['no seccomp entry', ['name=apparmor']],
|
|
147
|
+
['an unconfined profile', ['name=seccomp,profile=unconfined']],
|
|
148
|
+
['no security options at all', null],
|
|
149
|
+
])('is unavailable with %s', async (title, securityOptions) => {
|
|
150
|
+
const result = await placeWith(engine({ securityOptions })).check();
|
|
151
|
+
expect(result).toMatchObject({ available: false, code: 'docker_seccomp_missing' });
|
|
152
|
+
expect(result.message).toMatch(/seccomp/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('accepts the older name of the builtin seccomp profile', async () => {
|
|
156
|
+
expect(await placeWith(engine({ securityOptions: ['name=seccomp,profile=default'] })).check()).toMatchObject({ available: true });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('says the CLI is missing only when the executable does not exist', async () => {
|
|
160
|
+
const place = placeWith(async () => { throw new SpaceError('command_spawn_failed', 'spawn docker ENOENT', { errno: 'ENOENT' }); });
|
|
161
|
+
const result = await place.check();
|
|
162
|
+
expect(result.available).toBe(false);
|
|
163
|
+
expect(result.code).toBe('docker_cli_missing');
|
|
164
|
+
expect(result.message).toMatch(/Install Docker/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('names the errno of any other spawn failure', async () => {
|
|
168
|
+
const place = placeWith(async () => { throw new SpaceError('command_spawn_failed', 'spawn docker EACCES', { errno: 'EACCES' }); });
|
|
169
|
+
const result = await place.check();
|
|
170
|
+
expect(result.code).toBe('docker_cli_unusable');
|
|
171
|
+
expect(result.message).toContain('EACCES');
|
|
172
|
+
expect(result.message).toContain('/opt/bin/docker');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('says the daemon is not running when the CLI answers without a server', async () => {
|
|
176
|
+
const stdout = JSON.stringify({ Client: { Version: '29.3.0' }, Server: null });
|
|
177
|
+
const place = placeWith(async () => ({ code: 1, stdout, stderr: 'Cannot connect to the Docker daemon' }));
|
|
178
|
+
const result = await place.check();
|
|
179
|
+
expect(result.code).toBe('docker_daemon_unreachable');
|
|
180
|
+
expect(result.message).toMatch(/Start Docker Desktop or Colima/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('says the daemon is unreachable on a timeout', async () => {
|
|
184
|
+
const place = placeWith(async () => { throw new SpaceError('command_timeout', 'too slow'); });
|
|
185
|
+
expect((await place.check()).code).toBe('docker_daemon_unreachable');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('docker place: create', () => {
|
|
190
|
+
it('creates the space container with exactly the hardening flags', async () => {
|
|
191
|
+
const fake = createFakeDocker({ resources: [toolsResource()] });
|
|
192
|
+
await makePlace(fake).create(SPEC);
|
|
193
|
+
|
|
194
|
+
const spaceCreate = fake.calls.map((call) => call.args).find((args) => args[0] === 'create' && !args.includes('--network-alias'));
|
|
195
|
+
expect(spaceCreate).toEqual([
|
|
196
|
+
'create',
|
|
197
|
+
'--name', CONTAINER,
|
|
198
|
+
'--label', 'openchamber.space=true',
|
|
199
|
+
'--label', `openchamber.space.id=${ID}`,
|
|
200
|
+
'--label', 'openchamber.space.role=space',
|
|
201
|
+
'--label', `openchamber.space.owner=${OWNER}`,
|
|
202
|
+
'--label', `openchamber.space.project=${SPEC.project}`,
|
|
203
|
+
'--label', 'openchamber.space.name=Fix a=b, then c',
|
|
204
|
+
'--label', 'openchamber.space.created=2026-09-19T10:00:00.000Z',
|
|
205
|
+
'--init',
|
|
206
|
+
'--user', '1000:1000',
|
|
207
|
+
'--read-only',
|
|
208
|
+
'--tmpfs', '/tmp:rw,exec,nosuid,size=256m',
|
|
209
|
+
'--security-opt', 'no-new-privileges',
|
|
210
|
+
'--cap-drop', 'ALL',
|
|
211
|
+
'--pids-limit', '512',
|
|
212
|
+
'--memory', '4294967296',
|
|
213
|
+
'--memory-swap', '4294967296',
|
|
214
|
+
'--shm-size', '64m',
|
|
215
|
+
'--ipc', 'private',
|
|
216
|
+
'--cgroupns', 'private',
|
|
217
|
+
'--log-driver', 'local',
|
|
218
|
+
'--log-opt', 'max-size=10m',
|
|
219
|
+
'--log-opt', 'max-file=1',
|
|
220
|
+
'--log-opt', 'compress=false',
|
|
221
|
+
'--network', NETWORK,
|
|
222
|
+
'--mount', `type=volume,src=${WORK},dst=/spaces/${ID}`,
|
|
223
|
+
'--mount', `type=volume,src=${HOME},dst=/home/space`,
|
|
224
|
+
'--mount', `type=volume,src=${TOOLS},dst=/opt/openchamber-tools,readonly`,
|
|
225
|
+
'--env', 'HOME=/home/space',
|
|
226
|
+
'--env', 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/openchamber-tools/node_modules/.bin',
|
|
227
|
+
'--env', 'HTTPS_PROXY=http://gatekeeper:3128',
|
|
228
|
+
'--env', 'https_proxy=http://gatekeeper:3128',
|
|
229
|
+
'--env', 'HTTP_PROXY=http://gatekeeper:3128',
|
|
230
|
+
'--env', 'http_proxy=http://gatekeeper:3128',
|
|
231
|
+
'--env', 'NO_PROXY=gatekeeper,localhost,127.0.0.1',
|
|
232
|
+
'--env', 'no_proxy=gatekeeper,localhost,127.0.0.1',
|
|
233
|
+
'--env', 'NODE_USE_ENV_PROXY=1',
|
|
234
|
+
'--env', 'OPENCODE_DISABLE_MODELS_FETCH=1',
|
|
235
|
+
'--env', 'OPENCODE_DISABLE_AUTOUPDATE=1',
|
|
236
|
+
'--env', 'OPENCHAMBER_RELAY_HOST=off',
|
|
237
|
+
SPACE_BASE_IMAGE,
|
|
238
|
+
'/bin/sh', '-c',
|
|
239
|
+
'while [ ! -s /home/space/.openchamber-space/token ]; do /bin/sleep 0.2; done; OPENCHAMBER_UI_PASSWORD="$(/bin/cat /home/space/.openchamber-space/token)"; export OPENCHAMBER_UI_PASSWORD; exec openchamber serve --foreground --api-only --host 127.0.0.1 --port 27600',
|
|
240
|
+
]);
|
|
241
|
+
expect(Object.keys(SPACE_ENVIRONMENT)).not.toContain('OPENCHAMBER_UI_PASSWORD');
|
|
242
|
+
expect(SPACE_SERVER_COMMAND.join(' ')).not.toMatch(/\n/);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('creates the gatekeeper with the hardening of a space, no mount, and a limit of its own', async () => {
|
|
246
|
+
const fake = createFakeDocker({ resources: [toolsResource()] });
|
|
247
|
+
await makePlace(fake).create(SPEC);
|
|
248
|
+
|
|
249
|
+
const create = fake.calls.map((call) => call.args).find((args) => args[0] === 'create' && args.includes('--network-alias'));
|
|
250
|
+
const flags = create.filter((arg, index) => create[index - 1] !== '--label' && arg !== '--label');
|
|
251
|
+
expect(flags).toEqual([
|
|
252
|
+
'create',
|
|
253
|
+
'--name', GATEKEEPER,
|
|
254
|
+
'--init',
|
|
255
|
+
'--user', '1000:1000',
|
|
256
|
+
'--read-only',
|
|
257
|
+
'--tmpfs', '/tmp:rw,noexec,nosuid,size=16m',
|
|
258
|
+
'--security-opt', 'no-new-privileges',
|
|
259
|
+
'--cap-drop', 'ALL',
|
|
260
|
+
'--pids-limit', '512',
|
|
261
|
+
'--memory', '268435456',
|
|
262
|
+
'--memory-swap', '268435456',
|
|
263
|
+
'--shm-size', '64m',
|
|
264
|
+
'--ipc', 'private',
|
|
265
|
+
'--cgroupns', 'private',
|
|
266
|
+
'--log-driver', 'local',
|
|
267
|
+
'--log-opt', 'max-size=10m',
|
|
268
|
+
'--log-opt', 'max-file=1',
|
|
269
|
+
'--log-opt', 'compress=false',
|
|
270
|
+
'--network', NETWORK,
|
|
271
|
+
'--network-alias', 'gatekeeper',
|
|
272
|
+
'--env', 'HOME=/tmp',
|
|
273
|
+
SPACE_BASE_IMAGE,
|
|
274
|
+
'/bin/sh', '-c',
|
|
275
|
+
'while [ ! -s /tmp/openchamber-gatekeeper/gatekeeper.cjs ]; do /bin/sleep 0.2; done; exec /usr/local/bin/node /tmp/openchamber-gatekeeper/gatekeeper.cjs 0.0.0.0 3128 8080 9099 300000 128 64 8',
|
|
276
|
+
]);
|
|
277
|
+
expect(create).toContain('openchamber.space.role=gatekeeper');
|
|
278
|
+
expect(create).not.toContain('--mount');
|
|
279
|
+
// The program travels on stdin, so it is in no argument list, and no secret is in one either.
|
|
280
|
+
const write = fake.calls.find((call) => isExec('gatekeeper.cjs.new')(call.args));
|
|
281
|
+
expect(write.options.stdin).toBe(GATEKEEPER_PROGRAM);
|
|
282
|
+
expect(write.args[4]).toBe(GATEKEEPER);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('starts the gatekeeper and waits for its corridor before the space container starts', async () => {
|
|
286
|
+
const fake = createFakeDocker({ resources: [toolsResource()] });
|
|
287
|
+
await makePlace(fake).create(SPEC);
|
|
288
|
+
|
|
289
|
+
const steps = changes(fake);
|
|
290
|
+
expect(steps.indexOf('exec gatekeeper curl')).toBeLessThan(steps.indexOf('create space'));
|
|
291
|
+
expect(steps.indexOf(`start ${GATEKEEPER}`)).toBeLessThan(steps.indexOf(`start ${CONTAINER}`));
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('prepares the volumes with a root one-shot that has no network and one capability', async () => {
|
|
295
|
+
const fake = createFakeDocker();
|
|
296
|
+
await makePlace(fake).create(SPEC);
|
|
297
|
+
|
|
298
|
+
const setupRun = fake.calls.map((call) => call.args).find(isRun('setup'));
|
|
299
|
+
const flags = setupRun.filter((arg, index) => setupRun[index - 1] !== '--label' && arg !== '--label');
|
|
300
|
+
expect(flags).toEqual([
|
|
301
|
+
'run', '--rm',
|
|
302
|
+
'--name', `openchamber-space-${ID}-setup`,
|
|
303
|
+
'--user', '0:0',
|
|
304
|
+
'--network', 'none',
|
|
305
|
+
'--cap-drop', 'ALL',
|
|
306
|
+
'--cap-add', 'CHOWN',
|
|
307
|
+
'--security-opt', 'no-new-privileges',
|
|
308
|
+
'--read-only',
|
|
309
|
+
'--ipc', 'private',
|
|
310
|
+
'--cgroupns', 'private',
|
|
311
|
+
'--memory', '134217728',
|
|
312
|
+
'--memory-swap', '134217728',
|
|
313
|
+
'--log-driver', 'local',
|
|
314
|
+
'--log-opt', 'max-size=10m',
|
|
315
|
+
'--log-opt', 'max-file=1',
|
|
316
|
+
'--log-opt', 'compress=false',
|
|
317
|
+
'--mount', `type=volume,src=${WORK},dst=/spaces/${ID}`,
|
|
318
|
+
'--mount', `type=volume,src=${HOME},dst=/home/space`,
|
|
319
|
+
SPACE_BASE_IMAGE,
|
|
320
|
+
'/bin/chown', '1000:1000', `/spaces/${ID}`, '/home/space',
|
|
321
|
+
]);
|
|
322
|
+
expect(setupRun).toContain('openchamber.space.role=setup');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('never asks docker for host paths, privileges, ports, devices or host namespaces', async () => {
|
|
326
|
+
const fake = createFakeDocker({ imagePresent: false });
|
|
327
|
+
await makePlace(fake).create(SPEC);
|
|
328
|
+
|
|
329
|
+
const forbidden = ['-v', '--volume', '--privileged', '-p', '--publish', '-P', '--publish-all', '--device', '--pid', '--uts', '--userns', '--cap-add=ALL', '--volumes-from'];
|
|
330
|
+
for (const { args } of fake.calls) {
|
|
331
|
+
for (const flag of forbidden) {
|
|
332
|
+
expect(args).not.toContain(flag);
|
|
333
|
+
}
|
|
334
|
+
expect(args.join(' ')).not.toContain('docker.sock');
|
|
335
|
+
args.forEach((arg, index) => {
|
|
336
|
+
if (args[index - 1] === '--mount') expect(arg).toMatch(new RegExp(`^type=volume,src=(openchamber-space-${ID}-volume-(work|home),dst=/|${TOOLS},dst=/opt/openchamber-tools(,readonly)?$)`));
|
|
337
|
+
// Only the tools filler has a way out.
|
|
338
|
+
if (args[index - 1] === '--network') expect(isRun('tools-fill')(args) ? ['bridge'] : [NETWORK, 'none']).toContain(arg);
|
|
339
|
+
if (args[index - 1] === '--ipc' || args[index - 1] === '--cgroupns') expect(arg).toBe('private');
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('pulls the image, fills the tools, then makes the space, starts it, and waits for its server', async () => {
|
|
345
|
+
const fake = createFakeDocker({ imagePresent: false });
|
|
346
|
+
await makePlace(fake).create(SPEC);
|
|
347
|
+
|
|
348
|
+
expect(changes(fake)).toEqual([
|
|
349
|
+
`pull ${SPACE_BASE_IMAGE}`,
|
|
350
|
+
`volume create ${TOOLS}`,
|
|
351
|
+
'run tools-fill',
|
|
352
|
+
'run tools-check',
|
|
353
|
+
...SPACE_STEPS,
|
|
354
|
+
]);
|
|
355
|
+
const networkCreate = fake.calls.map((call) => call.args).find((args) => args[1] === 'create' && args[0] === 'network');
|
|
356
|
+
expect(networkCreate.slice(0, 8)).toEqual([
|
|
357
|
+
'network', 'create', '--driver', 'bridge', '--internal', '--ipv6=false',
|
|
358
|
+
'--opt', 'com.docker.network.bridge.gateway_mode_ipv4=isolated',
|
|
359
|
+
]);
|
|
360
|
+
expect(networkCreate).toContain('openchamber.space.role=network');
|
|
361
|
+
|
|
362
|
+
// The outer network is the gatekeeper's way out, so it is an ordinary bridge and it is
|
|
363
|
+
// never internal. The space is not attached to it.
|
|
364
|
+
const outerCreate = fake.calls.map((call) => call.args).filter((args) => args[1] === 'create' && args[0] === 'network')[1];
|
|
365
|
+
expect(outerCreate.slice(0, 5)).toEqual(['network', 'create', '--driver', 'bridge', '--ipv6=false']);
|
|
366
|
+
expect(outerCreate).not.toContain('--internal');
|
|
367
|
+
expect(outerCreate).toContain('openchamber.space.role=outer-network');
|
|
368
|
+
expect(outerCreate[outerCreate.length - 1]).toBe(OUTER_NETWORK);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('skips the pull when the image is present', async () => {
|
|
372
|
+
const fake = createFakeDocker();
|
|
373
|
+
await makePlace(fake).create(SPEC);
|
|
374
|
+
expect(fake.calls.some((call) => call.args[0] === 'pull')).toBe(false);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('gives every docker call the executable path and a timeout', async () => {
|
|
378
|
+
const fake = createFakeDocker();
|
|
379
|
+
await makePlace(fake).create(SPEC);
|
|
380
|
+
for (const call of fake.calls) {
|
|
381
|
+
expect(call.file).toBe('/usr/bin/docker');
|
|
382
|
+
expect(call.options.timeoutMs).toBeGreaterThan(0);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('refuses when a resource with the same name exists, and touches nothing', async () => {
|
|
387
|
+
const stranger = { kind: 'volume', name: WORK, entry: { Name: WORK, Labels: null } };
|
|
388
|
+
const fake = createFakeDocker({ resources: [stranger] });
|
|
389
|
+
|
|
390
|
+
await expect(makePlace(fake).create(SPEC)).rejects.toMatchObject({ code: 'space_name_taken' });
|
|
391
|
+
expect(changes(fake)).toEqual([]);
|
|
392
|
+
expect(fake.names()).toEqual([`volume:${WORK}`]);
|
|
393
|
+
expect(fake.calls.some((call) => call.args[0] === 'volume' && call.args[1] === 'create')).toBe(false);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('rejects a bad spec before calling docker', async () => {
|
|
397
|
+
const fake = createFakeDocker();
|
|
398
|
+
await expect(makePlace(fake).create({ ...SPEC, id: '../etc' })).rejects.toMatchObject({ code: 'invalid_space_id' });
|
|
399
|
+
await expect(makePlace(fake).create({ ...SPEC, name: 'two\nlines' })).rejects.toMatchObject({ code: 'invalid_space_name' });
|
|
400
|
+
await expect(makePlace(fake).create({ ...SPEC, memoryBytes: 1024 })).rejects.toMatchObject({ code: 'invalid_memory_limit' });
|
|
401
|
+
await expect(makePlace(fake).create({ ...SPEC, memoryBytes: undefined })).rejects.toMatchObject({ code: 'invalid_memory_limit' });
|
|
402
|
+
expect(fake.calls).toEqual([]);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
describe('docker place: create rollback', () => {
|
|
407
|
+
const isCreate = (kind, name) => (args) => args[0] === kind && args[1] === 'create' && args[args.length - 1] === name;
|
|
408
|
+
const networks = [`network rm ${NETWORK}`, `network rm ${OUTER_NETWORK}`];
|
|
409
|
+
const withoutSpace = [`rm ${GATEKEEPER}`, `volume rm ${WORK}`, `volume rm ${HOME}`, ...networks];
|
|
410
|
+
const everything = [`rm ${GATEKEEPER}`, `rm ${CONTAINER}`, `volume rm ${WORK}`, `volume rm ${HOME}`, ...networks];
|
|
411
|
+
const steps = [
|
|
412
|
+
{ step: 'pull', failAt: (args) => args[0] === 'pull', imagePresent: false, code: 'image_pull_failed', removals: [] },
|
|
413
|
+
{ step: 'network', failAt: isCreate('network', NETWORK), removals: [] },
|
|
414
|
+
{ step: 'outer network', failAt: isCreate('network', OUTER_NETWORK), removals: [`network rm ${NETWORK}`] },
|
|
415
|
+
{ step: 'work volume', failAt: isCreate('volume', WORK), removals: networks },
|
|
416
|
+
{ step: 'home volume', failAt: isCreate('volume', HOME), removals: [`volume rm ${WORK}`, ...networks] },
|
|
417
|
+
{ step: 'volume ownership', failAt: isRun('setup'), removals: [`volume rm ${WORK}`, `volume rm ${HOME}`, ...networks] },
|
|
418
|
+
{ step: 'gatekeeper container', failAt: (args) => args[0] === 'create' && args.includes('--network-alias'), removals: withoutSpace },
|
|
419
|
+
{ step: 'gatekeeper network connect', failAt: (args) => args[0] === 'network' && args[1] === 'connect', removals: withoutSpace },
|
|
420
|
+
{ step: 'gatekeeper start', failAt: (args) => args[0] === 'start' && args[1] === GATEKEEPER, removals: withoutSpace },
|
|
421
|
+
{ step: 'gatekeeper program', failAt: isExec('gatekeeper.cjs.new'), code: 'gatekeeper_setup_failed', removals: withoutSpace },
|
|
422
|
+
{ step: 'space container', failAt: (args) => args[0] === 'create' && !args.includes('--network-alias'), removals: everything },
|
|
423
|
+
{ step: 'start', failAt: (args) => args[0] === 'start' && args[1] === CONTAINER, removals: everything },
|
|
424
|
+
{ step: 'plugin link', failAt: isExec('ln -sfn'), code: 'space_setup_failed', removals: everything },
|
|
425
|
+
{ step: 'token', failAt: isExec('token.new'), code: 'space_setup_failed', removals: everything },
|
|
426
|
+
];
|
|
427
|
+
|
|
428
|
+
for (const { step, failAt, imagePresent = true, code = 'docker_command_failed', removals: expected } of steps) {
|
|
429
|
+
it(`removes what it made when the ${step} step fails, and keeps the tools volume`, async () => {
|
|
430
|
+
const fake = createFakeDocker({ failAt, imagePresent, resources: [toolsResource()] });
|
|
431
|
+
|
|
432
|
+
await expect(makePlace(fake).create(SPEC)).rejects.toMatchObject({ code, details: { rollbackFailures: [], uncertain: false } });
|
|
433
|
+
expect(removals(fake)).toEqual(expected);
|
|
434
|
+
expect(fake.names()).toEqual([`volume:${TOOLS}`]);
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
it('rolls everything back when the gatekeeper never answers, and never makes the space', async () => {
|
|
439
|
+
// The gatekeeper starts and its control channel stays silent. Nothing of the space is made:
|
|
440
|
+
// a space must never exist without a way out that the host can talk to.
|
|
441
|
+
const fake = createFakeDocker({ gatekeeperReady: false, resources: [toolsResource()] });
|
|
442
|
+
|
|
443
|
+
const error = await makePlace(fake).create(SPEC).catch((caught) => caught);
|
|
444
|
+
expect(error.code).toBe('gatekeeper_not_ready');
|
|
445
|
+
expect(error.message).toMatch(/did not become ready within 60 seconds/);
|
|
446
|
+
expect(error.details).toMatchObject({ rollbackFailures: [], uncertain: false });
|
|
447
|
+
expect(changes(fake)).not.toContain('create space');
|
|
448
|
+
expect(removals(fake)).toEqual([`rm ${GATEKEEPER}`, `volume rm ${WORK}`, `volume rm ${HOME}`, `network rm ${NETWORK}`, `network rm ${OUTER_NETWORK}`]);
|
|
449
|
+
expect(fake.names()).toEqual([`volume:${TOOLS}`]);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('rolls the space back when its server never becomes ready, and keeps the tools volume', async () => {
|
|
453
|
+
const fake = createFakeDocker({ serverReady: false, resources: [toolsResource()] });
|
|
454
|
+
|
|
455
|
+
const error = await makePlace(fake).create(SPEC).catch((caught) => caught);
|
|
456
|
+
expect(error.code).toBe('space_server_not_ready');
|
|
457
|
+
// The fake clock moves only in `wait`, so this is the deadline and not a count of attempts.
|
|
458
|
+
expect(error.message).toMatch(/did not become ready within 120 seconds/);
|
|
459
|
+
expect(error.details).toMatchObject({ rollbackFailures: [], uncertain: false });
|
|
460
|
+
expect(removals(fake)).toEqual(everything);
|
|
461
|
+
expect(fake.names()).toEqual([`volume:${TOOLS}`]);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('does not call a timed-out request to the server inside an interrupted Docker step', async () => {
|
|
465
|
+
const fake = createFakeDocker({ resources: [toolsResource()] });
|
|
466
|
+
const runCommand = async (file, args, options) => {
|
|
467
|
+
if (isExec('curl')(args) && args[4] === CONTAINER) throw new SpaceError('command_timeout', 'docker exec did not finish within 13000 ms and was stopped');
|
|
468
|
+
return fake.runCommand(file, args, options);
|
|
469
|
+
};
|
|
470
|
+
let waits = 0;
|
|
471
|
+
const place = placeOn(runCommand, { now: fake.now, wait: async (milliseconds) => { waits += 1; await fake.wait(milliseconds); } });
|
|
472
|
+
|
|
473
|
+
const error = await place.create(SPEC).catch((caught) => caught);
|
|
474
|
+
expect(error.code).toBe('space_server_not_ready');
|
|
475
|
+
expect(error.details.uncertain).toBe(false);
|
|
476
|
+
expect(error.message).not.toMatch(/Docker may still finish/);
|
|
477
|
+
// Every wait was a pause of the readiness wait. The rollback did not pause for a second sweep.
|
|
478
|
+
expect(waits).toBe(240);
|
|
479
|
+
expect(fake.names()).toEqual([`volume:${TOOLS}`]);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('never starts a created container that fails verification, and removes it', async () => {
|
|
483
|
+
const fake = createFakeDocker({
|
|
484
|
+
alterContainer: (entry) => (entry.Name.endsWith('-space') ? { ...entry, HostConfig: { ...entry.HostConfig, Privileged: true } } : entry),
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const error = await makePlace(fake).create(SPEC).catch((caught) => caught);
|
|
488
|
+
expect(error.code).toBe('space_verification_failed');
|
|
489
|
+
expect(error.details.original.violations.map((violation) => violation.check)).toEqual(['privileged']);
|
|
490
|
+
expect(changes(fake)).not.toContain(`start ${CONTAINER}`);
|
|
491
|
+
expect(fake.names()).toEqual([`volume:${TOOLS}`]);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('never starts a gatekeeper that fails verification, and never makes the space', async () => {
|
|
495
|
+
const fake = createFakeDocker({
|
|
496
|
+
alterContainer: (entry) => (entry.Name.endsWith('-gatekeeper') ? { ...entry, HostConfig: { ...entry.HostConfig, ReadonlyRootfs: false } } : entry),
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
const error = await makePlace(fake).create(SPEC).catch((caught) => caught);
|
|
500
|
+
expect(error.code).toBe('space_verification_failed');
|
|
501
|
+
expect(error.details.original.violations.map((violation) => violation.check)).toEqual(['gatekeeper_read_only']);
|
|
502
|
+
expect(changes(fake)).not.toContain(`start ${GATEKEEPER}`);
|
|
503
|
+
expect(changes(fake)).not.toContain('create space');
|
|
504
|
+
expect(fake.names()).toEqual([`volume:${TOOLS}`]);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('reports the original error and what the rollback could not remove', async () => {
|
|
508
|
+
const fake = createFakeDocker({
|
|
509
|
+
failAt: (args) => isRun('setup')(args) || (args[0] === 'network' && args[1] === 'rm'),
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
const error = await makePlace(fake).create(SPEC).catch((caught) => caught);
|
|
513
|
+
expect(error.code).toBe('docker_command_failed');
|
|
514
|
+
expect(error.message).toMatch(/docker run --rm failed/);
|
|
515
|
+
expect(error.message).toMatch(new RegExp(`Clean-up also failed for: network ${NETWORK}`));
|
|
516
|
+
expect(error.details.rollbackFailures).toEqual([
|
|
517
|
+
{ kind: 'network', name: NETWORK, message: 'Error response from daemon: simulated failure' },
|
|
518
|
+
{ kind: 'network', name: OUTER_NETWORK, message: 'Error response from daemon: simulated failure' },
|
|
519
|
+
]);
|
|
520
|
+
expect(error.cause.code).toBe('docker_command_failed');
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
describe('docker place: image pull', () => {
|
|
525
|
+
it('puts what docker said first and stays neutral about the cause', async () => {
|
|
526
|
+
const fake = createFakeDocker({ imagePresent: false });
|
|
527
|
+
const stderr = 'error getting credentials - err: exit status 1, out: `A specified logon session does not exist.`';
|
|
528
|
+
const runCommand = async (file, args, options) => (
|
|
529
|
+
args[0] === 'pull' ? { code: 1, stdout: '', stderr: `${stderr}\n` } : fake.runCommand(file, args, options)
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
const error = await placeOn(runCommand).create(SPEC).catch((caught) => caught);
|
|
533
|
+
expect(error.code).toBe('image_pull_failed');
|
|
534
|
+
expect(error.message).toBe(`Could not create the space. Could not download the base image: ${stderr}. Common causes: no internet access on the Docker machine, or Docker's credential helper cannot run in this session.`);
|
|
535
|
+
expect(fake.names()).toEqual([]);
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
describe('docker place: create rollback after a timeout', () => {
|
|
540
|
+
it('sweeps again after the daemon finished the step late, including the unlabelled volumes it made', async () => {
|
|
541
|
+
// The CLI of the one-shot is killed. The first sweep removes the volumes and the network.
|
|
542
|
+
// Then the daemon runs the one-shot after all: it stays running and docker makes its `src=` volumes again, without labels.
|
|
543
|
+
const fake = createFakeDocker({ timeoutAt: isRun('setup'), resources: [toolsResource()] });
|
|
544
|
+
|
|
545
|
+
const error = await makePlace(fake).create(SPEC).catch((caught) => caught);
|
|
546
|
+
expect(error.code).toBe('command_timeout');
|
|
547
|
+
expect(error.details).toMatchObject({ uncertain: true, rollbackFailures: [] });
|
|
548
|
+
expect(error.message).toMatch(/look at the spaces list/);
|
|
549
|
+
expect(removals(fake)).toEqual([
|
|
550
|
+
`volume rm ${WORK}`, `volume rm ${HOME}`, `network rm ${NETWORK}`, `network rm ${OUTER_NETWORK}`,
|
|
551
|
+
`rm openchamber-space-${ID}-setup`, `volume rm ${WORK}`, `volume rm ${HOME}`,
|
|
552
|
+
]);
|
|
553
|
+
expect(fake.names()).toEqual([`volume:${TOOLS}`]);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('sweeps again after a timed-out create of the space container', async () => {
|
|
557
|
+
const fake = createFakeDocker({ timeoutAt: (args) => args[0] === 'create', resources: [toolsResource()] });
|
|
558
|
+
|
|
559
|
+
await expect(makePlace(fake).create(SPEC)).rejects.toMatchObject({ code: 'command_timeout', details: { uncertain: true } });
|
|
560
|
+
expect(fake.names()).toEqual([`volume:${TOOLS}`]);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it.each(['command_killed', 'command_output_too_large'])('treats %s like a timeout', async (code) => {
|
|
564
|
+
const fake = createFakeDocker({ timeoutAt: isRun('setup'), interruptionCode: code, resources: [toolsResource()] });
|
|
565
|
+
|
|
566
|
+
const error = await makePlace(fake).create(SPEC).catch((caught) => caught);
|
|
567
|
+
expect(error.code).toBe(code);
|
|
568
|
+
expect(error.details).toMatchObject({ uncertain: true, rollbackFailures: [] });
|
|
569
|
+
expect(removals(fake)).toContain(`rm openchamber-space-${ID}-setup`);
|
|
570
|
+
expect(fake.names()).toEqual([`volume:${TOOLS}`]);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('reports what the second sweep could not remove', async () => {
|
|
574
|
+
const fake = createFakeDocker({
|
|
575
|
+
timeoutAt: isRun('setup'),
|
|
576
|
+
failAt: (args) => args[0] === 'rm',
|
|
577
|
+
resources: [toolsResource()],
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const error = await makePlace(fake).create(SPEC).catch((caught) => caught);
|
|
581
|
+
expect(error.details.uncertain).toBe(true);
|
|
582
|
+
expect(error.details.rollbackFailures.map((item) => `${item.kind} ${item.name}`)).toContain(`container openchamber-space-${ID}-setup`);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('does not pause or sweep twice for an ordinary failure', async () => {
|
|
586
|
+
const fake = createFakeDocker({ failAt: (args) => args[0] === 'create', resources: [toolsResource()] });
|
|
587
|
+
let waits = 0;
|
|
588
|
+
const place = placeOn(fake.runCommand, { wait: async () => { waits += 1; } });
|
|
589
|
+
|
|
590
|
+
await expect(place.create(SPEC)).rejects.toMatchObject({ details: { uncertain: false } });
|
|
591
|
+
expect(waits).toBe(0);
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
describe('docker place: list', () => {
|
|
596
|
+
it('builds spaces from labels, with state and orphans', async () => {
|
|
597
|
+
const orphanId = 'ffffffffffff';
|
|
598
|
+
const orphanVolume = `openchamber-space-${orphanId}-volume-work`;
|
|
599
|
+
const fake = createFakeDocker({
|
|
600
|
+
resources: [
|
|
601
|
+
...spaceResources(),
|
|
602
|
+
...spaceResources({ id: '111111111111', running: false }),
|
|
603
|
+
{ kind: 'volume', name: orphanVolume, entry: { Name: orphanVolume, Labels: labelsFor('volume', { id: orphanId }) } },
|
|
604
|
+
...spaceResources({ id: '222222222222', owner: 'install-b' }),
|
|
605
|
+
{ kind: 'container', name: 'openchamber-space-333333333333-space', entry: { Name: '/openchamber-space-333333333333-space', Config: { Labels: {} }, State: { Running: true } } },
|
|
606
|
+
],
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
const spaces = await makePlace(fake).list();
|
|
610
|
+
expect(spaces).toEqual([
|
|
611
|
+
{ id: ID, name: SPEC.name, project: SPEC.project, created: SPEC.created, state: 'running', orphans: [], damaged: false, missing: [] },
|
|
612
|
+
{ id: '111111111111', name: SPEC.name, project: SPEC.project, created: SPEC.created, state: 'exited', orphans: [], damaged: false, missing: [] },
|
|
613
|
+
{ id: orphanId, name: SPEC.name, project: SPEC.project, created: SPEC.created, state: 'missing', orphans: [{ kind: 'volume', name: orphanVolume }], damaged: false, missing: [] },
|
|
614
|
+
]);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('flags a space whose network or volume is gone, and names what is missing', async () => {
|
|
618
|
+
const fake = createFakeDocker({ resources: spaceResources().filter((resource) => resource.name !== HOME && resource.kind !== 'network') });
|
|
619
|
+
|
|
620
|
+
expect(await makePlace(fake).list()).toEqual([
|
|
621
|
+
{ id: ID, name: SPEC.name, project: SPEC.project, created: SPEC.created, state: 'running', orphans: [], damaged: true, missing: [NETWORK, OUTER_NETWORK, HOME] },
|
|
622
|
+
]);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('flags a running space whose gatekeeper is gone, or does not run, as damaged and not as missing', async () => {
|
|
626
|
+
const withoutGatekeeper = createFakeDocker({ resources: spaceResources().filter((resource) => resource.name !== GATEKEEPER) });
|
|
627
|
+
expect(await makePlace(withoutGatekeeper).list()).toEqual([
|
|
628
|
+
{ id: ID, name: SPEC.name, project: SPEC.project, created: SPEC.created, state: 'running', orphans: [], damaged: true, missing: [GATEKEEPER] },
|
|
629
|
+
]);
|
|
630
|
+
|
|
631
|
+
// The space runs and its way out does not. For the space that is as good as no gatekeeper.
|
|
632
|
+
const seeds = spaceResources();
|
|
633
|
+
seeds.find((resource) => resource.name === GATEKEEPER).entry.State.Running = false;
|
|
634
|
+
const stoppedGatekeeper = createFakeDocker({ resources: seeds });
|
|
635
|
+
expect(await makePlace(stoppedGatekeeper).list()).toMatchObject([{ state: 'running', damaged: true, missing: [GATEKEEPER] }]);
|
|
636
|
+
|
|
637
|
+
// A stopped space with a stopped gatekeeper is whole: `start` brings both up in order.
|
|
638
|
+
const bothStopped = createFakeDocker({ resources: spaceResources({ running: false }) });
|
|
639
|
+
expect(await makePlace(bothStopped).list()).toMatchObject([{ state: 'exited', damaged: false, missing: [] }]);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it('still lists the other spaces when a resource vanishes between the listing and the inspect', async () => {
|
|
643
|
+
const fake = createFakeDocker({ resources: [...spaceResources(), ...spaceResources({ id: '111111111111' })] });
|
|
644
|
+
const runCommand = async (file, args, options) => {
|
|
645
|
+
const result = await fake.runCommand(file, args, options);
|
|
646
|
+
// The listing still names a volume that is gone by the time of the inspect.
|
|
647
|
+
return args[0] === 'volume' && args[1] === 'ls' ? { ...result, stdout: `${result.stdout}\nopenchamber-space-222222222222-volume-work` } : result;
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
const spaces = await placeOn(runCommand).list();
|
|
651
|
+
expect(spaces.map((space) => [space.id, space.state, space.damaged])).toEqual([[ID, 'running', false], ['111111111111', 'running', false]]);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it('rejects when the inspect fails for any other reason', async () => {
|
|
655
|
+
const fake = createFakeDocker({ resources: spaceResources() });
|
|
656
|
+
const runCommand = async (file, args, options) => (
|
|
657
|
+
args[0] === 'volume' && args[1] === 'inspect'
|
|
658
|
+
? { code: 1, stdout: '[]', stderr: 'permission denied while trying to connect to the Docker daemon socket' }
|
|
659
|
+
: fake.runCommand(file, args, options)
|
|
660
|
+
);
|
|
661
|
+
|
|
662
|
+
await expect(placeOn(runCommand).list()).rejects.toMatchObject({ code: 'docker_command_failed' });
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it('ignores a resource the filter returned without our labels', async () => {
|
|
666
|
+
const entry = { Name: '/stray', Config: { Labels: { 'openchamber.space': 'true', 'openchamber.space.id': 'not-an-id' } }, State: { Running: true } };
|
|
667
|
+
const runCommand = async (file, args) => {
|
|
668
|
+
if (args[0] === 'ps') return { code: 0, stdout: 'stray\n', stderr: '' };
|
|
669
|
+
if (args[0] === 'inspect') return { code: 0, stdout: JSON.stringify([entry]), stderr: '' };
|
|
670
|
+
return { code: 0, stdout: '', stderr: '' };
|
|
671
|
+
};
|
|
672
|
+
expect(await placeOn(runCommand).list()).toEqual([]);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it('rejects when docker fails, never an empty list', async () => {
|
|
676
|
+
const fake = createFakeDocker({ resources: spaceResources(), failAt: (args) => args[0] === 'ps' });
|
|
677
|
+
await expect(makePlace(fake).list()).rejects.toMatchObject({ code: 'docker_command_failed' });
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('rejects when docker prints something unreadable', async () => {
|
|
681
|
+
const runCommand = async (file, args) => ({ code: 0, stdout: args[0] === 'ps' ? 'one\n' : 'not json', stderr: '' });
|
|
682
|
+
await expect(placeOn(runCommand).list()).rejects.toMatchObject({ code: 'docker_output_unreadable' });
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('lets a runner failure through', async () => {
|
|
686
|
+
const runCommand = async () => { throw new SpaceError('command_timeout', 'too slow'); };
|
|
687
|
+
await expect(placeOn(runCommand).list()).rejects.toMatchObject({ code: 'command_timeout' });
|
|
688
|
+
});
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
describe('docker place: remove', () => {
|
|
692
|
+
it('removes only resources with our marker, this id and this owner', async () => {
|
|
693
|
+
const unlabelled = `openchamber-space-${ID}-volume-cache`;
|
|
694
|
+
const fake = createFakeDocker({
|
|
695
|
+
resources: [
|
|
696
|
+
...spaceResources(),
|
|
697
|
+
{ kind: 'volume', name: unlabelled, entry: { Name: unlabelled, Labels: null } },
|
|
698
|
+
{ kind: 'volume', name: 'other-install', entry: { Name: 'other-install', Labels: labelsFor('volume', { owner: 'install-b' }) } },
|
|
699
|
+
...spaceResources({ id: '111111111111' }),
|
|
700
|
+
],
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const result = await makePlace(fake).remove(ID);
|
|
704
|
+
expect(result.failed).toEqual([]);
|
|
705
|
+
expect(result.removed).toEqual([
|
|
706
|
+
{ kind: 'container', name: GATEKEEPER },
|
|
707
|
+
{ kind: 'container', name: CONTAINER },
|
|
708
|
+
{ kind: 'volume', name: WORK },
|
|
709
|
+
{ kind: 'volume', name: HOME },
|
|
710
|
+
{ kind: 'network', name: NETWORK },
|
|
711
|
+
{ kind: 'network', name: OUTER_NETWORK },
|
|
712
|
+
]);
|
|
713
|
+
expect(fake.names()).toEqual([
|
|
714
|
+
`volume:${unlabelled}`,
|
|
715
|
+
'volume:other-install',
|
|
716
|
+
...spaceResources({ id: '111111111111' }).map((resource) => `${resource.kind}:${resource.name}`),
|
|
717
|
+
]);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it('treats a resource that vanished meanwhile as removed', async () => {
|
|
721
|
+
const fake = createFakeDocker({ resources: spaceResources() });
|
|
722
|
+
const runCommand = async (file, args, options) => {
|
|
723
|
+
if (args[0] === 'volume' && args[1] === 'rm') {
|
|
724
|
+
return { code: 1, stdout: '', stderr: `Error response from daemon: get ${args[2]}: no such volume` };
|
|
725
|
+
}
|
|
726
|
+
return fake.runCommand(file, args, options);
|
|
727
|
+
};
|
|
728
|
+
const result = await placeOn(runCommand).remove(ID);
|
|
729
|
+
expect(result.failed).toEqual([]);
|
|
730
|
+
expect(result.removed).toHaveLength(6);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it('treats a container that is already being removed as removed', async () => {
|
|
734
|
+
const fake = createFakeDocker({ resources: spaceResources() });
|
|
735
|
+
const runCommand = async (file, args, options) => (
|
|
736
|
+
args[0] === 'rm'
|
|
737
|
+
? { code: 1, stdout: '', stderr: `Error response from daemon: removal of container ${CONTAINER} is already in progress` }
|
|
738
|
+
: fake.runCommand(file, args, options)
|
|
739
|
+
);
|
|
740
|
+
|
|
741
|
+
const result = await placeOn(runCommand).remove(ID);
|
|
742
|
+
expect(result.failed.filter((item) => item.kind === 'container')).toEqual([]);
|
|
743
|
+
expect(result.removed).toContainEqual({ kind: 'container', name: CONTAINER });
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it('removes the rest when one resource vanished between the listing and the inspect', async () => {
|
|
747
|
+
const fake = createFakeDocker({ resources: spaceResources() });
|
|
748
|
+
const runCommand = async (file, args, options) => {
|
|
749
|
+
const result = await fake.runCommand(file, args, options);
|
|
750
|
+
return args[0] === 'ps' ? { ...result, stdout: `${result.stdout}\nopenchamber-space-${ID}-setup` } : result;
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
const result = await placeOn(runCommand).remove(ID);
|
|
754
|
+
expect(result.failed).toEqual([]);
|
|
755
|
+
expect(fake.names()).toEqual([]);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it('reports what it could not remove and keeps going', async () => {
|
|
759
|
+
const fake = createFakeDocker({ resources: spaceResources(), failAt: (args) => args[0] === 'volume' && args[1] === 'rm' && args[2] === WORK });
|
|
760
|
+
const result = await makePlace(fake).remove(ID);
|
|
761
|
+
expect(result.failed).toEqual([{ kind: 'volume', name: WORK, message: 'Error response from daemon: simulated failure' }]);
|
|
762
|
+
expect(result.removed.map((item) => item.name)).toEqual([GATEKEEPER, CONTAINER, HOME, NETWORK, OUTER_NETWORK]);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it('is a no-op for a space that does not exist', async () => {
|
|
766
|
+
const fake = createFakeDocker();
|
|
767
|
+
expect(await makePlace(fake).remove(ID)).toEqual({ removed: [], failed: [] });
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it('rejects when docker cannot list', async () => {
|
|
771
|
+
const fake = createFakeDocker({ resources: spaceResources(), failAt: (args) => args[0] === 'volume' && args[1] === 'ls' });
|
|
772
|
+
await expect(makePlace(fake).remove(ID)).rejects.toMatchObject({ code: 'docker_command_failed' });
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
describe('docker place: exec, stop, start, verify', () => {
|
|
777
|
+
it('execs argv as the space user with stdin and a timeout', async () => {
|
|
778
|
+
const fake = createFakeDocker({ resources: spaceResources() });
|
|
779
|
+
const result = await makePlace(fake).exec(ID, ['id', '-u'], { stdin: 'input', timeoutMs: 5000 });
|
|
780
|
+
|
|
781
|
+
expect(result).toEqual({ code: 0, stdout: '1000\n', stderr: '' });
|
|
782
|
+
const call = fake.calls[fake.calls.length - 1];
|
|
783
|
+
expect(call.args).toEqual(['exec', '--interactive', '--user', '1000:1000', CONTAINER, 'id', '-u']);
|
|
784
|
+
expect(call.options).toEqual({ stdin: 'input', timeoutMs: 5000 });
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it('rejects an empty command', async () => {
|
|
788
|
+
const fake = createFakeDocker({ resources: spaceResources() });
|
|
789
|
+
await expect(makePlace(fake).exec(ID, [])).rejects.toMatchObject({ code: 'invalid_command' });
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it('refuses a container with our name that lacks our labels', async () => {
|
|
793
|
+
const entry = hardenedContainerEntry({ name: CONTAINER, labels: {}, network: NETWORK, volumes: [] });
|
|
794
|
+
const fake = createFakeDocker({ resources: [{ kind: 'container', name: CONTAINER, entry }] });
|
|
795
|
+
const place = makePlace(fake);
|
|
796
|
+
|
|
797
|
+
await expect(place.exec(ID, ['id'])).rejects.toMatchObject({ code: 'space_not_ours' });
|
|
798
|
+
await expect(place.stop(ID)).rejects.toMatchObject({ code: 'space_not_ours' });
|
|
799
|
+
await expect(place.start(ID)).rejects.toMatchObject({ code: 'space_not_ours' });
|
|
800
|
+
await expect(place.verify(ID)).rejects.toMatchObject({ code: 'space_not_ours' });
|
|
801
|
+
expect(changes(fake)).toEqual([]);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it('refuses a space of another installation', async () => {
|
|
805
|
+
const fake = createFakeDocker({ resources: spaceResources({ owner: 'install-b' }) });
|
|
806
|
+
await expect(makePlace(fake).stop(ID)).rejects.toMatchObject({ code: 'space_not_ours' });
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it('says when the space does not exist', async () => {
|
|
810
|
+
await expect(makePlace(createFakeDocker()).start(ID)).rejects.toMatchObject({ code: 'space_not_found' });
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it('hands out the argv of the space container, for git to start, and changes nothing', async () => {
|
|
814
|
+
const fake = createFakeDocker({ resources: spaceResources() });
|
|
815
|
+
expect(await makePlace(fake).execArgv(ID)).toEqual(['/usr/bin/docker', 'exec', '--interactive', '--user', '1000:1000', CONTAINER]);
|
|
816
|
+
expect(changes(fake)).toEqual([]);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it('hands out no argv for a stopped space, and says so plainly', async () => {
|
|
820
|
+
const fake = createFakeDocker({ resources: spaceResources({ running: false }) });
|
|
821
|
+
await expect(makePlace(fake).execArgv(ID)).rejects.toMatchObject({ code: 'space_not_running', message: expect.stringMatching(/is stopped/) });
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
// The argv goes to git, which starts docker itself, so the ownership check has to come first here.
|
|
825
|
+
it('hands out no argv for a stranger\'s container, a space of another installation, a missing space, or one in the middle of a move', async () => {
|
|
826
|
+
const stranger = hardenedContainerEntry({ name: CONTAINER, labels: {}, network: NETWORK, volumes: [] });
|
|
827
|
+
await expect(makePlace(createFakeDocker({ resources: [{ kind: 'container', name: CONTAINER, entry: stranger }] })).execArgv(ID)).rejects.toMatchObject({ code: 'space_not_ours' });
|
|
828
|
+
await expect(makePlace(createFakeDocker({ resources: spaceResources({ owner: 'install-b' }) })).execArgv(ID)).rejects.toMatchObject({ code: 'space_not_ours' });
|
|
829
|
+
await expect(makePlace(createFakeDocker()).execArgv(ID)).rejects.toMatchObject({ code: 'space_not_found' });
|
|
830
|
+
const aside = spaceResources({ running: false }).map((resource) => (resource.name === CONTAINER
|
|
831
|
+
? { ...resource, name: `${CONTAINER}-old`, entry: { ...resource.entry, Name: `/${CONTAINER}-old` } }
|
|
832
|
+
: resource));
|
|
833
|
+
await expect(makePlace(createFakeDocker({ resources: aside })).execArgv(ID)).rejects.toMatchObject({ code: 'space_move_unfinished' });
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it('stops the space, starts the same container again, and waits for its server', async () => {
|
|
837
|
+
const fake = createFakeDocker({ resources: [toolsResource(), ...spaceResources()] });
|
|
838
|
+
const place = makePlace(fake);
|
|
839
|
+
|
|
840
|
+
await place.stop(ID);
|
|
841
|
+
expect((await place.list())[0].state).toBe('exited');
|
|
842
|
+
await place.start(ID);
|
|
843
|
+
expect((await place.list())[0].state).toBe('running');
|
|
844
|
+
// The space stops before its gatekeeper and starts after it, so it never runs without one.
|
|
845
|
+
expect(changes(fake)).toEqual([
|
|
846
|
+
`stop ${CONTAINER}`,
|
|
847
|
+
`stop ${GATEKEEPER}`,
|
|
848
|
+
'run tools-check',
|
|
849
|
+
`start ${GATEKEEPER}`,
|
|
850
|
+
...GATEKEEPER_START_STEPS,
|
|
851
|
+
`start ${CONTAINER}`,
|
|
852
|
+
'exec curl',
|
|
853
|
+
]);
|
|
854
|
+
expect(fake.token(CONTAINER)).toBe('seeded-token');
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it('refuses to start a space whose gatekeeper is gone, and starts nothing', async () => {
|
|
858
|
+
const fake = createFakeDocker({
|
|
859
|
+
resources: [toolsResource(), ...spaceResources({ running: false }).filter((resource) => resource.name !== GATEKEEPER)],
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
await expect(makePlace(fake).start(ID)).rejects.toMatchObject({ code: 'gatekeeper_missing' });
|
|
863
|
+
expect(changes(fake)).not.toContain(`start ${CONTAINER}`);
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
it('runs a command in the gatekeeper when the caller asks for that target, and nowhere else', async () => {
|
|
867
|
+
const fake = createFakeDocker({ resources: spaceResources() });
|
|
868
|
+
const place = makePlace(fake);
|
|
869
|
+
|
|
870
|
+
await place.exec(ID, ['id', '-u'], { target: 'gatekeeper' });
|
|
871
|
+
expect(fake.calls[fake.calls.length - 1].args.slice(0, 6)).toEqual(['exec', '--interactive', '--user', '1000:1000', GATEKEEPER, 'id']);
|
|
872
|
+
|
|
873
|
+
await expect(place.exec(ID, ['id'], { target: 'setup' })).rejects.toMatchObject({ code: 'invalid_exec_target' });
|
|
874
|
+
const withoutGatekeeper = createFakeDocker({ resources: spaceResources().filter((resource) => resource.name !== GATEKEEPER) });
|
|
875
|
+
await expect(makePlace(withoutGatekeeper).exec(ID, ['id'], { target: 'gatekeeper' })).rejects.toMatchObject({ code: 'gatekeeper_missing' });
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it('leaves a running space alone when asked to start it, and still looks after its gatekeeper', async () => {
|
|
879
|
+
const fake = createFakeDocker({ resources: [toolsResource({ key: 'ffffffffffffffff' }), ...spaceResources({ tools: `openchamber-tools-${OWNER}-ffffffffffffffff` })] });
|
|
880
|
+
|
|
881
|
+
await makePlace(fake).start(ID);
|
|
882
|
+
// The space's own container and its tools are untouched. Its way out is not its container.
|
|
883
|
+
expect(changes(fake)).toEqual(GATEKEEPER_START_STEPS);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it('brings back a gatekeeper that died under a space that still runs, without touching the space', async () => {
|
|
887
|
+
const seeds = spaceResources();
|
|
888
|
+
// What an agent can force: the gatekeeper is killed for its memory and the space runs on.
|
|
889
|
+
seeds.find((resource) => resource.name === GATEKEEPER).entry.State.Running = false;
|
|
890
|
+
const fake = createFakeDocker({ resources: [toolsResource(), ...seeds] });
|
|
891
|
+
const place = makePlace(fake);
|
|
892
|
+
|
|
893
|
+
expect(await place.list()).toMatchObject([{ state: 'running', damaged: true, missing: [GATEKEEPER] }]);
|
|
894
|
+
await place.start(ID);
|
|
895
|
+
|
|
896
|
+
expect(changes(fake)).toEqual([`start ${GATEKEEPER}`, ...GATEKEEPER_START_STEPS]);
|
|
897
|
+
expect(await place.list()).toMatchObject([{ state: 'running', damaged: false, missing: [] }]);
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
it('rejects a start whose server never becomes ready, and leaves the space as it is', async () => {
|
|
901
|
+
const fake = createFakeDocker({ serverReady: false, resources: [toolsResource(), ...spaceResources({ running: false })] });
|
|
902
|
+
|
|
903
|
+
await expect(makePlace(fake).start(ID)).rejects.toMatchObject({ code: 'space_server_not_ready' });
|
|
904
|
+
expect(removals(fake)).toEqual([]);
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it('verifies a hardened space and reports a missing network', async () => {
|
|
908
|
+
const fake = createFakeDocker({ resources: [toolsResource(), ...spaceResources()] });
|
|
909
|
+
expect(await makePlace(fake).verify(ID)).toEqual([]);
|
|
910
|
+
|
|
911
|
+
const withoutNetwork = createFakeDocker({ resources: [toolsResource(), ...spaceResources().filter((resource) => resource.kind !== 'network')] });
|
|
912
|
+
const violations = await makePlace(withoutNetwork).verify(ID);
|
|
913
|
+
expect(violations.map((violation) => violation.check)).toEqual([
|
|
914
|
+
'network_internal', 'network_host_isolation', 'network_labels', 'gatekeeper_outer_network_labels',
|
|
915
|
+
]);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it('verifies the gatekeeper too, and says when a space has none', async () => {
|
|
919
|
+
const withoutGatekeeper = createFakeDocker({ resources: [toolsResource(), ...spaceResources().filter((resource) => resource.name !== GATEKEEPER)] });
|
|
920
|
+
expect((await makePlace(withoutGatekeeper).verify(ID)).map((violation) => violation.check)).toEqual(['gatekeeper_missing']);
|
|
921
|
+
|
|
922
|
+
// A gatekeeper that lost a network, or that anyone could reach the tools of.
|
|
923
|
+
const seeds = spaceResources();
|
|
924
|
+
const gatekeeper = seeds.find((resource) => resource.name === GATEKEEPER).entry;
|
|
925
|
+
delete gatekeeper.NetworkSettings.Networks[OUTER_NETWORK];
|
|
926
|
+
gatekeeper.Mounts = [{ Type: 'volume', Name: TOOLS, Source: '/x', Destination: '/opt/openchamber-tools', RW: true }];
|
|
927
|
+
const changed = createFakeDocker({ resources: [toolsResource(), ...seeds] });
|
|
928
|
+
expect((await makePlace(changed).verify(ID)).map((violation) => violation.check)).toEqual(['gatekeeper_mounts', 'gatekeeper_networks']);
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
it('reports a space whose tools volume lost its labels', async () => {
|
|
932
|
+
const fake = createFakeDocker({ resources: [toolsResource({ labels: null }), ...spaceResources()] });
|
|
933
|
+
expect((await makePlace(fake).verify(ID)).map((violation) => violation.check)).toEqual(['tools_labels']);
|
|
934
|
+
});
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
describe('docker place: tools volume', () => {
|
|
938
|
+
const OTHER_KEY = '0123456789abcdef';
|
|
939
|
+
const fillCalls = (fake) => fake.calls.filter((call) => isRun('tools-fill')(call.args));
|
|
940
|
+
|
|
941
|
+
it('reuses a filled volume of this owner and this key', async () => {
|
|
942
|
+
const fake = createFakeDocker({ resources: [toolsResource()] });
|
|
943
|
+
await makePlace(fake).create(SPEC);
|
|
944
|
+
|
|
945
|
+
expect(changes(fake)).toEqual(['run tools-check', ...SPACE_STEPS]);
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
it('fills a new volume with labels, a hardened one-shot, and the input on stdin', async () => {
|
|
949
|
+
const fake = createFakeDocker();
|
|
950
|
+
await makePlace(fake).create(SPEC);
|
|
951
|
+
|
|
952
|
+
const volumeCreate = fake.calls.map((call) => call.args).find((args) => args[0] === 'volume' && args[1] === 'create' && args[args.length - 1] === TOOLS);
|
|
953
|
+
expect(volumeCreate).toEqual([
|
|
954
|
+
'volume', 'create',
|
|
955
|
+
'--label', 'openchamber.space=true',
|
|
956
|
+
'--label', 'openchamber.space.role=tools',
|
|
957
|
+
'--label', `openchamber.space.owner=${OWNER}`,
|
|
958
|
+
'--label', `openchamber.space.tools.key=${KEY}`,
|
|
959
|
+
'--label', 'openchamber.space.tools.description=web 1.24.2, opencode 1.18.31',
|
|
960
|
+
'--label', 'openchamber.space.created=2026-09-20T08:00:00.000Z',
|
|
961
|
+
TOOLS,
|
|
962
|
+
]);
|
|
963
|
+
|
|
964
|
+
const [fill] = fillCalls(fake);
|
|
965
|
+
const flags = fill.args.filter((arg, index) => fill.args[index - 1] !== '--label' && arg !== '--label');
|
|
966
|
+
expect(flags.slice(0, -5)).toEqual([
|
|
967
|
+
'run', '--rm', '--interactive',
|
|
968
|
+
'--name', `${TOOLS}-fill`,
|
|
969
|
+
'--init',
|
|
970
|
+
'--user', '0:0',
|
|
971
|
+
'--network', 'bridge',
|
|
972
|
+
'--cap-drop', 'ALL',
|
|
973
|
+
'--security-opt', 'no-new-privileges',
|
|
974
|
+
'--read-only',
|
|
975
|
+
'--tmpfs', '/tmp:rw,exec,nosuid,size=1g',
|
|
976
|
+
'--pids-limit', '512',
|
|
977
|
+
'--ipc', 'private',
|
|
978
|
+
'--cgroupns', 'private',
|
|
979
|
+
'--memory', '2147483648',
|
|
980
|
+
'--memory-swap', '2147483648',
|
|
981
|
+
'--log-driver', 'local',
|
|
982
|
+
'--log-opt', 'max-size=10m',
|
|
983
|
+
'--log-opt', 'max-file=1',
|
|
984
|
+
'--log-opt', 'compress=false',
|
|
985
|
+
'--mount', `type=volume,src=${TOOLS},dst=/opt/openchamber-tools`,
|
|
986
|
+
'--env', 'HOME=/tmp',
|
|
987
|
+
SPACE_BASE_IMAGE,
|
|
988
|
+
]);
|
|
989
|
+
expect(flags.slice(-5, -3)).toEqual(['/usr/local/bin/node', '-e']);
|
|
990
|
+
expect(flags.slice(-2)).toEqual(['/opt/openchamber-tools', '/tmp/openchamber-fill']);
|
|
991
|
+
expect(fill.args).toContain('openchamber.space.role=tools-fill');
|
|
992
|
+
expect(fill.args).not.toContain('--cap-add');
|
|
993
|
+
|
|
994
|
+
// A fill may take minutes, so it has a timeout and an output cap of its own.
|
|
995
|
+
expect(fill.options.timeoutMs).toBe(30 * 60_000);
|
|
996
|
+
expect(fill.options.maxOutputBytes).toBe(16 * 1024 * 1024);
|
|
997
|
+
const input = fill.options.stdin;
|
|
998
|
+
expect(Buffer.isBuffer(input)).toBe(true);
|
|
999
|
+
const header = JSON.parse(input.subarray(0, input.indexOf(10)).toString('utf8'));
|
|
1000
|
+
expect(header).toEqual({ key: KEY, files: [{ name: 'package.json', bytes: Buffer.byteLength(SOURCE.packageJson) }] });
|
|
1001
|
+
expect(input.subarray(input.indexOf(10) + 1).toString('utf8')).toBe(SOURCE.packageJson);
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it('checks the fill marker without a network, read-only, as the space user', async () => {
|
|
1005
|
+
const fake = createFakeDocker({ resources: [toolsResource()] });
|
|
1006
|
+
await makePlace(fake).create(SPEC);
|
|
1007
|
+
|
|
1008
|
+
const check = fake.calls.map((call) => call.args).find(isRun('tools-check'));
|
|
1009
|
+
const flags = check.filter((arg, index) => check[index - 1] !== '--label' && arg !== '--label');
|
|
1010
|
+
expect(flags).toEqual([
|
|
1011
|
+
'run', '--rm',
|
|
1012
|
+
'--name', `${TOOLS}-check`,
|
|
1013
|
+
'--user', '1000:1000',
|
|
1014
|
+
'--network', 'none',
|
|
1015
|
+
'--cap-drop', 'ALL',
|
|
1016
|
+
'--security-opt', 'no-new-privileges',
|
|
1017
|
+
'--read-only',
|
|
1018
|
+
'--ipc', 'private',
|
|
1019
|
+
'--cgroupns', 'private',
|
|
1020
|
+
'--memory', '134217728',
|
|
1021
|
+
'--memory-swap', '134217728',
|
|
1022
|
+
'--log-driver', 'local',
|
|
1023
|
+
'--log-opt', 'max-size=10m',
|
|
1024
|
+
'--log-opt', 'max-file=1',
|
|
1025
|
+
'--log-opt', 'compress=false',
|
|
1026
|
+
'--mount', `type=volume,src=${TOOLS},dst=/opt/openchamber-tools,readonly`,
|
|
1027
|
+
SPACE_BASE_IMAGE,
|
|
1028
|
+
'/bin/sh', '-c', '[ -f "$1" ] || exit 42; /bin/cat "$1"', 'sh', '/opt/openchamber-tools/.filled',
|
|
1029
|
+
]);
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
it('names every program that the host runs in a container by its absolute path in the image', async () => {
|
|
1033
|
+
// The space PATH starts with the npm .bin of the tools volume. A package there that ships a bin
|
|
1034
|
+
// named curl or sh must never be what the host runs.
|
|
1035
|
+
const fake = createFakeDocker();
|
|
1036
|
+
const place = makePlace(fake);
|
|
1037
|
+
await place.create(SPEC);
|
|
1038
|
+
await place.stop(ID);
|
|
1039
|
+
await place.start(ID);
|
|
1040
|
+
|
|
1041
|
+
const programs = fake.calls.map((call) => call.args).flatMap((args) => {
|
|
1042
|
+
if (args[0] === 'exec') return [args[5]];
|
|
1043
|
+
if (args[0] === 'run' || args[0] === 'create') return [args[args.indexOf(SPACE_BASE_IMAGE) + 1]];
|
|
1044
|
+
return [];
|
|
1045
|
+
});
|
|
1046
|
+
expect([...new Set(programs)].sort()).toEqual(['/bin/chown', '/bin/sh', '/usr/bin/curl', '/usr/local/bin/node']);
|
|
1047
|
+
// The fixed scripts take their own commands from the image too.
|
|
1048
|
+
const scripts = fake.calls.map((call) => call.args).filter((args) => args[0] === 'exec' && args[5] === '/bin/sh').map((args) => args[7]);
|
|
1049
|
+
expect(scripts.length).toBeGreaterThan(0);
|
|
1050
|
+
for (const script of scripts) expect(script.startsWith('PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin;')).toBe(true);
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it('keeps newlines out of every argument, because Docker Desktop on Windows was never verified with one', async () => {
|
|
1054
|
+
const fake = createFakeDocker();
|
|
1055
|
+
await makePlace(fake).create(SPEC);
|
|
1056
|
+
|
|
1057
|
+
for (const { args } of fake.calls) {
|
|
1058
|
+
for (const arg of args) expect(arg).not.toMatch(/[\r\n]/);
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
it('removes the filler and the volume when the fill fails, and makes no space', async () => {
|
|
1063
|
+
const fake = createFakeDocker({ failAt: isRun('tools-fill') });
|
|
1064
|
+
|
|
1065
|
+
const error = await makePlace(fake).create(SPEC).catch((caught) => caught);
|
|
1066
|
+
expect(error.code).toBe('tools_fill_failed');
|
|
1067
|
+
expect(error.message).toMatch(/Installing web 1.24.2, opencode 1.18.31 failed: .*simulated failure/);
|
|
1068
|
+
expect(error.message).toMatch(/npm registry/);
|
|
1069
|
+
expect(error.details).toMatchObject({ uncertain: false, rollbackFailures: [] });
|
|
1070
|
+
expect(removals(fake)).toEqual([`volume rm ${TOOLS}`]);
|
|
1071
|
+
expect(fake.names()).toEqual([]);
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
it('sweeps again when the fill was interrupted, and removes the late filler and the volume it made again', async () => {
|
|
1075
|
+
const fake = createFakeDocker({ timeoutAt: isRun('tools-fill') });
|
|
1076
|
+
|
|
1077
|
+
const error = await makePlace(fake).create(SPEC).catch((caught) => caught);
|
|
1078
|
+
expect(error.code).toBe('command_timeout');
|
|
1079
|
+
expect(error.details.uncertain).toBe(true);
|
|
1080
|
+
expect(error.cause.details).toEqual({ uncertain: true, rollbackFailures: [] });
|
|
1081
|
+
expect(removals(fake)).toEqual([`volume rm ${TOOLS}`, `rm ${TOOLS}-fill`, `volume rm ${TOOLS}`]);
|
|
1082
|
+
expect(fake.names()).toEqual([]);
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
it('fills again when a volume of ours has no marker, because that fill was interrupted', async () => {
|
|
1086
|
+
const leftover = { kind: 'container', name: `${TOOLS}-fill`, entry: { Name: `/${TOOLS}-fill`, Config: { Labels: toolsLabels({ role: 'tools-fill' }) }, Mounts: [], State: { Running: true } } };
|
|
1087
|
+
const fake = createFakeDocker({ resources: [toolsResource({ filled: false }), leftover] });
|
|
1088
|
+
await makePlace(fake).create(SPEC);
|
|
1089
|
+
|
|
1090
|
+
expect(changes(fake).slice(0, 6)).toEqual([
|
|
1091
|
+
`rm ${TOOLS}-fill`,
|
|
1092
|
+
'run tools-check',
|
|
1093
|
+
`volume rm ${TOOLS}`,
|
|
1094
|
+
`volume create ${TOOLS}`,
|
|
1095
|
+
'run tools-fill',
|
|
1096
|
+
'run tools-check',
|
|
1097
|
+
]);
|
|
1098
|
+
expect(await makePlace(fake).verify(ID)).toEqual([]);
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
it.each([
|
|
1102
|
+
['no labels', null],
|
|
1103
|
+
['the labels of another installation', toolsLabels({ owner: 'install-b' })],
|
|
1104
|
+
['another key in its labels', toolsLabels({ key: OTHER_KEY })],
|
|
1105
|
+
['the labels of a filler', toolsLabels({ role: 'tools-fill' })],
|
|
1106
|
+
])('refuses a volume with our name and %s, and touches nothing', async (title, labels) => {
|
|
1107
|
+
const fake = createFakeDocker({ resources: [toolsResource({ labels })] });
|
|
1108
|
+
|
|
1109
|
+
const error = await makePlace(fake).create(SPEC).catch((caught) => caught);
|
|
1110
|
+
expect(error.code).toBe('tools_volume_not_ours');
|
|
1111
|
+
expect(error.message).toContain(TOOLS);
|
|
1112
|
+
expect(changes(fake)).toEqual([]);
|
|
1113
|
+
expect(fake.names()).toEqual([`volume:${TOOLS}`]);
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
it('rejects when the docker CLI cannot reach the daemon for the marker check, and never takes its exit code 1 for a missing marker', async () => {
|
|
1117
|
+
const fake = createFakeDocker({ failAt: isRun('tools-check'), resources: [toolsResource(), ...spaceResources()] });
|
|
1118
|
+
const place = makePlace(fake);
|
|
1119
|
+
|
|
1120
|
+
const error = await place.create({ ...SPEC, id: '111111111111' }).catch((caught) => caught);
|
|
1121
|
+
expect(error.code).toBe('docker_command_failed');
|
|
1122
|
+
expect(error.message).toMatch(/failed to connect to the docker API/);
|
|
1123
|
+
expect(removals(fake)).toEqual([]);
|
|
1124
|
+
expect(fake.calls.some((call) => isRun('tools-fill')(call.args))).toBe(false);
|
|
1125
|
+
expect(fake.names()).toContain(`volume:${TOOLS}`);
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
describe.each([
|
|
1129
|
+
['a marker for other content', 'ffffffffffffffff'],
|
|
1130
|
+
['an empty marker, as a crash of the Docker machine right after a fill can leave it', ''],
|
|
1131
|
+
])('%s', (title, marker) => {
|
|
1132
|
+
// The first check finds the wrong marker. Later checks see what the fake really holds.
|
|
1133
|
+
const wrongOnce = (fake) => {
|
|
1134
|
+
let answered = false;
|
|
1135
|
+
return async (file, args, options) => {
|
|
1136
|
+
if (isRun('tools-check')(args) && !answered) {
|
|
1137
|
+
answered = true;
|
|
1138
|
+
return { code: 0, stdout: `${marker}\n`, stderr: '' };
|
|
1139
|
+
}
|
|
1140
|
+
return fake.runCommand(file, args, options);
|
|
1141
|
+
};
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
it('removes the volume and fills again when no container mounts it', async () => {
|
|
1145
|
+
const fake = createFakeDocker({ resources: [toolsResource()] });
|
|
1146
|
+
await placeOn(wrongOnce(fake), { wait: fake.wait, now: fake.now }).create(SPEC);
|
|
1147
|
+
|
|
1148
|
+
// The wrong answer came from the wrapper, so the fake saw what followed it.
|
|
1149
|
+
expect(changes(fake).slice(0, 4)).toEqual([`volume rm ${TOOLS}`, `volume create ${TOOLS}`, 'run tools-fill', 'run tools-check']);
|
|
1150
|
+
expect(await makePlace(fake).verify(ID)).toEqual([]);
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
it('rejects when spaces still mount it, and says in plain words what frees it', async () => {
|
|
1154
|
+
const fake = createFakeDocker({ resources: [toolsResource(), ...spaceResources()] });
|
|
1155
|
+
const place = placeOn(wrongOnce(fake), { wait: fake.wait, now: fake.now });
|
|
1156
|
+
|
|
1157
|
+
const error = await place.create({ ...SPEC, id: '111111111111' }).catch((caught) => caught);
|
|
1158
|
+
expect(error.code).toBe('tools_marker_mismatch');
|
|
1159
|
+
expect(error.message).toMatch(/Apply or discard the work in the spaces that were made with web 1\.24\.2, opencode 1\.18\.31, remove those spaces, then try again/);
|
|
1160
|
+
// Nobody needs a terminal for this, so the message names no docker command.
|
|
1161
|
+
expect(error.message).not.toMatch(/docker |volume rm/);
|
|
1162
|
+
expect(fake.calls.some((call) => isRun('tools-fill')(call.args))).toBe(false);
|
|
1163
|
+
expect(fake.names()).toContain(`volume:${TOOLS}`);
|
|
1164
|
+
});
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
it.each([2, 125, 126, 127, 137])('rejects exit code %i of the marker check and keeps the volume', async (code) => {
|
|
1168
|
+
const fake = createFakeDocker({ resources: [toolsResource()] });
|
|
1169
|
+
const runCommand = async (file, args, options) => (
|
|
1170
|
+
isRun('tools-check')(args) ? { code, stdout: '', stderr: 'something went wrong' } : fake.runCommand(file, args, options)
|
|
1171
|
+
);
|
|
1172
|
+
|
|
1173
|
+
await expect(placeOn(runCommand, { wait: fake.wait, now: fake.now }).create(SPEC)).rejects.toMatchObject({ code: 'docker_command_failed' });
|
|
1174
|
+
expect(fake.names()).toEqual([`volume:${TOOLS}`]);
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
describe('a tools volume that another process pruned between ensure and create', () => {
|
|
1178
|
+
// Docker makes a missing `src=` volume again at `docker create`, without labels.
|
|
1179
|
+
const pruneBeforeCreate = (fake, replacement = null) => async (file, args, options) => {
|
|
1180
|
+
if (args[0] === 'create') {
|
|
1181
|
+
await fake.runCommand(file, ['volume', 'rm', TOOLS], options);
|
|
1182
|
+
if (replacement) await fake.runCommand(file, ['volume', 'create', ...replacement, TOOLS], options);
|
|
1183
|
+
}
|
|
1184
|
+
return fake.runCommand(file, args, options);
|
|
1185
|
+
};
|
|
1186
|
+
|
|
1187
|
+
it('removes the unlabelled volume by name when the create fails, so the owner is not stuck for good', async () => {
|
|
1188
|
+
const fake = createFakeDocker({ resources: [toolsResource()] });
|
|
1189
|
+
const place = placeOn(pruneBeforeCreate(fake), { wait: fake.wait, now: fake.now });
|
|
1190
|
+
|
|
1191
|
+
const error = await place.create(SPEC).catch((caught) => caught);
|
|
1192
|
+
expect(error.code).toBe('space_verification_failed');
|
|
1193
|
+
expect(error.details.original.violations.map((violation) => violation.check)).toEqual(['tools_labels']);
|
|
1194
|
+
expect(error.details.rollbackFailures).toEqual([]);
|
|
1195
|
+
expect(fake.names()).toEqual([]);
|
|
1196
|
+
|
|
1197
|
+
// The next create fills again and works.
|
|
1198
|
+
await makePlace(fake).create(SPEC);
|
|
1199
|
+
expect(await makePlace(fake).verify(ID)).toEqual([]);
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
it('never removes a volume with labels, not even labels of someone else', async () => {
|
|
1203
|
+
const fake = createFakeDocker({ resources: [toolsResource()] });
|
|
1204
|
+
const place = placeOn(pruneBeforeCreate(fake, ['--label', 'com.example.owner=someone-else']), { wait: fake.wait, now: fake.now });
|
|
1205
|
+
|
|
1206
|
+
await expect(place.create(SPEC)).rejects.toMatchObject({ code: 'space_verification_failed' });
|
|
1207
|
+
expect(fake.names()).toEqual([`volume:${TOOLS}`]);
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
it('does the same when a move to new tools fails', async () => {
|
|
1211
|
+
const oldTools = `openchamber-tools-${OWNER}-0123456789abcdef`;
|
|
1212
|
+
const fake = createFakeDocker({ resources: [toolsResource(), toolsResource({ key: '0123456789abcdef' }), ...spaceResources({ running: false, tools: oldTools })] });
|
|
1213
|
+
const place = placeOn(pruneBeforeCreate(fake), { wait: fake.wait, now: fake.now });
|
|
1214
|
+
|
|
1215
|
+
await expect(place.start(ID)).rejects.toMatchObject({ code: 'space_verification_failed', details: { rollbackFailures: [] } });
|
|
1216
|
+
expect(fake.names()).not.toContain(`volume:${TOOLS}`);
|
|
1217
|
+
expect(fake.names()).toContain(`container:${CONTAINER}`);
|
|
1218
|
+
});
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
it('shares one fill between two creates that run at the same time', async () => {
|
|
1222
|
+
let release;
|
|
1223
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
1224
|
+
const fake = createFakeDocker({ beforeFill: () => gate });
|
|
1225
|
+
const place = makePlace(fake);
|
|
1226
|
+
|
|
1227
|
+
const both = Promise.all([place.create(SPEC), place.create({ ...SPEC, id: '111111111111' })]);
|
|
1228
|
+
// Both creates are waiting for the tools by now, and only one of them runs the filler.
|
|
1229
|
+
await new Promise((resolve) => { setTimeout(resolve, 20); });
|
|
1230
|
+
release();
|
|
1231
|
+
await both;
|
|
1232
|
+
|
|
1233
|
+
expect(fillCalls(fake)).toHaveLength(1);
|
|
1234
|
+
expect((await place.list()).map((space) => space.state)).toEqual(['running', 'running']);
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
it('fills again after a failed fill, so one failure does not stick', async () => {
|
|
1238
|
+
let failures = 1;
|
|
1239
|
+
const fake = createFakeDocker({ failAt: (args) => isRun('tools-fill')(args) && failures-- > 0 });
|
|
1240
|
+
const place = makePlace(fake);
|
|
1241
|
+
|
|
1242
|
+
await expect(place.create(SPEC)).rejects.toMatchObject({ code: 'tools_fill_failed' });
|
|
1243
|
+
await place.create(SPEC);
|
|
1244
|
+
expect(fillCalls(fake)).toHaveLength(2);
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
it('removes older tools volumes after a new fill, keeps one that a space still mounts, and leaves other owners alone', async () => {
|
|
1248
|
+
const inUse = `openchamber-tools-${OWNER}-${OTHER_KEY}`;
|
|
1249
|
+
const unused = toolsResource({ key: 'aaaaaaaaaaaaaaaa' });
|
|
1250
|
+
const foreign = toolsResource({ owner: 'install-b', key: OTHER_KEY });
|
|
1251
|
+
const fake = createFakeDocker({
|
|
1252
|
+
resources: [toolsResource({ key: OTHER_KEY }), unused, foreign, ...spaceResources({ id: '111111111111', tools: inUse })],
|
|
1253
|
+
});
|
|
1254
|
+
await makePlace(fake).create(SPEC);
|
|
1255
|
+
|
|
1256
|
+
expect(fake.names()).toContain(`volume:${inUse}`);
|
|
1257
|
+
expect(fake.names()).not.toContain(`volume:${unused.name}`);
|
|
1258
|
+
expect(fake.names()).toContain(`volume:${foreign.name}`);
|
|
1259
|
+
expect(fake.names()).toContain(`volume:${TOOLS}`);
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
it('does not list a tools volume as a space, and does not remove it with one', async () => {
|
|
1263
|
+
const fake = createFakeDocker({ resources: [toolsResource(), ...spaceResources()] });
|
|
1264
|
+
const place = makePlace(fake);
|
|
1265
|
+
|
|
1266
|
+
expect((await place.list()).map((space) => space.id)).toEqual([ID]);
|
|
1267
|
+
await place.remove(ID);
|
|
1268
|
+
expect(fake.names()).toEqual([`volume:${TOOLS}`]);
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
it('rejects a place without a tools source', () => {
|
|
1272
|
+
expect(() => createDockerPlace({ runCommand: async () => {}, dockerPath: 'docker', owner: OWNER })).toThrow(expect.objectContaining({ code: 'invalid_tools_source' }));
|
|
1273
|
+
});
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
describe('docker place: server token', () => {
|
|
1277
|
+
it('writes a fresh token over stdin after the start, and keeps it out of every argument', async () => {
|
|
1278
|
+
const fake = createFakeDocker({ resources: [toolsResource()] });
|
|
1279
|
+
await makePlace(fake).create(SPEC);
|
|
1280
|
+
|
|
1281
|
+
const token = fake.token(CONTAINER);
|
|
1282
|
+
expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
1283
|
+
const write = fake.calls.find((call) => isExec('token.new')(call.args));
|
|
1284
|
+
expect(write.options.stdin).toBe(token);
|
|
1285
|
+
expect(fake.calls.indexOf(write)).toBeGreaterThan(fake.calls.findIndex((call) => call.args[0] === 'start'));
|
|
1286
|
+
for (const { args } of fake.calls) {
|
|
1287
|
+
expect(args.join(' ')).not.toContain(token);
|
|
1288
|
+
}
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
it('gives every space a token of its own', async () => {
|
|
1292
|
+
const fake = createFakeDocker({ resources: [toolsResource()] });
|
|
1293
|
+
const place = makePlace(fake);
|
|
1294
|
+
await place.create(SPEC);
|
|
1295
|
+
await place.create({ ...SPEC, id: '111111111111' });
|
|
1296
|
+
|
|
1297
|
+
expect(fake.token('openchamber-space-111111111111-space')).not.toBe(fake.token(CONTAINER));
|
|
1298
|
+
});
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
describe('docker place: new tools at the next start', () => {
|
|
1302
|
+
const OLD_KEY = '0123456789abcdef';
|
|
1303
|
+
const OLD_TOOLS = `openchamber-tools-${OWNER}-${OLD_KEY}`;
|
|
1304
|
+
const ASIDE = `${CONTAINER}-old`;
|
|
1305
|
+
// The gatekeeper start comes before the space start, so a test that fails "the start" must say which.
|
|
1306
|
+
const isSpaceStart = (args) => args[0] === 'start' && args[1] !== GATEKEEPER;
|
|
1307
|
+
const stoppedOnOldTools = () => [toolsResource({ key: OLD_KEY }), ...spaceResources({ running: false, tools: OLD_TOOLS })];
|
|
1308
|
+
const toolsOf = async (fake, name = CONTAINER) => {
|
|
1309
|
+
const result = await fake.runCommand('docker', ['inspect', '--type', 'container', name], {});
|
|
1310
|
+
return JSON.parse(result.stdout)[0]?.Mounts.find((mount) => mount.Destination === '/opt/openchamber-tools').Name;
|
|
1311
|
+
};
|
|
1312
|
+
|
|
1313
|
+
it('makes the container again on the current tools, with the same labels, memory and token', async () => {
|
|
1314
|
+
const fake = createFakeDocker({ resources: stoppedOnOldTools() });
|
|
1315
|
+
const place = makePlace(fake);
|
|
1316
|
+
await place.start(ID);
|
|
1317
|
+
|
|
1318
|
+
expect(changes(fake)).toEqual([
|
|
1319
|
+
`volume create ${TOOLS}`,
|
|
1320
|
+
'run tools-fill',
|
|
1321
|
+
'run tools-check',
|
|
1322
|
+
// The old tools volume is still mounted by the stopped space, so Docker keeps it.
|
|
1323
|
+
`volume rm ${OLD_TOOLS}`,
|
|
1324
|
+
// The gatekeeper is up before the space container is made again. It mounts no tools
|
|
1325
|
+
// volume, so the move itself leaves it alone.
|
|
1326
|
+
`start ${GATEKEEPER}`,
|
|
1327
|
+
...GATEKEEPER_START_STEPS,
|
|
1328
|
+
`rename ${CONTAINER} ${ASIDE}`,
|
|
1329
|
+
'create space',
|
|
1330
|
+
'start <id>',
|
|
1331
|
+
'exec curl',
|
|
1332
|
+
`rm ${ASIDE}`,
|
|
1333
|
+
`volume rm ${OLD_TOOLS}`,
|
|
1334
|
+
]);
|
|
1335
|
+
expect(await toolsOf(fake)).toBe(TOOLS);
|
|
1336
|
+
expect(fake.names()).not.toContain(`volume:${OLD_TOOLS}`);
|
|
1337
|
+
expect(fake.token(CONTAINER)).toBe('seeded-token');
|
|
1338
|
+
expect(await place.verify(ID)).toEqual([]);
|
|
1339
|
+
expect(await place.list()).toEqual([
|
|
1340
|
+
{ id: ID, name: SPEC.name, project: SPEC.project, created: SPEC.created, state: 'running', orphans: [], damaged: false, missing: [] },
|
|
1341
|
+
]);
|
|
1342
|
+
const create = fake.calls.map((call) => call.args).find((args) => args[0] === 'create');
|
|
1343
|
+
expect(create[create.indexOf('--memory') + 1]).toBe('4294967296');
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
it('puts the old container back when the start of the new one fails', async () => {
|
|
1347
|
+
const fake = createFakeDocker({ failAt: isSpaceStart, resources: [toolsResource(), ...stoppedOnOldTools()] });
|
|
1348
|
+
const place = makePlace(fake);
|
|
1349
|
+
|
|
1350
|
+
const error = await place.start(ID).catch((caught) => caught);
|
|
1351
|
+
expect(error.code).toBe('docker_command_failed');
|
|
1352
|
+
expect(error.message).toMatch(/The space keeps its old container\./);
|
|
1353
|
+
expect(error.details.rollbackFailures).toEqual([]);
|
|
1354
|
+
expect(await toolsOf(fake)).toBe(OLD_TOOLS);
|
|
1355
|
+
expect(fake.names()).not.toContain(`container:${ASIDE}`);
|
|
1356
|
+
expect(fake.names()).toContain(`volume:${OLD_TOOLS}`);
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
it('removes nothing when the create fails, and puts the old container back', async () => {
|
|
1360
|
+
const fake = createFakeDocker({ resources: [toolsResource(), ...stoppedOnOldTools()] });
|
|
1361
|
+
const runCommand = async (file, args, options) => (
|
|
1362
|
+
args[0] === 'create' ? { code: 1, stdout: '', stderr: 'Error response from daemon: simulated failure' } : fake.runCommand(file, args, options)
|
|
1363
|
+
);
|
|
1364
|
+
|
|
1365
|
+
const error = await placeOn(runCommand, { wait: fake.wait, now: fake.now }).start(ID).catch((caught) => caught);
|
|
1366
|
+
expect(error.message).toMatch(/The space keeps its old container\./);
|
|
1367
|
+
expect(removals(fake)).toEqual([]);
|
|
1368
|
+
expect(await toolsOf(fake)).toBe(OLD_TOOLS);
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
it('removes nothing by name when a failed create left a container behind, says so, and repairs it at the next start', async () => {
|
|
1372
|
+
let failures = 1;
|
|
1373
|
+
const fake = createFakeDocker({ failAt: (args) => args[0] === 'create' && failures-- > 0, resources: [toolsResource(), ...stoppedOnOldTools()] });
|
|
1374
|
+
const place = makePlace(fake);
|
|
1375
|
+
|
|
1376
|
+
// There is no id to remove by, so the leftover keeps the plain name and the old container stays aside.
|
|
1377
|
+
const error = await place.start(ID).catch((caught) => caught);
|
|
1378
|
+
expect(error.code).toBe('docker_command_failed');
|
|
1379
|
+
expect(removals(fake)).toEqual([]);
|
|
1380
|
+
expect(error.details.rollbackFailures).toEqual([{ kind: 'container', name: ASIDE, message: expect.stringContaining('already in use') }]);
|
|
1381
|
+
expect(error.message).toMatch(/Start the space again to repair it/);
|
|
1382
|
+
expect(await toolsOf(fake, ASIDE)).toBe(OLD_TOOLS);
|
|
1383
|
+
|
|
1384
|
+
await place.start(ID);
|
|
1385
|
+
expect(await toolsOf(fake)).toBe(TOOLS);
|
|
1386
|
+
expect(fake.names().filter((name) => name.startsWith('container:')).sort()).toEqual([`container:${GATEKEEPER}`, `container:${CONTAINER}`].sort());
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
it('puts the old container back when the new one fails verification, and never starts it', async () => {
|
|
1390
|
+
const fake = createFakeDocker({
|
|
1391
|
+
alterContainer: (entry) => (entry.Name.endsWith('-space') ? { ...entry, HostConfig: { ...entry.HostConfig, Privileged: true } } : entry),
|
|
1392
|
+
resources: [toolsResource(), ...stoppedOnOldTools()],
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
await expect(makePlace(fake).start(ID)).rejects.toMatchObject({ code: 'space_verification_failed' });
|
|
1396
|
+
expect(changes(fake).filter((line) => line.startsWith('start'))).toEqual([`start ${GATEKEEPER}`]);
|
|
1397
|
+
expect(await toolsOf(fake)).toBe(OLD_TOOLS);
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
it('puts the old container back when the new server never becomes ready', async () => {
|
|
1401
|
+
const fake = createFakeDocker({ serverReady: false, resources: [toolsResource(), ...stoppedOnOldTools()] });
|
|
1402
|
+
|
|
1403
|
+
await expect(makePlace(fake).start(ID)).rejects.toMatchObject({ code: 'space_server_not_ready' });
|
|
1404
|
+
expect(await toolsOf(fake)).toBe(OLD_TOOLS);
|
|
1405
|
+
expect(fake.names()).not.toContain(`container:${ASIDE}`);
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
it('reports what it could not put back', async () => {
|
|
1409
|
+
const fake = createFakeDocker({
|
|
1410
|
+
failAt: (args) => args[0] === 'create' || (args[0] === 'rename' && args[1] === ASIDE),
|
|
1411
|
+
resources: [toolsResource(), ...stoppedOnOldTools()],
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
const error = await makePlace(fake).start(ID).catch((caught) => caught);
|
|
1415
|
+
expect(error.details.rollbackFailures).toEqual([{ kind: 'container', name: ASIDE, message: expect.stringContaining('simulated failure') }]);
|
|
1416
|
+
// The message must not promise what did not happen.
|
|
1417
|
+
expect(error.message).not.toMatch(/keeps its old container/);
|
|
1418
|
+
expect(error.message).toMatch(new RegExp(`Putting the old container back failed for: container ${ASIDE}\\. Start the space again`));
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
it('removes the new container by the id that create printed, never by name', async () => {
|
|
1422
|
+
const fake = createFakeDocker({ failAt: isSpaceStart, resources: [toolsResource(), ...stoppedOnOldTools()] });
|
|
1423
|
+
|
|
1424
|
+
await expect(makePlace(fake).start(ID)).rejects.toMatchObject({ code: 'docker_command_failed' });
|
|
1425
|
+
const removed = fake.calls.map((call) => call.args).filter((args) => args[0] === 'rm').map((args) => args[args.length - 1]);
|
|
1426
|
+
expect(removed).toHaveLength(1);
|
|
1427
|
+
expect(removed[0]).toMatch(CONTAINER_ID);
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
// The defect of the first 1b draft: the rollback removed the plain name, and the plain
|
|
1431
|
+
// name had gone back to the old container in the meantime. The space lost its only container.
|
|
1432
|
+
it('keeps the old container when it got its name back before the create landed', async () => {
|
|
1433
|
+
const fake = createFakeDocker({ resources: [toolsResource(), ...stoppedOnOldTools()] });
|
|
1434
|
+
const oldId = JSON.parse((await fake.runCommand('docker', ['inspect', '--type', 'container', CONTAINER], {})).stdout)[0].Id;
|
|
1435
|
+
// Another process repairs the move right after the rename, before this create.
|
|
1436
|
+
const runCommand = async (file, args, options) => {
|
|
1437
|
+
if (args[0] === 'create') await fake.runCommand(file, ['rename', ASIDE, CONTAINER], options);
|
|
1438
|
+
return fake.runCommand(file, args, options);
|
|
1439
|
+
};
|
|
1440
|
+
|
|
1441
|
+
const error = await placeOn(runCommand, { wait: fake.wait, now: fake.now }).start(ID).catch((caught) => caught);
|
|
1442
|
+
expect(error.code).toBe('docker_command_failed');
|
|
1443
|
+
expect(error.message).toMatch(/is already in use/);
|
|
1444
|
+
expect(removals(fake)).toEqual([]);
|
|
1445
|
+
// The old container is back under its name, so nothing is left to repair.
|
|
1446
|
+
expect(error.details.rollbackFailures).toEqual([]);
|
|
1447
|
+
const survivor = JSON.parse((await fake.runCommand('docker', ['inspect', '--type', 'container', CONTAINER], {})).stdout)[0];
|
|
1448
|
+
expect(survivor.Id).toBe(oldId);
|
|
1449
|
+
expect(await toolsOf(fake)).toBe(OLD_TOOLS);
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
it('shares one run between two starts of the same space, so a container is never moved twice at once', async () => {
|
|
1453
|
+
const fake = createFakeDocker({ resources: stoppedOnOldTools() });
|
|
1454
|
+
const place = makePlace(fake);
|
|
1455
|
+
|
|
1456
|
+
await Promise.all([place.start(ID), place.start(ID)]);
|
|
1457
|
+
expect(changes(fake).filter((line) => line.startsWith('rename') || line === 'create space')).toEqual([`rename ${CONTAINER} ${ASIDE}`, 'create space']);
|
|
1458
|
+
expect((await place.list())[0].state).toBe('running');
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
it('makes a stop wait for a start of the same space that is under way, so a stopped space stays stopped', async () => {
|
|
1462
|
+
const fake = createFakeDocker({ resources: [toolsResource(), ...stoppedOnOldTools()] });
|
|
1463
|
+
let place;
|
|
1464
|
+
let stopping;
|
|
1465
|
+
// The stop arrives between the create and the start of the move.
|
|
1466
|
+
const runCommand = async (file, args, options) => {
|
|
1467
|
+
if (isSpaceStart(args)) stopping = place.stop(ID);
|
|
1468
|
+
return fake.runCommand(file, args, options);
|
|
1469
|
+
};
|
|
1470
|
+
place = placeOn(runCommand, { wait: fake.wait, now: fake.now });
|
|
1471
|
+
|
|
1472
|
+
await place.start(ID);
|
|
1473
|
+
await stopping;
|
|
1474
|
+
expect(changes(fake).slice(-5)).toEqual(['exec curl', `rm ${ASIDE}`, `volume rm ${OLD_TOOLS}`, `stop ${CONTAINER}`, `stop ${GATEKEEPER}`]);
|
|
1475
|
+
expect((await place.list())[0].state).toBe('exited');
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
it('stops after a start that failed, too', async () => {
|
|
1479
|
+
const fake = createFakeDocker({ serverReady: false, resources: [toolsResource(), ...spaceResources({ running: false })] });
|
|
1480
|
+
const place = makePlace(fake);
|
|
1481
|
+
|
|
1482
|
+
const [started] = await Promise.allSettled([place.start(ID), place.stop(ID)]);
|
|
1483
|
+
expect(started.status).toBe('rejected');
|
|
1484
|
+
expect((await place.list())[0].state).toBe('exited');
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
it('does not rename anything from verify or exec while a move is under way, and says to start the space', async () => {
|
|
1488
|
+
const fake = createFakeDocker({ resources: [toolsResource(), ...stoppedOnOldTools()] });
|
|
1489
|
+
let place;
|
|
1490
|
+
const during = [];
|
|
1491
|
+
// Between the rename and the create of a move, other operations look at the space.
|
|
1492
|
+
const runCommand = async (file, args, options) => {
|
|
1493
|
+
if (args[0] === 'create') {
|
|
1494
|
+
for (const operation of [() => place.verify(ID), () => place.exec(ID, ['id'])]) {
|
|
1495
|
+
during.push(await operation().catch((caught) => caught.code));
|
|
1496
|
+
}
|
|
1497
|
+
during.push((await place.list()).map((space) => space.state));
|
|
1498
|
+
}
|
|
1499
|
+
return fake.runCommand(file, args, options);
|
|
1500
|
+
};
|
|
1501
|
+
place = placeOn(runCommand, { wait: fake.wait, now: fake.now });
|
|
1502
|
+
await place.start(ID);
|
|
1503
|
+
|
|
1504
|
+
expect(during).toEqual(['space_move_unfinished', 'space_move_unfinished', ['exited']]);
|
|
1505
|
+
expect(changes(fake).filter((line) => line.startsWith('rename'))).toEqual([`rename ${CONTAINER} ${ASIDE}`]);
|
|
1506
|
+
expect(await toolsOf(fake)).toBe(TOOLS);
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
it('never moves a running space, whatever tools it runs on', async () => {
|
|
1510
|
+
const fake = createFakeDocker({ resources: [toolsResource({ key: OLD_KEY }), ...spaceResources({ tools: OLD_TOOLS })] });
|
|
1511
|
+
|
|
1512
|
+
await makePlace(fake).start(ID);
|
|
1513
|
+
// Only its gatekeeper is looked after. The space container and its tools are untouched.
|
|
1514
|
+
expect(changes(fake)).toEqual(GATEKEEPER_START_STEPS);
|
|
1515
|
+
expect(await toolsOf(fake)).toBe(OLD_TOOLS);
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
describe('after the process died in the middle of a move', () => {
|
|
1519
|
+
const asAside = (resource) => (resource.name === CONTAINER ? { ...resource, name: ASIDE, entry: { ...resource.entry, Name: `/${ASIDE}` } } : resource);
|
|
1520
|
+
|
|
1521
|
+
it('lists the space as stopped, refuses to read or stop it without changing anything, and repairs it at the next start', async () => {
|
|
1522
|
+
const fake = createFakeDocker({ resources: [toolsResource(), ...spaceResources({ running: false }).map(asAside)] });
|
|
1523
|
+
const place = makePlace(fake);
|
|
1524
|
+
|
|
1525
|
+
expect(await place.list()).toMatchObject([{ id: ID, state: 'exited', orphans: [] }]);
|
|
1526
|
+
for (const operation of [() => place.verify(ID), () => place.exec(ID, ['id']), () => place.stop(ID)]) {
|
|
1527
|
+
const error = await operation().catch((caught) => caught);
|
|
1528
|
+
expect(error.code).toBe('space_move_unfinished');
|
|
1529
|
+
expect(error.message).toMatch(/Start the space to repair it/);
|
|
1530
|
+
}
|
|
1531
|
+
expect(changes(fake)).toEqual([]);
|
|
1532
|
+
|
|
1533
|
+
await place.start(ID);
|
|
1534
|
+
expect(changes(fake)).toEqual([
|
|
1535
|
+
`rename ${ASIDE} ${CONTAINER}`, 'run tools-check', `start ${GATEKEEPER}`, ...GATEKEEPER_START_STEPS, `start ${CONTAINER}`, 'exec curl',
|
|
1536
|
+
]);
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
it('drops an unfinished new container by its id and goes back to the old one before it starts', async () => {
|
|
1540
|
+
const unfinished = spaceResources({ running: false }).filter((resource) => resource.kind === 'container');
|
|
1541
|
+
const fake = createFakeDocker({ resources: [toolsResource(), ...stoppedOnOldTools().map(asAside), ...unfinished] });
|
|
1542
|
+
const place = makePlace(fake);
|
|
1543
|
+
|
|
1544
|
+
expect(await place.list()).toHaveLength(1);
|
|
1545
|
+
await place.start(ID);
|
|
1546
|
+
expect(changes(fake).slice(0, 2)).toEqual(['rm <id>', `rename ${ASIDE} ${CONTAINER}`]);
|
|
1547
|
+
expect(await toolsOf(fake)).toBe(TOOLS);
|
|
1548
|
+
expect(fake.names()).not.toContain(`container:${ASIDE}`);
|
|
1549
|
+
});
|
|
1550
|
+
|
|
1551
|
+
it('removes the unfinished container without force, and takes a container that another process started meanwhile for a started space', async () => {
|
|
1552
|
+
const unfinished = spaceResources({ running: false }).filter((resource) => resource.kind === 'container');
|
|
1553
|
+
const fake = createFakeDocker({ resources: [toolsResource(), ...stoppedOnOldTools().map(asAside), ...unfinished] });
|
|
1554
|
+
// The other process starts its new container between this process's inspect and its removal.
|
|
1555
|
+
const runCommand = async (file, args, options) => {
|
|
1556
|
+
if (args[0] === 'rm') await fake.runCommand(file, ['start', CONTAINER], options);
|
|
1557
|
+
return fake.runCommand(file, args, options);
|
|
1558
|
+
};
|
|
1559
|
+
|
|
1560
|
+
await placeOn(runCommand, { wait: fake.wait, now: fake.now }).start(ID);
|
|
1561
|
+
|
|
1562
|
+
const removal = fake.calls.map((call) => call.args).find((args) => args[0] === 'rm');
|
|
1563
|
+
expect(removal).not.toContain('--force');
|
|
1564
|
+
expect(removal[removal.length - 1]).toMatch(CONTAINER_ID);
|
|
1565
|
+
// Docker refused, so the running container is still there, and nothing else was touched.
|
|
1566
|
+
expect(fake.names()).toContain(`container:${CONTAINER}`);
|
|
1567
|
+
expect(changes(fake).filter((line) => line.startsWith('rename') || line === 'create space')).toEqual([]);
|
|
1568
|
+
expect((await makePlace(fake).list())[0].state).toBe('running');
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
it('rejects when the unfinished container cannot be removed and is not running either', async () => {
|
|
1572
|
+
const unfinished = spaceResources({ running: false }).filter((resource) => resource.kind === 'container');
|
|
1573
|
+
const fake = createFakeDocker({ failAt: (args) => args[0] === 'rm', resources: [toolsResource(), ...stoppedOnOldTools().map(asAside), ...unfinished] });
|
|
1574
|
+
|
|
1575
|
+
await expect(makePlace(fake).start(ID)).rejects.toMatchObject({ code: 'space_recreate_failed' });
|
|
1576
|
+
expect(changes(fake).filter((line) => line.startsWith('rename'))).toEqual([]);
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
it('says that the space is gone when its container vanishes during the repair', async () => {
|
|
1580
|
+
const fake = createFakeDocker({ resources: [toolsResource(), ...stoppedOnOldTools().map(asAside)] });
|
|
1581
|
+
const runCommand = async (file, args, options) => {
|
|
1582
|
+
const result = await fake.runCommand(file, args, options);
|
|
1583
|
+
// Someone removes the space right after the rename of the repair.
|
|
1584
|
+
if (args[0] === 'rename') await fake.runCommand(file, ['rm', '--force', CONTAINER], options);
|
|
1585
|
+
return result;
|
|
1586
|
+
};
|
|
1587
|
+
|
|
1588
|
+
const error = await placeOn(runCommand, { wait: fake.wait, now: fake.now }).start(ID).catch((caught) => caught);
|
|
1589
|
+
expect(error).toBeInstanceOf(SpaceError);
|
|
1590
|
+
expect(error.code).toBe('space_not_found');
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
it('leaves a running container alone and keeps the leftover aside container for `remove`', async () => {
|
|
1594
|
+
const running = spaceResources().filter((resource) => resource.kind === 'container');
|
|
1595
|
+
const fake = createFakeDocker({ resources: [toolsResource(), ...stoppedOnOldTools().map(asAside), ...running] });
|
|
1596
|
+
|
|
1597
|
+
await makePlace(fake).start(ID);
|
|
1598
|
+
expect(changes(fake)).toEqual(GATEKEEPER_START_STEPS);
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
it('removes both containers with the space', async () => {
|
|
1602
|
+
const unfinished = spaceResources({ running: false }).filter((resource) => resource.kind === 'container');
|
|
1603
|
+
const fake = createFakeDocker({ resources: [toolsResource(), ...stoppedOnOldTools().map(asAside), ...unfinished] });
|
|
1604
|
+
|
|
1605
|
+
const result = await makePlace(fake).remove(ID);
|
|
1606
|
+
expect(result.failed).toEqual([]);
|
|
1607
|
+
expect(fake.names().filter((name) => name.startsWith('container:'))).toEqual([]);
|
|
1608
|
+
});
|
|
1609
|
+
});
|
|
1610
|
+
});
|