Skip to content

Commit 1cc4d02

Browse files
committed
add digest pinning support for compose
1 parent 637b7c8 commit 1cc4d02

File tree

7 files changed

+107
-3
lines changed

7 files changed

+107
-3
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414

1515
- **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`, `once`, `threshold`).
1616
- **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.
17+
- **Compose forced digest pinning toggle** — Added `digestpin` support for docker-compose triggers so compose image updates can be written as `tag@digest` when a replacement digest is available. Includes per-container labels (`dd.compose.digestpin` / `wud.compose.digestpin`) and watcher-level defaults (`DD_WATCHER_{name}_COMPOSE_DIGESTPIN`) for generated compose triggers.
1718

1819
- **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`).
1920
- **Watcher-wide compose-native default** — Added `DD_WATCHER_{name}_COMPOSE_NATIVE=true` to enable compose-native path discovery for all containers watched by a Docker watcher, with per-container `dd.compose.native` still taking precedence.

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@ When using the Docker Compose trigger, container labels can override trigger set
405405
| `dd.compose.dryrun` | `wud.compose.dryrun` | `DD_TRIGGER_DOCKERCOMPOSE_xxx_DRYRUN` | `true` / `false` |
406406
| `dd.compose.auto` | `wud.compose.auto` | `DD_TRIGGER_DOCKERCOMPOSE_xxx_AUTO` | `true` / `false` |
407407
| `dd.compose.once` | `wud.compose.once` | `DD_TRIGGER_DOCKERCOMPOSE_xxx_ONCE` | `true` / `false` |
408+
| `dd.compose.digestpin` | `wud.compose.digestpin` | `DD_TRIGGER_DOCKERCOMPOSE_xxx_DIGESTPIN` | `true` / `false` |
408409
| `dd.compose.native` | `wud.compose.native` | `DD_WATCHER_<WATCHER_NAME>_COMPOSE_NATIVE` | `true` / `false` |
409410
| `dd.compose.threshold` | `wud.compose.threshold` | `DD_TRIGGER_DOCKERCOMPOSE_xxx_THRESHOLD` | `all` / `major` / `minor` / `patch` |
410411

@@ -413,6 +414,7 @@ Behavior notes:
413414
- `dd.compose.file` / `wud.compose.file` causes drydock to create (or reuse) a scoped `dockercompose` trigger for that container.
414415
- 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.
415416
- `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`).
417+
- `dd.compose.digestpin` / `wud.compose.digestpin` forces compose image updates to write `tag@digest` when the update digest is known.
416418
- 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.
417419
- `DD_WATCHER_<WATCHER_NAME>_COMPOSE_NATIVE=true` enables compose-native lookup by default for all containers in that watcher (container label can still override).
418420
- If `dd.compose.auto` is omitted, normal trigger default applies (`auto=true`).
@@ -423,6 +425,7 @@ Behavior notes:
423425
- `DD_WATCHER_<WATCHER_NAME>_COMPOSE_DRYRUN`
424426
- `DD_WATCHER_<WATCHER_NAME>_COMPOSE_AUTO`
425427
- `DD_WATCHER_<WATCHER_NAME>_COMPOSE_ONCE`
428+
- `DD_WATCHER_<WATCHER_NAME>_COMPOSE_DIGESTPIN`
426429
- `DD_WATCHER_<WATCHER_NAME>_COMPOSE_NATIVE`
427430
- `DD_WATCHER_<WATCHER_NAME>_COMPOSE_THRESHOLD`
428431
These defaults apply when corresponding compose labels are not present.

