Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
7ef99cc
♻️ refactor(compose): replace CLI shelling with Docker Engine API
s-b-e-n-s-o-n Mar 10, 2026
1a2164c
🐛 fix(api): include dockercompose in manual update trigger search
s-b-e-n-s-o-n Mar 10, 2026
f139817
🐛 fix(ci): retry qlty on timeout and increase limit to 8 minutes
s-b-e-n-s-o-n Mar 9, 2026
3945d2c
📝 docs(changelog): add compose Engine API and trigger search fixes
s-b-e-n-s-o-n Mar 10, 2026
25413d5
♻️ refactor(compose): extract shared runtime refresh and re-enable co…
s-b-e-n-s-o-n Mar 10, 2026
ee066ae
📝 docs(compose): replace CLI references with Engine API and add compo…
s-b-e-n-s-o-n Mar 10, 2026
9d22888
📝 docs(oidc): mark DD_PUBLIC_URL as required and add to all examples
s-b-e-n-s-o-n Mar 10, 2026
63a0db0
✅ test(compose): add runtime context passthrough and multi-file skipP…
s-b-e-n-s-o-n Mar 10, 2026
989cc23
📝 docs(changelog): add compose refactor, composeFileOnce, and docs fixes
s-b-e-n-s-o-n Mar 10, 2026
c8c48a0
🐛 fix(ui): show loading state in confirm dialog during async actions
s-b-e-n-s-o-n Mar 10, 2026
79c60b8
✨ feat(ui): disable action buttons and show spinner during in-progres…
s-b-e-n-s-o-n Mar 10, 2026
04847fc
💄 style(ui): remove blue active indicator bar from sidebar nav items
s-b-e-n-s-o-n Mar 10, 2026
fd40544
🔧 chore: add compose trigger to QA env and gitignore .claude/
s-b-e-n-s-o-n Mar 10, 2026
e2d4970
🐛 fix(ui): clear filterKind when navigating via Ctrl+K search
s-b-e-n-s-o-n Mar 10, 2026
d64e432
🐛 fix(ui): close confirm dialog immediately on accept
s-b-e-n-s-o-n Mar 10, 2026
477e690
♻️ refactor(ui): remove dead loading state from confirm dialog
s-b-e-n-s-o-n Mar 10, 2026
5f87d7a
📝 docs: fix stale changelog entry and update readme compose description
s-b-e-n-s-o-n Mar 10, 2026
abb4581
🗑️ refactor(ui): remove 10 dead icon/color functions and their tests
s-b-e-n-s-o-n Mar 10, 2026
2532395
🗑️ chore: remove dead config (babel.config.js, doc script, !website)
s-b-e-n-s-o-n Mar 10, 2026
d75389b
🗑️ refactor(app): remove dead buildComposeCommandEnvironment
s-b-e-n-s-o-n Mar 10, 2026
68c6c01
🗑️ refactor(ui): remove unused type exports from container service
s-b-e-n-s-o-n Mar 10, 2026
e6b3905
🐛 fix(ui): accept colon-separated icon prefixes in dd.display.icon label
s-b-e-n-s-o-n Mar 10, 2026
f089d0e
🐛 fix(api): include dockercompose in default trigger type search
s-b-e-n-s-o-n Mar 10, 2026
ef0d1b3
🐛 fix(ui): normalize nested icon prefixes before proxy request
s-b-e-n-s-o-n Mar 10, 2026
0b96f51
🐛 fix(docker): reconcile container names after external recreate
s-b-e-n-s-o-n Mar 10, 2026
efb5626
✨ feat(mqtt): add HASS_FILTER_INCLUDE and HASS_FILTER_EXCLUDE options
s-b-e-n-s-o-n Mar 10, 2026
03a1c66
✨ feat(oidc): add CAFILE and INSECURE TLS options for OIDC discovery
s-b-e-n-s-o-n Mar 10, 2026
6849fb1
📝 docs: add OIDC TLS and MQTT filter configuration docs
s-b-e-n-s-o-n Mar 10, 2026
4943b9c
✅ test: add edge-case coverage for fix agents' changes
s-b-e-n-s-o-n Mar 10, 2026
173b418
🔧 chore: remove snyk from lefthook pre-push pipeline
s-b-e-n-s-o-n Mar 10, 2026
637bbc9
🔧 chore(qa): add Mosquitto broker and icon test labels to QA env
s-b-e-n-s-o-n Mar 10, 2026
ac7c134
🐛 fix(ui): wire bouncer-blocked state into container detail views
s-b-e-n-s-o-n Mar 10, 2026
4d3f3c8
🔧 chore(qa): disable cosign signature verification in QA env
s-b-e-n-s-o-n Mar 10, 2026
d0135ed
🔧 chore: add clean-tree gate to pre-push pipeline
s-b-e-n-s-o-n Mar 10, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ jobs:
- name: Qlty check (all plugins)
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2
with:
timeout_minutes: 5
timeout_minutes: 8
max_attempts: 3
retry_on: error
retry_on: any
command: qlty check --all --no-progress

