release: r016 fix Docker update cache matching

This commit is contained in:
Mikei386
2026-08-23 14:27:46 +02:00
parent c4c04ae7af
commit 9d46a8c4b6
11 changed files with 178 additions and 37 deletions
+44
View File
@@ -0,0 +1,44 @@
<?php
require_once __DIR__ . '/unraid-mcp-status-helper.php';
function expect_same(string $label, mixed $expected, mixed $actual): void {
if ($expected === $actual) {
echo "PASS: $label\n";
return;
}
fwrite(STDERR, "FAIL: $label\nExpected: " . json_encode($expected) . "\nActual: " . json_encode($actual) . "\n");
exit(1);
}
$native = [
'Windows11' => 'false',
'WindowsXP' => false,
'AirConnect' => ['status' => 'true'],
];
expect_same('native container-name key', 'update-available', docker_update_state($native, 'Windows11', 'dockurr/windows'));
expect_same('boolean false is an update', 'update-available', docker_update_state($native, 'WindowsXP', 'dockurr/windows'));
expect_same('object status is understood', 'up-to-date', docker_update_state($native, 'AirConnect', '1activegeek/airconnect:latest'));
$legacy = [
'library/redis:latest' => ['status' => 'false'],
'dockurr/windows:latest' => ['updated' => true],
];
expect_same('Docker Hub library alias', 'update-available', docker_update_state($legacy, 'Redis', 'redis'));
expect_same('implicit latest tag', 'up-to-date', docker_update_state($legacy, 'Windows11', 'dockurr/windows'));
$list = [
['container' => 'Virtual-DSM', 'image' => 'vdsm/virtual-dsm:latest', 'updateAvailable' => true],
];
expect_same('list format with updateAvailable', 'update-available', docker_update_state($list, 'Virtual-DSM', 'vdsm/virtual-dsm:latest'));
$summary = docker_update_status_from(
['Windows11' => 'false', 'AirConnect' => 'true'],
['Windows11|dockurr/windows', 'AirConnect|1activegeek/airconnect:latest', 'Mystery|example/mystery'],
);
expect_same('known update count', 1, $summary['updates_available']);
expect_same('unknown count', 1, $summary['unknown_count']);
expect_same('incomplete cache is explicit', false, $summary['status_complete']);
expect_same('reported count marked as minimum', true, $summary['updates_available_is_minimum']);
echo "All Docker update cache tests passed.\n";
+117 -24
View File
@@ -234,36 +234,129 @@ function shares_list(?string $requested): array {
return ['schema_version' => '1.0', 'share_count' => count($items), 'shares' => $items];
}
function docker_update_state_value(mixed $value): ?string {
if (is_array($value)) {
foreach (['update_available', 'updateAvailable'] as $field) {
if (!array_key_exists($field, $value)) continue;
$flag = $value[$field];
if (is_bool($flag)) return $flag ? 'update-available' : 'up-to-date';
$flag = strtolower(trim((string)$flag));
if (in_array($flag, ['true', '1', 'yes'], true)) return 'update-available';
if (in_array($flag, ['false', '0', 'no'], true)) return 'up-to-date';
}
foreach (['status', 'updated', 'update_status', 'updateStatus'] as $field) {
if (array_key_exists($field, $value)) return docker_update_state_value($value[$field]);
}
return null;
}
if (is_bool($value)) return $value ? 'up-to-date' : 'update-available';
if (!is_string($value) && !is_int($value)) return null;
$state = strtolower(trim((string)$value));
if (in_array($state, ['true', '1', 'up-to-date', 'up_to_date', 'current', 'updated'], true)) {
return 'up-to-date';
}
if (in_array($state, ['false', '0', 'update-available', 'update_available', 'outdated'], true)) {
return 'update-available';
}
return null;
}
function docker_image_aliases(string $image): array {
$image = trim($image);
$without_digest = preg_replace('/@sha256:[a-f0-9]+$/i', '', $image) ?? $image;
$aliases = [$image, $without_digest];
$last_part = basename($without_digest);
if (!str_contains($last_part, ':')) $aliases[] = $without_digest . ':latest';
if (!str_contains($without_digest, '/')) {
$aliases[] = 'library/' . $without_digest;
if (!str_contains($last_part, ':')) $aliases[] = 'library/' . $without_digest . ':latest';
} elseif (str_starts_with($without_digest, 'library/')) {
$aliases[] = substr($without_digest, strlen('library/'));
}
return array_values(array_unique(array_filter($aliases, fn($value) => $value !== '')));
}
function docker_update_state(array $updates, string $name, string $image): ?string {
// Unraid's native Docker manager stores the cache by container name.
// Older/plugin-generated caches may instead use an image reference, so
// keep those forms as compatibility fallbacks.
$cache = isset($updates['containers']) && is_array($updates['containers'])
? $updates['containers'] : $updates;
$keys = array_values(array_unique(array_merge([$name], docker_image_aliases($image))));
$key_lookup = [];
foreach ($cache as $key => $_value) {
if (is_string($key)) $key_lookup[strtolower($key)] = $key;
}
foreach ($keys as $candidate) {
$actual = $key_lookup[strtolower($candidate)] ?? null;
if ($actual === null) continue;
$state = docker_update_state_value($cache[$actual]);
if ($state !== null) return $state;
}
// Also understand list/digest keyed formats that carry their identity in
// the entry itself. This prevents a future Unraid cache format change from
// turning every container into an unexplained `unknown` again.
foreach ($cache as $entry) {
if (!is_array($entry)) continue;
$entry_name = (string)($entry['container'] ?? $entry['container_name'] ?? $entry['name'] ?? '');
$entry_image = (string)($entry['image'] ?? $entry['repository'] ?? '');
$name_matches = $entry_name !== '' && strcasecmp($entry_name, $name) === 0;
$image_matches = $entry_image !== '' && in_array(
strtolower($entry_image),
array_map('strtolower', docker_image_aliases($image)),
true,
);
if (!$name_matches && !$image_matches) continue;
$state = docker_update_state_value($entry);
if ($state !== null) return $state;
}
return null;
}
function docker_update_status_from(array $updates, array $rows): array {
$items = [];
foreach ($rows as $row) {
[$name, $image] = array_pad(explode('|', $row, 2), 2, '');
$state = docker_update_state($updates, $name, $image);
$items[] = [
'container' => $name,
'image' => $image,
'status' => $state ?? 'unknown',
];
}
$available = count(array_filter($items, fn($x) => $x['status'] === 'update-available'));
$unknown = count(array_filter($items, fn($x) => $x['status'] === 'unknown'));
return [
'schema_version' => '1.1',
'container_count' => count($items),
'updates_available' => $available,
'unknown_count' => $unknown,
'status_complete' => $unknown === 0,
'updates_available_is_minimum' => $unknown > 0,
'containers' => $items,
];
}
function docker_update_status(): array {
$path = '/var/lib/docker/unraid-update-status.json';
$updates = is_readable($path) ? json_decode((string)file_get_contents($path), true) : [];
if (!is_array($updates)) $updates = [];
$rows = [];
exec('/usr/bin/docker ps -a --format ' . escapeshellarg('{{.Names}}|{{.Image}}'), $rows);
$items = [];
foreach ($rows as $row) {
[$name, $image] = array_pad(explode('|', $row, 2), 2, '');
$key = $image;
if (!isset($updates[$key]) && !str_contains($key, '/')) $key = 'library/' . $key;
$state = $updates[$key]['status'] ?? null;
$items[] = [
'container' => $name,
'image' => $image,
'status' => $state === 'true' ? 'up-to-date' : ($state === 'false' ? 'update-available' : 'unknown'),
];
}
$available = count(array_filter($items, fn($x) => $x['status'] === 'update-available'));
return ['schema_version' => '1.0', 'container_count' => count($items), 'updates_available' => $available, 'containers' => $items];
return docker_update_status_from($updates, $rows);
}
$action = $argv[1] ?? '';
switch ($action) {
case 'system-health': respond(system_health()); break;
case 'storage-status': respond(storage_status()); break;
case 'disk-health': respond(disk_health($argv[2] ?? null)); break;
case 'notifications': respond(notification_list((int)($argv[2] ?? 20), $argv[3] ?? 'all')); break;
case 'shares-list': respond(shares_list(null)); break;
case 'share-inspect': respond(shares_list($argv[2] ?? '')); break;
case 'docker-update-status': respond(docker_update_status()); break;
default: fail_status('Unknown read-only status action');
if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
$action = $argv[1] ?? '';
switch ($action) {
case 'system-health': respond(system_health()); break;
case 'storage-status': respond(storage_status()); break;
case 'disk-health': respond(disk_health($argv[2] ?? null)); break;
case 'notifications': respond(notification_list((int)($argv[2] ?? 20), $argv[3] ?? 'all')); break;
case 'shares-list': respond(shares_list(null)); break;
case 'share-inspect': respond(shares_list($argv[2] ?? '')); break;
case 'docker-update-status': respond(docker_update_status()); break;
default: fail_status('Unknown read-only status action');
}
}