Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Compose label-driven docker-compose trigger configuration** — Added support for container labels to create and scope compose triggers from discovered containers, including `dd.compose.file` / `wud.compose.file` and compose trigger options (`backup`, `prune`, `dryrun`, `auto`, `threshold`).
- **Compose-file digest update support** — Docker-compose trigger now supports digest-pinned image references in compose files (`image@sha256:...` and `image:tag@sha256:...`) so digest-based services can be updated without dropping pinning.

- **Compose-native auto-compose discovery** — Added `dd.compose.native` / `wud.compose.native` container labels to enable deriving compose file paths from native Compose labels (`com.docker.compose.project.config_files` + `com.docker.compose.project.working_dir`) when `dd.compose.file` is not set. This requires the resolved compose path to exist inside the drydock container (same path context used by `docker compose`).
- **Watcher-wide compose-native default** — Added `DD_WATCHER_DOCKER_{name}_COMPOSENATIVE=true` to enable compose-native path discovery for all containers watched by a Docker watcher, with per-container `dd.compose.native` still taking precedence.

### Fixed

- **TrueForge registry default behavior** — Fixed TrueForge registry integration so it works out of the box with default configuration.
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,14 +402,37 @@ When using the Docker Compose trigger, container labels can override trigger set
| `dd.compose.prune` | `wud.compose.prune` | `DD_TRIGGER_DOCKERCOMPOSE_xxx_PRUNE` | `true` / `false` |
| `dd.compose.dryrun` | `wud.compose.dryrun` | `DD_TRIGGER_DOCKERCOMPOSE_xxx_DRYRUN` | `true` / `false` |
| `dd.compose.auto` | `wud.compose.auto` | `DD_TRIGGER_DOCKERCOMPOSE_xxx_AUTO` | `true` / `false` |
| `dd.compose.native` | `wud.compose.native` | `DD_WATCHER_DOCKER_xxx_COMPOSENATIVE` | `true` / `false` |
| `dd.compose.threshold` | `wud.compose.threshold` | `DD_TRIGGER_DOCKERCOMPOSE_xxx_THRESHOLD` | `all` / `major` / `minor` / `patch` |

Behavior notes:

- `dd.compose.file` / `wud.compose.file` causes drydock to create (or reuse) a scoped `dockercompose` trigger for that container.
- That generated compose trigger is set with `requireinclude=true` and auto-appended to the container include list, so it only runs for explicitly associated containers.
- `dd.compose.native` / `wud.compose.native` enables deriving compose file paths from native Compose labels (`com.docker.compose.project.config_files` and `com.docker.compose.project.working_dir`).
- Compose-native/automatic detection requires the resolved compose file path to be valid inside the drydock container (same path that `docker compose` uses); if Compose was run from a host-only path, bind-mount that path into drydock at the same location or set `dd.compose.file` explicitly.
- `DD_WATCHER_DOCKER_xxx_COMPOSENATIVE=true` enables compose-native lookup by default for all containers in that watcher (container label can still override).
- If `dd.compose.auto` is omitted, normal trigger default applies (`auto=true`).

Troubleshooting path mismatch:

- Symptom: compose-native is enabled, but DryDock cannot resolve/update the compose file.
- Cause: the path from Compose labels exists on the host, but not inside the DryDock container at the same absolute path.
- Fix: bind-mount the host compose project path into DryDock using the same container path, or set `dd.compose.file` per container.

Example (host path and container path are identical):

```yaml
services:
drydock:
image: codeswhat/drydock:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/stacks:/opt/stacks
```

If your stack was started from `/opt/stacks/myapp/docker-compose.yml`, DryDock must also see that file at `/opt/stacks/myapp/docker-compose.yml`.

`dd.*` labels take precedence when both `dd.*` and `wud.*` are present.