test:
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ apps/web/content/docs/
# Storybook build output
storybook-static/

# Claude Code project instructions
# Claude Code project instructions and local config
CLAUDE.md
.claude/
artifacts/
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Compose trigger uses Docker Engine API** — Compose-managed container updates now use the Docker Engine API directly (pull, stop, recreate) instead of shelling out to `docker compose` / `docker-compose` CLI. Eliminates `spawn docker ENOENT` errors in environments without Docker CLI binaries installed.
- **Compose self-update delegates to parent orchestrator** — Self-update for compose-managed Drydock containers now uses the parent Docker trigger's helper-container transition with health gates and rollback, instead of direct stop/recreate.
- **Compose runtime refresh extracted** — Shared `refreshComposeServiceWithDockerApi()` helper eliminates the recursive `recreateContainer` → `updateContainerWithCompose` call chain. Both code paths now converge on the same explicit, non-recursive method.
- **Compose-file-once batch mode re-enabled** — `COMPOSEFILEONCE=true` now works with the Docker Engine API runtime. First container per service gets a full runtime refresh; subsequent containers sharing the same service skip the refresh.
- **Self-update controller testable entrypoint** — Extracted process-level entry logic to a separate entrypoint module, making the controller independently testable without triggering process-exit side effects.
- **Dockercompose YAML patching simplified** — Removed redundant type guards and dead-code branches from compose file patching helpers, reducing code paths and improving maintainability.
- **Dashboard fetches recent-status from backend** — Dashboard now fetches pre-computed container statuses from `/api/containers/recent-status` instead of scanning the raw audit log client-side.
Expand Down Expand Up @@ -118,6 +122,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Action buttons disable and show spinner during in-progress actions** — Container action buttons (Stop, Start, Restart, Update, Delete) now show a disabled state with a spinner while the action runs in the background, providing clear visual feedback. The confirm dialog closes immediately on accept instead of blocking the UI.
- **Command palette clears stale filter on navigation** — Navigating to a container via Ctrl+K search now clears the active `filterKind`, preventing stale filter state from hiding the navigated container.
- **Manual update button works with compose triggers** — The update container endpoint now searches for both `docker` and `dockercompose` trigger types, matching the existing preview endpoint behavior. Previously, users with only a compose trigger saw "No docker trigger found for this container".
- **CI: qlty retry on timeout** — Changed `retry_on` from `error` to `any` so qlty timeouts trigger retries. Increased timeout from 5 to 8 minutes.
- **OIDC docs clarified `DD_PUBLIC_URL` requirement** — OIDC documentation now explicitly marks `DD_PUBLIC_URL` as required and includes it in all provider example configurations (Authelia, Auth0, Authentik, Dex). Without this variable, the OIDC provider fails to register at startup.
- **Compose trigger docs updated for Engine API** — Removed stale references to `docker compose config --quiet` CLI validation and `docker compose pull`/`up` commands. Docs now reflect in-process YAML validation and Docker Engine API runtime. Added callout that Docker Compose CLI is not required.
- **Container actions docs updated for compose triggers** — Update action documentation now mentions both Docker and Docker Compose trigger support.
- **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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ Docker Hub, GHCR, ECR, GCR, GAR, GitLab, Quay, Harbor, Artifactory, Nexus, and m
<tr>
<td align="center">
<h3>Docker Compose Updates</h3>
Auto-pull and recreate services via docker-compose with service-scoped compose image patching
Auto-pull and recreate services via Docker Engine API with YAML-preserving service-scoped image patching
</td>
<td align="center">
<h3>Distributed Agents</h3>
Expand Down
49 changes: 49 additions & 0 deletions app/api/backup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,55 @@ describe('Backup Router', () => {
);
});

test('should rollback successfully with a dockercompose trigger', async () => {
const handler = getHandler('post', '/:id/rollback');
const container = {
id: 'c1',
name: 'nginx',
image: { registry: { name: 'hub' } },
};
const latestBackup = {
id: 'b1',
containerId: 'c1',
imageName: 'library/nginx',
imageTag: '1.24',
};

mockGetContainer.mockReturnValue(container);
mockGetBackupsByName.mockReturnValue([latestBackup]);

const mockCurrentContainer = {};
const mockContainerSpec = { State: { Running: true } };
const composeTrigger = {
type: 'dockercompose',
getWatcher: vi.fn(() => ({ dockerApi: {} })),
pullImage: vi.fn().mockResolvedValue(undefined),
getCurrentContainer: vi.fn().mockResolvedValue(mockCurrentContainer),
inspectContainer: vi.fn().mockResolvedValue(mockContainerSpec),
stopAndRemoveContainer: vi.fn().mockResolvedValue(undefined),
recreateContainer: vi.fn().mockResolvedValue(undefined),
};
mockGetState.mockReturnValue({
trigger: { 'dockercompose.default': composeTrigger },
registry: { hub: { getAuthPull: vi.fn().mockResolvedValue({}) } },
});

const req = createMockRequest({ params: { id: 'c1' } });
const res = createMockResponse();
await handler(req, res);

expect(composeTrigger.pullImage).toHaveBeenCalled();
expect(composeTrigger.stopAndRemoveContainer).toHaveBeenCalled();
expect(composeTrigger.recreateContainer).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Container rolled back successfully',
backup: latestBackup,
}),
);
});

