Skip to content

Interview Task: Fix-519 docker image cleanup #581

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ to join us in shaping a more versatile, stable, and secure app landscape.
</dependencies>
<background-jobs>
<job>OCA\AppAPI\BackgroundJob\ExAppInitStatusCheckJob</job>
<job>OCA\AppAPI\BackgroundJob\DockerImageCleanupJob</job>
</background-jobs>
<repair-steps>
<post-migration>
Expand Down
110 changes: 110 additions & 0 deletions lib/BackgroundJob/DockerImageCleanupJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\AppAPI\BackgroundJob;

use OCA\AppAPI\DeployActions\DockerActions;
use OCA\AppAPI\Service\DaemonConfigService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
use OCP\IAppConfig;
use Psr\Log\LoggerInterface;

class DockerImageCleanupJob extends TimedJob
{
private const DEFAULT_INTERVAL_DAYS = 7; // Default interval in days
private const SECONDS_IN_DAY = 86400; // Number of seconds in a day

public function __construct(
ITimeFactory $time,
private readonly DockerActions $dockerActions,
private readonly DaemonConfigService $daemonConfigService,
private readonly IAppConfig $appConfig,
protected LoggerInterface $logger,
)
{
parent::__construct($time);

// Get the configured interval in days or use default
$intervalDays = (int)$this->appConfig->getValueString('app_api', 'docker_cleanup_interval_days', (string)self::DEFAULT_INTERVAL_DAYS);

// If interval is 0, job is disabled
if ($intervalDays === 0) {
$this->setInterval(0);
return;
}

// Convert days to seconds for the job interval
$this->setInterval($intervalDays * self::SECONDS_IN_DAY);
}

protected function run($argument): void
{
// Check if cleanup is enabled
$enabled = $this->appConfig->getValueString('app_api', 'docker_cleanup_enabled', 'yes');
if ($enabled !== 'yes') {
$this->logger->debug('Docker image cleanup is disabled');
return;
}

$this->logger->info('Starting Docker image cleanup job');

try {
// Get cleanup filters from config
$filters = [];

// Handle dangling images filter
$pruneDangling = $this->appConfig->getValueString('app_api', 'docker_cleanup_dangling', 'yes');
if ($pruneDangling === 'yes') {
$filters['dangling'] = true;
}

// Handle until timestamp filter
$pruneUntil = $this->appConfig->getValueString('app_api', 'docker_cleanup_until', '');
if ($pruneUntil !== '') {
$filters['until'] = $pruneUntil;
}

// Handle label filters
$pruneLabels = $this->appConfig->getValueString('app_api', 'docker_cleanup_labels', '');
if ($pruneLabels !== '') {
$labels = json_decode($pruneLabels, true);
if (is_array($labels)) {
$filters['label'] = $labels;
}
}

$defaultDaemonConfigName = $this->appConfig->getValueString('app_api', 'default_daemon_config', lazy: true);
$daemonConfig = $this->daemonConfigService->getDaemonConfigByName($defaultDaemonConfigName);

$dockerUrl = $this->dockerActions->buildDockerUrl($daemonConfig);
$this->dockerActions->initGuzzleClient($daemonConfig);

$result = $this->dockerActions->pruneImages($dockerUrl, $filters);

if (empty($result['imagesDeleted'])) {
$this->logger->info(sprintf('No unused Docker images found for daemon %s', $daemonConfig->getName()));
return;
}

$this->logger->info(
sprintf(
'Successfully pruned %d Docker images from daemon %s, reclaimed %d bytes',
count($result['imagesDeleted']),
$daemonConfig->getName(),
$result['spaceReclaimed']
)
);

} catch (\Exception $e) {
$this->logger->error('Error during Docker image cleanup: ' . $e->getMessage());
}
}

}
41 changes: 41 additions & 0 deletions lib/DeployActions/DockerActions.php
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,47 @@ public function buildDockerUrl(DaemonConfig $daemonConfig): string {
return $url;
}

/**
* Prune unused Docker images
*
* @param string $dockerUrl Docker daemon URL
* @param array $filters Optional filters to apply:
* - dangling: bool - When true, prune only unused and untagged images
* - until: string - Prune images created before this timestamp
* - label: array - Prune images with specified labels
* @return array{imagesDeleted: array<string>, spaceReclaimed: int} Result of the prune operation
*/
public function pruneImages(string $dockerUrl, array $filters = []): array {
try {
$url = $this->buildApiUrl($dockerUrl, 'images/prune');
if (!empty($filters)) {
$url .= '?' . http_build_query(['filters' => json_encode($filters)]);
}

$response = $this->guzzleClient->post($url);
$result = json_decode($response->getBody()->getContents(), true);

$this->logger->info(
sprintf(
'Pruned %d Docker images, reclaimed %d bytes',
count($result['ImagesDeleted'] ?? []),
$result['SpaceReclaimed'] ?? 0
)
);

return [
'imagesDeleted' => $result['ImagesDeleted'] ?? [],
'spaceReclaimed' => $result['SpaceReclaimed'] ?? 0
];
} catch (GuzzleException $e) {
$this->logger->error('Error pruning Docker images: ' . $e->getMessage());
return [
'imagesDeleted' => [],
'spaceReclaimed' => 0
];
}
}

public function initGuzzleClient(DaemonConfig $daemonConfig): void {
$guzzleParams = [];
if ($this->isLocalSocket($daemonConfig->getHost())) {
Expand Down
Loading