Skip to content

feat(image-cleanup): event-driven cleanup of orphaned ExApp Docker images via HaRP#865

Open
oleksandr-nc wants to merge 1 commit into
mainfrom
feat/exapp-image-cleanup
Open

feat(image-cleanup): event-driven cleanup of orphaned ExApp Docker images via HaRP#865
oleksandr-nc wants to merge 1 commit into
mainfrom
feat/exapp-image-cleanup

Conversation

@oleksandr-nc

@oleksandr-nc oleksandr-nc commented May 11, 2026

Copy link
Copy Markdown
Contributor

Adds automatic cleanup of ExApp Docker images orphaned by uninstall or update, scheduled per-event via a one-shot QueuedJob with a configurable grace period (default 24h). Docker's own 409 response handles the "still in use" case, so no AppAPI-side state tracks image references.

HaRP-only by design; direct Docker support is intentionally not implemented since it's being removed in NC35. Requires HaRP >= 0.4.1 for the companion endpoints (MINIMUM_HARP_VERSION bumped, so the setup check flags older daemons); against an older HaRP the feature degrades to a no-op.

Per-call CLI overrides on app:unregister (--purge-now / --keep-image) and app:update (--purge-old-image-now / --keep-old-image). Admin settings expose a master toggle and the grace period (0-720 hours). The master toggle governs only the automatic (grace) path: an explicit --purge-now is always honored, and --keep-image also cancels a still-pending cleanup job of that app, so a keep decision wins over a job queued by an earlier uninstall or update.

Unregistering a daemon is the last moment its Docker host is reachable, so grace periods cannot be honored there: the web flow removes the daemon's ExApps and purges their images immediately (when automatic cleanup is enabled), and both flows flush still-queued cleanup jobs of that daemon by deleting their images right away before dropping the jobs. The occ flow requires apps to be removed first, so their queued jobs are what the flush picks up.

Targets Nextcloud 35.

Companion HaRP endpoint already landed in nextcloud/HaRP#102, released in HaRP 0.4.1.


A few non-obvious choices, documented for future reference:

1. Per-event one-shot job vs periodic prune

