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
-
- ),
- 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
+
+ ),
+ 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