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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.5.1-rc.4] β€” 2026-06-29

### Added

- **Optional mount-prefix fallback for Docker Compose path matching.** When a watched container's resolved compose file path differs from the trigger's configured compose file only by a mount prefix (common with Portainer and bind-mounted compose files), drydock can now match on the trailing `<project-dir>/<file>` tail instead of skipping the container. Off by default β€” enable it per trigger with `DD_ACTION_DOCKERCOMPOSE_<name>_MOUNT_PREFIX_FALLBACK=true`. It stays opt-in because tail matching cannot distinguish two stacks that share a project-directory name across environments (e.g. `/prod/myapp` vs `/staging/myapp`). (#365)

- **`$currentReleaseNotes` trigger template variable.** Trigger templates (notification bodies, command arguments, and the like) can now reference `$currentReleaseNotes` to include the release notes for the container's currently running version, alongside the existing variable for the update target's notes. (#295)

- **Container software version in the detail panels and a new Version column in the containers table.** Drydock now surfaces the application version baked into an image β€” read from the `org.opencontainers.image.version` OCI label, falling back to the running container's inspect metadata β€” as `image.softwareVersion`. It appears in the container side panel, the full-page detail view, and a new **Version** column in the containers table. The existing **Tag** column (column key `version`, preserved so saved column preferences keep working) continues to show the image tag; the new **Version** column shows `image.softwareVersion`, falling back to the tag when no software version is available. `dd.inspect.tag.path` now dual-writes the extracted value into `image.softwareVersion` as well as overwriting the image tag, so the Version column is populated for inspect-path containers with no label change needed. The Version column is visible by default for new installs; existing users have it inserted into their saved column list automatically on first load after upgrading. (#209)

- **`dd.inspect.tag.version-only` opt-in label.** When `dd.inspect.tag.path` is set, the extracted value normally overwrites the image tag (enabling update detection against the semver embedded in the running container). Setting `dd.inspect.tag.version-only=true` routes the extracted value to `image.softwareVersion` only, leaving the real image tag intact for update detection. This is useful when the inspect path carries a displayable application version that differs in format from the registry tag β€” the Version column shows it without disrupting how drydock matches updates. The default (tag overwrite) is unchanged when the label is absent. (#209)

- **Container uptime.** The side panel and full-page detail view now show how long a container has been running (from the Docker `State.StartedAt` timestamp), and a new opt-in **Uptime** column can be enabled in the containers table via the column picker. The value updates live and falls back to an em-dash when the start time is unknown.

### Changed

- **Container validation now tolerates fields written by newer drydock versions.** The store validator no longer rejects unknown keys, so a `dd.json` written by a newer release stays readable after a downgrade. Note: this protects downgrades from v1.5.1 onward β€” rolling back from v1.5.1 to v1.5.0 (which predates this change) still requires removing the new `details.startedAt` and `image.softwareVersion` fields from `dd.json`, since v1.5.0 rejects them.

### Fixed

- **Completed i18n coverage for the last untranslated UI surfaces.** A code-level audit found several strings that still rendered in English for non-English users; they now resolve through the translation catalog: the trigger status badge (`active`/`inactive`), the running/writes-compose `yes`/`no` preview values, the "container actions disabled by server configuration" tooltip, the update-maturity "Available for N days" tooltip (the translate function is now threaded through the container mapper, which previously left the existing catalog keys unused), the grouped "Update All" success toast (which appended a raw English `in <group>` β€” it now interpolates the group name through a translatable key), the security-view severity tooltips (`CRITICAL`/`HIGH`/`MEDIUM`/`LOW`), the backup operation `unknown` fallback label, and the search-bar hint footer connectors. The new English catalog keys ship now; the 16 community locales fill in through the normal Crowdin sync after release. (#329)

### Security

- **Base image refreshed to clear 24 container-scan CVEs.** Bumped the pinned `node:24-alpine` base from a stale digest (Node 24.16.0, Alpine 3.21) to the current digest (Node 24.18.0, Alpine 3.24) and added `libexpat` to the targeted `apk upgrade` set. This resolves all 11 Node binary CVEs reported by the image scan β€” including the one critical (CVE-2026-48930) and four high β€” plus 13 medium `libexpat` CVEs (now `2.8.2-r0`). A rebuild + rescan confirms zero critical/high/Node/libexpat findings remain. The three `busybox`/`ssl_client` findings (CVE-2025-60876, medium) have no upstream fix in Alpine yet and are tracked for a later base bump. All previously pinned Alpine package versions still resolve on 3.24, so the build is otherwise unchanged.

## [1.5.1-rc.3] β€” 2026-06-28

### Added
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# checkov:skip=CKV_DOCKER_3: entrypoint uses su-exec for runtime privilege drop
# Common Stage
FROM node:24-alpine@sha256:21f403ab171f2dc89bad4dd69d7721bfd15f084ccb46cdd225f31f2bc59b5c9a AS base
FROM node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS base
WORKDIR /home/node/app

LABEL maintainer="CodesWhat"
Expand Down Expand Up @@ -34,7 +34,7 @@ RUN apk add --no-cache \
tzdata=2026b-r0 \
&& apk add --no-cache cosign=3.0.6-r1 \
&& apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/testing trivy \
&& apk upgrade --no-cache zlib libcrypto3 libssl3 \
&& apk upgrade --no-cache zlib libcrypto3 libssl3 libexpat \
&& mkdir /store && chown node:node /store

# Build stage for healthcheck binary (~65KB static binary)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ High-level themes only β€” see [CHANGELOG.md](CHANGELOG.md) for per-release deta
| **v1.3.x** βœ… | Security & Stability | Trivy scanning, Update Bouncer, SBOM, 7 new registries, 4 new triggers, re2js regex engine |
| **v1.4.x** βœ… | UI Modernization & Hardening | Tailwind 4 + custom components, 6 themes, Cmd/K palette, OpenAPI 3.1, compose-native YAML updates, dual-slot scanning, OIDC hardening |
| **v1.5.0** βœ… | Observability & i18n | trigger taxonomy split (`DD_ACTION_*`/`DD_NOTIFICATION_*`), WebSocket log viewer, dashboard customization, resource monitoring, notification outbox + DLQ, security scan digest, 17 locales, SSE Last-Event-ID replay, edge agent dial-out with Ed25519 auth (experimental, `DD_EXPERIMENTAL_PORTWING=true`) |
| **v1.5.1** | Security & Maintenance | GCR/GAR pull-auth fix, registry TLS completion (M-2), hook env-var injection hardening, `DD_SESSION_SECRET__FILE` support, debug-dump credential redaction, secret-file permission check, maturity gate deadlock fix, full UI translatability + community translations, maintenance-window auto-apply gate |
| **v1.5.1** | Security & Maintenance | GCR/GAR pull-auth fix, registry TLS completion (M-2), hook env-var injection hardening, `DD_SESSION_SECRET__FILE` support, debug-dump credential redaction, secret-file permission check, maturity gate deadlock fix, full UI translatability + community translations, maintenance-window auto-apply gate, container uptime display, Tag/Version column split surfacing software version (OCI label, with `dd.inspect.tag.path` dual-write + opt-in `dd.inspect.tag.version-only` routing), opt-in compose mount-prefix matching, `$currentReleaseNotes` template var |
| **v1.6.0** | Scanner Decoupling & Release Intel | Backend-based scanner + Grype, notification templates, declarative update policy, table-only UI, SBOM off-heap storage |
| **v1.7.0** | Smart Updates & UX | Dependency-aware ordering, image prune, static image monitoring, keyboard shortcuts, PWA |
| **v1.8.0** | Fleet Management & Live Config | YAML config, live UI config, volume browser, parallel updates, SQLite store migration |
Expand Down
84 changes: 84 additions & 0 deletions app/model/container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,12 +904,96 @@ test('model should accept details with empty env values', async () => {
]);
});

test('model should accept details with startedAt iso date', async () => {
const containerValidated = container.validate({
id: 'container-startedat-test',
name: 'startedat-test',
watcher: 'test',
image: {
id: 'image-startedat-test',
registry: { name: 'hub', url: 'https://hub' },
name: 'organization/image',
tag: { value: '1.0.0', semver: true },
digest: { watch: false },
architecture: 'arch',
os: 'os',
},
details: {
ports: [],
volumes: [],
env: [],
startedAt: '2024-06-01T12:00:00.000Z',
},
});
expect(containerValidated.details.startedAt).toBe('2024-06-01T12:00:00.000Z');
});

test('model should accept details without startedAt', async () => {
const containerValidated = container.validate({
id: 'container-no-startedat',
name: 'no-startedat',
watcher: 'test',
image: {
id: 'image-no-startedat',
registry: { name: 'hub', url: 'https://hub' },
name: 'organization/image',
tag: { value: '1.0.0', semver: true },
digest: { watch: false },
architecture: 'arch',
os: 'os',
},
details: {
ports: [],
volumes: [],
env: [],
},
});
expect(containerValidated.details.startedAt).toBeUndefined();
});

test('model should reject details with non-ISO startedAt', async () => {
expect(() => {
container.validate({
id: 'container-bad-startedat',
name: 'bad-startedat',
watcher: 'test',
image: {
id: 'image-bad-startedat',
registry: { name: 'hub', url: 'https://hub' },
name: 'organization/image',
tag: { value: '1.0.0', semver: true },
digest: { watch: false },
architecture: 'arch',
os: 'os',
},
details: {
ports: [],
volumes: [],
env: [],
startedAt: 'not-a-date',
},
});
}).toThrow('"details.startedAt" must be in iso format');
});

test('model should reject empty error message', async () => {
expect(() => {
container.validate(createContainerWithError(''));
}).toThrow('ValidationError: "error.message" is not allowed to be empty');
});

test('validate should allow and preserve unknown future fields (forward compatibility)', () => {
const input = {
...createValidContainer(),
someFutureField: 'x',
};
let result: unknown;
expect(() => {
result = container.validate(input);
}).not.toThrow();
expect((result as Record<string, unknown>).someFutureField).toBe('x');
});

test.each([
['service-old-0123456789', true],
['service-old-01234567890', true],
Expand Down
6 changes: 5 additions & 1 deletion app/model/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export interface ContainerImage {
os: string;
variant?: string;
created?: string;
softwareVersion?: string;
}

export interface ContainerResult {
Expand Down Expand Up @@ -136,6 +137,7 @@ export interface ContainerRuntimeDetails {
ports: string[];
volumes: string[];
env: ContainerRuntimeEnv[];
startedAt?: string;
}

export interface ContainerUpdateOperationState {
Expand Down Expand Up @@ -328,6 +330,7 @@ const schema = joi.object({
os: joi.string().min(1).required(),
variant: joi.string(),
created: joi.string().isoDate(),
softwareVersion: joi.string(),
})
.required(),
result: joi.object({
Expand Down Expand Up @@ -385,6 +388,7 @@ const schema = joi.object({
}),
)
.required(),
startedAt: joi.string().isoDate().optional(),
}),
});

Expand Down Expand Up @@ -811,7 +815,7 @@ function addResultChangedFunction(container: Container) {
* @returns {*}
*/
export function validate(container: unknown): Container {
const validation = schema.validate(container);
const validation = schema.validate(container, { allowUnknown: true });
if (validation.error) {
throw new Error(`Error when validating container properties ${validation.error}`);
}
Expand Down
74 changes: 74 additions & 0 deletions app/triggers/providers/dockercompose/Dockercompose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3143,6 +3143,80 @@ describe('Dockercompose Trigger', () => {
);
});

test('triggerBatch should use configured path when label path differs only by mount prefix (issue #365)', async () => {
// Portainer bind-mounts /drydock/mystack -> /data/compose/mystack inside the container.
// The label reflects the in-container path; Drydock's configured path is the host path.
trigger.configuration.file = '/drydock/mystack/docker-compose.yml';
trigger.configuration.mountPrefixFallback = true;
fs.access.mockResolvedValue(undefined);

const container = {
name: 'portainer-app',
watcher: 'local',
labels: { 'dd.compose.file': '/data/compose/mystack/docker-compose.yml' },
};

const processComposeFileSpy = vi.spyOn(trigger, 'processComposeFile').mockResolvedValue();

await trigger.triggerBatch([container]);

// Should warn about the mount prefix mismatch (not the "do not match" hard skip)
expect(mockLog.warn).toHaveBeenCalledWith(expect.stringContaining('mount prefix'));
expect(mockLog.warn).not.toHaveBeenCalledWith(
expect.stringContaining('do not match configured file'),
);
// processComposeFile must be called with the Drydock-accessible (configured) path
expect(processComposeFileSpy).toHaveBeenCalledWith('/drydock/mystack/docker-compose.yml', [
container,
]);
});

test('triggerBatch should hard-skip when tail segments do not match (issue #365 no fallback)', async () => {
// stackA vs stackB β€” different project-dir segment, no tail match even with flag on
trigger.configuration.file = '/drydock/stackA/docker-compose.yml';
trigger.configuration.mountPrefixFallback = true;
fs.access.mockResolvedValue(undefined);

const container = {
name: 'unrelated-app',
watcher: 'local',
labels: { 'dd.compose.file': '/data/compose/stackB/docker-compose.yml' },
};

const processComposeFileSpy = vi.spyOn(trigger, 'processComposeFile').mockResolvedValue();

await trigger.triggerBatch([container]);

expect(mockLog.warn).toHaveBeenCalledWith(
expect.stringContaining('do not match configured file'),
);
expect(mockLog.warn).not.toHaveBeenCalledWith(expect.stringContaining('mount prefix'));
expect(processComposeFileSpy).not.toHaveBeenCalled();
});

test('triggerBatch should hard-skip when tail matches but mountPrefixFallback is off (default)', async () => {
// Same tail (mystack/docker-compose.yml) but flag is NOT set β€” must hard-skip, not use fallback.
trigger.configuration.file = '/drydock/mystack/docker-compose.yml';
// mountPrefixFallback is absent from the config (default false)
fs.access.mockResolvedValue(undefined);

const container = {
name: 'portainer-app',
watcher: 'local',
labels: { 'dd.compose.file': '/data/compose/mystack/docker-compose.yml' },
};

const processComposeFileSpy = vi.spyOn(trigger, 'processComposeFile').mockResolvedValue();

await trigger.triggerBatch([container]);

expect(mockLog.warn).toHaveBeenCalledWith(
expect.stringContaining('do not match configured file'),
);
expect(mockLog.warn).not.toHaveBeenCalledWith(expect.stringContaining('mount prefix'));
expect(processComposeFileSpy).not.toHaveBeenCalled();
});

test('triggerBatch should warn when no containers matched any compose file', async () => {
trigger.configuration.file = undefined;

Expand Down
21 changes: 21 additions & 0 deletions app/triggers/providers/dockercompose/Dockercompose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ interface DockercomposeTriggerConfiguration extends DockerTriggerConfiguration {
reconciliationMode: 'warn' | 'block' | 'off';
digestPinning: boolean;
composeFileOnce: boolean;
mountPrefixFallback: boolean;
}

interface DockerApiLike {
Expand Down Expand Up @@ -416,6 +417,7 @@ class Dockercompose extends Docker<DockercomposeTriggerConfiguration> {
reconciliationMode: this.joi.string().valid('warn', 'block', 'off').default('warn'),
digestPinning: this.joi.boolean().default(false),
composeFileOnce: this.joi.boolean().default(false),
mountPrefixFallback: this.joi.boolean().default(false),
})
.rename('composefilelabel', 'composeFileLabel', {
ignoreUndefined: true,
Expand All @@ -432,6 +434,10 @@ class Dockercompose extends Docker<DockercomposeTriggerConfiguration> {
.rename('composefileonce', 'composeFileOnce', {
ignoreUndefined: true,
override: true,
})
.rename('mountprefixfallback', 'mountPrefixFallback', {
ignoreUndefined: true,
override: true,
});
}

Expand Down Expand Up @@ -1434,6 +1440,21 @@ class Dockercompose extends Docker<DockercomposeTriggerConfiguration> {
}

if (configuredComposeFilePath && !composeFiles.includes(configuredComposeFilePath)) {
if (this.configuration.mountPrefixFallback) {
const configuredTail = path.join(
path.basename(path.dirname(configuredComposeFilePath)),
path.basename(configuredComposeFilePath),
);
const tailMatch = composeFiles.some(
(f) => path.join(path.basename(path.dirname(f)), path.basename(f)) === configuredTail,
);
if (tailMatch) {
this.log.warn(
`Container ${container.name} compose file path differs by mount prefix; using configured path ${configuredComposeFilePath} instead of label path(s) ${composeFiles.join(', ')} (issue #365 fallback)`,
);
return [configuredComposeFilePath];
}
}
this.log.warn(
`Skip container ${container.name} because compose files ${composeFiles.join(', ')} do not match configured file ${configuredComposeFilePath}`,
);
Expand Down
19 changes: 19 additions & 0 deletions app/triggers/providers/trigger-expression-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,25 @@ describe('trigger-expression-parser', () => {
expect(output).toBe('Release title');
});

test('renderSimple should expose currentReleaseNotes template variable', () => {
const output = renderSimple('${currentReleaseNotes.title}', {
...baseContainer,
currentReleaseNotes: {
title: 'Current title',
body: '',
url: '',
publishedAt: '',
provider: 'github',
},
} as any);
expect(output).toBe('Current title');
});

test('renderSimple should return empty string for currentReleaseNotes when not set', () => {
const output = renderSimple('${currentReleaseNotes}', baseContainer as any);
expect(output).toBe('');
});

test('renderSimple should expose currentTag variable from container image tag', () => {
const output = renderSimple('Tag is ${currentTag}', {
...baseContainer,
Expand Down
1 change: 1 addition & 0 deletions app/triggers/providers/trigger-expression-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ export function renderSimple(template: string, container: Container): string {
container,
event: event && typeof event === 'object' ? event : {},
releaseNotes: container.result?.releaseNotes,
currentReleaseNotes: container.currentReleaseNotes,
suggestedTag: container.result?.suggestedTag ?? container.result?.tag ?? '',
currentTag: container.image?.tag?.value ?? '',
isDigestUpdate: isDigest,
Expand Down
Loading
Loading