Skip to content

fix: prevent orphaned old Watchtower containers from persisting - #1743

Merged
nicholas-fedor merged 7 commits into
mainfrom
fix/old-named-watchtower-container-cleanup
Jun 8, 2026
Merged

fix: prevent orphaned old Watchtower containers from persisting#1743
nicholas-fedor merged 7 commits into
mainfrom
fix/old-named-watchtower-container-cleanup

Conversation

@nicholas-fedor

@nicholas-fedor nicholas-fedor commented Jun 7, 2026

Copy link
Copy Markdown
Owner

This PR addresses a persistent issue where orphaned watchtower-old-* containers survive cleanup, resist other safeguards, and continue update and self-update cycles. It adds multiple layers of defense to detect and stop old-named Watchtower instances.

Problems

  • Old-named Watchtower containers (watchtower-old-*) were not excluded from update cycles, allowing stale instances to be "updated" again
  • RemoveExcessWatchtowerInstances only ran once at startup, missing containers that were still stopping
  • No self-termination logic existed for an old container that somehow started
  • The WatchtowerOldPrefix constant was duplicated across packages, risking drift
  • Scope-based cleanup could accidentally cross scope boundaries
  • Image collection was skipped during old-container cleanup due to nil currentContainer

Solutions

  • Add ExcludeOldNamedWatchtowerFilter as the outermost filter to reject watchtower-old-* containers before any other filter logic runs
  • Add CleanupOldWatchtowerContainers for per-update-cycle cleanup with scope awareness derived from the current container
  • Extend ShouldExitDueToInvalidRestart with a name-based check so old-named containers self-terminate on startup
  • Add a pre-update self-check in Update() that detects old-named current containers, sets restart policy to "no", and exits
  • Move WatchtowerOldPrefix to the types package as a single shared constant
  • Pass the current container object to removeExcessContainers so image infos are collected for deferred removal

Changes

  • Add ExcludeOldNamedWatchtowerFilter and IsOldNamedWatchtower to pkg/filters
  • Move filter chain wrapper to outermost position in BuildFilter
  • Add CleanupOldWatchtowerContainers with scope-aware old container cleanup
  • Add errOldNamedSelfDetected error for self-termination signaling
  • Extend ShouldExitDueToInvalidRestart with name-based detection
  • Add restart policy update to "no" before exit in preRun
  • Add deriveScopeFromCurrentContainer helper for scope-aware cleanup
  • Add pre-update self-check in Update() for old-named current container
  • Move WatchtowerOldPrefix to pkg/types as single source of truth
  • Update container_id.go, update.go, and filters.go to use shared constant
  • Pass current container to removeExcessContainers for image collection
  • Add LastUpdateConfig capture to MockClient for restart policy assertions
  • Add tests for IsOldNamedWatchtower, self-check, and restart policy update
  • Update BuildFilter tests to mock IsWatchtower for new filter chain

Summary by CodeRabbit

  • Bug Fixes

    • Improved detection and handling of old Watchtower containers; the process now attempts to disable restart policy for self-detected old containers before exiting.
    • Excludes old predecessor containers from update eligibility to avoid interfering with self-update flows.
  • New Features

    • Automatic scoped cleanup/removal of lingering old Watchtower containers during update cycles.
    • New filters to reliably identify and exclude old Watchtower containers.
  • Tests

    • Expanded unit tests covering old-container detection, restart-policy update, filtering, and cleanup behavior.
  • Documentation

    • Updated user-facing messages and notification text to refer to “old Watchtower containers.”