app/triggers/providers/dockercompose/Dockercompose.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ describe('Dockercompose Trigger', () => {
174174
trigger.configuration = {
175175
dryrun: true,
176176
backup: false,
177+
digestpin: false,
177178
composeFileLabel: 'dd.compose.file',
178179
};
179180

@@ -591,6 +592,37 @@ describe('Dockercompose Trigger', () => {
591592
expect(dockerTriggerSpy).toHaveBeenCalledWith(container);
592593
});
593594

595+
test('processComposeFile should force digest pinning for tag updates when digestpin is enabled', async () => {
596+
trigger.configuration.dryrun = false;
597+
trigger.configuration.backup = false;
598+
trigger.configuration.digestpin = true;
599+
const container = makeContainer({
600+
tagValue: '1.0.0',
601+
updateKind: 'tag',
602+
remoteValue: '1.1.0',
603+
result: {
604+
digest: 'sha256:newdigest',
605+
},
606+
});
607+
608+
vi.spyOn(trigger, 'getComposeFileAsObject').mockResolvedValue(
609+
makeCompose({ nginx: { image: 'nginx:1.0.0' } }),
610+
);
611+
612+
const { writeComposeFileSpy, dockerTriggerSpy } = spyOnProcessComposeHelpers(
613+
trigger,
614+
'image: nginx:1.0.0',
615+
);
616+
617+
await trigger.processComposeFile('/opt/drydock/test/stack.yml', [container]);
618+
619+
expect(writeComposeFileSpy).toHaveBeenCalledWith(
620+
'/opt/drydock/test/stack.yml',
621+
'image: nginx:1.1.0@sha256:newdigest',
622+
);
623+
expect(dockerTriggerSpy).toHaveBeenCalledWith(container);
624+
});
625+
594626
test('processComposeFile should skip update when compose is digest-pinned but tag update has no remote digest', async () => {
595627
trigger.configuration.dryrun = false;
596628
trigger.configuration.backup = false;
@@ -1634,4 +1666,32 @@ describe('Dockercompose Trigger', () => {
16341666
keptPinned: true,
16351667
});
16361668
});
1669+
1670+
test('buildUpdatedComposeImage should force pinning for non-digest compose images when enabled', () => {
1671+
const result = testable_buildUpdatedComposeImage(
1672+
'nginx:1.0.0',
1673+
'nginx:2.0.0',
1674+
{ kind: 'tag', remoteValue: '2.0.0' },
1675+
'sha256:newdigest',
1676+
true,
1677+
);
1678+
expect(result).toEqual({
1679+
image: 'nginx:2.0.0@sha256:newdigest',
1680+
keptPinned: true,
1681+
});
1682+
});
1683+
1684+
test('buildUpdatedComposeImage should fall back when forced pinning has no digest available', () => {
1685+
const result = testable_buildUpdatedComposeImage(
1686+
'nginx:1.0.0',
1687+
'nginx:2.0.0',
1688+
{ kind: 'tag', remoteValue: '2.0.0' },
1689+
undefined,
1690+
true,
1691+
);
1692+
expect(result).toEqual({
1693+
image: 'nginx:2.0.0',
1694+
keptPinned: false,
1695+
});
1696+
});
16371697
});

app/triggers/providers/dockercompose/Dockercompose.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,15 @@ function normalizeImageWithoutDigest(image) {
4949
return normalizeImplicitLatest(imageWithoutDigest);
5050
}
5151

