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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- **WebSocket log streams no longer reject anonymous-auth sessions** ([#636](https://github.com/CodesWhat/drydock/issues/636)). Both WS upgrade paths β€” the system log stream and the container log stream β€” gated on `isAuthenticatedSession()` requiring `session.passport.user`, which `passport-anonymous` never sets, so under `DD_ANONYMOUS_AUTH_CONFIRM=true` the log stream WebSocket always rejected the upgrade even though every REST endpoint worked. `isAuthenticatedSession` now also accepts the session when anonymous authentication is the registered mode.
- **Container start/stop/restart/rollback return an explicit 501 instead of an ambiguous 404 for agent containers without lifecycle transport** ([#637](https://github.com/CodesWhat/drydock/issues/637)). `POST /:id/start|stop|restart` and `POST /:id/rollback` returned a bare 404 `No docker trigger found for this container` whenever the lookup missed, indistinguishable from "container not found" β€” for agent-owned containers this was the only signal the UI got. That lookup miss now returns 501 naming the likely cause (the agent's connection typically hasn't advertised `usesControllerDockerTransport`) when `container.agent` is set; non-agent containers still get the existing 404. This complements the native-transport support that shipped in rc.11 via [#651](https://github.com/CodesWhat/drydock/pull/651), which closed #637's core gap β€” this is the remaining explicit-error half.

### Security

- **`brace-expansion`, `ip-address`, and `fast-uri` overrides advanced to patched releases.** `brace-expansion` moved to 5.0.9 in `app/`, `ui/`, and `e2e/` (CVE-2026-69152, [GHSA-rgw5-rvv9-x895](https://github.com/advisories/GHSA-rgw5-rvv9-x895)); `ip-address` moved to 10.3.1 in `app/` (CVE-2026-54272, CVE-2026-69192, CVE-2026-69198), pulled in transitively via `express-rate-limit` and `mqtt` β†’ `socks`; `fast-uri` advanced from 4.1.1 to 4.1.2 in `app/` and `ui/` (host confusion via backslash authority introducer, CVE-2026-18446, [GHSA-7p8r-x3mc-p8w7](https://github.com/advisories/GHSA-7p8r-x3mc-p8w7), superseding [#658](https://github.com/CodesWhat/drydock/pull/658)).
||||||| parent of 16d6927c (πŸ› fix(api): honor active anonymous auth in WS log-stream upgrades)

## [1.6.0-rc.11] β€” 2026-08-01

Expand Down
20 changes: 20 additions & 0 deletions app/agent/AgentClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7869,6 +7869,26 @@ describe('AgentClient', () => {
});

describe('Portwing Docker API transport', () => {
test('reports whether a watcher uses controller Docker transport', async () => {
await client.handleComponentSync(
[
{
type: 'docker',
name: 'docker',
configuration: {
transport: 'docker-api',
execution: 'controller',
events: 'portwing',
},
},
],
[],
);

expect(client.hasControllerDockerTransport('docker')).toBe(true);
expect(client.hasControllerDockerTransport('missing')).toBe(false);
});

test('component sync synthesizes docker/update only for a controller Docker transport watcher', async () => {
const watcher = {
type: 'docker',
Expand Down
9 changes: 9 additions & 0 deletions app/agent/AgentClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,15 @@ export class AgentClient {
return this.watcherSnapshotCache.get(watcherSnapshotCacheKey(watcherType, watcherName));
}

/**
* Whether the given watcher on this agent advertises controller Docker
* transport, i.e. lifecycle actions (start/stop/restart/rollback) execute
* locally on the controller instead of being proxied to the agent.
*/
hasControllerDockerTransport(watcherName: string): boolean {
return this.controllerDockerTransportWatchers.has(watcherName);
}

private parseBaseUrl(): URL {
// Validate the URL to prevent request forgery (CodeQL js/request-forgery)
const parsed = new URL(this.getCandidateUrl());
Expand Down
183 changes: 183 additions & 0 deletions app/api/backup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ const {
mockGetAllBackups,
mockGetBackup,
mockGetState,
mockGetAgent,
} = vi.hoisted(() => ({
mockRouter: { use: vi.fn(), get: vi.fn(), post: vi.fn() },
mockGetContainer: vi.fn(),
mockGetBackupsByName: vi.fn(),
mockGetAllBackups: vi.fn(),
mockGetBackup: vi.fn(),
mockGetState: vi.fn(),
mockGetAgent: vi.fn(),
}));

vi.mock('express', () => ({
Expand All @@ -40,6 +42,10 @@ vi.mock('../registry', () => ({
getState: mockGetState,
}));

vi.mock('../agent/manager', () => ({
getAgent: mockGetAgent,
}));

const { mockBackupLog } = vi.hoisted(() => ({
mockBackupLog: { info: vi.fn(), warn: vi.fn(), debug: vi.fn() },
}));
Expand Down Expand Up @@ -263,6 +269,183 @@ describe('Backup Router', () => {
});
});

test('should return 404 when a capable agent has no docker trigger registered yet', async () => {
const handler = getHandler('post', '/:id/rollback');
mockGetContainer.mockReturnValue({
id: 'c1',
name: 'nginx',
agent: 'edge-1',
watcher: 'edge-1',
});
mockGetBackupsByName.mockReturnValue([
{
id: 'b1',
containerId: 'c1',
imageName: 'library/nginx',
imageTag: '1.24',
},
]);
mockGetState.mockReturnValue({ trigger: {} });
mockGetAgent.mockReturnValue({ hasControllerDockerTransport: vi.fn(() => true) });

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

expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: expect.stringContaining('No docker trigger found'),
});
});

test('should return 404, not 501, when the agent is unknown or disconnected', async () => {
const handler = getHandler('post', '/:id/rollback');
mockGetContainer.mockReturnValue({
id: 'c1',
name: 'nginx',
agent: 'edge-1',
watcher: 'edge-1',
});
mockGetBackupsByName.mockReturnValue([
{
id: 'b1',
containerId: 'c1',
imageName: 'library/nginx',
imageTag: '1.24',
},
]);
mockGetState.mockReturnValue({ trigger: {} });
mockGetAgent.mockReturnValue(undefined);

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

expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: expect.stringContaining('No docker trigger found'),
});
});

test('should return 501 when the agent-owned container agent lacks controller docker transport', async () => {
const handler = getHandler('post', '/:id/rollback');
mockGetContainer.mockReturnValue({
id: 'c1',
name: 'nginx',
agent: 'edge-1',
watcher: 'edge-1',
});
mockGetBackupsByName.mockReturnValue([
{
id: 'b1',
containerId: 'c1',
imageName: 'library/nginx',
imageTag: '1.24',
},
]);
mockGetState.mockReturnValue({ trigger: {} });
mockGetAgent.mockReturnValue({ hasControllerDockerTransport: vi.fn(() => false) });

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

expect(res.status).toHaveBeenCalledWith(501);
expect(res.json).toHaveBeenCalledWith({
error: expect.stringContaining("container's agent connection"),
});
});

test('should return 501, not 500, when a legacy incapable AgentTrigger is registered for the container', async () => {
const handler = getHandler('post', '/:id/rollback');
const legacyAgentTrigger = {
type: 'docker',
agent: 'edge-1',
getWatcher: vi.fn(() => {
throw new Error(
'AgentTrigger docker.edge-1 cannot provide local Docker capability getWatcher; the agent does not advertise controller Docker transport',
);
}),
};
mockGetContainer.mockReturnValue({
id: 'c1',
name: 'nginx',
agent: 'edge-1',
watcher: 'edge-1',
});
mockGetBackupsByName.mockReturnValue([
{
id: 'b1',
containerId: 'c1',
imageName: 'library/nginx',
imageTag: '1.24',
},
]);
mockGetState.mockReturnValue({ trigger: { 'docker.edge-1': legacyAgentTrigger } });
mockGetAgent.mockReturnValue({ hasControllerDockerTransport: vi.fn(() => false) });

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

expect(legacyAgentTrigger.getWatcher).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(501);
expect(res.json).toHaveBeenCalledWith({
error: expect.stringContaining("container's agent connection"),
});
});

test('should roll back an agent-owned container whose agent advertises controller docker transport', async () => {
const handler = getHandler('post', '/:id/rollback');
const container = {
id: 'c1',
name: 'nginx',
agent: 'edge-1',
watcher: 'edge-1',
image: { registry: { name: 'hub' } },
};
const latestBackup = {
id: 'b1',
containerId: 'c1',
imageName: 'library/nginx',
imageTag: '1.24',
};

mockGetContainer.mockReturnValue(container);
mockGetBackupsByName.mockReturnValue([latestBackup]);
mockGetAgent.mockReturnValue({ hasControllerDockerTransport: vi.fn(() => true) });

const mockCurrentContainer = {};
const mockContainerSpec = { State: { Running: true } };
const mockTrigger = {
type: 'docker',
agent: 'edge-1',
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: { 'docker.edge-1': mockTrigger },
registry: { hub: { getAuthPull: vi.fn().mockResolvedValue({}) } },
});

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

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

test('should rollback successfully', async () => {
const handler = getHandler('post', '/:id/rollback');
const container = {
Expand Down
12 changes: 11 additions & 1 deletion app/api/backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import {
} from '../triggers/providers/docker/created-container-candidate.js';
import { recordAuditEvent } from './audit-events.js';
import { requireDestructiveActionConfirmation } from './destructive-confirmation.js';
import { findDockerTriggerForContainer, NO_DOCKER_TRIGGER_FOUND_ERROR } from './docker-trigger.js';
import {
AGENT_LIFECYCLE_UNSUPPORTED_ERROR,
findDockerTriggerForContainer,
isAgentLifecycleUnsupported,
NO_DOCKER_TRIGGER_FOUND_ERROR,
} from './docker-trigger.js';
import { sendErrorResponse } from './error-response.js';
import { handleContainerActionError } from './helpers.js';

Expand Down Expand Up @@ -82,6 +87,11 @@ async function rollbackContainer(req: Request, res: Response) {
backup = backups[0];
}

if (isAgentLifecycleUnsupported(container)) {
sendErrorResponse(res, 501, AGENT_LIFECYCLE_UNSUPPORTED_ERROR);
return;
}

const trigger = findDockerTriggerForContainer(registry.getState().trigger, container);
if (!trigger) {
sendErrorResponse(res, 404, NO_DOCKER_TRIGGER_FOUND_ERROR);
Expand Down
Loading
Loading