Skip to content

Commit fc34ffb

Browse files
committed
✨ feat(self-update): support updating through a TCP Docker host
The self-update helper container previously required a bind-mounted /var/run/docker.sock and refused to run otherwise. Drydock can now self-update when its watcher reaches Docker over TCP (e.g. through a socket proxy such as sockguard). - resolveHelperDockerConnection inspects the watcher's Dockerode modem: a TCP host yields a TCP helper, otherwise the socket bind is required. - In TCP mode the helper joins Drydock's network (cloned NetworkMode) and receives DD_SELF_UPDATE_DOCKER_HOST/PORT/PROTOCOL instead of a socket bind mount. - runSelfUpdateController builds a TCP Dockerode client when those env vars are set, skipping the socket version probe and redirect guard.
1 parent af2376d commit fc34ffb

5 files changed

Lines changed: 350 additions & 31 deletions

File tree

app/triggers/providers/docker/SelfUpdateTransitionShared.test.ts

Lines changed: 208 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import fs from 'node:fs';
22
import path from 'node:path';
33
import { describe, expect, test, vi } from 'vitest';
44

5-
import { executeSelfUpdateTransition, findDockerSocketBind } from './SelfUpdateTransitionShared.js';
5+
import {
6+
executeSelfUpdateTransition,
7+
findDockerSocketBind,
8+
resolveHelperDockerConnection,
9+
} from './SelfUpdateTransitionShared.js';
610
import {
711
SELF_UPDATE_HEALTH_TIMEOUT_MS,
812
SELF_UPDATE_POLL_INTERVAL_MS,
@@ -227,4 +231,207 @@ describe('SelfUpdateTransitionShared', () => {
227231
name: expect.stringMatching(/^socket-proxy-old-\d+$/),
228232
});
229233
});
234+
235+
test('rolls back when new container inspect fails', async () => {
236+
const context = createContext();
237+
context.newContainer.inspect.mockRejectedValue(new Error('inspect failed'));
238+
const dependencies = createDependencies({
239+
createContainer: vi.fn().mockResolvedValue(context.newContainer),
240+
});
241+
const log = { info: vi.fn(), warn: vi.fn() };
242+
243+
await expect(
244+
executeSelfUpdateTransition(dependencies, context, createContainer(), log),
245+
).rejects.toThrow('inspect failed');
246+
247+
expect(context.newContainer.remove).toHaveBeenCalledWith({ force: true });
248+
expect(context.currentContainer.rename).toHaveBeenNthCalledWith(2, { name: 'drydock' });
249+
expect(log.warn).toHaveBeenCalledWith(
250+
'Failed to inspect new container, rolling back: inspect failed',
251+
);
252+
});
253+
254+
test('rolls back when new container inspect fails and remove also fails', async () => {
255+
const context = createContext();
256+
context.newContainer.inspect.mockRejectedValue(new Error('inspect failed'));
257+
context.newContainer.remove.mockRejectedValue(new Error('remove also failed'));
258+
const dependencies = createDependencies({
259+
createContainer: vi.fn().mockResolvedValue(context.newContainer),
260+
});
261+
const log = { info: vi.fn(), warn: vi.fn() };
262+
263+
await expect(
264+
executeSelfUpdateTransition(dependencies, context, createContainer(), log),
265+
).rejects.toThrow('inspect failed');
266+
267+
expect(context.currentContainer.rename).toHaveBeenNthCalledWith(2, { name: 'drydock' });
268+
});
269+
});
270+
271+
describe('resolveHelperDockerConnection', () => {
272+
function makeDeps(socketPath?: string) {
273+
return {
274+
findDockerSocketBind: vi.fn().mockReturnValue(socketPath),
275+
};
276+
}
277+
278+
test('returns tcp mode when modem.host is a non-empty string', () => {
279+
const deps = makeDeps();
280+
const result = resolveHelperDockerConnection(
281+
deps,
282+
{ createContainer: vi.fn(), modem: { host: 'docker-host', port: 2376, protocol: 'https' } },
283+
undefined,
284+
);
285+
expect(result).toEqual({ mode: 'tcp', host: 'docker-host', port: 2376, protocol: 'https' });
286+
expect(deps.findDockerSocketBind).not.toHaveBeenCalled();
287+
});
288+
289+
test('defaults port to 2375 and protocol to http when not provided', () => {
290+
const deps = makeDeps();
291+
const result = resolveHelperDockerConnection(
292+
deps,
293+
{ createContainer: vi.fn(), modem: { host: 'docker-host' } },
294+
undefined,
295+
);
296+
expect(result).toEqual({ mode: 'tcp', host: 'docker-host', port: 2375, protocol: 'http' });
297+
});
298+
299+
test('defaults port to 2375 when port is 0', () => {
300+
const deps = makeDeps();
301+
const result = resolveHelperDockerConnection(
302+
deps,
303+
{ createContainer: vi.fn(), modem: { host: 'docker-host', port: 0 } },
304+
undefined,
305+
);
306+
expect(result).toEqual({ mode: 'tcp', host: 'docker-host', port: 2375, protocol: 'http' });
307+
});
308+
309+
test('returns socket mode when modem.host is absent and socket bind is found', () => {
310+
const deps = makeDeps('/var/run/docker.sock');
311+
const spec = createCurrentContainerSpec();
312+
const result = resolveHelperDockerConnection(deps, { createContainer: vi.fn() }, spec);
313+
expect(result).toEqual({ mode: 'socket', socketPath: '/var/run/docker.sock' });
314+
expect(deps.findDockerSocketBind).toHaveBeenCalledWith(spec);
315+
});
316+
317+
test('returns socket mode when modem.host is an empty string', () => {
318+
const deps = makeDeps('/var/run/docker.sock');
319+
const result = resolveHelperDockerConnection(
320+
deps,
321+
{ createContainer: vi.fn(), modem: { host: '' } },
322+
createCurrentContainerSpec(),
323+
);
324+
expect(result).toEqual({ mode: 'socket', socketPath: '/var/run/docker.sock' });
325+
});
326+
327+
test('throws when no modem.host and no socket bind found', () => {
328+
const deps = makeDeps(undefined);
329+
expect(() =>
330+
resolveHelperDockerConnection(deps, { createContainer: vi.fn() }, undefined),
331+
).toThrow(
332+
'Self-update requires the Docker socket to be bind-mounted (e.g. /var/run/docker.sock:/var/run/docker.sock), or the watcher must be configured with a TCP Docker host',
333+
);
334+
});
335+
});
336+
337+
describe('executeSelfUpdateTransition TCP mode', () => {
338+
function createTcpContext(networkMode?: string) {
339+
const currentContainer = {
340+
rename: vi.fn().mockResolvedValue(undefined),
341+
};
342+
const newContainer = {
343+
inspect: vi.fn().mockResolvedValue({ Id: 'new-container-id' }),
344+
remove: vi.fn().mockResolvedValue(undefined),
345+
};
346+
const helperContainer = {
347+
start: vi.fn().mockResolvedValue(undefined),
348+
};
349+
const dockerApi = {
350+
createContainer: vi.fn().mockResolvedValue(helperContainer),
351+
modem: { host: 'docker-proxy', port: 2375, protocol: 'http' },
352+
};
353+
const spec: Record<string, unknown> = {
354+
Name: '/drydock',
355+
Id: 'old-container-id',
356+
};
357+
if (networkMode !== undefined) {
358+
spec.HostConfig = { NetworkMode: networkMode };
359+
}
360+
return {
361+
dockerApi,
362+
auth: { username: 'bot', password: 'token' },
363+
newImage: 'ghcr.io/acme/drydock:2.0.0',
364+
currentContainer,
365+
currentContainerSpec: spec,
366+
newContainer,
367+
helperContainer,
368+
};
369+
}
370+
371+
function createTcpDependencies() {
372+
return {
373+
getConfiguration: () => ({ dryrun: false }),
374+
findDockerSocketBind: vi.fn().mockReturnValue(undefined),
375+
insertContainerImageBackup: vi.fn(),
376+
pullImage: vi.fn().mockResolvedValue(undefined),
377+
getCloneRuntimeConfigOptions: vi.fn().mockResolvedValue({ runtime: true }),
378+
cloneContainer: vi.fn(() => ({ cloned: true })),
379+
createContainer: vi.fn(),
380+
createOperationId: vi.fn(() => 'tcp-op-id'),
381+
resolveFinalizeUrl: vi.fn(() => 'http://127.0.0.1:3000/api/v1/internal/self-update/finalize'),
382+
resolveFinalizeSecret: vi.fn(() => 'tcp-secret'),
383+
};
384+
}
385+
386+
test('tcp mode: helper HostConfig has no Binds and includes TCP env vars', async () => {
387+
const context = createTcpContext('host');
388+
const deps = createTcpDependencies();
389+
deps.createContainer = vi.fn().mockResolvedValue(context.newContainer);
390+
const log = { info: vi.fn(), warn: vi.fn() };
391+
392+
await executeSelfUpdateTransition(deps, context as never, { name: 'drydock', image: {} }, log);
393+
394+
expect(context.dockerApi.createContainer).toHaveBeenCalledWith(
395+
expect.objectContaining({
396+
Env: expect.arrayContaining([
397+
'DD_SELF_UPDATE_DOCKER_HOST=docker-proxy',
398+
'DD_SELF_UPDATE_DOCKER_PORT=2375',
399+
'DD_SELF_UPDATE_DOCKER_PROTOCOL=http',
400+
]),
401+
HostConfig: {
402+
AutoRemove: true,
403+
NetworkMode: 'host',
404+
},
405+
}),
406+
);
407+
const call = context.dockerApi.createContainer.mock.calls[0][0];
408+
expect(call.HostConfig.Binds).toBeUndefined();
409+
});
410+
411+
test('tcp mode: helper HostConfig has no NetworkMode when spec has none', async () => {
412+
const context = createTcpContext(undefined);
413+
const deps = createTcpDependencies();
414+
deps.createContainer = vi.fn().mockResolvedValue(context.newContainer);
415+
const log = { info: vi.fn(), warn: vi.fn() };
416+
417+
await executeSelfUpdateTransition(deps, context as never, { name: 'drydock', image: {} }, log);
418+
419+
const call = context.dockerApi.createContainer.mock.calls[0][0];
420+
expect(call.HostConfig).toEqual({ AutoRemove: true });
421+
expect(call.HostConfig.NetworkMode).toBeUndefined();
422+
expect(call.HostConfig.Binds).toBeUndefined();
423+
});
424+
425+
test('tcp mode: helper HostConfig has no NetworkMode when NetworkMode is empty string', async () => {
426+
const context = createTcpContext('');
427+
const deps = createTcpDependencies();
428+
deps.createContainer = vi.fn().mockResolvedValue(context.newContainer);
429+
const log = { info: vi.fn(), warn: vi.fn() };
430+
431+
await executeSelfUpdateTransition(deps, context as never, { name: 'drydock', image: {} }, log);
432+
433+
const call = context.dockerApi.createContainer.mock.calls[0][0];
434+
expect(call.HostConfig).toEqual({ AutoRemove: true });
435+
expect(call.HostConfig.NetworkMode).toBeUndefined();
436+
});
230437
});