test('should rollback successfully when a valid backupId is provided', async () => {
const handler = getHandler('post', '/:id/rollback');
const container = {
Expand Down
28 changes: 28 additions & 0 deletions app/api/container-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,34 @@ describe('Container Actions Router', () => {
);
});

test('should update container successfully with a dockercompose trigger', async () => {
const container = {
id: 'c1',
name: 'nginx',
image: { name: 'nginx' },
updateAvailable: true,
};
const updatedContainer = { ...container, image: { name: 'nginx:latest' } };
mockGetContainer.mockReturnValueOnce(container).mockReturnValueOnce(updatedContainer);
const mockTriggerFn = vi.fn().mockResolvedValue(undefined);
const trigger = { type: 'dockercompose', trigger: mockTriggerFn };
mockGetState.mockReturnValue({ trigger: { 'dockercompose.default': trigger } });

const handler = getHandler('post', '/:id/update');
const req = createMockRequest({ params: { id: 'c1' } });
const res = createMockResponse();
await handler(req, res);

expect(mockTriggerFn).toHaveBeenCalledWith(container);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Container updated successfully',
result: updatedContainer,
}),
);
});

test('should return 404 when container not found', async () => {
mockGetContainer.mockReturnValue(undefined);

Expand Down
4 changes: 3 additions & 1 deletion app/api/container-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,9 @@ async function updateContainer(req: Request, res: Response) {
return;
}

const trigger = findDockerTriggerForContainer(registry.getState().trigger, container);
const trigger = findDockerTriggerForContainer(registry.getState().trigger, container, {
triggerTypes: ['docker', 'dockercompose'],
});
if (!trigger) {
sendErrorResponse(res, 404, NO_DOCKER_TRIGGER_FOUND_ERROR);
return;
Expand Down
10 changes: 5 additions & 5 deletions app/api/docker-trigger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe('docker-trigger helper', () => {
expect(result).toBeUndefined();
});

test('ignores compose triggers by default', () => {
test('includes compose triggers by default', () => {
const composeTrigger = { type: 'dockercompose' };

const result = findDockerTriggerForContainer(
Expand All @@ -36,21 +36,21 @@ describe('docker-trigger helper', () => {
{ id: 'c1' },
);

expect(result).toBeUndefined();
expect(result).toBe(composeTrigger);
});

test('can include compose triggers when requested', () => {
test('can limit trigger types when requested', () => {
const composeTrigger = { type: 'dockercompose' };

const result = findDockerTriggerForContainer(
{
'dockercompose.default': composeTrigger,
},
{ id: 'c1' },
{ triggerTypes: ['docker', 'dockercompose'] },
{ triggerTypes: ['docker'] },
);

expect(result).toBe(composeTrigger);
expect(result).toBeUndefined();
});

test('skips docker triggers with a different agent than the container', () => {
Expand Down
2 changes: 1 addition & 1 deletion app/api/docker-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type Docker from '../triggers/providers/docker/Docker.js';
import type Trigger from '../triggers/providers/Trigger.js';

export const NO_DOCKER_TRIGGER_FOUND_ERROR = 'No docker trigger found for this container';
const DEFAULT_TRIGGER_TYPES = ['docker'];
const DEFAULT_TRIGGER_TYPES = ['docker', 'dockercompose'];

interface FindDockerTriggerForContainerOptions {
triggerTypes?: string[];
Expand Down
22 changes: 22 additions & 0 deletions app/api/webhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,28 @@ describe('Webhook Router', () => {
});
});

test('should trigger update and return 200 with a dockercompose trigger', async () => {
const container = { name: 'my-nginx', image: { name: 'nginx' } };
mockGetContainers.mockReturnValue([container]);
const mockTrigger = vi.fn().mockResolvedValue(undefined);
mockGetState.mockReturnValue({
watcher: {},
trigger: { 'dockercompose.default': { type: 'dockercompose', trigger: mockTrigger } },
});

const handler = getHandler('post', '/update/:containerName');
const req = createMockRequest({ params: { containerName: 'my-nginx' } });
const res = createMockResponse();
await handler(req, res);

expect(mockTrigger).toHaveBeenCalledWith(container);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
message: 'Update triggered for container my-nginx',
result: { container: 'my-nginx' },
});
});

test('should sanitize reflected containerName in successful update response', async () => {
const containerName = '\u001b[31mmy-nginx\u001b[0m\nnext';
const container = { name: containerName, image: { name: 'nginx' } };
Expand Down
Loading