- Add ExcludeOldNamedWatchtowerFilter to reject watchtower-old-* containers
- Chain the filter as a base filter in BuildFilter to exclude old instances
- Add CleanupOldWatchtowerContainers for per-update-cycle old container cleanup
- Derive scope from current container to avoid crossing scope boundaries
- Skip cleanup when scope is unknown to prevent accidental cross-scope removal
- Add shouldUpdateContainer guard to skip old-named Watchtower containers
- Move WatchtowerOldPrefix to types package as single source of truth
- Update filters, container_id, and update to use shared constant
- Add ExcludeOldNamedWatchtowerFilter to reject watchtower-old-* containers
- Chain filter as outermost wrapper in BuildFilter for early short-circuit
- Add IsOldNamedWatchtower positive predicate for readable guard clauses
- Add CleanupOldWatchtowerContainers for per-update-cycle old container cleanup
- Pass current container to removeExcessContainers to enable image collection
- Derive scope from current container to avoid crossing scope boundaries
- Skip cleanup when current container not found in list
- Add ShouldExitDueToInvalidRestart name-based check for old-named containers
- Update restart policy to "no" before exit to prevent Docker restart
- Add pre-update self-check that detects and stops old-named current container
- Move WatchtowerOldPrefix to types package as single source of truth
- Update misleading comments and log messages for accuracy
@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7a05f43c-f7e1-4ad3-9d1b-723528a205ed

📥 Commits

Reviewing files that changed from the base of the PR and between eee7501 and f03768f.

📒 Files selected for processing (1)
  • internal/actions/cleanup_test.go

📝 Walkthrough

Walkthrough

Adds centralized old-name prefix and filters, detects old watchtower containers at startup (attempts to set RestartPolicy to "no" and exit), excludes old containers from updates, adds scoped per-cycle cleanup action, updates mocks and tests, and adjusts notification wording.

Changes

Old-Named Watchtower Container Lifecycle

