diff --git a/CHANGELOG.md b/CHANGELOG.md index 23af5421..d60db55c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,9 @@ 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 6321a987..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, @@ -361,6 +362,39 @@ 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 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(); @@ -715,6 +749,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 +1048,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; @@ -1572,6 +1674,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 +1743,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 () => { @@ -2815,7 +2945,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 = { @@ -2839,7 +2969,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 = { @@ -2864,6 +2994,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 // ----------------------------------------------------------------------- @@ -3719,6 +3884,417 @@ 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('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 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( + 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.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; + } 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.isHostToContainerBindMountCacheLoaded()).toBe(true); + expect(trigger.getHostToContainerBindMountCache()).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.getHostToContainerBindMountCache()).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.isHostToContainerBindMountCacheLoaded()).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.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', + ); + + expect(mappedPath).toBe('/drydock/monitoring/compose.yaml'); + }); + + test('mapComposePathToContainerBindMount should map nested files when bind source ends with path separator', () => { + trigger.setHostToContainerBindMountCache([ + { + 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.setHostToContainerBindMountCache([ + { + 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.setHostToContainerBindMountCache([ + { + 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.setHostToContainerBindMountCache([ + { + 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'); diff --git a/app/triggers/providers/dockercompose/Dockercompose.ts b/app/triggers/providers/dockercompose/Dockercompose.ts index e9e90fdf..95959772 100644 --- a/app/triggers/providers/dockercompose/Dockercompose.ts +++ b/app/triggers/providers/dockercompose/Dockercompose.ts @@ -16,10 +16,12 @@ 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; 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'; @@ -35,6 +37,9 @@ interface DockerApiLike { Config?: { Labels?: Record; }; + HostConfig?: { + Binds?: string[]; + }; }>; exec: (options: unknown) => Promise<{ start: (options: { Detach: boolean; Tty: boolean }) => Promise<{ @@ -55,6 +60,11 @@ type ContainersByComposeFileEntry = { containers: unknown[]; }; +type HostToContainerBindMount = { + source: string; + destination: string; +}; + type ComposeContainerReference = { name?: string; labels?: Record; @@ -99,8 +109,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) => { @@ -399,6 +418,9 @@ class Dockercompose extends Docker { _composeCacheMaxEntries = COMPOSE_CACHE_MAX_ENTRIES; _composeObjectCache = new Map(); _composeDocumentCache = new Map(); + _hostToContainerBindMountsLoaded = false; + _hostToContainerBindMountsLoadPromise: Promise | null = null; + _hostToContainerBindMounts: HostToContainerBindMount[] = []; get _composeFileLocksHeld() { return this._composeFileLockManager._composeFileLocksHeld; @@ -441,6 +463,129 @@ class Dockercompose extends Docker { } } + parseHostToContainerBindMount(bindDefinition: string): HostToContainerBindMount | null { + // 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) { + 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 || !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._hostToContainerBindMountsLoadPromise) { + await this._hostToContainerBindMountsLoadPromise; + return; + } + + if (this._hostToContainerBindMountsLoaded) { + return; + } + + this._hostToContainerBindMountsLoadPromise = (async () => { + 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})`, + ); + } + })(); + + try { + await this._hostToContainerBindMountsLoadPromise; + } finally { + this._hostToContainerBindMountsLoadPromise = null; + } + } + + 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 +733,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 +792,8 @@ class Dockercompose extends Docker { } async resolveComposeFilesForContainer(container: ComposeContainerReference): Promise { + await this.ensureHostToContainerBindMountsLoaded(container); + const composeFilesFromConfiguration = this.getConfiguredComposeFilesForContainer(container, { includeDefaultComposeFile: false, }); @@ -1221,13 +1367,10 @@ 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(); for (const container of containers) { @@ -1248,6 +1391,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) { @@ -1269,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 { @@ -1480,9 +1646,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]); diff --git a/apps/web/app/docs/layout.tsx b/apps/web/app/docs/layout.tsx index 15b15c71..61a2b0b1 100644 --- a/apps/web/app/docs/layout.tsx +++ b/apps/web/app/docs/layout.tsx @@ -1,36 +1,60 @@ +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 — this version + is not yet released. + + + v1.3.9 release notes → + + + + + Drydock + Drydock + + ), + url: "/", + }} + links={[ + { + text: "GitHub", + url: "https://github.com/CodesWhat/drydock", + external: true, + }, + ]} + > + {children} + + ); } 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" }, ]; 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