+
+
+
+
+
+ {{ t('app_api', 'Remove old images after ExApp update') }}
+
+
+
@@ -63,6 +85,7 @@ import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import NcInputField from '@nextcloud/vue/components/NcInputField'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import AppAPIIcon from './icons/AppAPIIcon.vue'
import DaemonConfigList from './DaemonConfig/DaemonConfigList.vue'
@@ -76,6 +99,7 @@ export default {
NcNoteCard,
NcInputField,
NcSelect,
+ NcCheckboxRadioSwitch,
},
data() {
return {
@@ -103,6 +127,7 @@ export default {
this.saveOptions({
init_timeout: this.state.init_timeout,
container_restart_policy: this.state.container_restart_policy,
+ image_cleanup_interval_days: this.state.image_cleanup_interval_days,
})
}, 2000)()
},
diff --git a/tests/php/BackgroundJob/DockerImageCleanupJobTest.php b/tests/php/BackgroundJob/DockerImageCleanupJobTest.php
new file mode 100644
index 00000000..9d765ae2
--- /dev/null
+++ b/tests/php/BackgroundJob/DockerImageCleanupJobTest.php
@@ -0,0 +1,230 @@
+createJob(0);
+
+ $this->daemonConfigService->expects(self::never())
+ ->method('getRegisteredDaemonConfigs');
+
+ $this->dockerActions->expects(self::never())
+ ->method('pruneImages');
+
+ $this->invokeRun($job);
+ }
+
+ public function testRunPrunesDockerDaemons(): void {
+ $job = $this->createJob();
+
+ $daemon = $this->createDaemonConfig('local-docker', DockerActions::DEPLOY_ID);
+
+ $this->daemonConfigService->method('getRegisteredDaemonConfigs')
+ ->willReturn([$daemon]);
+
+ $this->dockerActions->expects(self::once())
+ ->method('initGuzzleClient')
+ ->with($daemon);
+
+ $this->dockerActions->expects(self::once())
+ ->method('buildDockerUrl')
+ ->with($daemon)
+ ->willReturn('http://localhost');
+
+ $this->dockerActions->expects(self::once())
+ ->method('pruneImages')
+ ->with('http://localhost', ['dangling' => ['true']])
+ ->willReturn(['SpaceReclaimed' => 1048576, 'ImagesDeleted' => []]);
+
+ $this->invokeRun($job);
+ }
+
+ public function testRunSkipsNonDockerDaemons(): void {
+ $job = $this->createJob();
+
+ $k8sDaemon = $this->createDaemonConfig('k8s-cluster', 'kubernetes-install');
+ $manualDaemon = $this->createDaemonConfig('manual', 'manual-install');
+
+ $this->daemonConfigService->method('getRegisteredDaemonConfigs')
+ ->willReturn([$k8sDaemon, $manualDaemon]);
+
+ $this->dockerActions->expects(self::never())
+ ->method('pruneImages');
+
+ $this->invokeRun($job);
+ }
+
+ public function testRunSkipsHarpDaemons(): void {
+ $job = $this->createJob();
+
+ $harpDaemon = $this->createDaemonConfig(
+ 'harp-daemon',
+ DockerActions::DEPLOY_ID,
+ ['harp' => true]
+ );
+
+ $this->daemonConfigService->method('getRegisteredDaemonConfigs')
+ ->willReturn([$harpDaemon]);
+
+ $this->dockerActions->expects(self::never())
+ ->method('pruneImages');
+
+ $this->logger->expects(self::once())
+ ->method('debug')
+ ->with(self::stringContains('Skipping image prune for HaRP daemon'));
+
+ $this->invokeRun($job);
+ }
+
+ public function testRunLogsErrorWhenPruneFails(): void {
+ $job = $this->createJob();
+
+ $daemon = $this->createDaemonConfig('local-docker', DockerActions::DEPLOY_ID);
+
+ $this->daemonConfigService->method('getRegisteredDaemonConfigs')
+ ->willReturn([$daemon]);
+
+ $this->dockerActions->method('buildDockerUrl')->willReturn('http://localhost');
+ $this->dockerActions->method('pruneImages')
+ ->willReturn(['error' => 'Connection refused']);
+
+ $this->logger->expects(self::once())
+ ->method('error')
+ ->with(self::stringContains('Connection refused'));
+
+ $this->invokeRun($job);
+ }
+
+ public function testRunCatchesExceptionsPerDaemon(): void {
+ $job = $this->createJob();
+
+ $daemon1 = $this->createDaemonConfig('failing-daemon', DockerActions::DEPLOY_ID);
+ $daemon2 = $this->createDaemonConfig('working-daemon', DockerActions::DEPLOY_ID);
+
+ $this->daemonConfigService->method('getRegisteredDaemonConfigs')
+ ->willReturn([$daemon1, $daemon2]);
+
+ $this->dockerActions->method('buildDockerUrl')->willReturn('http://localhost');
+
+ $callCount = 0;
+ $this->dockerActions->method('pruneImages')
+ ->willReturnCallback(function () use (&$callCount) {
+ $callCount++;
+ if ($callCount === 1) {
+ throw new \Exception('Daemon unreachable');
+ }
+ return ['SpaceReclaimed' => 0, 'ImagesDeleted' => null];
+ });
+
+ $this->logger->expects(self::once())
+ ->method('error')
+ ->with(self::stringContains('Daemon unreachable'));
+
+ $this->invokeRun($job);
+
+ // Verify the second daemon was still processed
+ self::assertSame(2, $callCount);
+ }
+
+ public function testRunPrunesMultipleDaemons(): void {
+ $job = $this->createJob();
+
+ $daemon1 = $this->createDaemonConfig('docker-1', DockerActions::DEPLOY_ID);
+ $daemon2 = $this->createDaemonConfig('docker-2', DockerActions::DEPLOY_ID);
+
+ $this->daemonConfigService->method('getRegisteredDaemonConfigs')
+ ->willReturn([$daemon1, $daemon2]);
+
+ $this->dockerActions->method('buildDockerUrl')->willReturn('http://localhost');
+
+ $this->dockerActions->expects(self::exactly(2))
+ ->method('pruneImages');
+
+ $this->invokeRun($job);
+ }
+
+ public function testRunFiltersMixedDaemons(): void {
+ $job = $this->createJob();
+
+ $dockerDaemon = $this->createDaemonConfig('docker', DockerActions::DEPLOY_ID);
+ $harpDaemon = $this->createDaemonConfig('harp', DockerActions::DEPLOY_ID, ['harp' => true]);
+ $k8sDaemon = $this->createDaemonConfig('k8s', 'kubernetes-install');
+
+ $this->daemonConfigService->method('getRegisteredDaemonConfigs')
+ ->willReturn([$dockerDaemon, $harpDaemon, $k8sDaemon]);
+
+ $this->dockerActions->method('buildDockerUrl')->willReturn('http://localhost');
+
+ // Only the plain Docker daemon should be pruned
+ $this->dockerActions->expects(self::once())
+ ->method('pruneImages');
+
+ $this->invokeRun($job);
+ }
+
+ private function createJob(int $intervalDays = 7): DockerImageCleanupJob {
+ $this->appConfig = $this->createMock(IAppConfig::class);
+ $this->daemonConfigService = $this->createMock(DaemonConfigService::class);
+ $this->dockerActions = $this->createMock(DockerActions::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->timeFactory = $this->createMock(ITimeFactory::class);
+ $this->timeFactory->method('getTime')->willReturn(time());
+
+ $this->appConfig->method('getValueInt')
+ ->willReturnCallback(function (string $appId, string $key, int $default) use ($intervalDays) {
+ if ($key === Application::CONF_IMAGE_CLEANUP_INTERVAL_DAYS) {
+ return $intervalDays;
+ }
+ return $default;
+ });
+
+ return new DockerImageCleanupJob(
+ $this->timeFactory,
+ $this->appConfig,
+ $this->daemonConfigService,
+ $this->dockerActions,
+ $this->logger,
+ );
+ }
+
+ private function createDaemonConfig(string $name, string $deployId, array $deployConfig = []): DaemonConfig {
+ return new DaemonConfig([
+ 'name' => $name,
+ 'accepts_deploy_id' => $deployId,
+ 'deploy_config' => $deployConfig,
+ ]);
+ }
+
+ /**
+ * Invoke the protected run() method via reflection.
+ */
+ private function invokeRun(DockerImageCleanupJob $job): void {
+ $method = new \ReflectionMethod($job, 'run');
+ $method->invoke($job, null);
+ }
+}
diff --git a/tests/php/Command/ExApp/UpdateImageCleanupTest.php b/tests/php/Command/ExApp/UpdateImageCleanupTest.php
new file mode 100644
index 00000000..6900c96b
--- /dev/null
+++ b/tests/php/Command/ExApp/UpdateImageCleanupTest.php
@@ -0,0 +1,175 @@
+dockerActions = $this->createMock(DockerActions::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->appConfig = $this->createMock(IAppConfig::class);
+
+ $this->dockerActions->method('getAcceptsDeployId')
+ ->willReturn(DockerActions::DEPLOY_ID);
+
+ $this->command = new Update(
+ $this->createMock(AppAPIService::class),
+ $this->createMock(ExAppService::class),
+ $this->createMock(DaemonConfigService::class),
+ $this->dockerActions,
+ $this->createMock(KubernetesActions::class),
+ $this->createMock(ManualActions::class),
+ $this->logger,
+ $this->createMock(ExAppArchiveFetcher::class),
+ $this->createMock(ExAppFetcher::class),
+ $this->createMock(ExAppDeployOptionsService::class),
+ $this->appConfig,
+ );
+ }
+
+ public function testRemovesOldImageWhenEnabled(): void {
+ $daemon = $this->createDaemonConfig(DockerActions::DEPLOY_ID);
+
+ $this->appConfig->method('getValueBool')
+ ->willReturnCallback(function (string $appId, string $key, bool $default) {
+ if ($key === Application::CONF_IMAGE_CLEANUP_ON_UPDATE) {
+ return true;
+ }
+ return $default;
+ });
+
+ $this->dockerActions->expects(self::once())
+ ->method('buildDockerUrl')
+ ->with($daemon)
+ ->willReturn('http://localhost');
+
+ $this->dockerActions->expects(self::once())
+ ->method('removeImage')
+ ->with('http://localhost', 'ghcr.io/nextcloud/app:1.0')
+ ->willReturn('');
+
+ $this->logger->expects(self::once())
+ ->method('info')
+ ->with(self::stringContains('removed after updating'));
+
+ $this->invokeRemoveOldImage('ghcr.io/nextcloud/app:1.0', $daemon, 'test-app');
+ }
+
+ public function testSkipsWhenDisabled(): void {
+ $daemon = $this->createDaemonConfig(DockerActions::DEPLOY_ID);
+
+ $this->appConfig->method('getValueBool')
+ ->willReturnCallback(function (string $appId, string $key, bool $default) {
+ if ($key === Application::CONF_IMAGE_CLEANUP_ON_UPDATE) {
+ return false;
+ }
+ return $default;
+ });
+
+ $this->dockerActions->expects(self::never())
+ ->method('removeImage');
+
+ $this->invokeRemoveOldImage('ghcr.io/nextcloud/app:1.0', $daemon, 'test-app');
+ }
+
+ public function testSkipsWhenOldImageNameEmpty(): void {
+ $daemon = $this->createDaemonConfig(DockerActions::DEPLOY_ID);
+
+ $this->dockerActions->expects(self::never())
+ ->method('removeImage');
+
+ $this->invokeRemoveOldImage('', $daemon, 'test-app');
+ }
+
+ public function testSkipsForNonDockerDaemons(): void {
+ $daemon = $this->createDaemonConfig('kubernetes-install');
+
+ $this->appConfig->method('getValueBool')
+ ->willReturn(true);
+
+ $this->dockerActions->expects(self::never())
+ ->method('removeImage');
+
+ $this->invokeRemoveOldImage('ghcr.io/nextcloud/app:1.0', $daemon, 'test-app');
+ }
+
+ public function testLogsWarningWhenRemoveFails(): void {
+ $daemon = $this->createDaemonConfig(DockerActions::DEPLOY_ID);
+
+ $this->appConfig->method('getValueBool')
+ ->willReturnCallback(function (string $appId, string $key, bool $default) {
+ if ($key === Application::CONF_IMAGE_CLEANUP_ON_UPDATE) {
+ return true;
+ }
+ return $default;
+ });
+
+ $this->dockerActions->method('buildDockerUrl')->willReturn('http://localhost');
+ $this->dockerActions->method('removeImage')
+ ->willReturn('Failed to remove image: server error');
+
+ $this->logger->expects(self::once())
+ ->method('warning')
+ ->with(self::stringContains('Old image cleanup for test-app'));
+
+ $this->invokeRemoveOldImage('ghcr.io/nextcloud/app:1.0', $daemon, 'test-app');
+ }
+
+ public function testDefaultSettingIsDisabled(): void {
+ $daemon = $this->createDaemonConfig(DockerActions::DEPLOY_ID);
+
+ // Don't configure appConfig — let it return the default (false)
+ $this->appConfig->method('getValueBool')
+ ->willReturnCallback(function (string $appId, string $key, bool $default) {
+ return $default;
+ });
+
+ $this->dockerActions->expects(self::never())
+ ->method('removeImage');
+
+ $this->invokeRemoveOldImage('ghcr.io/nextcloud/app:1.0', $daemon, 'test-app');
+ }
+
+ private function createDaemonConfig(string $deployId): DaemonConfig {
+ return new DaemonConfig([
+ 'name' => 'test-daemon',
+ 'accepts_deploy_id' => $deployId,
+ 'deploy_config' => [],
+ ]);
+ }
+
+ private function invokeRemoveOldImage(string $oldImageName, DaemonConfig $daemonConfig, string $appId): void {
+ $method = new \ReflectionMethod($this->command, 'removeOldImageIfEnabled');
+ $method->invoke($this->command, $oldImageName, $daemonConfig, $appId);
+ }
+}
diff --git a/tests/php/DeployActions/DockerActionsImageCleanupTest.php b/tests/php/DeployActions/DockerActionsImageCleanupTest.php
new file mode 100644
index 00000000..61832d40
--- /dev/null
+++ b/tests/php/DeployActions/DockerActionsImageCleanupTest.php
@@ -0,0 +1,236 @@
+appConfig = $this->createMock(IAppConfig::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->guzzleClient = $this->createMock(Client::class);
+
+ $this->appConfig->method('getValueString')
+ ->willReturnCallback(function (string $appId, string $key, string $default) {
+ if ($key === 'docker_api_version') {
+ return '';
+ }
+ return $default;
+ });
+
+ $this->dockerActions = new DockerActions(
+ $this->logger,
+ $this->appConfig,
+ $this->createMock(IConfig::class),
+ $this->createMock(ICertificateManager::class),
+ $this->createMock(IAppManager::class),
+ $this->createMock(IURLGenerator::class),
+ $this->createMock(AppAPICommonService::class),
+ $this->createMock(ExAppService::class),
+ $this->createMock(ITempManager::class),
+ $this->createMock(ICrypto::class),
+ $this->createMock(ExAppDeployOptionsService::class),
+ );
+
+ // Inject mock Guzzle client via reflection (private property)
+ $reflection = new \ReflectionClass($this->dockerActions);
+ $property = $reflection->getProperty('guzzleClient');
+ $property->setValue($this->dockerActions, $this->guzzleClient);
+ }
+
+ // --- removeImage() tests ---
+
+ public function testRemoveImageReturnsEmptyStringOnSuccess(): void {
+ $this->guzzleClient->expects(self::once())
+ ->method('delete')
+ ->with(self::DOCKER_URL . '/' . self::API_VERSION . '/images/test-image:1.0')
+ ->willReturn(new Response(200));
+
+ $this->logger->expects(self::once())
+ ->method('info')
+ ->with(self::stringContains('Successfully removed Docker image'));
+
+ $result = $this->dockerActions->removeImage(self::DOCKER_URL, 'test-image:1.0');
+
+ self::assertSame('', $result);
+ }
+
+ public function testRemoveImageReturnsEmptyStringOn404(): void {
+ $this->guzzleClient->method('delete')->willThrowException(
+ new ClientException('Not Found', new Request('DELETE', ''), new Response(404))
+ );
+
+ $this->logger->expects(self::once())
+ ->method('debug')
+ ->with(self::stringContains('not found (already removed)'));
+
+ $result = $this->dockerActions->removeImage(self::DOCKER_URL, 'missing-image:1.0');
+
+ self::assertSame('', $result);
+ }
+
+ public function testRemoveImageReturnsEmptyStringOn409(): void {
+ $this->guzzleClient->method('delete')->willThrowException(
+ new ClientException('Conflict', new Request('DELETE', ''), new Response(409))
+ );
+
+ $this->logger->expects(self::once())
+ ->method('warning')
+ ->with(self::stringContains('in use, skipping removal'));
+
+ $result = $this->dockerActions->removeImage(self::DOCKER_URL, 'in-use-image:1.0');
+
+ self::assertSame('', $result);
+ }
+
+ public function testRemoveImageReturnsErrorOnServerError(): void {
+ $this->guzzleClient->method('delete')->willThrowException(
+ new ClientException('Server Error', new Request('DELETE', ''), new Response(500))
+ );
+
+ $this->logger->expects(self::once())
+ ->method('error')
+ ->with(self::stringContains('Failed to remove image'));
+
+ $result = $this->dockerActions->removeImage(self::DOCKER_URL, 'some-image:1.0');
+
+ self::assertNotEmpty($result);
+ self::assertStringContainsString('Failed to remove image', $result);
+ }
+
+ public function testRemoveImageReturnsErrorOnUnexpectedStatusCode(): void {
+ $this->guzzleClient->method('delete')->willReturn(new Response(204));
+
+ $result = $this->dockerActions->removeImage(self::DOCKER_URL, 'test-image:1.0');
+
+ self::assertNotEmpty($result);
+ self::assertStringContainsString('Unexpected status 204', $result);
+ }
+
+ // --- pruneImages() tests ---
+
+ public function testPruneImagesReturnsResultWithFilters(): void {
+ $body = json_encode([
+ 'SpaceReclaimed' => 104857600,
+ 'ImagesDeleted' => [
+ ['Untagged' => 'old-image:1.0'],
+ ['Deleted' => 'sha256:abc123'],
+ ],
+ ], JSON_THROW_ON_ERROR);
+ $this->guzzleClient->expects(self::once())
+ ->method('post')
+ ->with(
+ self::DOCKER_URL . '/' . self::API_VERSION . '/images/prune',
+ self::callback(function (array $options) {
+ return isset($options['query']['filters'])
+ && $options['query']['filters'] === '{"dangling":["true"]}';
+ })
+ )
+ ->willReturn(new Response(200, [], $body));
+
+ $result = $this->dockerActions->pruneImages(self::DOCKER_URL, ['dangling' => ['true']]);
+
+ self::assertSame(104857600, $result['SpaceReclaimed']);
+ self::assertCount(2, $result['ImagesDeleted']);
+ }
+
+ public function testPruneImagesWithNoFiltersOmitsQueryParam(): void {
+ $body = json_encode(['SpaceReclaimed' => 0, 'ImagesDeleted' => null], JSON_THROW_ON_ERROR);
+ $this->guzzleClient->expects(self::once())
+ ->method('post')
+ ->with(
+ self::DOCKER_URL . '/' . self::API_VERSION . '/images/prune',
+ self::callback(function (array $options) {
+ return !isset($options['query']['filters']);
+ })
+ )
+ ->willReturn(new Response(200, [], $body));
+
+ $result = $this->dockerActions->pruneImages(self::DOCKER_URL);
+
+ self::assertSame(0, $result['SpaceReclaimed']);
+ }
+
+ public function testPruneImagesReturnsErrorOnGuzzleException(): void {
+ $this->guzzleClient->method('post')->willThrowException(
+ new ClientException('Forbidden', new Request('POST', ''), new Response(403))
+ );
+
+ $result = $this->dockerActions->pruneImages(self::DOCKER_URL, ['dangling' => ['true']]);
+
+ self::assertArrayHasKey('error', $result);
+ }
+
+ public function testPruneImagesLogsInfoWithImageCount(): void {
+ $body = json_encode([
+ 'SpaceReclaimed' => 1048576,
+ 'ImagesDeleted' => [['Deleted' => 'sha256:abc']],
+ ], JSON_THROW_ON_ERROR);
+ $this->guzzleClient->method('post')->willReturn(new Response(200, [], $body));
+
+ $this->logger->expects(self::once())
+ ->method('info')
+ ->with(self::stringContains('1 images removed'));
+
+ $this->dockerActions->pruneImages(self::DOCKER_URL);
+ }
+
+ public function testPruneImagesLogsErrorOnFailure(): void {
+ $this->guzzleClient->method('post')->willThrowException(
+ new ClientException('Connection refused', new Request('POST', ''), new Response(500))
+ );
+
+ $this->logger->expects(self::once())
+ ->method('error')
+ ->with(self::stringContains('Failed to prune Docker images'));
+
+ $this->dockerActions->pruneImages(self::DOCKER_URL);
+ }
+
+ public function testPruneImagesHandlesNullImagesDeleted(): void {
+ $body = json_encode(['SpaceReclaimed' => 0, 'ImagesDeleted' => null], JSON_THROW_ON_ERROR);
+ $this->guzzleClient->method('post')->willReturn(new Response(200, [], $body));
+
+ $this->logger->expects(self::once())
+ ->method('info')
+ ->with(self::stringContains('0 images removed'));
+
+ $result = $this->dockerActions->pruneImages(self::DOCKER_URL);
+
+ self::assertSame(0, $result['SpaceReclaimed']);
+ }
+}