Layer / File(s) Summary
Prefix constant & filter helpers
pkg/types/container.go, pkg/container/container_id.go, pkg/filters/filters.go, pkg/filters/filters_test.go
Add exported WatchtowerOldPrefix, replace IsOldNamedContainer with IsOldContainer, and introduce ExcludeOldWatchtowerFilter, IsOldWatchtower, and ExcludeOldWatchtowerFilterChain; update hostname selection to use new predicate and add filter tests.
Scheduling exit & startup restart-policy recovery
internal/scheduling/scheduling.go, internal/scheduling/scheduling_test.go, cmd/root.go
Treat old-name-prefix containers as invalid restart candidates; preRun best-effort calls UpdateContainer to set RestartPolicy.Name="no" before exiting; runMain log/comment wording updated; tests add name-based cases.
Cleanup old containers action & docs
internal/actions/cleanup.go, internal/actions/doc.go, internal/actions/cleanup_test.go
Add CleanupOldWatchtowerContainers to find and remove old-prefix predecessors within derived scope (skip current ID), normalize empty scope to "none", resolve current container for image comparison, delegate to removeExcessContainers, and update doc/log wording and predecessor checks to use IsOldContainer.
Update pipeline integration
internal/actions/update.go, internal/actions/errors.go, internal/actions/mocks/client.go, internal/actions/update_context_test.go
Update detects self-as-old, sets restart policy to "no" then returns errOldSelfDetected; initializes cleanup info earlier; performs per-cycle scoped cleanup via deriveScopeFromCurrentContainer; shouldUpdateContainer excludes old containers; mocks record last UpdateConfig; tests verify UpdateContainer call and RestartPolicy=no.
Mock updates & tests
internal/actions/mocks/client.go, pkg/container/container_id_test.go, pkg/filters/filters_test.go, internal/scheduling/scheduling_test.go, internal/actions/cleanup_test.go, pkg/notifications/*
Mock TestData.LastUpdateConfig records UpdateConfig; update-context tests assert self-detection and RestartPolicy=no; expand and adjust filter, scheduling, and container selection tests; small test renames and notification wording updates included.
Notification wording
pkg/notifications/common_templates.go, pkg/notifications/preview/data/preview_strings.go
Replace wording from “excess Watchtower instances” to “old Watchtower containers” in notification templates and preview strings.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • nicholas-fedor/watchtower#1713: Modifies self-update handling for watchtower-old-* rename logic and cleanup target selection, overlapping with this PR's old-name handling.
  • nicholas-fedor/watchtower#1626: Changes in the update pipeline (restartStaleContainer, shouldUpdateContainer) that intersect with this PR's update and restart logic.
  • nicholas-fedor/watchtower#1075: Previously implemented UpdateContainer-based restart-policy changes during self-update; directly related to the restart-policy adjustments added here.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.88% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main objective: preventing old Watchtower containers from persisting by adding cleanup and self-detection mechanisms across multiple files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@nicholas-fedor nicholas-fedor linked an issue Jun 7, 2026 that may be closed by this pull request
3 tasks
@codacy-production

codacy-production Bot commented Jun 7, 2026

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 2 medium

Alerts:
⚠ 2 issues (≤ 0 issues of at least minor severity)

Results:
2 new issues

Category Results
Complexity 2 medium

View in Codacy

🟢 Metrics 15 complexity · 136 duplication

Metric Results
Complexity 15
Duplication 136

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@codecov

codecov Bot commented Jun 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.88889% with 15 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/actions/update.go 71.15% 10 Missing and 5 partials ⚠️

Impacted file tree graph

@@           Coverage Diff            @@
##             main    #1743    +/-   ##
========================================
  Coverage   74.49%   74.49%            
========================================
  Files          61       61            
  Lines       10223    10345   +122     
========================================
+ Hits         7616     7707    +91     
- Misses       2328     2356    +28     
- Partials      279      282     +3     
Files with missing lines Coverage Δ
internal/actions/cleanup.go 95.01% <100.00%> (+0.85%) ⬆️
internal/scheduling/scheduling.go 90.47% <100.00%> (+1.95%) ⬆️
pkg/container/container_id.go 97.26% <100.00%> (ø)
pkg/filters/filters.go 96.55% <100.00%> (+0.34%) ⬆️
pkg/types/container.go 0.00% <ø> (ø)
internal/actions/update.go 83.08% <71.15%> (-0.71%) ⬇️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
pkg/filters/filters.go (1)

68-93: ⚡ Quick win

Unify old-named detection behind a single predicate.

ExcludeOldNamedWatchtowerFilter and IsOldNamedWatchtower currently encode the same matching logic separately. Reusing IsOldNamedWatchtower inside the exclusion filter would reduce drift risk.

♻️ Proposed refactor
 func ExcludeOldNamedWatchtowerFilter(c types.FilterableContainer) bool {
-	if !c.IsWatchtower() {
-		return true
-	}
-
-	if strings.HasPrefix(strings.TrimLeft(c.Name(), "/"), types.WatchtowerOldPrefix) {
+	if IsOldNamedWatchtower(c) {
 		logrus.WithField("container", c.Name()).
 			Debug("Excluding old-named Watchtower container from update cycle")
 
 		return false
 	}
 
 	return true
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/filters/filters.go` around lines 68 - 93, ExcludeOldNamedWatchtowerFilter
duplicates the old-name detection logic; replace its manual check with a call to
IsOldNamedWatchtower to centralize the predicate. Update
ExcludeOldNamedWatchtowerFilter so it first returns true when !c.IsWatchtower()
(unchanged), then calls IsOldNamedWatchtower(c) to decide exclusion, logging
with logrus.WithField("container", c.Name()).Debug(...) when
IsOldNamedWatchtower(c) is true and returning false; otherwise return true.
Ensure IsOldNamedWatchtower remains the single source of truth for the
strings.HasPrefix(strings.TrimLeft(c.Name(), "/"), types.WatchtowerOldPrefix)
check.
pkg/filters/filters_test.go (1)

454-478: ⚡ Quick win

Add one BuildFilter integration assertion for old-named Watchtower exclusion.

The new predicate tests are good, but a direct BuildFilter(...) case with IsWatchtower=true and Name=/watchtower-old-... would better guard filter-chain wiring regressions.

Also applies to: 680-849

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/filters/filters_test.go` around lines 454 - 478, Add an integration
assertion to the TestBuildFilterNoneScope test that exercises BuildFilter for
the legacy Watchtower name: create a mock FilterableContainer where IsWatchtower
returns true and Name returns a string like "/watchtower-old-..." (and other
required mocks same as existing scoped/unscoped), then call the existing filter
and assert it returns false (excluded); this ensures BuildFilter's filter chain
properly excludes old-named Watchtower containers alongside the new predicates.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/actions/update_context_test.go`:
- Around line 214-330: The ginkgo.When block titled "the current container is an
old-named Watchtower instance" is declared inside the
TestUpdateAction_MidOperationCancellationCheck test function, which breaks
Ginkgo node registration; move that entire ginkgo.When { ... } block out of the
TestUpdateAction_MidOperationCancellationCheck function and place it at
top-level test scope (under an existing or new ginkgo.Describe/ginkgo.When for
the Update action), keeping all inner ginkgo.It specs intact and ensuring
references to Update, mockActions.CreateMockContainerWithConfig, and the
CurrentContainerID test cases remain the same.

In `@internal/actions/update.go`:
- Around line 1925-1947: The deriveScopeFromCurrentContainer function
incorrectly uses the sentinel "none" for unscoped containers which collides with
an explicit scope value "none"; change deriveScopeFromCurrentContainer (the
function that iterates allContainers and checks c.Scope()) to return the empty
string "" for unscoped containers (i.e., when containerHasScope is false or
containerScope == "") so unscoped and unknown remain distinct, and ensure
cleanup normalization in cleanup.go continues to treat unscoped as "" so
explicit "none" scope stays valid.

---

Nitpick comments:
In `@pkg/filters/filters_test.go`:
- Around line 454-478: Add an integration assertion to the
TestBuildFilterNoneScope test that exercises BuildFilter for the legacy
Watchtower name: create a mock FilterableContainer where IsWatchtower returns
true and Name returns a string like "/watchtower-old-..." (and other required
mocks same as existing scoped/unscoped), then call the existing filter and
assert it returns false (excluded); this ensures BuildFilter's filter chain
properly excludes old-named Watchtower containers alongside the new predicates.

In `@pkg/filters/filters.go`:
- Around line 68-93: ExcludeOldNamedWatchtowerFilter duplicates the old-name
detection logic; replace its manual check with a call to IsOldNamedWatchtower to
centralize the predicate. Update ExcludeOldNamedWatchtowerFilter so it first
returns true when !c.IsWatchtower() (unchanged), then calls
IsOldNamedWatchtower(c) to decide exclusion, logging with
logrus.WithField("container", c.Name()).Debug(...) when IsOldNamedWatchtower(c)
is true and returning false; otherwise return true. Ensure IsOldNamedWatchtower
remains the single source of truth for the
strings.HasPrefix(strings.TrimLeft(c.Name(), "/"), types.WatchtowerOldPrefix)
check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 56215aea-f7a8-4ab5-938b-62542808e753

📥 Commits

Reviewing files that changed from the base of the PR and between 60ac592 and aa88520.

📒 Files selected for processing (12)
  • cmd/root.go
  • internal/actions/cleanup.go
  • internal/actions/errors.go
  • internal/actions/mocks/client.go
  • internal/actions/update.go
  • internal/actions/update_context_test.go
  • internal/scheduling/scheduling.go
  • internal/scheduling/scheduling_test.go
  • pkg/container/container_id.go
  • pkg/filters/filters.go
  • pkg/filters/filters_test.go
  • pkg/types/container.go

Comment thread internal/actions/update_context_test.go Outdated
Comment thread internal/actions/update.go
- Move ginkgo When block to top-level Describe scope from inside a
  standard Go test function to fix Ginkgo node registration
- Change deriveScopeFromCurrentContainer to return found bool and ""
  for unscoped so unscoped and not-found remain distinct
- Normalize "" to "none" at the caller before passing to cleanup
- Add BuildFilter integration test asserting old-named Watchtower
  containers are excluded by the composed filter chain
- Refactor ExcludeOldNamedWatchtowerFilter to delegate to
  IsOldNamedWatchtower for single-source-of-truth predicate
…old" terminology

- Rename `IsOldNamedContainer` to `IsOldContainer` in container package
- Rename `ExcludeOldNamedWatchtowerFilter` to `ExcludeOldWatchtowerFilter` and related functions
- Rename `IsOldNamedWatchtower` to `IsOldWatchtower` in filters package
- Rename `ExcludeOldNamedWatchtowerFilterChain` to `ExcludeOldWatchtowerFilterChain`
- Rename `errOldNamedSelfDetected` to `errOldSelfDetected` in errors package
- Update all call sites across actions, scheduling, and container packages
- Update test names, log messages, and notification templates to match new terminology
- Update doc.go function reference from `CheckForMultipleWatchtowerInstances` to `CheckForMultipleWatchtowerContainers`

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/actions/cleanup.go (1)

128-179: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Scope parameter should be normalized to avoid missing unscoped containers.

The containerScope is normalized from empty string to "none" at line 168, but the scope parameter is not normalized. If the caller passes scope="" (meaning unscoped), the comparison at line 171 will fail to match containers that have no scope label (which normalize to "none").

Consider normalizing the scope parameter at the start of the function:

Proposed fix
 func CleanupOldWatchtowerContainers(
 	ctx context.Context,
 	client container.Client,
 	cleanupImages bool,
 	scope string,
 	currentContainerID types.ContainerID,
 	removeImageInfos *[]types.RemovedImageInfo,
 ) (int, error) {
+	// Normalize empty scope to "none" to match container scope normalization
+	if scope == "" {
+		scope = "none"
+	}
+
 	logrus.WithFields(logrus.Fields{
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/actions/cleanup.go` around lines 128 - 179, The function
CleanupOldWatchtowerContainers normalizes containerScope to "none" when a
container has no scope label but does not normalize the incoming scope
parameter, so a caller passing scope=="" won't match unscoped containers; fix by
normalizing the scope parameter at the start of CleanupOldWatchtowerContainers
(e.g., if scope == "" set scope = "none") before any scope comparisons (so the
later check comparing containerScope to scope uses the same normalization).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/actions/cleanup.go`:
- Around line 128-179: The function CleanupOldWatchtowerContainers normalizes
containerScope to "none" when a container has no scope label but does not
normalize the incoming scope parameter, so a caller passing scope=="" won't
match unscoped containers; fix by normalizing the scope parameter at the start
of CleanupOldWatchtowerContainers (e.g., if scope == "" set scope = "none")
before any scope comparisons (so the later check comparing containerScope to
scope uses the same normalization).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 263e92e9-761f-47e4-98fa-2a156b965e2a

📥 Commits

Reviewing files that changed from the base of the PR and between 8337d52 and 289c80b.

📒 Files selected for processing (15)
  • cmd/root.go
  • internal/actions/cleanup.go
  • internal/actions/cleanup_test.go
  • internal/actions/doc.go
  • internal/actions/errors.go
  • internal/actions/update.go
  • internal/actions/update_context_test.go
  • internal/scheduling/scheduling.go
  • internal/scheduling/scheduling_test.go
  • pkg/container/container_id.go
  • pkg/container/container_id_test.go
  • pkg/filters/filters.go
  • pkg/filters/filters_test.go
  • pkg/notifications/common_templates.go
  • pkg/notifications/preview/data/preview_strings.go
✅ Files skipped from review due to trivial changes (3)
  • pkg/notifications/preview/data/preview_strings.go
  • internal/actions/doc.go
  • internal/actions/cleanup_test.go
🚧 Files skipped from review as they are similar to previous changes (5)
  • internal/actions/errors.go
  • internal/scheduling/scheduling_test.go
  • internal/actions/update_context_test.go
  • internal/scheduling/scheduling.go
  • internal/actions/update.go

- Add scope normalization in CleanupOldWatchtowerContainers to handle empty scope values
- Add tests covering no-container, non-Watchtower, and current-only scenarios
- Include scope normalization tests for empty scope matching unscoped old containers
- Add scope filtering tests to verify old containers in different scopes are skipped
- Verify container removal behavior with mock client expectations
@nicholas-fedor
nicholas-fedor merged commit dfa9723 into main Jun 8, 2026
18 of 20 checks passed
@nicholas-fedor
nicholas-fedor deleted the fix/old-named-watchtower-container-cleanup branch June 8, 2026 06:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: container updates not complete when watchtower itself updates

1 participant