@@ -29,10 +29,10 @@ const maxRemovalAttempts = 30
2929// RemovalRetryDelay sets the delay before retrying removal operations.
3030var RemovalRetryDelay = 1 * time .Second
3131
32- // RemoveExcessWatchtowerInstances ensures a single Watchtower instance within the same scope.
32+ // RemoveExcessWatchtowerInstances ensures a single Watchtower container within the same scope.
3333//
3434// It identifies multiple Watchtower containers within the same scope, stops all but the current,
35- // and collects removed images for deferred removal if enabled, preventing conflicts from concurrent instances .
35+ // and collects removed images for deferred removal if enabled, preventing conflicts from concurrent containers .
3636// Chain identification uses the current container's labels to determine old containers to remove.
3737// Scoped instances only remove other instances in the same scope, allowing coexistence with different scopes.
3838// Removal operations respect scope boundaries to prevent cross-scope interference.
@@ -41,12 +41,12 @@ var RemovalRetryDelay = 1 * time.Second
4141// - ctx: Context for cancellation and timeouts.
4242// - client: Container client for Docker operations.
4343// - cleanupImages: Remove images if true.
44- // - watchtowerScope: Scope to filter Watchtower instances .
45- // - removeImageInfos: Pointer to slice of images to remove after stopping excess instances .
44+ // - watchtowerScope: Scope to filter Watchtower containers .
45+ // - removeImageInfos: Pointer to slice of images to remove after stopping excess containers .
4646// - currentContainer: The current running Watchtower container.
4747//
4848// Returns:
49- // - int: Number of removed Watchtower instances .
49+ // - int: Number of removed Watchtower containers .
5050// - error: Non-nil if removal fails, nil if single instance or successful removal.
5151func RemoveExcessWatchtowerInstances (
5252 ctx context.Context ,
@@ -66,15 +66,15 @@ func RemoveExcessWatchtowerInstances(
6666
6767 return ""
6868 }(),
69- }).Debug ("Starting removal of excess Watchtower instances " )
69+ }).Debug ("Starting removal of excess Watchtower containers " )
7070
7171 // List all containers to find excess instances
7272 allContainers , err := client .ListContainers (ctx , filters .NoFilter )
7373 if err != nil {
7474 return 0 , fmt .Errorf ("failed to list containers: %w" , err )
7575 }
7676
77- // Retrieve containers that are excess Watchtower instances within the same scope
77+ // Retrieve containers that are excess Watchtower containers within the same scope
7878 excessWatchtowerContainers := getExcessContainers (
7979 scope ,
8080 currentContainer ,
@@ -104,6 +104,134 @@ func RemoveExcessWatchtowerInstances(
104104 return removed , nil
105105}
106106
107+ // CleanupOldWatchtowerContainers removes old Watchtower containers that
108+ // linger from a previous self-update. Unlike RemoveExcessWatchtowerInstances
109+ // (which runs once at startup), this is designed to be called during each update
110+ // cycle to catch any old containers that the startup cleanup may have missed.
111+ //
112+ // It identifies containers matching the watchtower-old- prefix within the same
113+ // scope as the current container, stops them, and optionally cleans up their
114+ // images. This ensures that even if an old container survives the initial
115+ // cleanup (e.g., it was still stopping), it won't persist across update cycles.
116+ //
117+ // Parameters:
118+ // - ctx: Context for cancellation and timeouts.
119+ // - client: Container client for Docker operations.
120+ // - cleanupImages: Remove images if true.
121+ // - scope: Scope to filter Watchtower containers (empty for unscoped).
122+ // - currentContainerID: ID of the currently running Watchtower container.
123+ // - removeImageInfos: Pointer to slice of images to remove after stopping old containers.
124+ //
125+ // Returns:
126+ // - int: Number of removed old Watchtower containers.
127+ // - error: Non-nil if removal fails, nil if none found or successful removal.
128+ func CleanupOldWatchtowerContainers (
129+ ctx context.Context ,
130+ client container.Client ,
131+ cleanupImages bool ,
132+ scope string ,
133+ currentContainerID types.ContainerID ,
134+ removeImageInfos * []types.RemovedImageInfo ,
135+ ) (int , error ) {
136+ logrus .WithFields (logrus.Fields {
137+ "scope" : scope ,
138+ "current_id" : currentContainerID ,
139+ "cleanup_images" : cleanupImages ,
140+ }).Debug ("Checking for old Watchtower containers" )
141+
142+ // Normalize empty scope to "none" for consistent comparison.
143+ if scope == "" {
144+ scope = "none"
145+ }
146+
147+ // List all containers to find old instances
148+ allContainers , err := client .ListContainers (ctx , filters .NoFilter )
149+ if err != nil {
150+ return 0 , fmt .Errorf ("failed to list containers: %w" , err )
151+ }
152+
153+ // Find old Watchtower containers within the same scope
154+ var oldContainers []types.Container
155+
156+ // Iterate all containers to find old Watchtower containers that
157+ // should be cleaned up. Non-Watchtower containers and containers with
158+ // normal names are skipped immediately.
159+ for _ , c := range allContainers {
160+ // Skip non-Watchtower containers
161+ if ! c .IsWatchtower () {
162+ continue
163+ }
164+
165+ // Skip containers that are not old predecessors
166+ if ! container .IsOldContainer (c .Name ()) {
167+ continue
168+ }
169+
170+ // Scope check: only clean up old containers in the same scope
171+ containerScope , containerHasScope := c .Scope ()
172+ if ! containerHasScope || containerScope == "" {
173+ containerScope = "none"
174+ }
175+
176+ if containerScope != scope {
177+ logrus .WithFields (logrus.Fields {
178+ "container" : c .Name (),
179+ "container_scope" : containerScope ,
180+ "current_scope" : scope ,
181+ }).Debug ("Skipping old Watchtower container in different scope" )
182+
183+ continue
184+ }
185+
186+ // The updater's self-detection handles the case where the current
187+ // container is old (it exits before reaching this point), but
188+ // we still guard here for safety in case this function is called from
189+ // a different code path.
190+ if c .ID () == currentContainerID {
191+ continue
192+ }
193+
194+ oldContainers = append (oldContainers , c )
195+ }
196+
197+ if len (oldContainers ) == 0 {
198+ logrus .Debug ("No old Watchtower containers found" )
199+
200+ return 0 , nil
201+ }
202+
203+ logrus .WithFields (logrus.Fields {
204+ "count" : len (oldContainers ),
205+ "containers" : containerNames (oldContainers ),
206+ }).Info ("Found old Watchtower containers, cleaning up" )
207+
208+ // Find the current container in the list so image collection works
209+ var currentContainerObj types.Container
210+
211+ for _ , c := range allContainers {
212+ if c .ID () == currentContainerID {
213+ currentContainerObj = c
214+
215+ break
216+ }
217+ }
218+
219+ // Reuse the existing removal logic for old containers
220+ removed , err := removeExcessContainers (
221+ ctx ,
222+ client ,
223+ oldContainers ,
224+ cleanupImages ,
225+ currentContainerObj ,
226+ removeImageInfos ,
227+ )
228+ if err != nil {
229+ return removed , err
230+ }
231+
232+ return removed , nil
233+ }
234+
107235// getExcessContainers retrieves a list of excess Watchtower containers that should be removed.
108236//
109237// It identifies containers that are duplicates within the same scope or part of a container chain,
@@ -186,9 +314,9 @@ func getFilteredContainers(
186314 var excessContainers []types.Container
187315
188316 currentID := string (currentContainer .ID ())
189- if container .IsOldNamedContainer (currentContainer .Name ()) {
190- // Detection selected an old-named predecessor. Resolve the true
191- // successor by lineage: prefer a non-old-named container whose
317+ if container .IsOldContainer (currentContainer .Name ()) {
318+ // Detection selected an old predecessor. Resolve the true
319+ // successor by lineage: prefer a non-old container whose
192320 // chain label contains the old container's ID, confirming it is
193321 // the direct successor in the self-update chain.
194322 currentScope , currentHasScope := currentContainer .Scope ()
@@ -200,7 +328,7 @@ func getFilteredContainers(
200328 scopeMatchID := ""
201329
202330 for _ , c := range filteredContainers {
203- if container .IsOldNamedContainer (c .Name ()) {
331+ if container .IsOldContainer (c .Name ()) {
204332 continue
205333 }
206334
@@ -276,9 +404,9 @@ func getChainedContainers(
276404 var chainedContainers []types.Container
277405
278406 effectiveCurrent := currentContainer
279- if currentContainer != nil && container .IsOldNamedContainer (currentContainer .Name ()) {
280- // Detection selected old-named . Resolve the true successor by
281- // lineage: prefer a non-old-named Watchtower container whose chain
407+ if currentContainer != nil && container .IsOldContainer (currentContainer .Name ()) {
408+ // Detection selected old. Resolve the true successor by
409+ // lineage: prefer a non-old Watchtower container whose chain
282410 // label contains the old container's ID. Fall back to scope-only
283411 // matching if no explicit lineage link exists.
284412 currentScope , currentHasScope := currentContainer .Scope ()
@@ -289,7 +417,7 @@ func getChainedContainers(
289417 chainMatch := false
290418
291419 for _ , c := range allContainers {
292- if ! c .IsWatchtower () || container .IsOldNamedContainer (c .Name ()) {
420+ if ! c .IsWatchtower () || container .IsOldContainer (c .Name ()) {
293421 continue
294422 }
295423
@@ -544,7 +672,7 @@ func removeExcessContainers(
544672 }
545673
546674 logrus .WithField ("removed_instances" , excessInstancesRemoved ).
547- Info ("Successfully removed all excess Watchtower instances " )
675+ Info ("Successfully removed all excess Watchtower containers " )
548676
549677 return excessInstancesRemoved , nil
550678}
0 commit comments