From 60f03f527c73a7d906f09bedab21e12846879401 Mon Sep 17 00:00:00 2001 From: s-b-e-n-s-o-n <80784472+s-b-e-n-s-o-n@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:50:45 -0400 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=90=9B=20fix(compose):=20allow=20co?= =?UTF-8?q?mpose=20files=20mounted=20outside=20app=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove overly strict working-directory boundary enforcement from runComposeCommand that rejected compose files bind-mounted outside /home/node/app. This bug existed since rc.3 — the boundary check always converted absolute paths to relative via path.relative() then rejected anything outside process.cwd(). Compose file paths are operator-configured (Docker labels or env vars) and already validated during resolution, so path traversal protection here is unnecessary. Fixes force-update failures for setups with compose files at paths like /drydock/docker-compose.yml (documented mount pattern). --- CHANGELOG.md | 1 + .../dockercompose/Dockercompose.test.ts | 38 ++++++++++++++++--- .../providers/dockercompose/Dockercompose.ts | 4 +- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23af5421..2560e93a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Compose trigger rejects compose files mounted outside app directory** — Removed overly strict working-directory boundary enforcement from `runComposeCommand` that rejected compose files bind-mounted outside `/home/node/app`, breaking documented mount patterns like `/drydock/docker-compose.yml`. Compose file paths are operator-configured and already validated during resolution. - **Silent error on recheck failure** — "Recheck for Updates" button now displays an error banner when the backend request fails instead of silently stopping the spinner with no feedback. - **Silent error on env reveal failure** — Environment variable reveal in the container detail panel now shows an inline error message when the API call fails instead of silently failing. - **Security scans persist across navigation** — Navigating away from the Security view no longer cancels in-flight batch scans. Module-scoped scan state survives unmount and the progress banner reappears on return. diff --git a/app/triggers/providers/dockercompose/Dockercompose.test.ts b/app/triggers/providers/dockercompose/Dockercompose.test.ts index 6321a987..5b2013ee 100644 --- a/app/triggers/providers/dockercompose/Dockercompose.test.ts +++ b/app/triggers/providers/dockercompose/Dockercompose.test.ts @@ -1572,6 +1572,25 @@ describe('Dockercompose Trigger', () => { ); }); + test('runComposeCommand should accept compose files outside the working directory', async () => { + execFile.mockImplementationOnce((_command, _args, _options, callback) => { + callback(null, '', ''); + return {}; + }); + + const logContainer = { debug: vi.fn(), warn: vi.fn() }; + const composeFilePath = path.resolve(process.cwd(), '..', 'external', 'compose.yaml'); + + await trigger.runComposeCommand(composeFilePath, ['up', '-d'], logContainer); + + expect(execFile).toHaveBeenCalledWith( + 'docker', + ['compose', '-f', composeFilePath, 'up', '-d'], + expect.objectContaining({ cwd: path.dirname(composeFilePath) }), + expect.any(Function), + ); + }); + test('runComposeCommand should throw when compose command fails', async () => { execFile.mockImplementationOnce((_command, _args, _options, callback) => { callback(new Error('boom'), '', 'boom'); @@ -1622,14 +1641,23 @@ describe('Dockercompose Trigger', () => { ); }); - test('runComposeCommand should reject compose file path traversal', async () => { + test('runComposeCommand should resolve relative compose file paths outside working directory', async () => { + execFile.mockImplementationOnce((_command, _args, _options, callback) => { + callback(null, '', ''); + return {}; + }); + const logContainer = { debug: vi.fn(), warn: vi.fn() }; + const expectedPath = path.resolve(process.cwd(), '../outside/stack.yml'); - await expect( - trigger.runComposeCommand('../outside/stack.yml', ['pull', 'nginx'], logContainer), - ).rejects.toThrow(/Compose file path must stay inside/); + await trigger.runComposeCommand('../outside/stack.yml', ['pull', 'nginx'], logContainer); - expect(execFile).not.toHaveBeenCalled(); + expect(execFile).toHaveBeenCalledWith( + 'docker', + ['compose', '-f', expectedPath, 'pull', 'nginx'], + expect.objectContaining({ cwd: path.dirname(expectedPath) }), + expect.any(Function), + ); }); test('getContainerRunningState should assume running when inspect fails', async () => { diff --git a/app/triggers/providers/dockercompose/Dockercompose.ts b/app/triggers/providers/dockercompose/Dockercompose.ts index e9e90fdf..7424d59b 100644 --- a/app/triggers/providers/dockercompose/Dockercompose.ts +++ b/app/triggers/providers/dockercompose/Dockercompose.ts @@ -1480,9 +1480,7 @@ class Dockercompose extends Docker { async runComposeCommand(composeFile, composeArgs, logContainer, composeFiles = [composeFile]) { const composeFileChain = this.normalizeComposeFileChain(composeFile, composeFiles); const composeFilePaths = composeFileChain.map((composeFilePathToResolve) => - this.resolveComposeFilePath(composeFilePathToResolve, { - enforceWorkingDirectoryBoundary: true, - }), + this.resolveComposeFilePath(composeFilePathToResolve), ); const composeFileArgs = composeFilePaths.flatMap((composeFilePath) => ['-f', composeFilePath]); const composeWorkingDirectory = path.dirname(composeFilePaths[0]); From e23b4f692006143898dbc0f635e18d82bf62c317 Mon Sep 17 00:00:00 2001 From: s-b-e-n-s-o-n <80784472+s-b-e-n-s-o-n@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:10:40 -0400 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=92=84=20style(docs):=20add=20RC=20?= =?UTF-8?q?banner=20to=20docs=20site=20matching=20website?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use Fumadocs Banner component to display pre-release warning across all docs pages, matching the existing homepage RC banner. Prevents users from following v1.4 quickstart instructions (argon2id hash) when running v1.3.9 (SHA hash only). --- apps/web/app/docs/layout.tsx | 74 +++++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/apps/web/app/docs/layout.tsx b/apps/web/app/docs/layout.tsx index 15b15c71..dd1ec1d6 100644 --- a/apps/web/app/docs/layout.tsx +++ b/apps/web/app/docs/layout.tsx @@ -1,36 +1,56 @@ +import { Banner } from "fumadocs-ui/components/banner"; import { DocsLayout } from "fumadocs-ui/layouts/docs"; import Image from "next/image"; +import Link from "next/link"; import type { ReactNode } from "react"; import { source } from "@/lib/source"; export default function Layout({ children }: { children: ReactNode }) { return ( - - Drydock - Drydock - - ), - url: "/", - }} - links={[ - { - text: "GitHub", - url: "https://github.com/CodesWhat/drydock", - external: true, - }, - ]} - > - {children} - + <> + + + + RC + + + You're viewing v1.4.0 release candidate docs. + + + v1.3.9 is the current stable release + + + + + Drydock + Drydock + + ), + url: "/", + }} + links={[ + { + text: "GitHub", + url: "https://github.com/CodesWhat/drydock", + external: true, + }, + ]} + > + {children} + + ); } From 833706b5594b64010dee7fcdc15502479f3a1155 Mon Sep 17 00:00:00 2001 From: s-b-e-n-s-o-n <80784472+s-b-e-n-s-o-n@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:20:25 -0400 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=92=84=20style(docs):=20make=20RC?= =?UTF-8?q?=20banner=20red=20and=20fix=20version=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change banner from amber to red for stronger pre-release warning - Add "this version is not yet released" text to banner - Change sidebar version picker from "v1.4 (Latest)" to "v1.4 (RC)" --- apps/web/app/docs/layout.tsx | 14 +++++++++----- apps/web/scripts/sync-docs.mjs | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/web/app/docs/layout.tsx b/apps/web/app/docs/layout.tsx index dd1ec1d6..e1c73c24 100644 --- a/apps/web/app/docs/layout.tsx +++ b/apps/web/app/docs/layout.tsx @@ -8,19 +8,23 @@ import { source } from "@/lib/source"; export default function Layout({ children }: { children: ReactNode }) { return ( <> - + - + RC - You're viewing v1.4.0 release candidate docs. + You're viewing v1.4.0 release candidate{" "} + docs — this version is not yet released. - v1.3.9 is the current stable release + v1.3.9 stable diff --git a/apps/web/scripts/sync-docs.mjs b/apps/web/scripts/sync-docs.mjs index 675e8e89..62832eed 100644 --- a/apps/web/scripts/sync-docs.mjs +++ b/apps/web/scripts/sync-docs.mjs @@ -12,7 +12,7 @@ const targetDir = join(webRoot, "content", "docs"); // Version definitions — order matters (first = default/active tab) const versions = [ - { slug: "v1.4", source: "current", title: "v1.4 (Latest)" }, + { slug: "v1.4", source: "current", title: "v1.4 (RC)" }, { slug: "v1.3", source: "v1.3", title: "v1.3" }, ]; From 0bab960b55dec52a1dadf245ea5cde3512563dc0 Mon Sep 17 00:00:00 2001 From: s-b-e-n-s-o-n <80784472+s-b-e-n-s-o-n@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:20:58 -0400 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=94=A7=20chore(docs):=20fix=20biome?= =?UTF-8?q?=20formatting=20in=20docs=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/docs/layout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/app/docs/layout.tsx b/apps/web/app/docs/layout.tsx index e1c73c24..f5b4a927 100644 --- a/apps/web/app/docs/layout.tsx +++ b/apps/web/app/docs/layout.tsx @@ -17,8 +17,8 @@ export default function Layout({ children }: { children: ReactNode }) { RC - You're viewing v1.4.0 release candidate{" "} - docs — this version is not yet released. + You're viewing v1.4.0 release candidate docs — this version + is not yet released. Date: Mon, 9 Mar 2026 15:36:38 -0400 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=90=9B=20fix(compose):=20remap=20ho?= =?UTF-8?q?st=20paths=20to=20container=20paths=20and=20add=20trigger=20aff?= =?UTF-8?q?inity=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Self-inspect Drydock's bind mounts to build host→container path mapping - Remap Docker label paths (com.docker.compose.project.config_files) from host-side to container-internal using longest-prefix bind mount matching - Skip containers whose compose files don't match the trigger's configured FILE path, eliminating cross-stack "does not exist" warnings - Add regression tests for host path remapping and affinity filtering --- CHANGELOG.md | 2 + .../dockercompose/Dockercompose.test.ts | 80 +++++++++++- .../providers/dockercompose/Dockercompose.ts | 116 +++++++++++++++++- 3 files changed, 191 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2560e93a..d60db55c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **Compose trigger rejects compose files mounted outside app directory** — Removed overly strict working-directory boundary enforcement from `runComposeCommand` that rejected compose files bind-mounted outside `/home/node/app`, breaking documented mount patterns like `/drydock/docker-compose.yml`. Compose file paths are operator-configured and already validated during resolution. +- **Compose trigger uses host paths instead of container paths** — Docker label auto-detection (`com.docker.compose.project.config_files`) now remaps host-side paths to container-internal paths using Drydock's own bind mount information. Previously, host paths like `/mnt/volume1/docker/stacks/monitoring/compose.yaml` were used directly inside the container where they don't exist, causing "does not exist" errors even when the file was properly mounted. +- **Compose trigger logs spurious warnings for unrelated containers** — When multiple compose triggers are configured, each trigger now silently skips containers whose resolved compose files don't match the trigger's configured `FILE` path, eliminating noisy cross-stack "does not exist" warnings. - **Silent error on recheck failure** — "Recheck for Updates" button now displays an error banner when the backend request fails instead of silently stopping the spinner with no feedback. - **Silent error on env reveal failure** — Environment variable reveal in the container detail panel now shows an inline error message when the API call fails instead of silently failing. - **Security scans persist across navigation** — Navigating away from the Security view no longer cancels in-flight batch scans. Module-scoped scan state survives unmount and the progress banner reappears on return. diff --git a/app/triggers/providers/dockercompose/Dockercompose.test.ts b/app/triggers/providers/dockercompose/Dockercompose.test.ts index 5b2013ee..9cbcc437 100644 --- a/app/triggers/providers/dockercompose/Dockercompose.test.ts +++ b/app/triggers/providers/dockercompose/Dockercompose.test.ts @@ -2843,7 +2843,7 @@ describe('Dockercompose Trigger', () => { }); test('triggerBatch should group containers by compose file and process each', async () => { - trigger.configuration.file = '/opt/drydock/test/compose.yml'; + trigger.configuration.file = undefined; fs.access.mockResolvedValue(undefined); const container1 = { @@ -2867,7 +2867,7 @@ describe('Dockercompose Trigger', () => { }); test('triggerBatch should group multiple containers under the same compose file', async () => { - trigger.configuration.file = '/opt/drydock/test/compose.yml'; + trigger.configuration.file = undefined; fs.access.mockResolvedValue(undefined); const container1 = { @@ -2892,6 +2892,41 @@ describe('Dockercompose Trigger', () => { ]); }); + test('triggerBatch should only process containers matching configured compose file affinity', async () => { + trigger.configuration.file = '/opt/drydock/test/monitoring.yml'; + fs.access.mockImplementation(async (composeFilePath) => { + if (`${composeFilePath}`.includes('/opt/drydock/test/mysql.yml')) { + const missingComposeError = new Error('ENOENT'); + missingComposeError.code = 'ENOENT'; + throw missingComposeError; + } + return undefined; + }); + + const monitoringContainer = { + name: 'monitoring-app', + watcher: 'local', + labels: { 'dd.compose.file': '/opt/drydock/test/monitoring.yml' }, + }; + const mysqlContainer = { + name: 'mysql-app', + watcher: 'local', + labels: { 'dd.compose.file': '/opt/drydock/test/mysql.yml' }, + }; + + const processComposeFileSpy = vi.spyOn(trigger, 'processComposeFile').mockResolvedValue(); + + await trigger.triggerBatch([monitoringContainer, mysqlContainer]); + + expect(processComposeFileSpy).toHaveBeenCalledTimes(1); + expect(processComposeFileSpy).toHaveBeenCalledWith('/opt/drydock/test/monitoring.yml', [ + monitoringContainer, + ]); + expect(mockLog.warn).not.toHaveBeenCalledWith( + expect.stringContaining('/opt/drydock/test/mysql.yml'), + ); + }); + // ----------------------------------------------------------------------- // getComposeFileForContainer // ----------------------------------------------------------------------- @@ -3747,6 +3782,47 @@ describe('Dockercompose Trigger', () => { ]); }); + test('resolveComposeFilesForContainer should map compose config file labels from host bind paths to container paths', async () => { + const originalHostname = process.env.HOSTNAME; + process.env.HOSTNAME = 'drydock-self'; + + mockDockerApi.getContainer.mockImplementation((containerName) => { + if (containerName === 'drydock-self') { + return { + inspect: vi.fn().mockResolvedValue({ + HostConfig: { + Binds: ['/mnt/volume1/docker/stacks:/drydock:rw'], + }, + }), + }; + } + return { + inspect: vi.fn().mockResolvedValue({ + State: { Running: true }, + }), + }; + }); + + try { + const composeFiles = await trigger.resolveComposeFilesForContainer({ + name: 'monitoring', + watcher: 'local', + labels: { + 'com.docker.compose.project.config_files': + '/mnt/volume1/docker/stacks/monitoring/compose.yaml', + }, + }); + + expect(composeFiles).toEqual(['/drydock/monitoring/compose.yaml']); + } finally { + if (originalHostname === undefined) { + delete process.env.HOSTNAME; + } else { + process.env.HOSTNAME = originalHostname; + } + } + }); + test('getImageNameFromReference should parse image names from tags and digests', () => { expect(trigger.getImageNameFromReference(undefined)).toBeUndefined(); expect(trigger.getImageNameFromReference('nginx:1.0.0')).toBe('nginx'); diff --git a/app/triggers/providers/dockercompose/Dockercompose.ts b/app/triggers/providers/dockercompose/Dockercompose.ts index 7424d59b..7ed34c07 100644 --- a/app/triggers/providers/dockercompose/Dockercompose.ts +++ b/app/triggers/providers/dockercompose/Dockercompose.ts @@ -35,6 +35,9 @@ interface DockerApiLike { Config?: { Labels?: Record; }; + HostConfig?: { + Binds?: string[]; + }; }>; exec: (options: unknown) => Promise<{ start: (options: { Detach: boolean; Tty: boolean }) => Promise<{ @@ -55,6 +58,11 @@ type ContainersByComposeFileEntry = { containers: unknown[]; }; +type HostToContainerBindMount = { + source: string; + destination: string; +}; + type ComposeContainerReference = { name?: string; labels?: Record; @@ -399,6 +407,8 @@ class Dockercompose extends Docker { _composeCacheMaxEntries = COMPOSE_CACHE_MAX_ENTRIES; _composeObjectCache = new Map(); _composeDocumentCache = new Map(); + _hostToContainerBindMountsLoaded = false; + _hostToContainerBindMounts: HostToContainerBindMount[] = []; get _composeFileLocksHeld() { return this._composeFileLockManager._composeFileLocksHeld; @@ -441,6 +451,94 @@ class Dockercompose extends Docker { } } + parseHostToContainerBindMount(bindDefinition: string): HostToContainerBindMount | null { + const [sourceRaw, destinationRaw] = bindDefinition.split(':'); + const source = sourceRaw?.trim(); + const destination = destinationRaw?.trim(); + if (!source || !destination) { + return null; + } + if (!path.isAbsolute(source) || !path.isAbsolute(destination)) { + return null; + } + return { + source: path.resolve(source), + destination: path.resolve(destination), + }; + } + + getSelfContainerIdentifier(): string | null { + const hostname = process.env.HOSTNAME?.trim(); + if (!hostname || hostname.includes('/')) { + return null; + } + return hostname; + } + + async ensureHostToContainerBindMountsLoaded(container: ComposeContainerReference): Promise { + if (this._hostToContainerBindMountsLoaded) { + return; + } + + const selfContainerIdentifier = this.getSelfContainerIdentifier(); + if (!selfContainerIdentifier) { + this._hostToContainerBindMountsLoaded = true; + return; + } + + const watcher = this.getWatcher(container); + const dockerApi = getDockerApiFromWatcher(watcher); + if (!dockerApi) { + return; + } + + this._hostToContainerBindMountsLoaded = true; + try { + const selfContainerInspect = await dockerApi.getContainer(selfContainerIdentifier).inspect(); + const bindDefinitions = selfContainerInspect?.HostConfig?.Binds; + if (!Array.isArray(bindDefinitions)) { + return; + } + this._hostToContainerBindMounts = bindDefinitions + .map((bindDefinition) => this.parseHostToContainerBindMount(bindDefinition)) + .filter((bindMount): bindMount is HostToContainerBindMount => bindMount !== null) + .sort((left, right) => right.source.length - left.source.length); + } catch (e) { + this.log.debug( + `Unable to inspect bind mounts for compose host-path remapping (${e.message})`, + ); + } + } + + mapComposePathToContainerBindMount(composeFilePath: string): string { + if (!path.isAbsolute(composeFilePath) || this._hostToContainerBindMounts.length === 0) { + return composeFilePath; + } + const normalizedComposeFilePath = path.resolve(composeFilePath); + + for (const bindMount of this._hostToContainerBindMounts) { + if (normalizedComposeFilePath === bindMount.source) { + return bindMount.destination; + } + const sourcePrefix = bindMount.source.endsWith(path.sep) + ? bindMount.source + : `${bindMount.source}${path.sep}`; + if (!normalizedComposeFilePath.startsWith(sourcePrefix)) { + continue; + } + const relativeComposePath = path.relative(bindMount.source, normalizedComposeFilePath); + if (!relativeComposePath || relativeComposePath === '.') { + return bindMount.destination; + } + if (relativeComposePath.startsWith('..') || path.isAbsolute(relativeComposePath)) { + continue; + } + return path.join(bindMount.destination, relativeComposePath); + } + + return composeFilePath; + } + resolveComposeFilePath( composeFilePathToResolve: string, options: { @@ -588,11 +686,10 @@ class Dockercompose extends Docker { ? path.resolve(composeWorkingDirectory, composeFilePathRaw) : composeFilePathRaw; try { - composeFiles.add( - this.resolveComposeFilePath(composeFilePath, { - label: `Compose file label ${COMPOSE_PROJECT_CONFIG_FILES_LABEL}`, - }), - ); + const resolvedComposeFilePath = this.resolveComposeFilePath(composeFilePath, { + label: `Compose file label ${COMPOSE_PROJECT_CONFIG_FILES_LABEL}`, + }); + composeFiles.add(this.mapComposePathToContainerBindMount(resolvedComposeFilePath)); } catch (e) { this.log.warn( `Compose file label ${COMPOSE_PROJECT_CONFIG_FILES_LABEL} on container ${containerName} is invalid (${e.message})`, @@ -648,6 +745,8 @@ class Dockercompose extends Docker { } async resolveComposeFilesForContainer(container: ComposeContainerReference): Promise { + await this.ensureHostToContainerBindMountsLoaded(container); + const composeFilesFromConfiguration = this.getConfiguredComposeFilesForContainer(container, { includeDefaultComposeFile: false, }); @@ -1229,6 +1328,7 @@ class Dockercompose extends Docker { async triggerBatch(containers): Promise { // Group containers by their ordered compose file chain const containersByComposeFile = new Map(); + const configuredComposeFilePath = this.getDefaultComposeFilePath(); for (const container of containers) { // Filter on containers running on local host @@ -1248,6 +1348,12 @@ class Dockercompose extends Docker { ); continue; } + if (configuredComposeFilePath && !composeFiles.includes(configuredComposeFilePath)) { + this.log.debug( + `Skip container ${container.name} because compose files ${composeFiles.join(', ')} do not match configured file ${configuredComposeFilePath}`, + ); + continue; + } let missingComposeFile = null as string | null; for (const composeFile of composeFiles) { From 808728042fc8cd7016cb567c6a7e7b209956cc75 Mon Sep 17 00:00:00 2001 From: s-b-e-n-s-o-n <80784472+s-b-e-n-s-o-n@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:22:14 -0400 Subject: [PATCH 06/10] =?UTF-8?q?=E2=9C=85=20test(compose):=20harden=20ser?= =?UTF-8?q?vice=20matching=20and=20add=20YAML=20mutation=20safety=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prevent cross-project false positives in getServiceKey() — containers with com.docker.compose.service label that doesn't match any service in the compose file now return undefined instead of falling through to image-name matching - Skip image-name matching entirely for containers with Docker Compose identity labels (project, config_files, working_dir) - Add regression test verifying YAML AST mutation only updates service image fields, not matching strings in comments or env vars --- .../dockercompose/Dockercompose.test.ts | 85 +++++++++++++++++++ .../providers/dockercompose/Dockercompose.ts | 14 ++- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/app/triggers/providers/dockercompose/Dockercompose.test.ts b/app/triggers/providers/dockercompose/Dockercompose.test.ts index 9cbcc437..5f19cc67 100644 --- a/app/triggers/providers/dockercompose/Dockercompose.test.ts +++ b/app/triggers/providers/dockercompose/Dockercompose.test.ts @@ -361,6 +361,23 @@ describe('Dockercompose Trigger', () => { expect(result?.service).toBe('beta'); }); + test('mapCurrentVersionToUpdateVersion should not fall back to image matching when compose service label is unknown', () => { + const compose = makeCompose({ + nginx: { image: 'nginx:1.0.0' }, + }); + const container = makeContainer({ + labels: { + 'com.docker.compose.project': 'other-stack', + 'com.docker.compose.service': 'unknown-service', + }, + }); + + const result = trigger.mapCurrentVersionToUpdateVersion(compose, container); + + expect(result).toBeUndefined(); + expect(mockLog.warn).toHaveBeenCalledWith(expect.stringContaining('Could not find service')); + }); + test('mapCurrentVersionToUpdateVersion should return undefined when service not found', () => { const compose = makeCompose({ redis: { image: 'redis:7.0.0' } }); const container = makeContainer(); @@ -715,6 +732,37 @@ describe('Dockercompose Trigger', () => { expect(updatedCompose).toContain('MIRROR_IMAGE=nginx:1.0.0'); }); + test('processComposeFile should not rewrite matching image strings in comments or env vars', async () => { + trigger.configuration.dryrun = false; + trigger.configuration.backup = false; + + const container = makeContainer(); + + vi.spyOn(trigger, 'getComposeFileAsObject').mockResolvedValue( + makeCompose({ nginx: { image: 'nginx:1.0.0' } }), + ); + + const composeWithCommentsAndEnv = [ + 'services:', + ' nginx:', + ' image: nginx:1.0.0', + ' # do not touch: nginx:1.0.0', + ' environment:', + ' - MIRROR_IMAGE=nginx:1.0.0', + ' - COMMENT_IMAGE=nginx:1.0.0 # note', + '', + ].join('\n'); + const { writeComposeFileSpy } = spyOnProcessComposeHelpers(trigger, composeWithCommentsAndEnv); + + await trigger.processComposeFile('/opt/drydock/test/stack.yml', [container]); + + const [, updatedCompose] = writeComposeFileSpy.mock.calls[0]; + expect(updatedCompose).toContain(' image: nginx:1.1.0'); + expect(updatedCompose).toContain('# do not touch: nginx:1.0.0'); + expect(updatedCompose).toContain('MIRROR_IMAGE=nginx:1.0.0'); + expect(updatedCompose).toContain('COMMENT_IMAGE=nginx:1.0.0 # note'); + }); + test('processComposeFile should preserve commented-out fields in compose file', async () => { trigger.configuration.dryrun = false; trigger.configuration.backup = false; @@ -983,6 +1031,43 @@ describe('Dockercompose Trigger', () => { ); }); + test('processComposeFile should ignore containers with unknown compose service labels even when image matches', async () => { + trigger.configuration.dryrun = false; + + const containerInProject = makeContainer({ + name: 'nginx-main', + labels: { + 'com.docker.compose.project': 'main-stack', + 'com.docker.compose.service': 'nginx', + }, + }); + const containerFromOtherProject = makeContainer({ + name: 'nginx-other', + labels: { + 'com.docker.compose.project': 'other-stack', + 'com.docker.compose.service': 'unknown-service', + }, + }); + + vi.spyOn(trigger, 'getComposeFileAsObject').mockResolvedValue( + makeCompose({ nginx: { image: 'nginx:1.0.0' } }), + ); + + const { composeUpdateSpy } = spyOnProcessComposeHelpers(trigger); + + await trigger.processComposeFile('/opt/drydock/test/stack.yml', [ + containerInProject, + containerFromOtherProject, + ]); + + expect(composeUpdateSpy).toHaveBeenCalledTimes(1); + expect(composeUpdateSpy).toHaveBeenCalledWith( + '/opt/drydock/test/stack.yml', + 'nginx', + containerInProject, + ); + }); + test('processComposeFile should handle digest images with @ in compose file', async () => { trigger.configuration.dryrun = false; trigger.configuration.backup = false; diff --git a/app/triggers/providers/dockercompose/Dockercompose.ts b/app/triggers/providers/dockercompose/Dockercompose.ts index 7ed34c07..96a34dab 100644 --- a/app/triggers/providers/dockercompose/Dockercompose.ts +++ b/app/triggers/providers/dockercompose/Dockercompose.ts @@ -16,6 +16,7 @@ const COMPOSE_COMMAND_MAX_BUFFER_BYTES = 10 * 1024 * 1024; const YAML_MAX_ALIAS_COUNT = 10_000; const COMPOSE_RENAME_MAX_RETRIES = 5; const COMPOSE_RENAME_RETRY_MS = 200; +const COMPOSE_PROJECT_LABEL = 'com.docker.compose.project'; const COMPOSE_PROJECT_CONFIG_FILES_LABEL = 'com.docker.compose.project.config_files'; const COMPOSE_PROJECT_WORKING_DIR_LABEL = 'com.docker.compose.project.working_dir'; const COMPOSE_CACHE_MAX_ENTRIES = 256; @@ -107,8 +108,17 @@ function getDockerApiFromWatcher(watcher: unknown): DockerApiLike | undefined { function getServiceKey(compose, container, currentImage) { const composeServiceName = container.labels?.['com.docker.compose.service']; - if (composeServiceName && compose.services?.[composeServiceName]) { - return composeServiceName; + if (composeServiceName) { + return compose.services?.[composeServiceName] ? composeServiceName : undefined; + } + + const hasComposeIdentityLabels = Boolean( + container.labels?.[COMPOSE_PROJECT_LABEL] || + container.labels?.[COMPOSE_PROJECT_CONFIG_FILES_LABEL] || + container.labels?.[COMPOSE_PROJECT_WORKING_DIR_LABEL], + ); + if (hasComposeIdentityLabels) { + return undefined; } const matchesServiceImage = (serviceImage, imageToMatch) => { From 222985ec9febf0f93d64d159261cc38d007854e8 Mon Sep 17 00:00:00 2001 From: s-b-e-n-s-o-n <80784472+s-b-e-n-s-o-n@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:41:00 -0400 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=94=A7=20chore:=20fix=20biome=20for?= =?UTF-8?q?matting=20in=20compose=20trigger=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dockercompose/Dockercompose.test.ts | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) diff --git a/app/triggers/providers/dockercompose/Dockercompose.test.ts b/app/triggers/providers/dockercompose/Dockercompose.test.ts index 5f19cc67..9894357e 100644 --- a/app/triggers/providers/dockercompose/Dockercompose.test.ts +++ b/app/triggers/providers/dockercompose/Dockercompose.test.ts @@ -378,6 +378,22 @@ describe('Dockercompose Trigger', () => { expect(mockLog.warn).toHaveBeenCalledWith(expect.stringContaining('Could not find service')); }); + test('mapCurrentVersionToUpdateVersion should not fall back to image matching when compose identity labels exist without a service label', () => { + const compose = makeCompose({ + nginx: { image: 'nginx:1.0.0' }, + }); + const container = makeContainer({ + labels: { + 'com.docker.compose.project': 'other-stack', + }, + }); + + const result = trigger.mapCurrentVersionToUpdateVersion(compose, container); + + expect(result).toBeUndefined(); + expect(mockLog.warn).toHaveBeenCalledWith(expect.stringContaining('Could not find service')); + }); + test('mapCurrentVersionToUpdateVersion should return undefined when service not found', () => { const compose = makeCompose({ redis: { image: 'redis:7.0.0' } }); const container = makeContainer(); @@ -3908,6 +3924,212 @@ describe('Dockercompose Trigger', () => { } }); + test('parseHostToContainerBindMount should return null when bind definition is missing source or destination', () => { + expect(trigger.parseHostToContainerBindMount('/mnt/volume1/docker/stacks')).toBeNull(); + expect(trigger.parseHostToContainerBindMount(':/drydock')).toBeNull(); + }); + + test('parseHostToContainerBindMount should return null when source or destination is not absolute', () => { + expect(trigger.parseHostToContainerBindMount('relative/path:/drydock')).toBeNull(); + expect( + trigger.parseHostToContainerBindMount('/mnt/volume1/docker/stacks:relative/path'), + ).toBeNull(); + }); + + test('ensureHostToContainerBindMountsLoaded should return early when watcher docker api is unavailable', async () => { + const originalHostname = process.env.HOSTNAME; + process.env.HOSTNAME = 'drydock-self'; + + vi.spyOn(trigger, 'getWatcher').mockReturnValue({} as any); + + try { + await trigger.ensureHostToContainerBindMountsLoaded({ name: 'monitoring' } as any); + + expect(trigger._hostToContainerBindMountsLoaded).toBe(false); + expect(trigger._hostToContainerBindMounts).toEqual([]); + } finally { + if (originalHostname === undefined) { + delete process.env.HOSTNAME; + } else { + process.env.HOSTNAME = originalHostname; + } + } + }); + + test('ensureHostToContainerBindMountsLoaded should skip when bind definitions are not an array', async () => { + const originalHostname = process.env.HOSTNAME; + process.env.HOSTNAME = 'drydock-self'; + + mockDockerApi.getContainer.mockReturnValue({ + inspect: vi.fn().mockResolvedValue({ + HostConfig: { + Binds: null, + }, + }), + }); + + try { + await trigger.ensureHostToContainerBindMountsLoaded({ + name: 'monitoring', + watcher: 'local', + } as any); + + expect(trigger._hostToContainerBindMountsLoaded).toBe(true); + expect(trigger._hostToContainerBindMounts).toEqual([]); + } finally { + if (originalHostname === undefined) { + delete process.env.HOSTNAME; + } else { + process.env.HOSTNAME = originalHostname; + } + } + }); + + test('ensureHostToContainerBindMountsLoaded should parse and sort bind mounts by source path length', async () => { + const originalHostname = process.env.HOSTNAME; + process.env.HOSTNAME = 'drydock-self'; + + mockDockerApi.getContainer.mockReturnValue({ + inspect: vi.fn().mockResolvedValue({ + HostConfig: { + Binds: ['/mnt/volume1/docker:/drydock-base:rw', '/mnt/volume1/docker/stacks:/drydock:rw'], + }, + }), + }); + + try { + await trigger.ensureHostToContainerBindMountsLoaded({ + name: 'monitoring', + watcher: 'local', + } as any); + + expect(trigger._hostToContainerBindMounts).toEqual([ + { + source: '/mnt/volume1/docker/stacks', + destination: '/drydock', + }, + { + source: '/mnt/volume1/docker', + destination: '/drydock-base', + }, + ]); + } finally { + if (originalHostname === undefined) { + delete process.env.HOSTNAME; + } else { + process.env.HOSTNAME = originalHostname; + } + } + }); + + test('ensureHostToContainerBindMountsLoaded should log debug message when inspect fails', async () => { + const originalHostname = process.env.HOSTNAME; + process.env.HOSTNAME = 'drydock-self'; + + mockDockerApi.getContainer.mockReturnValue({ + inspect: vi.fn().mockRejectedValue(new Error('inspect failed')), + }); + + try { + await trigger.ensureHostToContainerBindMountsLoaded({ + name: 'monitoring', + watcher: 'local', + } as any); + + expect(trigger._hostToContainerBindMountsLoaded).toBe(true); + expect(mockLog.debug).toHaveBeenCalledWith( + expect.stringContaining('Unable to inspect bind mounts for compose host-path remapping'), + ); + } finally { + if (originalHostname === undefined) { + delete process.env.HOSTNAME; + } else { + process.env.HOSTNAME = originalHostname; + } + } + }); + + test('mapComposePathToContainerBindMount should map exact source paths to destination paths', () => { + trigger._hostToContainerBindMounts = [ + { + source: '/mnt/volume1/docker/stacks/monitoring/compose.yaml', + destination: '/drydock/monitoring/compose.yaml', + }, + ]; + + const mappedPath = trigger.mapComposePathToContainerBindMount( + '/mnt/volume1/docker/stacks/monitoring/compose.yaml', + ); + + expect(mappedPath).toBe('/drydock/monitoring/compose.yaml'); + }); + + test('mapComposePathToContainerBindMount should map nested files when bind source ends with path separator', () => { + trigger._hostToContainerBindMounts = [ + { + source: '/mnt/volume1/docker/stacks/', + destination: '/drydock/', + }, + ]; + + const mappedPath = trigger.mapComposePathToContainerBindMount( + '/mnt/volume1/docker/stacks/monitoring/compose.yaml', + ); + + expect(mappedPath).toBe('/drydock/monitoring/compose.yaml'); + }); + + test('mapComposePathToContainerBindMount should return original path when no bind source matches', () => { + trigger._hostToContainerBindMounts = [ + { + source: '/mnt/volume1/docker/stacks/', + destination: '/drydock/', + }, + ]; + + const composePath = '/opt/other/stack/compose.yaml'; + const mappedPath = trigger.mapComposePathToContainerBindMount(composePath); + + expect(mappedPath).toBe(composePath); + }); + + test('mapComposePathToContainerBindMount should return destination root when computed relative path is empty', () => { + trigger._hostToContainerBindMounts = [ + { + source: '/mnt/volume1/docker/stacks/', + destination: '/drydock/', + }, + ]; + const relativeSpy = vi.spyOn(path, 'relative').mockReturnValueOnce(''); + + try { + const mappedPath = trigger.mapComposePathToContainerBindMount( + '/mnt/volume1/docker/stacks/monitoring/compose.yaml', + ); + expect(mappedPath).toBe('/drydock/'); + } finally { + relativeSpy.mockRestore(); + } + }); + + test('mapComposePathToContainerBindMount should skip unsafe relative compose paths that escape source', () => { + trigger._hostToContainerBindMounts = [ + { + source: '/mnt/volume1/docker/stacks/', + destination: '/drydock/', + }, + ]; + const relativeSpy = vi.spyOn(path, 'relative').mockReturnValueOnce('../escape'); + + try { + const composePath = '/mnt/volume1/docker/stacks/monitoring/compose.yaml'; + const mappedPath = trigger.mapComposePathToContainerBindMount(composePath); + expect(mappedPath).toBe(composePath); + } finally { + relativeSpy.mockRestore(); + } + }); + test('getImageNameFromReference should parse image names from tags and digests', () => { expect(trigger.getImageNameFromReference(undefined)).toBeUndefined(); expect(trigger.getImageNameFromReference('nginx:1.0.0')).toBe('nginx'); From deb14ac97e70574b6dcafa93f868c1d0c27a494f Mon Sep 17 00:00:00 2001 From: s-b-e-n-s-o-n <80784472+s-b-e-n-s-o-n@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:45:33 -0400 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=94=A7=20chore(lefthook):=20skip=20?= =?UTF-8?q?snyk=20scans=20on=20feature=20branches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snyk has a 200 scan/month limit. Only run snyk-deps and snyk-code on main and release/* branch pushes to conserve quota. --- lefthook.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lefthook.yml b/lefthook.yml index 8093a61e..773ad551 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -53,15 +53,18 @@ pre-push: - run: '! command -v zizmor >/dev/null 2>&1' priority: 6 - # ── Local security gates (not in CI, skip if tool missing) ─────── + # ── Local security gates (skip if tool missing or feature branch) ─ + # Snyk has a 200 scan/month limit — only run on release/main pushes. snyk-deps: root: app/ run: ../scripts/snyk-deps-gate.sh --org=codeswhat skip: - run: '! command -v snyk >/dev/null 2>&1' + - run: 'git rev-parse --abbrev-ref HEAD | grep -qvE "^(main|release/)"' priority: 7 snyk-code: run: scripts/snyk-code-gate.sh --org=codeswhat skip: - run: '! command -v snyk >/dev/null 2>&1' + - run: 'git rev-parse --abbrev-ref HEAD | grep -qvE "^(main|release/)"' priority: 8 From a112f5db60fc0142fc80423668bada1c6d011b00 Mon Sep 17 00:00:00 2001 From: s-b-e-n-s-o-n <80784472+s-b-e-n-s-o-n@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:05:47 -0400 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=92=84=20style(docs):=20clarify=20R?= =?UTF-8?q?C=20banner=20stable=20link=20destination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename "v1.3.9 stable" to "v1.3.9 release notes →" so users know the link goes to GitHub release notes, not stable docs. --- apps/web/app/docs/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/docs/layout.tsx b/apps/web/app/docs/layout.tsx index f5b4a927..61a2b0b1 100644 --- a/apps/web/app/docs/layout.tsx +++ b/apps/web/app/docs/layout.tsx @@ -24,7 +24,7 @@ export default function Layout({ children }: { children: ReactNode }) { href="https://github.com/CodesWhat/drydock/releases/tag/v1.3.9" className="font-medium text-red-700 underline underline-offset-2 hover:text-red-900 dark:text-red-300 dark:hover:text-red-100" > - v1.3.9 stable + v1.3.9 release notes → From 8a161b5c90bffab3205fb7ffd65cd50d5eb061ff Mon Sep 17 00:00:00 2001 From: s-b-e-n-s-o-n <80784472+s-b-e-n-s-o-n@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:25:22 -0400 Subject: [PATCH 10/10] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(compose):?= =?UTF-8?q?=20harden=20bind=20mount=20loading=20and=20improve=20encapsulat?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add promise deduplication to ensureHostToContainerBindMountsLoaded to prevent redundant Docker API calls during concurrent batch processing - Validate HOSTNAME against Docker container ID/name pattern instead of only rejecting slashes - Use split(':', 2) in parseHostToContainerBindMount to correctly handle :rw/:ro mount options - Replace direct _hostToContainerBindMounts field access with protected accessor methods - Extract resolveAndGroupContainersByComposeFile from triggerBatch to reduce complexity - Add resetHostToContainerBindMountCache to beforeEach for test isolation - Add 8 new tests: getSelfContainerIdentifier edge cases, promise deduplication, mount options --- .../dockercompose/Dockercompose.test.ts | 197 ++++++++++++++++-- .../providers/dockercompose/Dockercompose.ts | 130 ++++++++---- 2 files changed, 271 insertions(+), 56 deletions(-) diff --git a/app/triggers/providers/dockercompose/Dockercompose.test.ts b/app/triggers/providers/dockercompose/Dockercompose.test.ts index 9894357e..fe79d24c 100644 --- a/app/triggers/providers/dockercompose/Dockercompose.test.ts +++ b/app/triggers/providers/dockercompose/Dockercompose.test.ts @@ -280,6 +280,7 @@ describe('Dockercompose Trigger', () => { trigger = new Dockercompose(); trigger.log = mockLog; + trigger.resetHostToContainerBindMountCache(); trigger.configuration = { dryrun: true, backup: false, @@ -3929,6 +3930,111 @@ describe('Dockercompose Trigger', () => { expect(trigger.parseHostToContainerBindMount(':/drydock')).toBeNull(); }); + test('parseHostToContainerBindMount should ignore trailing mount options', () => { + expect(trigger.parseHostToContainerBindMount('/mnt/volume1/docker/stacks:/drydock:rw')).toEqual( + { + source: '/mnt/volume1/docker/stacks', + destination: '/drydock', + }, + ); + expect(trigger.parseHostToContainerBindMount('/mnt/volume1/docker/stacks:/drydock:ro')).toEqual( + { + source: '/mnt/volume1/docker/stacks', + destination: '/drydock', + }, + ); + }); + + test('getSelfContainerIdentifier should return null when HOSTNAME contains slash', () => { + const originalHostname = process.env.HOSTNAME; + process.env.HOSTNAME = 'pod/name'; + + try { + expect(trigger.getSelfContainerIdentifier()).toBeNull(); + } finally { + if (originalHostname === undefined) { + delete process.env.HOSTNAME; + } else { + process.env.HOSTNAME = originalHostname; + } + } + }); + + test('getSelfContainerIdentifier should return null when HOSTNAME is whitespace', () => { + const originalHostname = process.env.HOSTNAME; + process.env.HOSTNAME = ' '; + + try { + expect(trigger.getSelfContainerIdentifier()).toBeNull(); + } finally { + if (originalHostname === undefined) { + delete process.env.HOSTNAME; + } else { + process.env.HOSTNAME = originalHostname; + } + } + }); + + test('getSelfContainerIdentifier should return null when HOSTNAME is undefined', () => { + const originalHostname = process.env.HOSTNAME; + delete process.env.HOSTNAME; + + try { + expect(trigger.getSelfContainerIdentifier()).toBeNull(); + } finally { + if (originalHostname === undefined) { + delete process.env.HOSTNAME; + } else { + process.env.HOSTNAME = originalHostname; + } + } + }); + + test('getSelfContainerIdentifier should return null when HOSTNAME starts with non-alphanumeric character', () => { + const originalHostname = process.env.HOSTNAME; + process.env.HOSTNAME = '-drydock-self'; + + try { + expect(trigger.getSelfContainerIdentifier()).toBeNull(); + } finally { + if (originalHostname === undefined) { + delete process.env.HOSTNAME; + } else { + process.env.HOSTNAME = originalHostname; + } + } + }); + + test('getSelfContainerIdentifier should return null when HOSTNAME has unsupported characters', () => { + const originalHostname = process.env.HOSTNAME; + process.env.HOSTNAME = 'drydock$self'; + + try { + expect(trigger.getSelfContainerIdentifier()).toBeNull(); + } finally { + if (originalHostname === undefined) { + delete process.env.HOSTNAME; + } else { + process.env.HOSTNAME = originalHostname; + } + } + }); + + test('getSelfContainerIdentifier should return trimmed hostname when HOSTNAME is valid', () => { + const originalHostname = process.env.HOSTNAME; + process.env.HOSTNAME = ' drydock-self '; + + try { + expect(trigger.getSelfContainerIdentifier()).toBe('drydock-self'); + } finally { + if (originalHostname === undefined) { + delete process.env.HOSTNAME; + } else { + process.env.HOSTNAME = originalHostname; + } + } + }); + test('parseHostToContainerBindMount should return null when source or destination is not absolute', () => { expect(trigger.parseHostToContainerBindMount('relative/path:/drydock')).toBeNull(); expect( @@ -3945,8 +4051,67 @@ describe('Dockercompose Trigger', () => { try { await trigger.ensureHostToContainerBindMountsLoaded({ name: 'monitoring' } as any); - expect(trigger._hostToContainerBindMountsLoaded).toBe(false); - expect(trigger._hostToContainerBindMounts).toEqual([]); + expect(trigger.isHostToContainerBindMountCacheLoaded()).toBe(false); + expect(trigger.getHostToContainerBindMountCache()).toEqual([]); + } finally { + if (originalHostname === undefined) { + delete process.env.HOSTNAME; + } else { + process.env.HOSTNAME = originalHostname; + } + } + }); + + test('ensureHostToContainerBindMountsLoaded should wait for an in-flight load to finish', async () => { + const originalHostname = process.env.HOSTNAME; + process.env.HOSTNAME = 'drydock-self'; + + let resolveInspect: ((value: any) => void) | undefined; + const inspectPromise = new Promise((resolve) => { + resolveInspect = resolve; + }); + mockDockerApi.getContainer.mockReturnValue({ + inspect: vi.fn().mockReturnValue(inspectPromise), + }); + + try { + const firstLoad = trigger.ensureHostToContainerBindMountsLoaded({ + name: 'monitoring', + watcher: 'local', + } as any); + await Promise.resolve(); + + let secondLoadResolved = false; + const secondLoad = trigger + .ensureHostToContainerBindMountsLoaded({ + name: 'monitoring', + watcher: 'local', + } as any) + .then(() => { + secondLoadResolved = true; + }); + + await Promise.resolve(); + expect(secondLoadResolved).toBe(false); + + if (!resolveInspect) { + throw new Error('resolveInspect was not initialized'); + } + resolveInspect({ + HostConfig: { + Binds: ['/mnt/volume1/docker/stacks:/drydock:rw'], + }, + }); + + await Promise.all([firstLoad, secondLoad]); + + expect(mockDockerApi.getContainer).toHaveBeenCalledTimes(1); + expect(trigger.getHostToContainerBindMountCache()).toEqual([ + { + source: '/mnt/volume1/docker/stacks', + destination: '/drydock', + }, + ]); } finally { if (originalHostname === undefined) { delete process.env.HOSTNAME; @@ -3974,8 +4139,8 @@ describe('Dockercompose Trigger', () => { watcher: 'local', } as any); - expect(trigger._hostToContainerBindMountsLoaded).toBe(true); - expect(trigger._hostToContainerBindMounts).toEqual([]); + expect(trigger.isHostToContainerBindMountCacheLoaded()).toBe(true); + expect(trigger.getHostToContainerBindMountCache()).toEqual([]); } finally { if (originalHostname === undefined) { delete process.env.HOSTNAME; @@ -4003,7 +4168,7 @@ describe('Dockercompose Trigger', () => { watcher: 'local', } as any); - expect(trigger._hostToContainerBindMounts).toEqual([ + expect(trigger.getHostToContainerBindMountCache()).toEqual([ { source: '/mnt/volume1/docker/stacks', destination: '/drydock', @@ -4036,7 +4201,7 @@ describe('Dockercompose Trigger', () => { watcher: 'local', } as any); - expect(trigger._hostToContainerBindMountsLoaded).toBe(true); + expect(trigger.isHostToContainerBindMountCacheLoaded()).toBe(true); expect(mockLog.debug).toHaveBeenCalledWith( expect.stringContaining('Unable to inspect bind mounts for compose host-path remapping'), ); @@ -4050,12 +4215,12 @@ describe('Dockercompose Trigger', () => { }); test('mapComposePathToContainerBindMount should map exact source paths to destination paths', () => { - trigger._hostToContainerBindMounts = [ + trigger.setHostToContainerBindMountCache([ { source: '/mnt/volume1/docker/stacks/monitoring/compose.yaml', destination: '/drydock/monitoring/compose.yaml', }, - ]; + ]); const mappedPath = trigger.mapComposePathToContainerBindMount( '/mnt/volume1/docker/stacks/monitoring/compose.yaml', @@ -4065,12 +4230,12 @@ describe('Dockercompose Trigger', () => { }); test('mapComposePathToContainerBindMount should map nested files when bind source ends with path separator', () => { - trigger._hostToContainerBindMounts = [ + trigger.setHostToContainerBindMountCache([ { source: '/mnt/volume1/docker/stacks/', destination: '/drydock/', }, - ]; + ]); const mappedPath = trigger.mapComposePathToContainerBindMount( '/mnt/volume1/docker/stacks/monitoring/compose.yaml', @@ -4080,12 +4245,12 @@ describe('Dockercompose Trigger', () => { }); test('mapComposePathToContainerBindMount should return original path when no bind source matches', () => { - trigger._hostToContainerBindMounts = [ + trigger.setHostToContainerBindMountCache([ { source: '/mnt/volume1/docker/stacks/', destination: '/drydock/', }, - ]; + ]); const composePath = '/opt/other/stack/compose.yaml'; const mappedPath = trigger.mapComposePathToContainerBindMount(composePath); @@ -4094,12 +4259,12 @@ describe('Dockercompose Trigger', () => { }); test('mapComposePathToContainerBindMount should return destination root when computed relative path is empty', () => { - trigger._hostToContainerBindMounts = [ + trigger.setHostToContainerBindMountCache([ { source: '/mnt/volume1/docker/stacks/', destination: '/drydock/', }, - ]; + ]); const relativeSpy = vi.spyOn(path, 'relative').mockReturnValueOnce(''); try { @@ -4113,12 +4278,12 @@ describe('Dockercompose Trigger', () => { }); test('mapComposePathToContainerBindMount should skip unsafe relative compose paths that escape source', () => { - trigger._hostToContainerBindMounts = [ + trigger.setHostToContainerBindMountCache([ { source: '/mnt/volume1/docker/stacks/', destination: '/drydock/', }, - ]; + ]); const relativeSpy = vi.spyOn(path, 'relative').mockReturnValueOnce('../escape'); try { diff --git a/app/triggers/providers/dockercompose/Dockercompose.ts b/app/triggers/providers/dockercompose/Dockercompose.ts index 96a34dab..95959772 100644 --- a/app/triggers/providers/dockercompose/Dockercompose.ts +++ b/app/triggers/providers/dockercompose/Dockercompose.ts @@ -21,6 +21,7 @@ const COMPOSE_PROJECT_CONFIG_FILES_LABEL = 'com.docker.compose.project.config_fi const COMPOSE_PROJECT_WORKING_DIR_LABEL = 'com.docker.compose.project.working_dir'; const COMPOSE_CACHE_MAX_ENTRIES = 256; const POST_START_ENVIRONMENT_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; +const SELF_CONTAINER_IDENTIFIER_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; const ROOT_MODE_BREAK_GLASS_HINT = 'use socket proxy or adjust file permissions/group_add; break-glass root mode requires DD_RUN_AS_ROOT=true + DD_ALLOW_INSECURE_ROOT=true'; @@ -418,6 +419,7 @@ class Dockercompose extends Docker { _composeObjectCache = new Map(); _composeDocumentCache = new Map(); _hostToContainerBindMountsLoaded = false; + _hostToContainerBindMountsLoadPromise: Promise | null = null; _hostToContainerBindMounts: HostToContainerBindMount[] = []; get _composeFileLocksHeld() { @@ -462,7 +464,9 @@ class Dockercompose extends Docker { } parseHostToContainerBindMount(bindDefinition: string): HostToContainerBindMount | null { - const [sourceRaw, destinationRaw] = bindDefinition.split(':'); + // Docker bind mounts follow ":[:options]". + // We only need source + destination; mount options (for example :rw/:ro) are ignored. + const [sourceRaw, destinationRaw] = bindDefinition.split(':', 2); const source = sourceRaw?.trim(); const destination = destinationRaw?.trim(); if (!source || !destination) { @@ -479,44 +483,77 @@ class Dockercompose extends Docker { getSelfContainerIdentifier(): string | null { const hostname = process.env.HOSTNAME?.trim(); - if (!hostname || hostname.includes('/')) { + if (!hostname || !SELF_CONTAINER_IDENTIFIER_PATTERN.test(hostname)) { return null; } return hostname; } + protected isHostToContainerBindMountCacheLoaded(): boolean { + return this._hostToContainerBindMountsLoaded; + } + + protected getHostToContainerBindMountCache(): HostToContainerBindMount[] { + return [...this._hostToContainerBindMounts]; + } + + protected setHostToContainerBindMountCache(bindMounts: HostToContainerBindMount[]): void { + this._hostToContainerBindMounts = [...bindMounts]; + } + + protected resetHostToContainerBindMountCache(): void { + this._hostToContainerBindMountsLoaded = false; + this._hostToContainerBindMountsLoadPromise = null; + this._hostToContainerBindMounts = []; + } + async ensureHostToContainerBindMountsLoaded(container: ComposeContainerReference): Promise { - if (this._hostToContainerBindMountsLoaded) { + if (this._hostToContainerBindMountsLoadPromise) { + await this._hostToContainerBindMountsLoadPromise; return; } - const selfContainerIdentifier = this.getSelfContainerIdentifier(); - if (!selfContainerIdentifier) { - this._hostToContainerBindMountsLoaded = true; + if (this._hostToContainerBindMountsLoaded) { return; } - const watcher = this.getWatcher(container); - const dockerApi = getDockerApiFromWatcher(watcher); - if (!dockerApi) { - return; - } + this._hostToContainerBindMountsLoadPromise = (async () => { + const selfContainerIdentifier = this.getSelfContainerIdentifier(); + if (!selfContainerIdentifier) { + this._hostToContainerBindMountsLoaded = true; + return; + } - this._hostToContainerBindMountsLoaded = true; - try { - const selfContainerInspect = await dockerApi.getContainer(selfContainerIdentifier).inspect(); - const bindDefinitions = selfContainerInspect?.HostConfig?.Binds; - if (!Array.isArray(bindDefinitions)) { + const watcher = this.getWatcher(container); + const dockerApi = getDockerApiFromWatcher(watcher); + if (!dockerApi) { return; } - this._hostToContainerBindMounts = bindDefinitions - .map((bindDefinition) => this.parseHostToContainerBindMount(bindDefinition)) - .filter((bindMount): bindMount is HostToContainerBindMount => bindMount !== null) - .sort((left, right) => right.source.length - left.source.length); - } catch (e) { - this.log.debug( - `Unable to inspect bind mounts for compose host-path remapping (${e.message})`, - ); + + this._hostToContainerBindMountsLoaded = true; + try { + const selfContainerInspect = await dockerApi + .getContainer(selfContainerIdentifier) + .inspect(); + const bindDefinitions = selfContainerInspect?.HostConfig?.Binds; + if (!Array.isArray(bindDefinitions)) { + return; + } + this._hostToContainerBindMounts = bindDefinitions + .map((bindDefinition) => this.parseHostToContainerBindMount(bindDefinition)) + .filter((bindMount): bindMount is HostToContainerBindMount => bindMount !== null) + .sort((left, right) => right.source.length - left.source.length); + } catch (e) { + this.log.debug( + `Unable to inspect bind mounts for compose host-path remapping (${e.message})`, + ); + } + })(); + + try { + await this._hostToContainerBindMountsLoadPromise; + } finally { + this._hostToContainerBindMountsLoadPromise = null; } } @@ -1330,15 +1367,11 @@ class Dockercompose extends Docker { await this.triggerBatch([container]); } - /** - * Update the docker-compose stack. - * @param containers the containers - * @returns {Promise} - */ - async triggerBatch(containers): Promise { - // Group containers by their ordered compose file chain + async resolveAndGroupContainersByComposeFile( + containers: ComposeContainerReference[], + configuredComposeFilePath: string | null, + ): Promise> { const containersByComposeFile = new Map(); - const configuredComposeFilePath = this.getDefaultComposeFilePath(); for (const container of containers) { // Filter on containers running on local host @@ -1385,17 +1418,34 @@ class Dockercompose extends Docker { const composeFile = composeFiles[0]; const composeFileKey = composeFiles.join('\n'); - - if (!containersByComposeFile.has(composeFileKey)) { - containersByComposeFile.set(composeFileKey, { - composeFile, - composeFiles, - containers: [], - }); + const existingEntry = containersByComposeFile.get(composeFileKey); + if (existingEntry) { + existingEntry.containers.push(container); + continue; } - containersByComposeFile.get(composeFileKey).containers.push(container); + + containersByComposeFile.set(composeFileKey, { + composeFile, + composeFiles, + containers: [container], + }); } + return containersByComposeFile; + } + + /** + * Update the docker-compose stack. + * @param containers the containers + * @returns {Promise} + */ + async triggerBatch(containers): Promise { + const configuredComposeFilePath = this.getDefaultComposeFilePath(); + const containersByComposeFile = await this.resolveAndGroupContainersByComposeFile( + containers, + configuredComposeFilePath, + ); + // Process each compose file group const batchResults: unknown[] = []; for (const {