</details>
Expand Down
163 changes: 161 additions & 2 deletions app/watchers/providers/docker/Docker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Docker, {
testable_filterBySegmentCount,
testable_getContainerDisplayName,
testable_getContainerName,
testable_getComposeFilePathFromLabels,
testable_getCurrentPrefix,
testable_getFirstDigitIndex,
testable_getImageForRegistryLookup,
Expand Down Expand Up @@ -1514,7 +1515,6 @@ describe('Docker Watcher', () => {
expect(docker.composeTriggersByContainer.container123).toBeUndefined();
expect(storeContainer.updateContainer).not.toHaveBeenCalled();
});

test('should skip store update when inspect payload does not change tracked fields', async () => {
await docker.register('watcher', 'docker', 'test', {});
docker.log = createMockLogWithChild(['info']);
Expand Down Expand Up @@ -2864,6 +2864,76 @@ describe('Docker Watcher', () => {
expect(result.triggerInclude).toBe('dockercompose.tmp-test-container-wud');
});

test('should auto-include dockercompose trigger from compose-native labels when dd.compose.native is true', async () => {
const container = await setupContainerDetailTest(docker, {
container: {
Image: 'nginx:1.0.0',
Names: ['/test-container-native'],
Labels: {
'dd.compose.native': 'true',
'com.docker.compose.project.working_dir': '/opt/my-stack',
'com.docker.compose.project.config_files': 'docker-compose.yml',
},
},
});

const result = await docker.addImageDetailsToContainer(container);

expect(registry.ensureDockercomposeTriggerForContainer).toHaveBeenCalledWith(
'test-container-native',
'/opt/my-stack/docker-compose.yml',
{},
);
expect(result.triggerInclude).toBe('dockercompose.my-stack-test-container-native');
});

test('should auto-include dockercompose trigger from compose-native labels when watcher composenative is enabled', async () => {
const container = await setupContainerDetailTest(docker, {
registerConfig: {
composenative: true,
},
container: {
Image: 'nginx:1.0.0',
Names: ['/test-container-native-global'],
Labels: {
'com.docker.compose.project.working_dir': '/opt/global-stack',
'com.docker.compose.project.config_files': 'compose.yml',
},
},
});

const result = await docker.addImageDetailsToContainer(container);

expect(registry.ensureDockercomposeTriggerForContainer).toHaveBeenCalledWith(
'test-container-native-global',
'/opt/global-stack/compose.yml',
{},
);
expect(result.triggerInclude).toBe('dockercompose.global-stack-test-container-native-global');
});

test('should not auto-include dockercompose trigger from compose-native labels when dd.compose.native is false', async () => {
const container = await setupContainerDetailTest(docker, {
registerConfig: {
composenative: true,
},
container: {
Image: 'nginx:1.0.0',
Names: ['/test-container-native-disabled'],
Labels: {
'dd.compose.native': 'false',
'com.docker.compose.project.working_dir': '/opt/disabled-stack',
'com.docker.compose.project.config_files': 'compose.yml',
},
},
});

const result = await docker.addImageDetailsToContainer(container);

expect(registry.ensureDockercomposeTriggerForContainer).not.toHaveBeenCalled();
expect(result.triggerInclude).toBeUndefined();
});

test('should pass compose trigger options from labels', async () => {
const container = await setupContainerDetailTest(docker, {
container: {
Expand Down Expand Up @@ -4688,6 +4758,96 @@ describe('Docker Watcher', () => {
expect(testable_getLabel({}, 'dd.display.name')).toBeUndefined();
});

test('getComposeFilePathFromLabels should prefer dd.compose.file over compose-native labels', () => {
const composeFile = testable_getComposeFilePathFromLabels(
{
'dd.compose.file': '/opt/explicit/docker-compose.yml',
'dd.compose.native': 'true',
'com.docker.compose.project.working_dir': '/opt/native',
'com.docker.compose.project.config_files': 'compose.yml',
},
false,
);
expect(composeFile).toBe('/opt/explicit/docker-compose.yml');
});

test('getComposeFilePathFromLabels should resolve compose-native labels when enabled globally', () => {
const composeFile = testable_getComposeFilePathFromLabels(
{
'com.docker.compose.project.working_dir': '/opt/native',
'com.docker.compose.project.config_files': 'compose.yml',
},
true,
);
expect(composeFile).toBe('/opt/native/compose.yml');
});

test('getComposeFilePathFromLabels should return undefined when compose-native labels are disabled', () => {
const composeFile = testable_getComposeFilePathFromLabels(
{
'dd.compose.native': 'false',
'com.docker.compose.project.working_dir': '/opt/native',
'com.docker.compose.project.config_files': 'compose.yml',
},
true,
);
expect(composeFile).toBeUndefined();
});

test('getComposeFilePathFromLabels should fallback to watcher setting when compose-native label is blank', () => {
const composeFile = testable_getComposeFilePathFromLabels(
{
'dd.compose.native': ' ',
'com.docker.compose.project.working_dir': '/opt/native',
'com.docker.compose.project.config_files': 'compose.yml',
},
true,
);
expect(composeFile).toBe('/opt/native/compose.yml');
});

test('getComposeFilePathFromLabels should return undefined when compose-native config files label is missing', () => {
const composeFile = testable_getComposeFilePathFromLabels(
{
'com.docker.compose.project.working_dir': '/opt/native',
},
true,
);
expect(composeFile).toBeUndefined();
});

test('getComposeFilePathFromLabels should keep absolute compose-native config file path', () => {
const composeFile = testable_getComposeFilePathFromLabels(
{
'com.docker.compose.project.working_dir': '/opt/native',
'com.docker.compose.project.config_files': '/etc/compose/docker-compose.yml',
},
true,
);
expect(composeFile).toBe('/etc/compose/docker-compose.yml');
});

test('getComposeFilePathFromLabels should return relative compose-native config file when working dir is absent', () => {
const composeFile = testable_getComposeFilePathFromLabels(
{
'com.docker.compose.project.config_files': 'compose.yml',
},
true,
);
expect(composeFile).toBe('compose.yml');
});

test('getComposeFilePathFromLabels should return undefined when compose-native config files are only empty entries', () => {
const composeFile = testable_getComposeFilePathFromLabels(
{
'com.docker.compose.project.config_files': ' , , ',
'dd.compose.native': 'true',
},
false,
);
expect(composeFile).toBeUndefined();
});
Comment on lines +4840 to +4849
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for multiple config files scenario. The implementation includes a comment "first file wins" when multiple files are present in com.docker.compose.project.config_files, but there's no test case verifying this behavior. Consider adding a test case with 'com.docker.compose.project.config_files': 'docker-compose.yml,docker-compose.override.yml' to ensure only the first file is used.

Copilot uses AI. Check for mistakes.

Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for the wud.compose.native fallback label. While the integration tests cover dd.compose.native, there's no test verifying that wud.compose.native works as a fallback when dd.compose.native is not set. This is important because the label constants define both variants and getLabel is used to check both.

Suggested change
test('getComposeFilePathFromLabels should use wud.compose.native as a fallback when dd.compose.native is not set', () => {
const composeFile = testable_getComposeFilePathFromLabels(
{
'com.docker.compose.project.config_files': ' , , ',
'wud.compose.native': 'true',
},
false,
);
expect(composeFile).toBeUndefined();
});

Copilot uses AI. Check for mistakes.
test('appendTriggerId should return triggerInclude when triggerId is undefined', () => {
expect(testable_appendTriggerId('ntfy.default:major', undefined)).toBe('ntfy.default:major');
});
Expand Down Expand Up @@ -4722,7 +4882,6 @@ describe('Docker Watcher', () => {
expect(testable_removeTriggerId('dockercompose.test', 'dockercompose.test')).toBeUndefined();
});


test('getCurrentPrefix should return the non-numeric prefix before the first digit', () => {
expect(testable_getCurrentPrefix('v2026.2.1')).toBe('v');
});
Expand Down
Loading