52-
function buildUpdatedComposeImage(currentImage, fallbackImage, updateKind, remoteDigest) {
53-
if (!currentImage?.includes('@')) {
52+
function buildUpdatedComposeImage(
53+
currentImage,
54+
fallbackImage,
55+
updateKind,
56+
remoteDigest,
57+
forceDigestPin = false,
58+
) {
59+
const currentImageIsDigestPinned = Boolean(currentImage?.includes('@'));
60+
if (!currentImageIsDigestPinned && !forceDigestPin) {
5461
return {
5562
image: fallbackImage,
5663
keptPinned: false,
@@ -60,13 +67,20 @@ function buildUpdatedComposeImage(currentImage, fallbackImage, updateKind, remot
6067
const digestToPin =
6168
updateKind?.kind === 'digest' ? updateKind?.remoteValue : (remoteDigest ?? undefined);
6269
if (!digestToPin) {
70+
if (!currentImageIsDigestPinned) {
71+
return {
72+
image: fallbackImage,
73+
keptPinned: false,
74+
};
75+
}
6376
return {
6477
image: undefined,
6578
keptPinned: false,
6679
};
6780
}
6881

69-
const imageToPin = updateKind?.kind === 'digest' ? currentImage : fallbackImage;
82+
const imageToPin =
83+
updateKind?.kind === 'digest' && currentImageIsDigestPinned ? currentImage : fallbackImage;
7084
const { imageWithoutDigest } = splitDigestReference(imageToPin);
7185
return {
7286
image: `${imageWithoutDigest}@${digestToPin}`,
@@ -189,6 +203,7 @@ class Dockercompose extends Docker {
189203
// Make file optional since we now support per-container compose files
190204
file: this.joi.string().optional(),
191205
backup: this.joi.boolean().default(false),
206+
digestpin: this.joi.boolean().default(false),
192207
// Add configuration for the label name to look for
193208
composeFileLabel: this.joi.string().default('dd.compose.file'),
194209
});
@@ -566,11 +581,13 @@ class Dockercompose extends Docker {
566581
}
567582

568583
const currentImage = serviceToUpdate.image;
584+
const forceDigestPin = `${this.configuration.digestpin}`.trim().toLowerCase() === 'true';
569585
const digestAwareUpdate = buildUpdatedComposeImage(
570586
currentImage,
571587
this.getNewImageFullName(registry, container),
572588
container.updateKind,
573589
container.result?.digest,
590+
forceDigestPin,
574591
);
575592
const updateImage = digestAwareUpdate.image;
576593

app/watchers/providers/docker/Docker.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,7 @@ describe('Docker Watcher', () => {
604604
'dd.compose.file': '/tmp/my-stack/docker-compose.yml',
605605
'dd.compose.auto': 'true',
606606
'dd.compose.prune': 'false',
607+
'dd.compose.digestpin': 'true',
607608
},
608609
triggerInclude: 'ntfy.default:major',
609610
},
@@ -621,6 +622,7 @@ describe('Docker Watcher', () => {
621622
'/tmp/my-stack/docker-compose.yml',
622623
{
623624
auto: 'true',
625+
digestpin: 'true',
624626
prune: 'false',
625627
},
626628
);
@@ -653,6 +655,7 @@ describe('Docker Watcher', () => {
653655
compose: {
654656
threshold: 'minor',
655657
auto: false,
658+
digestpin: true,
656659
},
657660
});
658661

@@ -663,6 +666,7 @@ describe('Docker Watcher', () => {
663666
'/tmp/my-stack/docker-compose.yml',
664667
{
665668
auto: 'false',
669+
digestpin: 'true',
666670
threshold: 'minor',
667671
},
668672
);
@@ -1314,6 +1318,7 @@ describe('Docker Watcher', () => {
13141318
'dd.compose.backup': 'true',
13151319
'dd.compose.prune': 'false',
13161320
'dd.compose.auto': 'true',
1321+
'dd.compose.digestpin': 'true',
13171322
'dd.compose.threshold': 'minor',
13181323
},
13191324
},
@@ -1337,6 +1342,7 @@ describe('Docker Watcher', () => {
13371342
backup: 'true',
13381343
prune: 'false',
13391344
auto: 'true',
1345+
digestpin: 'true',
13401346
threshold: 'minor',
13411347
},
13421348
);

app/watchers/providers/docker/Docker.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import Watcher from '../../Watcher.js';
3737
import {
3838
ddComposeAuto,
3939
ddComposeBackup,
40+
ddComposeDigestpin,
4041
ddComposeDryrun,
4142
ddComposeFile,
4243
ddComposeNative,
@@ -58,6 +59,7 @@ import {
5859
ddWatchDigest,
5960
wudComposeAuto,
6061
wudComposeBackup,
62+
wudComposeDigestpin,
6163
wudComposeDryrun,
6264
wudDisplayIcon,
6365
wudDisplayName,
@@ -110,6 +112,7 @@ export interface DockerWatcherConfiguration extends ComponentConfiguration {
110112
dryrun?: boolean;
111113
auto?: boolean;
112114
once?: boolean;
115+
digestpin?: boolean;
113116
native?: boolean;
114117
threshold?: string;
115118
};
@@ -257,6 +260,13 @@ function getDockercomposeTriggerConfigurationFromLabels(
257260
dockercomposeConfig.once = once;
258261
}
259262

263+
const digestpin =
264+
getLabel(labels, ddComposeDigestpin, wudComposeDigestpin) ||
265+
normalizeComposeDefaultValue(composeDefaults.digestpin);
266+
if (digestpin !== undefined) {
267+
dockercomposeConfig.digestpin = digestpin;
268+
}
269+
260270
const threshold =
261271
getLabel(labels, ddComposeThreshold, wudComposeThreshold) ||
262272
normalizeConfigStringValue(composeDefaults.threshold);
@@ -1178,6 +1188,7 @@ class Docker extends Watcher {
11781188
dryrun: this.joi.boolean(),
11791189
auto: this.joi.boolean(),
11801190
once: this.joi.boolean(),
1191+
digestpin: this.joi.boolean(),
11811192
native: this.joi.boolean(),
11821193
threshold: this.joi.string(),
11831194
})

app/watchers/providers/docker/label.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ export const wudComposeAuto = 'wud.compose.auto';
111111
export const ddComposeOnce = 'dd.compose.once';
112112
export const wudComposeOnce = 'wud.compose.once';
113113

114+
/**
115+
* Optional dockercompose trigger forced digest pinning mode (true | false).
116+
*/
117+
export const ddComposeDigestpin = 'dd.compose.digestpin';
118+
export const wudComposeDigestpin = 'wud.compose.digestpin';
119+
114120
/**
115121
* Optional auto-compose discovery mode (true | false).
116122
* When enabled, use compose-native labels to derive compose file path.

0 commit comments

Comments
 (0)