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

## [Unreleased]

## [1.6.0-rc.12] β€” 2026-08-04

### Changed

- **Routine dependency maintenance across the root tooling, demo, UI, and website workspaces** ([#653](https://github.com/CodesWhat/drydock/pull/653), [#654](https://github.com/CodesWhat/drydock/pull/654), [#655](https://github.com/CodesWhat/drydock/pull/655), [#656](https://github.com/CodesWhat/drydock/pull/656)). The dependency-version guard's Next.js check became a 16.x floor instead of an exact pin so routine patch bumps stop tripping it ([#664](https://github.com/CodesWhat/drydock/pull/664)).
- **Crowdin translation sync** ([#665](https://github.com/CodesWhat/drydock/pull/665)): one French container-component string corrected.

### Fixed

- **"Agent Mismatch" no longer appears in the container list/SSE display during the brief window an agent's docker/dockercompose trigger is still (re)registering** ([#605](https://github.com/CodesWhat/drydock/issues/605)). Eligibility is recomputed live on every read, and `AgentClient._doHandshake()` deregisters the agent's components before awaiting the `/api/triggers` fetch and re-register. A read in that window found zero triggers for the agent and `computeUpdateEligibility` raised a hard `agent-mismatch` blocker, disabling the Update button, even though nothing was actually misconfigured — the condition self-corrected once registration finished. `agent-mismatch` now downgrades to a soft blocker (button stays enabled) on display surfaces whenever the container's own agent is mid-registration, per the new `AgentClient.isRegisteringComponents` flag (true only for the deregister→re-register span, not the whole reconnect backoff). Update **admission** (`app/updates/request-update.ts`) is unaffected and stays hard/fail-closed throughout, so an update can never be enqueued through a wrong-agent trigger during that window.
- **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.
- **Maturity clock: swallowed auth errors surfaced, per-container threshold respected** ([#604](https://github.com/CodesWhat/drydock/issues/604)). `getImagePublishedAt` failures β€” including GHCR/LSCR 401/403 auth errors β€” now log at `warn` instead of `debug`, so the maturity gate's silent fallback from the registry `publishedAt` to `updateDetectedAt` is no longer invisible. `getRawUpdateMaturityLevel` (`app/model/container.ts`) and `getContainerMaturityLevel` (`app/api/container/maturity-filter.ts`) now resolve each container's own `updatePolicy.maturityMinAgeDays` before falling back to the global `DD_UI_MATURITY_THRESHOLD_DAYS`, matching the gate's own `isUpdateSuppressed`/`isMaturityGatePending` logic so the hot/mature badge can no longer disagree with the gate in the same API response.
- **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)).

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

### Added
Expand Down Expand Up @@ -2308,7 +2326,8 @@ Remaining upstream-only changes (not ported β€” not applicable to drydock):
| Fix codeberg tests | Covered by drydock's own tests |
| Update changelog | Upstream-specific |

[Unreleased]: https://github.com/CodesWhat/drydock/compare/v1.6.0-rc.11...HEAD
[Unreleased]: https://github.com/CodesWhat/drydock/compare/v1.6.0-rc.12...HEAD
[1.6.0-rc.12]: https://github.com/CodesWhat/drydock/compare/v1.6.0-rc.11...v1.6.0-rc.12
[1.6.0-rc.11]: https://github.com/CodesWhat/drydock/compare/v1.6.0-rc.10...v1.6.0-rc.11
[1.6.0-rc.10]: https://github.com/CodesWhat/drydock/compare/v1.6.0-rc.9...v1.6.0-rc.10
[1.6.0-rc.9]: https://github.com/CodesWhat/drydock/compare/v1.6.0-rc.8...v1.6.0-rc.9
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
</div>

<p align="center">
<a href="https://github.com/CodesWhat/drydock/releases"><img src="https://img.shields.io/badge/version-1.6.0--rc.11-blue" alt="Version"></a>
<a href="https://github.com/CodesWhat/drydock/releases"><img src="https://img.shields.io/badge/version-1.6.0--rc.12-blue" alt="Version"></a>
<a href="https://github.com/orgs/CodesWhat/packages/container/package/drydock"><img src="https://img.shields.io/badge/platforms-amd64%20%7C%20arm64-informational?logo=linux&logoColor=white" alt="Multi-arch"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-C9A227" alt="License AGPL-3.0"></a>
<br>
Expand Down Expand Up @@ -178,6 +178,17 @@ See the [Quick Start guide](https://getdrydock.com/docs/quickstart) for Docker C
<h2 align="center" id="recent-updates">πŸ†• Recent Updates</h2>

<details open>
<summary><strong>v1.6.0-rc.12 highlights</strong></summary>

- **Security dependency refresh** β€” `brace-expansion` 5.0.9 (app/UI/e2e, CVE-2026-69152), `ip-address` 10.3.1 (app runtime, CVE-2026-54272/-69192/-69198), and `fast-uri` 4.1.2 (app/UI, CVE-2026-18446). ([#659](https://github.com/CodesWhat/drydock/pull/659))
- **Maturity clock** β€” the hot/mature badge resolves per-container `updatePolicy.maturityMinAgeDays` before the global threshold, matching the gate, and registry publish-date failures log at `warn` instead of disappearing at `debug`. ([#604](https://github.com/CodesWhat/drydock/issues/604))
- **Agent registration grace** β€” transient `agent-mismatch`/`no-update-trigger-configured` blockers soften on display surfaces while an agent's components re-register; admission stays fail-closed. ([#605](https://github.com/CodesWhat/drydock/issues/605))
- **WS log streams + anonymous auth** β€” log-stream WebSocket upgrades accept sessions when anonymous authentication is the registered mode. ([#636](https://github.com/CodesWhat/drydock/issues/636))
- **Explicit 501s** β€” lifecycle actions on agent containers without controller Docker transport return 501 naming the cause instead of an ambiguous 404. ([#637](https://github.com/CodesWhat/drydock/issues/637))

</details>

<details>
<summary><strong>v1.6.0-rc.11 highlights</strong></summary>

- **Portwing transport** β€” Portwing 0.9.0's exact `transport=docker-api`, `execution=controller`, `events=portwing` marker now routes native registry checks, single/batch updates, start/stop/restart, update previews, and backup rollbacks through authenticated Standard HTTP or Edge request/response/stream transport. Portwing remains the lifecycle-event source, and raw inventory cannot erase controller-enriched update results. ([#632](https://github.com/CodesWhat/drydock/issues/632), [#637](https://github.com/CodesWhat/drydock/issues/637), [Portwing #76](https://github.com/CodesWhat/portwing/issues/76))
Expand Down
118 changes: 117 additions & 1 deletion app/agent/AgentClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,50 @@ describe('AgentClient', () => {
expect(client.isConnected).toBe(true);
});

test('sets isRegisteringComponents for the deregister -> re-register span and resets after completion (#605)', async () => {
const observedDuringDeregister: boolean[] = [];
const observedDuringWatcherFetch: boolean[] = [];
const observedDuringTriggerFetch: boolean[] = [];

vi.mocked(registry.deregisterAgentComponents).mockImplementationOnce(async () => {
observedDuringDeregister.push(client.isRegisteringComponents);
});

axios.get
.mockResolvedValueOnce({ data: [] }) // containers
.mockImplementationOnce(async () => {
observedDuringWatcherFetch.push(client.isRegisteringComponents);
return { data: [] };
})
.mockImplementationOnce(async () => {
observedDuringTriggerFetch.push(client.isRegisteringComponents);
return { data: [] };
});

storeContainer.getContainers.mockReturnValue([]);

expect(client.isRegisteringComponents).toBe(false);
await client.handshake();

expect(observedDuringDeregister).toEqual([true]);
expect(observedDuringWatcherFetch).toEqual([true]);
expect(observedDuringTriggerFetch).toEqual([true]);
expect(client.isRegisteringComponents).toBe(false);
});

test('resets isRegisteringComponents to false when a mid-handshake step throws (#605)', async () => {
axios.get.mockResolvedValueOnce({ data: [] }); // containers succeeds
vi.mocked(registry.deregisterAgentComponents).mockRejectedValueOnce(
new Error('deregister failed'),
);
storeContainer.getContainers.mockReturnValue([]);

await expect(client.handshake()).rejects.toThrow('deregister failed');

expect(client.isRegisteringComponents).toBe(false);
expect(client.isConnected).toBe(false);
});

test('should emit agent-connected when transitioning to connected state', async () => {
axios.get
.mockResolvedValueOnce({ data: [] })
Expand Down Expand Up @@ -1120,6 +1164,13 @@ describe('AgentClient', () => {
expect(spy).toHaveBeenCalled();
});

test('should reset isRegisteringComponents to false on disconnect (#605)', () => {
client.isConnected = true;
client.isRegisteringComponents = true;
client.scheduleReconnect(1000);
expect(client.isRegisteringComponents).toBe(false);
});

test('should not schedule duplicate reconnects', () => {
const spy = vi.spyOn(client, 'startSse').mockImplementation(() => {});
client.scheduleReconnect(1000);
Expand Down Expand Up @@ -7575,10 +7626,23 @@ describe('AgentClient', () => {
});

describe('handleComponentSync (edge agent public shim)', () => {
test('deregisters agent components and re-registers watchers and triggers', async () => {
test('keeps isRegisteringComponents true through edge component replacement and resets it after success (#605)', async () => {
const watchers = [{ type: 'docker', name: 'local', configuration: {} }];
const triggers = [{ type: 'mock', name: 'update', configuration: {} }];
const observedSteps: string[] = [];

vi.mocked(registry.deregisterAgentComponents).mockImplementationOnce(async () => {
observedSteps.push(`deregister:${client.isRegisteringComponents}`);
});
vi.mocked(registry.registerComponent)
.mockImplementationOnce(async (component) => {
observedSteps.push(`${component.kind}:${client.isRegisteringComponents}`);
})
.mockImplementationOnce(async (component) => {
observedSteps.push(`${component.kind}:${client.isRegisteringComponents}`);
});

expect(client.isRegisteringComponents).toBe(false);
await client.handleComponentSync(watchers, triggers);

expect(registry.deregisterAgentComponents).toHaveBeenCalledWith('test-agent');
Expand All @@ -7588,6 +7652,38 @@ describe('AgentClient', () => {
expect(registry.registerComponent).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'trigger', provider: 'mock', name: 'update' }),
);
expect(observedSteps).toEqual(['deregister:true', 'watcher:true', 'trigger:true']);
expect(client.isRegisteringComponents).toBe(false);
});

test('resets isRegisteringComponents when edge watcher registration throws (#605)', async () => {
const observedDuringDeregister: boolean[] = [];
let observedDuringWatcherRegistration = false;

vi.mocked(registry.deregisterAgentComponents)
.mockImplementationOnce(async () => {
observedDuringDeregister.push(client.isRegisteringComponents);
})
.mockImplementationOnce(async () => {
observedDuringDeregister.push(client.isRegisteringComponents);
});
vi.mocked(registry.registerComponent).mockImplementationOnce(async () => {
observedDuringWatcherRegistration = client.isRegisteringComponents;
throw new Error('watcher registration failed');
});

await expect(
client.handleComponentSync(
[{ type: 'docker', name: 'local', configuration: {} }],
[{ type: 'mock', name: 'update', configuration: {} }],
),
).rejects.toThrow('watcher registration failed');

expect(observedDuringDeregister).toEqual([true, true]);
expect(observedDuringWatcherRegistration).toBe(true);
expect(registry.deregisterAgentComponents).toHaveBeenCalledTimes(2);
expect(registry.registerComponent).toHaveBeenCalledTimes(1);
expect(client.isRegisteringComponents).toBe(false);
});

test('works with empty watchers and triggers (no-op)', async () => {
Expand Down Expand Up @@ -7869,6 +7965,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
Loading
Loading