@@ -2,7 +2,11 @@ import fs from 'node:fs';
22import path from 'node:path' ;
33import { 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' ;
610import {
711 SELF_UPDATE_HEALTH_TIMEOUT_MS ,
812 SELF_UPDATE_POLL_INTERVAL_MS ,
@@ -227,4 +231,207 @@ describe('SelfUpdateTransitionShared', () => {
227231 name : expect . stringMatching ( / ^ s o c k e t - p r o x y - o l d - \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} ) ;
0 commit comments