Add GPU/macvlan passthrough, container edit/update, image prune/tag
Create Container form: - GPU passthrough dropdown (AMD/Intel via /dev/dri detection, NVIDIA excluded since it needs a different runtime) - device paths strictly validated server-side against the host's own detected list. - Macvlan network support: selecting a macvlan network reveals a static IP field and hides port mappings (meaningless once the container has its own LAN address), matching Unraid Docker Manager's "Custom: br0" behavior. Networks panel gained a matching macvlan network-creation flow, with the parent-interface dropdown read from Unraid's own network.cfg so it lists exactly what Docker Manager itself offers. Containers panel: - Edit: reopens the create form pre-filled from the container's current config (image/ports/volumes/env/network/restart policy/GPU/static IP); saving stops+removes the old container and recreates it under the same settings, since podman/Docker have no in-place "modify" API for most of this. - Update: same stop/remove/recreate flow, but pulls the current image first. "Check for Updates" compares each in-use image's local digest against its origin registry (Docker Hub/GHCR/self-hosted registries all verified live) with no podman-side feature backing it - implemented via the registry's own HTTP API. A small log-modal shows progress for both actions instead of a silent wait. - Fixed a real bug hit live: PodmanClient's flat 15s HTTP timeout aborted real image pulls/container creates mid-request; bumped to 600s (nginx already allows up to 640s for this plugin's requests). Images panel: - "Prune unused" (removes every image with zero containers referencing it, not just dangling ones - confirmation copy says so explicitly since this is more aggressive than it sounds) and per-image "Tag". Also several real UI bugs found via live screenshots: unused-image prune having no visible effect until reloaded, table action-button columns drifting row to row (a bare "display:flex" on a <td> was fighting the table layout algorithm), Templates category badges dumping raw multi-tag strings from real Unraid templates, and low-contrast search/filter controls that were nearly invisible against the card background. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
/**
|
||||
* RegistryClient.php
|
||||
*
|
||||
* "Is a newer image available?" — deliberately NOT a podman/libpod feature
|
||||
* (verified live: no libpod endpoint exists for this; every tool that
|
||||
* offers it, Watchtower/Diun/Unraid's own Docker Manager included,
|
||||
* re-implements the same registry-side check). This talks directly to the
|
||||
* target image's own registry using the standard Docker Registry HTTP API
|
||||
* V2: a GET on the manifest returns a "Docker-Content-Digest" header
|
||||
* without downloading any image layers, which is compared against the
|
||||
* digest of the image already pulled locally (PodmanClient::listImages()'s
|
||||
* own "Digest" field) — no local image ever needs pulling just to check.
|
||||
*
|
||||
* The auth flow is the generic Bearer-challenge dance every compliant
|
||||
* registry follows (RFC-ish, not just a Docker Hub thing): an
|
||||
* unauthenticated request gets a 401 with a WWW-Authenticate header naming
|
||||
* a token realm/service/scope, a token is fetched from that realm, and the
|
||||
* manifest request is retried with it. Verified live against three
|
||||
* different registries with three different auth setups — Docker Hub,
|
||||
* ghcr.io, and a self-hosted Gitea registry — using this exact same code
|
||||
* path for all three, not registry-specific special-casing.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class RegistryClient
|
||||
{
|
||||
/**
|
||||
* @return array{updateAvailable?:bool,remoteDigest?:string,error?:string}
|
||||
*/
|
||||
public static function checkForUpdate(string $reference, string $localDigest): array
|
||||
{
|
||||
[$registry, $repo, $tag] = self::parseReference($reference);
|
||||
$manifestUrl = "https://{$registry}/v2/{$repo}/manifests/{$tag}";
|
||||
$accept = 'application/vnd.docker.distribution.manifest.v2+json, ' .
|
||||
'application/vnd.docker.distribution.manifest.list.v2+json, ' .
|
||||
'application/vnd.oci.image.manifest.v1+json, ' .
|
||||
'application/vnd.oci.image.index.v1+json';
|
||||
|
||||
[$status, $headers] = self::httpRequest($manifestUrl, $accept, null);
|
||||
|
||||
if ($status === 401) {
|
||||
$challenge = self::parseAuthChallenge($headers['www-authenticate'] ?? '');
|
||||
if ($challenge === null) {
|
||||
return ['error' => 'Registry requires authentication this app cannot satisfy.'];
|
||||
}
|
||||
$token = self::fetchToken($challenge);
|
||||
if ($token === null) {
|
||||
return ['error' => 'Could not authenticate with the registry.'];
|
||||
}
|
||||
[$status, $headers] = self::httpRequest($manifestUrl, $accept, $token);
|
||||
}
|
||||
|
||||
if ($status !== 200) {
|
||||
return ['error' => "Registry returned HTTP {$status}."];
|
||||
}
|
||||
|
||||
$remoteDigest = $headers['docker-content-digest'] ?? null;
|
||||
if ($remoteDigest === null) {
|
||||
return ['error' => 'Registry response did not include a digest.'];
|
||||
}
|
||||
|
||||
return ['remoteDigest' => $remoteDigest, 'updateAvailable' => $remoteDigest !== $localDigest];
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits "docker.io/library/nginx:alpine" (or shorthand forms like
|
||||
* "nginx:alpine" or "someuser/repo:tag") into [registryHost, repoPath,
|
||||
* tag] — same reference-parsing convention every registry client
|
||||
* (including podman/Docker themselves) uses: the first path segment is
|
||||
* a registry host only if it contains a "." or ":" or is "localhost";
|
||||
* otherwise the whole reference is a Docker Hub repo, implicitly under
|
||||
* "library/" if it has no namespace of its own. docker.io's actual API
|
||||
* host is registry-1.docker.io, not docker.io itself — a Docker-Hub-
|
||||
* specific quirk, not something inferred from the general rule above.
|
||||
*
|
||||
* @return array{0:string,1:string,2:string}
|
||||
*/
|
||||
private static function parseReference(string $reference): array
|
||||
{
|
||||
$reference = explode('@', $reference, 2)[0]; // strip any @sha256:... suffix
|
||||
$tag = 'latest';
|
||||
$lastSlash = strrpos($reference, '/');
|
||||
$lastColon = strrpos($reference, ':');
|
||||
if ($lastColon !== false && ($lastSlash === false || $lastColon > $lastSlash)) {
|
||||
$tag = substr($reference, $lastColon + 1);
|
||||
$reference = substr($reference, 0, $lastColon);
|
||||
}
|
||||
|
||||
$parts = explode('/', $reference);
|
||||
$first = $parts[0];
|
||||
$looksLikeHost = str_contains($first, '.') || str_contains($first, ':') || $first === 'localhost';
|
||||
|
||||
if ($looksLikeHost) {
|
||||
$registry = $first;
|
||||
$repo = implode('/', array_slice($parts, 1));
|
||||
} else {
|
||||
$registry = 'docker.io';
|
||||
$repo = str_contains($reference, '/') ? $reference : "library/{$reference}";
|
||||
}
|
||||
|
||||
if ($registry === 'docker.io') {
|
||||
$registry = 'registry-1.docker.io';
|
||||
}
|
||||
|
||||
return [$registry, $repo, $tag];
|
||||
}
|
||||
|
||||
/** @return array{realm:string,service:string,scope:string}|null */
|
||||
private static function parseAuthChallenge(string $header): ?array
|
||||
{
|
||||
if (preg_match('/realm="([^"]+)"/', $header, $m) !== 1) {
|
||||
return null;
|
||||
}
|
||||
$service = preg_match('/service="([^"]+)"/', $header, $sm) === 1 ? $sm[1] : '';
|
||||
$scope = preg_match('/scope="([^"]+)"/', $header, $om) === 1 ? $om[1] : '';
|
||||
return ['realm' => $m[1], 'service' => $service, 'scope' => $scope];
|
||||
}
|
||||
|
||||
/** @param array{realm:string,service:string,scope:string} $challenge */
|
||||
private static function fetchToken(array $challenge): ?string
|
||||
{
|
||||
$params = array_filter(['service' => $challenge['service'], 'scope' => $challenge['scope']]);
|
||||
$url = $challenge['realm'] . '?' . http_build_query($params);
|
||||
[$status, , $body] = self::httpRequest($url, 'application/json', null, true);
|
||||
if ($status !== 200 || $body === null) {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode($body, true);
|
||||
// The spec allows either key; registries are inconsistent about
|
||||
// which one they actually send.
|
||||
return is_array($decoded) ? (string) ($decoded['token'] ?? $decoded['access_token'] ?? '') ?: null : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:int,1:array<string,string>,2:?string} [status, lowercased response headers, body (only when $withBody)]
|
||||
*/
|
||||
private static function httpRequest(string $url, string $accept, ?string $token, bool $withBody = false): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
$headers = ['Accept: ' . $accept];
|
||||
if ($token !== null) {
|
||||
$headers[] = "Authorization: Bearer {$token}";
|
||||
}
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
]);
|
||||
$raw = curl_exec($ch);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($raw === false) {
|
||||
return [0, [], null];
|
||||
}
|
||||
|
||||
$parsedHeaders = [];
|
||||
foreach (explode("\r\n", substr($raw, 0, $headerSize)) as $line) {
|
||||
if (str_contains($line, ':')) {
|
||||
[$k, $v] = explode(':', $line, 2);
|
||||
$parsedHeaders[strtolower(trim($k))] = trim($v);
|
||||
}
|
||||
}
|
||||
$body = $withBody ? substr($raw, $headerSize) : null;
|
||||
return [$status, $parsedHeaders, $body];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user