Skip to content

Commit 414f017

Browse files
committed
šŸ› fix(healthcheck): restore curl compatibility and deprecation schedule (#287)
1 parent 6706929 commit 414f017

File tree

16 files changed

+537
-42
lines changed

16 files changed

+537
-42
lines changed

ā€ŽCHANGELOG.mdā€Ž

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
1111
## [Unreleased]
1212

13+
### Fixed
14+
15+
- **Custom healthcheck backward compatibility restored** — The built-in `/bin/healthcheck` remains the default image probe and now handles TLS backends, while `curl` is again present in the Docker image for user-defined custom `healthcheck:` overrides during the v1.5.x deprecation window. v1.6.0 is the final warning release, and removal is now scheduled for v1.7.0. ([Discussion #287](https://github.com/CodesWhat/drydock/discussions/287))
16+
1317
## [1.5.0-rc.7] — 2026-04-08
1418

1519
### Added
@@ -145,7 +149,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
145149
- **Centralized rollback container guard** — `-old-{timestamp}` container rejection moved from Docker trigger to base Trigger class, covering all trigger types.
146150
- **Container list query internals modularized** — Extracted query-validation logic and split tests by concern.
147151
- **Container list filtering performance** — Status/kind filters avoid unnecessary full-collection loads; age/created sorting precomputes values.
148-
- **Healthcheck execution path optimized** — Default HEALTHCHECK probe replaced with a 65KB static C binary (`/bin/healthcheck`). curl is retained for backward compatibility with user-defined HEALTHCHECK overrides and will be removed in v1.6.0.
152+
- **Healthcheck execution path optimized** — Default HEALTHCHECK probe replaced with a 65KB static C binary (`/bin/healthcheck`). curl is retained for backward compatibility with user-defined HEALTHCHECK overrides during the deprecation window and is now scheduled for removal in v1.7.0, with v1.6.0 as the final warning release.
149153
- **Watcher event logging noise reduced** — First event-stream reconnect downgraded from `warn` to `info`.
150154
- **CI workflows renamed** — Dropped numeric prefixes from workflow filenames for clarity.
151155
- **Smoke load test profile removed** — Replaced with the ci profile for meaningful regression detection.

ā€ŽDEPRECATIONS.mdā€Ž

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,12 @@ Legacy `WUD_*` environment variables are accepted as fallbacks for their `DD_*`
116116
| | |
117117
| --- | --- |
118118
| **Deprecated in** | v1.5.0 |
119-
| **Removed in** | v1.6.0 |
119+
| **Removed in** | v1.7.0 |
120120
| **Affects** | Custom `healthcheck:` overrides in compose files that use `curl` |
121121

122-
The official Docker image previously included `curl` for custom healthcheck overrides. The built-in `HEALTHCHECK` now uses a lightweight static binary (`/bin/healthcheck`).
122+
The official Docker image keeps `curl` available in v1.5.x and v1.6.x for backward compatibility with custom healthcheck overrides. The default built-in `HEALTHCHECK` uses the lightweight static binary (`/bin/healthcheck`) instead.
123123

124-
**Migration:** Remove custom `healthcheck:` blocks from your drydock compose service — the image handles it automatically. If you need custom intervals, use `test: /bin/healthcheck ${DD_SERVER_PORT:-3000}`. See [Monitoring](https://getdrydock.com/docs/monitoring).
124+
**Migration:** Custom `curl`-based healthcheck overrides remain supported in v1.5.x. v1.6.0 is the final warning release. Removal is scheduled for v1.7.0. Prefer the built-in image healthcheck, or switch custom intervals to `test: /bin/healthcheck ${DD_SERVER_PORT:-3000}`. See [Monitoring](https://getdrydock.com/docs/monitoring).
125125

126126
---
127127

ā€ŽDockerfileā€Ž

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ HEALTHCHECK --interval=30s --timeout=5s CMD ["sh", "-c", "if [ -n \"$DD_SERVER_E
1717
# Install system packages, trivy, and cosign
1818
RUN apk add --no-cache \
1919
bash=5.3.3-r1 \
20+
curl=8.14.1-r2 \
2021
git=2.52.0-r0 \
2122
jq=1.8.1-r0 \
2223
openssl=3.5.5-r0 \
@@ -70,11 +71,11 @@ ENV DD_LOG_FORMAT=text
7071

7172
# Remove unnecessary network utilities (busybox symlinks) and npm to reduce attack surface.
7273
# curl is kept for backward compatibility with user-defined HEALTHCHECK overrides;
73-
# it will be removed in v1.6.0 — use the built-in /bin/healthcheck binary instead.
74+
# v1.6.0 is the final warning release, and removal is scheduled for v1.7.0.
7475
RUN rm -f /usr/bin/wget /usr/bin/nc \
7576
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
7677

77-
# Copy healthcheck binary (65KB static, replaces curl for HEALTHCHECK probe)
78+
# Copy healthcheck binary (65KB static, default HEALTHCHECK probe)
7879
COPY --from=healthcheck-build /bin/healthcheck /bin/healthcheck
7980

8081
# Default entrypoint
@@ -90,4 +91,4 @@ COPY --from=app-build /home/node/app/dist ./dist
9091
COPY --from=app-build /home/node/app/package.json ./package.json
9192

9293
# Copy ui
93-
COPY --from=ui-build /home/node/ui/dist/ ./ui
94+
COPY --from=ui-build /home/node/ui/dist/ ./ui

ā€Žapp/api/openapi/schemas.tsā€Ž

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,15 @@ export const openApiSchemas = {
191191
required: ['total', 'env', 'label'],
192192
additionalProperties: false,
193193
},
194+
CurlHealthcheckOverrideCompatibility: {
195+
type: 'object',
196+
properties: {
197+
detected: { type: 'boolean' },
198+
commandPreview: { type: 'string' },
199+
},
200+
required: ['detected'],
201+
additionalProperties: false,
202+
},
194203
ServerInfoResponse: {
195204
type: 'object',
196205
properties: {
@@ -213,8 +222,11 @@ export const openApiSchemas = {
213222
type: 'object',
214223
properties: {
215224
legacyInputs: { $ref: '#/components/schemas/LegacyInputSummary' },
225+
curlHealthcheckOverride: {
226+
$ref: '#/components/schemas/CurlHealthcheckOverrideCompatibility',
227+
},
216228
},
217-
required: ['legacyInputs'],
229+
required: ['legacyInputs', 'curlHealthcheckOverride'],
218230
additionalProperties: true,
219231
},
220232
},

ā€Žapp/api/server.test.tsā€Ž

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ vi.mock('../prometheus/compatibility.js', () => ({
3030
})),
3131
}));
3232

33+
vi.mock('../compatibility/curl-healthcheck.js', () => ({
34+
getCurlHealthcheckOverrideCompatibility: vi.fn(async () => ({
35+
detected: false,
36+
})),
37+
}));
38+
3339
// Mock express modules
3440
vi.mock('express', () => ({
3541
default: {
@@ -60,6 +66,9 @@ describe('Server Router', () => {
6066

6167
test('should call getServerConfiguration when route handler is called', async () => {
6268
const { getServerConfiguration } = await import('../configuration/index.js');
69+
const { getCurlHealthcheckOverrideCompatibility } = await import(
70+
'../compatibility/curl-healthcheck.js'
71+
);
6372
const router = serverRouter.init();
6473

6574
// Get the route handler function
@@ -69,9 +78,10 @@ describe('Server Router', () => {
6978
json: vi.fn(),
7079
};
7180

72-
routeHandler({}, mockRes);
81+
await routeHandler({}, mockRes);
7382

7483
expect(getServerConfiguration).toHaveBeenCalled();
84+
expect(getCurlHealthcheckOverrideCompatibility).toHaveBeenCalled();
7585
expect(mockRes.status).toHaveBeenCalledWith(200);
7686
expect(mockRes.json).toHaveBeenCalledWith({
7787
configuration: {
@@ -88,6 +98,9 @@ describe('Server Router', () => {
8898
env: { total: 1, keys: ['WUD_SERVER_PORT'] },
8999
label: { total: 2, keys: ['wud.watch'] },
90100
},
101+
curlHealthcheckOverride: {
102+
detected: false,
103+
},
91104
},
92105
});
93106
});
@@ -113,7 +126,7 @@ describe('Server Router', () => {
113126
json: vi.fn(),
114127
};
115128

116-
routeHandler({}, mockRes);
129+
await routeHandler({}, mockRes);
117130

118131
const payload = mockRes.json.mock.calls[0][0];
119132
expect(payload.configuration.tls).toEqual({
@@ -140,7 +153,7 @@ describe('Server Router', () => {
140153
json: vi.fn(),
141154
};
142155

143-
routeHandler({}, mockRes);
156+
await routeHandler({}, mockRes);
144157

145158
const payload = mockRes.json.mock.calls[0][0];
146159
expect(payload.configuration.tls).toBe(false);

ā€Žapp/api/server.tsā€Ž

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import express from 'express';
22
import nocache from 'nocache';
3+
import { getCurlHealthcheckOverrideCompatibility } from '../compatibility/curl-healthcheck.js';
34
import { getServerConfiguration, getWebhookConfiguration } from '../configuration/index.js';
45
import logger from '../log/index.js';
56
import { sanitizeLogParam } from '../log/sanitize.js';
@@ -15,9 +16,10 @@ const log = logger.child({ component: 'server' });
1516
* @param req
1617
* @param res
1718
*/
18-
function getServer(req, res) {
19+
async function getServer(req, res) {
1920
const serverConfig = getServerConfiguration();
2021
const webhookConfig = getWebhookConfiguration();
22+
const curlHealthcheckOverride = await getCurlHealthcheckOverrideCompatibility();
2123
const { tls, ...serverConfigWithoutTls } = serverConfig;
2224
const sanitizedTlsConfig =
2325
tls && typeof tls === 'object'
@@ -34,6 +36,7 @@ function getServer(req, res) {
3436
},
3537
compatibility: {
3638
legacyInputs: getLegacyInputSummary(),
39+
curlHealthcheckOverride,
3740
},
3841
});
3942
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
const mockExistsSync = vi.hoisted(() => vi.fn());
2+
const mockProbeSocketApiVersion = vi.hoisted(() => vi.fn());
3+
const mockDisableSocketRedirects = vi.hoisted(() => vi.fn());
4+
const mockInspect = vi.hoisted(() => vi.fn());
5+
const mockGetContainer = vi.hoisted(() => vi.fn(() => ({ inspect: mockInspect })));
6+
const mockDockerode = vi.hoisted(() =>
7+
vi.fn(function MockDockerode() {
8+
return {
9+
getContainer: mockGetContainer,
10+
};
11+
}),
12+
);
13+
14+
vi.mock('node:fs', () => ({
15+
default: {
16+
existsSync: (...args: unknown[]) => mockExistsSync(...args),
17+
},
18+
}));
19+
20+
vi.mock('dockerode', () => ({
21+
default: mockDockerode,
22+
}));
23+
24+
vi.mock('../watchers/providers/docker/socket-version-probe.js', () => ({
25+
probeSocketApiVersion: (...args: unknown[]) => mockProbeSocketApiVersion(...args),
26+
}));
27+
28+
vi.mock('../watchers/providers/docker/disable-socket-redirects.js', () => ({
29+
disableSocketRedirects: (...args: unknown[]) => mockDisableSocketRedirects(...args),
30+
}));
31+
32+
describe('curl healthcheck compatibility', () => {
33+
const originalHostname = process.env.HOSTNAME;
34+
35+
beforeEach(() => {
36+
vi.clearAllMocks();
37+
process.env.HOSTNAME = 'drydock-self';
38+
mockExistsSync.mockReturnValue(true);
39+
mockProbeSocketApiVersion.mockResolvedValue('1.44');
40+
mockInspect.mockResolvedValue({
41+
Config: {
42+
Healthcheck: {
43+
Test: ['CMD-SHELL', 'curl --fail http://localhost:3000/health || exit 1'],
44+
},
45+
},
46+
});
47+
});
48+
49+
afterAll(() => {
50+
if (originalHostname === undefined) {
51+
delete process.env.HOSTNAME;
52+
} else {
53+
process.env.HOSTNAME = originalHostname;
54+
}
55+
});
56+
57+
test('detects a custom curl healthcheck override on the current container', async () => {
58+
const { getCurlHealthcheckOverrideCompatibility } = await import('./curl-healthcheck.js');
59+
60+
const result = await getCurlHealthcheckOverrideCompatibility();
61+
62+
expect(result).toEqual({
63+
detected: true,
64+
commandPreview: 'CMD-SHELL curl --fail http://localhost:3000/health || exit 1',
65+
});
66+
expect(mockDockerode).toHaveBeenCalledWith({
67+
socketPath: '/var/run/docker.sock',
68+
version: 'v1.44',
69+
});
70+
expect(mockGetContainer).toHaveBeenCalledWith(expect.any(String));
71+
expect(mockDisableSocketRedirects).toHaveBeenCalled();
72+
});
73+
74+
test('returns not detected when hostname is not a valid container identifier', async () => {
75+
const { getCurlHealthcheckOverrideCompatibility } = await import('./curl-healthcheck.js');
76+
const originalHostname = process.env.HOSTNAME;
77+
process.env.HOSTNAME = 'pod/name';
78+
79+
try {
80+
await expect(getCurlHealthcheckOverrideCompatibility()).resolves.toEqual({
81+
detected: false,
82+
});
83+
expect(mockDockerode).not.toHaveBeenCalled();
84+
} finally {
85+
if (originalHostname === undefined) {
86+
delete process.env.HOSTNAME;
87+
} else {
88+
process.env.HOSTNAME = originalHostname;
89+
}
90+
}
91+
});
92+
93+
test('returns not detected when the healthcheck does not use curl', async () => {
94+
mockInspect.mockResolvedValue({
95+
Config: {
96+
Healthcheck: {
97+
Test: ['CMD', '/bin/healthcheck', '3000'],
98+
},
99+
},
100+
});
101+
const { getCurlHealthcheckOverrideCompatibility } = await import('./curl-healthcheck.js');
102+
103+
await expect(getCurlHealthcheckOverrideCompatibility()).resolves.toEqual({
104+
detected: false,
105+
});
106+
});
107+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import fs from 'node:fs';
2+
import Dockerode from 'dockerode';
3+
import { disableSocketRedirects } from '../watchers/providers/docker/disable-socket-redirects.js';
4+
import { probeSocketApiVersion } from '../watchers/providers/docker/socket-version-probe.js';
5+
6+
const DEFAULT_SOCKET_PATH = '/var/run/docker.sock';
7+
const SELF_CONTAINER_IDENTIFIER_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
8+
const CURL_HEALTHCHECK_PATTERN = /(^|[\s"'`])(?:\/usr\/bin\/)?curl(?=$|[\s"'`])/i;
9+
const COMMAND_PREVIEW_MAX_LENGTH = 160;
10+
11+
type HealthcheckInspect = {
12+
Config?: {
13+
Healthcheck?: {
14+
Test?: unknown;
15+
};
16+
};
17+
};
18+
19+
export interface CurlHealthcheckOverrideCompatibility {
20+
detected: boolean;
21+
commandPreview?: string;
22+
}
23+
24+
export function getSelfContainerIdentifier(hostname = process.env.HOSTNAME): string | null {
25+
const normalizedHostname = hostname?.trim();
26+
if (!normalizedHostname || !SELF_CONTAINER_IDENTIFIER_PATTERN.test(normalizedHostname)) {
27+
return null;
28+
}
29+
return normalizedHostname;
30+
}
31+
32+
export function getHealthcheckCommandPreview(test: unknown): string | undefined {
33+
if (!Array.isArray(test) || test.length === 0) {
34+
return undefined;
35+
}
36+
37+
const command = test
38+
.filter((part): part is string => typeof part === 'string' && part.trim().length > 0)
39+
.join(' ')
40+
.trim();
41+
42+
if (!command) {
43+
return undefined;
44+
}
45+
46+
if (command.length <= COMMAND_PREVIEW_MAX_LENGTH) {
47+
return command;
48+
}
49+
50+
return `${command.slice(0, COMMAND_PREVIEW_MAX_LENGTH - 1)}…`;
51+
}
52+
53+
export function usesCurlHealthcheckOverride(test: unknown): boolean {
54+
const command = getHealthcheckCommandPreview(test);
55+
return typeof command === 'string' && CURL_HEALTHCHECK_PATTERN.test(command);
56+
}
57+
58+
export async function getCurlHealthcheckOverrideCompatibility(): Promise<CurlHealthcheckOverrideCompatibility> {
59+
const selfContainerIdentifier = getSelfContainerIdentifier();
60+
if (!selfContainerIdentifier || !fs.existsSync(DEFAULT_SOCKET_PATH)) {
61+
return { detected: false };
62+
}
63+
64+
try {
65+
const apiVersion = await probeSocketApiVersion(DEFAULT_SOCKET_PATH);
66+
const dockerOptions: Dockerode.DockerOptions = {
67+
socketPath: DEFAULT_SOCKET_PATH,
68+
};
69+
if (apiVersion) {
70+
dockerOptions.version = `v${apiVersion}`;
71+
}
72+
73+
const dockerApi = new Dockerode(dockerOptions);
74+
disableSocketRedirects(dockerApi);
75+
76+
const inspect = (await dockerApi
77+
.getContainer(selfContainerIdentifier)
78+
.inspect()) as HealthcheckInspect;
79+
const healthcheckTest = inspect?.Config?.Healthcheck?.Test;
80+
81+
if (!usesCurlHealthcheckOverride(healthcheckTest)) {
82+
return { detected: false };
83+
}
84+
85+
return {
86+
detected: true,
87+
commandPreview: getHealthcheckCommandPreview(healthcheckTest),
88+
};
89+
} catch {
90+
return { detected: false };
91+
}
92+
}

0 commit comments

Comments
Ā (0)