Each orphan event (uninstall, update) schedules a QueuedJob that fires after the grace period, deletes the image, then removes itself from the queue.

  • vs. periodic dangling=true prune (the original approach in feat(IS-667): Add automatic cleanup of outdated ExApp Docker images #837): wrong scope (only catches already-untagged images), no per-event control, can't honor a per-uninstall override.
  • vs. periodic per-repo scan: would need AppAPI to track known repos persistently; harder to reason about per-event grace; periodic CPU cost.

2. Live Docker query for image ref vs AppAPI-side state

At hook time AppAPI asks HaRP for the container's Image field via the extended /docker/exapp/exists response (added in nextcloud/HaRP#102).

  • vs. current_image_ref column on ex_apps: schema migration, duplicates Docker's state, drift risk if a container is rebuilt out-of-band.
  • vs. AppConfig JSON list of orphans: another piece of state that can rot; concurrency footgun.
  • vs. re-fetch appinfo from the app store at uninstall: network call from a critical path, can fail offline / when an app is removed from the store, and the manifest may have changed since pull.

3. "In use" check delegated to Docker

The cleanup job just calls DELETE; Docker returns 409 if the image is still referenced by any container (running or stopped). No AppAPI-side refcount.

  • vs. AppAPI tracking which ExApps use what image: duplicates Docker's atomic refcount, opens a TOCTOU window between check and delete.

4. HaRP-only by design

The hooks short-circuit for non-HaRP Docker daemons. Direct Docker (and DockerSocketProxy) are being removed in NC35; carrying a parallel cleanup path would be code we'd delete in a few months. Existing direct-Docker users keep working, just without auto-cleanup until they migrate to HaRP.

Other choices worth noting

  • Pending jobs are state too: --keep-image / --purge-now cancel a still-queued job for the app, and the job re-checks the master toggle at run time, so disabling the feature also stops already-scheduled deletions.
  • Typed settings, coerced by key: image_cleanup_enabled / image_cleanup_grace_hours are stored typed (bool/int) so the read side can use getValueBool/getValueInt (with lazy: true on both sides). setAdminConfig coerces them by key name; unknown keys keep the legacy string path, so an API caller sending e.g. an int init_timeout can't flip a legacy setting's stored type and break its readers.
  • bytes_freed accuracy (caught in review of HaRP#102): parse Docker's DELETE response, only count the inspected size when at least one entry has a Deleted key. A multi-tag delete that only untags reports bytes_freed: 0 instead of lying.
  • Uninstall dialog deferred to a follow-up: the "Remove" action lives in nc-server's apps/appstore frontend, not this repo, so the dropdown + "don't ask again" UX needs a coordinated server PR. The HTTP endpoint here already accepts an imageCleanup=grace|now|keep request param so the future UI PR can wire to it without back-end changes.
  • E2E coverage: tests/test_image_cleanup.py (7 scenarios, incl. reinstall-before-cron, multi-tag bytes_freed, keep-cancels-stale-job, purge-now with the toggle off) is wired into the tests-deploy.yml HaRP job.

Screenshots

Screenshot From 2026-07-07 15-24-23

@coderabbitai

coderabbitai Bot commented May 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@oleksandr-nc, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 78df983c-0117-4f54-95b8-7c7a2f191d76

📥 Commits

Reviewing files that changed from the base of the PR and between 08d6aff and 5bea568.

⛔ Files ignored due to path filters (1)
  • js/app_api-adminSettings.js.map is excluded by !**/*.map
📒 Files selected for processing (23)
  • .github/workflows/tests-deploy.yml
  • AGENTS.md
  • js/app_api-adminSettings.js
  • lib/AppInfo/Application.php
  • lib/BackgroundJob/OrphanedImageCleanupJob.php
  • lib/Command/ExApp/Unregister.php
  • lib/Command/ExApp/Update.php
  • lib/Controller/ConfigController.php
  • lib/Controller/ExAppsPageController.php
  • lib/DeployActions/DockerActions.php
  • lib/Service/AppAPIService.php
  • lib/Service/DaemonConfigService.php
  • lib/Service/ExAppImageCleanupService.php
  • lib/Service/ImageCleanupChoice.php
  • lib/Settings/Admin.php
  • openapi-administration.json
  • openapi-full.json
  • src/components/AdminSettings.vue
  • tests/php/BackgroundJob/OrphanedImageCleanupJobTest.php
  • tests/php/Controller/ExAppsPageControllerTest.php
  • tests/php/Service/AppAPIServiceTest.php
  • tests/php/Service/ExAppImageCleanupServiceTest.php
  • tests/test_image_cleanup.py
📝 Walkthrough

Walkthrough

Changes

This PR adds automated Docker image cleanup for ExApps. New constants and an ImageCleanupChoice enum define cleanup configuration and modes (grace, purge-now, keep). A new OrphanedImageCleanupJob background job and ExAppImageCleanupService coordinate capturing image references, scheduling deferred cleanup, immediate purging, and canceling/flushing pending jobs, using new DockerActions methods (removeImage, getRunningImageRef). CLI commands (unregister, update), ExAppsPageController, AppAPIService, and DaemonConfigService are updated to trigger cleanup. ConfigController now supports typed admin config values, and AdminSettings.vue adds UI controls for the master toggle and grace period. OpenAPI specs and documentation are updated accordingly, along with new PHP unit tests, a Python end-to-end test, and CI workflow changes.

Sequence Diagram(s)

See hidden artifact.

Compact metadata

  • Files changed: 22
  • Estimated review effort: High

Related issues: None specified

Related PRs: None specified

Suggested labels: feature, enhancement, needs-review

Suggested reviewers: Backend maintainers familiar with DaemonConfigService/AppAPIService and Docker/HaRP integration

Poem

A rabbit sniffs at images old and cold,
Grace hours ticking, stories yet untold.
Purge now, or keep, or let time run its course,
Orphaned layers vanish without remorse.
Hop, commit, cleanup — tidy burrows, no more bloat! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: event-driven cleanup of orphaned ExApp Docker images via HaRP.
Description check ✅ Passed The description matches the changeset and describes the new image cleanup flow, flags, settings, and HaRP-only behavior.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.

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.

@oleksandr-nc oleksandr-nc force-pushed the feat/exapp-image-cleanup branch from ab3e6fe to 2812588 Compare May 11, 2026 08:35
@kyteinsky kyteinsky requested a review from Copilot May 11, 2026 10:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Implements event-driven cleanup of orphaned ExApp Docker images (HaRP-only) after uninstall/update, with admin-configurable enablement + grace period and per-command overrides.

Changes:

  • Add ExAppImageCleanupService + OrphanedImageCleanupJob to capture image refs and delete images immediately or via grace-period queued jobs.
  • Add admin settings + typed config persistence for image_cleanup_enabled and image_cleanup_grace_hours.
  • Add unit tests for scheduling/job behavior and an end-to-end test validating HaRP/Docker behavior (including multi-tag bytes_freed semantics).

Reviewed changes

Copilot reviewed 15 out of 17 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/test_image_cleanup.py New E2E test covering uninstall/update image cleanup scenarios via HaRP + Docker CLI.
tests/php/Service/ExAppImageCleanupServiceTest.php Unit tests for capture/schedule behavior and grace clamping.
tests/php/Controller/ExAppsPageControllerTest.php Updates controller test wiring for new cleanup service dependency.
tests/php/BackgroundJob/OrphanedImageCleanupJobTest.php Unit tests for the queued cleanup job behavior and logging paths.
src/components/AdminSettings.vue Adds admin UI for cleanup toggle and grace period input.
lib/Settings/Admin.php Exposes cleanup config values to the admin settings template.
lib/Service/ExAppImageCleanupService.php New service to capture refs, schedule/purge cleanup, and cancel pending jobs on daemon removal.
lib/Service/DaemonConfigService.php Cancels pending cleanup jobs when unregistering a daemon.
lib/DeployActions/DockerActions.php Adds HaRP-only removeImage() and getRunningImageRef() helpers.
lib/Controller/ExAppsPageController.php Captures image ref on uninstall and schedules cleanup based on request choice.
lib/Controller/ConfigController.php Persists admin config using bool/int/string setters based on value type.
lib/Command/ExApp/Update.php Adds options to purge/keep old image on update and schedules cleanup post-success.
lib/Command/ExApp/Unregister.php Adds options to purge/keep image on uninstall and schedules cleanup once container removed.
lib/BackgroundJob/OrphanedImageCleanupJob.php New queued job that attempts image deletion and logs outcomes (409/in use, not found, etc.).
lib/AppInfo/Application.php Adds config keys and defaults for image cleanup.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/components/AdminSettings.vue
Comment thread lib/Service/ExAppImageCleanupService.php Outdated
Comment thread tests/test_image_cleanup.py Outdated
@oleksandr-nc oleksandr-nc force-pushed the feat/exapp-image-cleanup branch 2 times, most recently from 3a4c171 to 08d6aff Compare July 7, 2026 12:14
@oleksandr-nc oleksandr-nc marked this pull request as ready for review July 7, 2026 12:25

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (3)
src/components/AdminSettings.vue (1)

61-70: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Grace-hours max (720) hardcoded twice in this file.

The 720 bound appears both in the template (:max="720") and in onGraceHoursInput's clamp (Math.min(720, ...)), duplicating the backend's Application::MAX_IMAGE_CLEANUP_GRACE_HOURS. If that constant ever changes, this file must be updated in three places (two here plus the backend) to stay in sync.

Consider deriving the bound from a single local constant, or better, exposing it via admin-initial-data so frontend and backend stay authoritative from one source.

♻️ Example: single local constant
+const MAX_IMAGE_CLEANUP_GRACE_HOURS = 720
+
 export default {
   ...
   methods: {
     onGraceHoursInput(value) {
       clearTimeout(this.graceHoursSaveTimer)
       this.graceHoursSaveTimer = setTimeout(() => {
-        const clamped = Math.min(720, Math.max(0, parseInt(value, 10) || 0))
+        const clamped = Math.min(MAX_IMAGE_CLEANUP_GRACE_HOURS, Math.max(0, parseInt(value, 10) || 0))
         this.state.image_cleanup_grace_hours = clamped
         this.saveOptions({ image_cleanup_grace_hours: clamped })
       }, 2000)
     },
   },
 }

Also applies to: 151-161

tests/php/BackgroundJob/OrphanedImageCleanupJobTest.php (1)

48-52: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

invokeRun() can't exercise the non-array-argument guard.

private function invokeRun(array $argument): void type-hints $argument as array, so the job's !is_array($argument) defensive branch is untestable through this helper, even though production code can receive an untyped argument from the queue infrastructure.

♻️ Loosen the helper's type to cover the guard branch
-	private function invokeRun(array $argument): void {
+	private function invokeRun(mixed $argument): void {
 		$reflection = new \ReflectionMethod($this->job, 'run');
 		$reflection->setAccessible(true);
 		$reflection->invoke($this->job, $argument);
 	}
tests/test_image_cleanup.py (1)

140-146: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse the occ() helper for the job-delete cleanup loop.

Line 145 re-implements the occ() command construction ([*OCC_PREFIX, "--no-warnings", ...]) instead of calling occ(...), causing drift risk if the helper's behavior changes later.

♻️ Proposed refactor
 def reset_state() -> None:
     """Best-effort uninstall + image purge so each scenario starts clean."""
     occ("app_api:app:unregister", APP_ID, "--silent", "--force", "--purge-now", check=False)
     # Drop any stray queued jobs from prior runs of this test.
     for j in list_pending_jobs("OrphanedImageCleanupJob"):
-        run([*OCC_PREFIX, "--no-warnings", "background-job:delete", str(j["id"])], check=False, stdout=DEVNULL, stderr=DEVNULL)
+        occ("background-job:delete", str(j["id"]), check=False)

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9c071afc-1c1f-4fb2-b5a5-9220881a2619

📥 Commits

Reviewing files that changed from the base of the PR and between 9d321ad and 08d6aff.

⛔ Files ignored due to path filters (1)
  • js/app_api-adminSettings.js.map is excluded by !**/*.map
📒 Files selected for processing (23)
  • .github/workflows/tests-deploy.yml
  • AGENTS.md
  • js/app_api-adminSettings.js
  • lib/AppInfo/Application.php
  • lib/BackgroundJob/OrphanedImageCleanupJob.php
  • lib/Command/ExApp/Unregister.php
  • lib/Command/ExApp/Update.php
  • lib/Controller/ConfigController.php
  • lib/Controller/ExAppsPageController.php
  • lib/DeployActions/DockerActions.php
  • lib/Service/AppAPIService.php
  • lib/Service/DaemonConfigService.php
  • lib/Service/ExAppImageCleanupService.php
  • lib/Service/ImageCleanupChoice.php
  • lib/Settings/Admin.php
  • openapi-administration.json
  • openapi-full.json
  • src/components/AdminSettings.vue
  • tests/php/BackgroundJob/OrphanedImageCleanupJobTest.php
  • tests/php/Controller/ExAppsPageControllerTest.php
  • tests/php/Service/AppAPIServiceTest.php
  • tests/php/Service/ExAppImageCleanupServiceTest.php
  • tests/test_image_cleanup.py

Comment thread lib/Controller/ExAppsPageController.php
Comment thread lib/DeployActions/DockerActions.php
Comment on lines +806 to +828
// The daemon config is deleted right after this, so a grace-period
// job could never reach its Docker socket again; when automatic
// cleanup is enabled, purge the image immediately instead
// (best-effort, Docker 409 protects shared images). This is an
// automatic path, so the master toggle is honored here even
// though PURGE_NOW mechanics are used.
$imageRef = $this->imageCleanupService->isMasterEnabled()
? $this->imageCleanupService->captureImageRef($daemonConfig, $exApp->getAppid(), ImageCleanupChoice::PURGE_NOW)
: null;
if (boolval($exApp->getDeployConfig()['harp'] ?? false)) {
$this->dockerActions->removeExApp($this->dockerActions->buildDockerUrl($daemonConfig), $exApp->getAppid(), true);
} else {
$this->dockerActions->removeContainer($this->dockerActions->buildDockerUrl($daemonConfig), $this->dockerActions->buildExAppContainerName($exApp->getAppid()));
$this->dockerActions->removeVolume($this->dockerActions->buildDockerUrl($daemonConfig), $this->dockerActions->buildExAppVolumeName($exApp->getAppid()));
}
if ($imageRef !== null) {
$this->imageCleanupService->scheduleCleanup(
$imageRef,
$exApp,
$daemonConfig,
ImageCleanupChoice::PURGE_NOW,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

New network calls now sit inside a loop-wide catch-all that silently aborts remaining ExApps.

The whole foreach ($targetDaemonExApps as $exApp) loop is wrapped by a single try { ... } catch (Exception) {} around the entire method body (unchanged, lines 797/832-833), with no logging. Adding isMasterEnabled()/captureImageRef()/scheduleCleanup() calls inside this loop means a single Guzzle failure while purging one ExApp's image can now silently stop disableExApp()/unregisterExApp() from running for all remaining ExApps on that daemon, with zero diagnostics — during a daemon-removal flow, that's the worst time for orphaned/undisabled ExApp registrations to go unnoticed.

Consider wrapping just the new image-cleanup calls in their own try/catch with a warning log (matching DaemonConfigService::unregisterDaemonConfig()'s best-effort pattern) so an image-purge failure can't halt disabling/unregistering the rest of the daemon's ExApps.

…ages via HaRP

Signed-off-by: Oleksander Piskun <oleksandr2088@icloud.com>
@oleksandr-nc oleksandr-nc force-pushed the feat/exapp-image-cleanup branch from 08d6aff to 5bea568 Compare July 7, 2026 12:54
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.

2 participants