app/triggers/providers/docker/SelfUpdateTransitionShared.ts

Lines changed: 70 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
SelfUpdateCreatedContainer,
1212
SelfUpdateDockerApi,
1313
SelfUpdateExecutionContext,
14+
SelfUpdateHelperContainerCreateOptions,
1415
SelfUpdateLogger,
1516
} from './self-update-types.js';
1617

@@ -53,6 +54,10 @@ interface SelfUpdateTransitionDependencies {
5354
resolveHelperImage?: () => string | undefined;
5455
}
5556

57+
type HelperDockerConnection =
58+
| { mode: 'tcp'; host: string; port: number; protocol: string }
59+
| { mode: 'socket'; socketPath: string };
60+
5661
function findDockerSocketBind(spec: SelfUpdateContainerSpec | undefined): string | undefined {
5762
const binds = spec?.HostConfig?.Binds;
5863
if (!Array.isArray(binds)) return undefined;
@@ -65,6 +70,30 @@ function findDockerSocketBind(spec: SelfUpdateContainerSpec | undefined): string
6570
return undefined;
6671
}
6772

73+
function resolveHelperDockerConnection(
74+
dependencies: Pick<SelfUpdateTransitionDependencies, 'findDockerSocketBind'>,
75+
dockerApi: SelfUpdateDockerApi,
76+
currentContainerSpec: SelfUpdateContainerSpec | undefined,
77+
): HelperDockerConnection {
78+
const modemHost = dockerApi.modem?.host;
79+
if (typeof modemHost === 'string' && modemHost.length > 0) {
80+
return {
81+
mode: 'tcp',
82+
host: modemHost,
83+
port: Number(dockerApi.modem?.port) || 2375,
84+
protocol: dockerApi.modem?.protocol || 'http',
85+
};
86+
}
87+
88+
const socketPath = dependencies.findDockerSocketBind(currentContainerSpec);
89+
if (!socketPath) {
90+
throw new Error(
91+
'Self-update requires the Docker socket to be bind-mounted (e.g. /var/run/docker.sock:/var/run/docker.sock), or the watcher must be configured with a TCP Docker host',
92+
);
93+
}
94+
return { mode: 'socket', socketPath };
95+
}
96+
6897
async function executeSelfUpdateTransition(
6998
dependencies: SelfUpdateTransitionDependencies,
7099
context: SelfUpdateExecutionContext,
@@ -79,12 +108,7 @@ async function executeSelfUpdateTransition(
79108
return false;
80109
}
81110

82-
const socketPath = dependencies.findDockerSocketBind(currentContainerSpec);
83-
if (!socketPath) {
84-
throw new Error(
85-
'Self-update requires the Docker socket to be bind-mounted (e.g. /var/run/docker.sock:/var/run/docker.sock)',
86-
);
87-
}
111+
const connection = resolveHelperDockerConnection(dependencies, dockerApi, currentContainerSpec);
88112

89113
dependencies.insertContainerImageBackup(context, container);
90114

@@ -140,36 +164,59 @@ async function executeSelfUpdateTransition(
140164
}
141165

142166
const oldContainerId = currentContainerSpec.Id;
143-
const socketMount = `${socketPath}:/var/run/docker.sock`;
144167
const selfUpdateOperationId = operationId || dependencies.createOperationId();
145168
const finalizeUrl = dependencies.resolveFinalizeUrl();
146169
const finalizeSecret = dependencies.resolveFinalizeSecret();
147170

171+
const baseEnv = [
172+
`DD_SELF_UPDATE_OP_ID=${selfUpdateOperationId}`,
173+
`DD_SELF_UPDATE_OLD_CONTAINER_ID=${oldContainerId}`,
174+
`DD_SELF_UPDATE_NEW_CONTAINER_ID=${newContainerId}`,
175+
`DD_SELF_UPDATE_OLD_CONTAINER_NAME=${oldName}`,
176+
`DD_SELF_UPDATE_FINALIZE_URL=${finalizeUrl}`,
177+
`DD_SELF_UPDATE_FINALIZE_SECRET=${finalizeSecret}`,
178+
`DD_SELF_UPDATE_START_TIMEOUT_MS=${SELF_UPDATE_START_TIMEOUT_MS}`,
179+
`DD_SELF_UPDATE_HEALTH_TIMEOUT_MS=${SELF_UPDATE_HEALTH_TIMEOUT_MS}`,
180+
`DD_SELF_UPDATE_POLL_INTERVAL_MS=${SELF_UPDATE_POLL_INTERVAL_MS}`,
181+
];
182+
183+
const tcpEnv =
184+
connection.mode === 'tcp'
185+
? [
186+
`DD_SELF_UPDATE_DOCKER_HOST=${connection.host}`,
187+
`DD_SELF_UPDATE_DOCKER_PORT=${connection.port}`,
188+
`DD_SELF_UPDATE_DOCKER_PROTOCOL=${connection.protocol}`,
189+
]
190+
: [];
191+
192+
let hostConfig: SelfUpdateHelperContainerCreateOptions['HostConfig'];
193+
if (connection.mode === 'socket') {
194+
hostConfig = {
195+
AutoRemove: true,
196+
Binds: [`${connection.socketPath}:/var/run/docker.sock`],
197+
};
198+
} else {
199+
const networkMode = currentContainerSpec.HostConfig?.NetworkMode;
200+
hostConfig = {
201+
AutoRemove: true,
202+
...(typeof networkMode === 'string' && networkMode.length > 0
203+
? { NetworkMode: networkMode }
204+
: {}),
205+
};
206+
}
207+
148208
logContainer.info('Spawning helper container for self-update transition');
149209
try {
150210
await dockerApi
151211
.createContainer({
152212
Image: dependencies.resolveHelperImage?.() ?? newImage,
153213
Cmd: ['node', 'dist/triggers/providers/docker/self-update-controller-entrypoint.js'],
154-
Env: [
155-
`DD_SELF_UPDATE_OP_ID=${selfUpdateOperationId}`,
156-
`DD_SELF_UPDATE_OLD_CONTAINER_ID=${oldContainerId}`,
157-
`DD_SELF_UPDATE_NEW_CONTAINER_ID=${newContainerId}`,
158-
`DD_SELF_UPDATE_OLD_CONTAINER_NAME=${oldName}`,
159-
`DD_SELF_UPDATE_FINALIZE_URL=${finalizeUrl}`,
160-
`DD_SELF_UPDATE_FINALIZE_SECRET=${finalizeSecret}`,
161-
`DD_SELF_UPDATE_START_TIMEOUT_MS=${SELF_UPDATE_START_TIMEOUT_MS}`,
162-
`DD_SELF_UPDATE_HEALTH_TIMEOUT_MS=${SELF_UPDATE_HEALTH_TIMEOUT_MS}`,
163-
`DD_SELF_UPDATE_POLL_INTERVAL_MS=${SELF_UPDATE_POLL_INTERVAL_MS}`,
164-
],
214+
Env: [...baseEnv, ...tcpEnv],
165215
Labels: {
166216
'dd.self-update.helper': 'true',
167217
'dd.self-update.operation-id': selfUpdateOperationId,
168218
},
169-
HostConfig: {
170-
AutoRemove: true,
171-
Binds: [socketMount],
172-
},
219+
HostConfig: hostConfig,
173220
name: `drydock-self-update-${Date.now()}`,
174221
})
175222
.then((helperContainer) => helperContainer.start());
@@ -190,4 +237,4 @@ async function executeSelfUpdateTransition(
190237
return true;
191238
}
192239

193-
export { executeSelfUpdateTransition, findDockerSocketBind };
240+
export { executeSelfUpdateTransition, findDockerSocketBind, resolveHelperDockerConnection };

0 commit comments

Comments
 (0)