detect newer version tags for pinned images and show them as a badge with release notes
This commit is contained in:
12 files changed
+456
-45
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Dialog from '$lib/components/ui/dialog';
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Box, ArrowRight, ExternalLink, Copy, Check, RefreshCw, ChevronDown, BookOpen } from 'lucide-svelte';
|
import { Box, ArrowRight, ExternalLink, Copy, Check, RefreshCw, ChevronDown, BookOpen, ShieldCheck, Info } from 'lucide-svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { appendEnvParam } from '$lib/stores/environment';
|
import { appendEnvParam } from '$lib/stores/environment';
|
||||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
let source = $state<string | null>(null);
|
let source = $state<string | null>(null);
|
||||||
let loadedFor = $state<string | null>(null);
|
let loadedFor = $state<string | null>(null);
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
|
let rateLimited = $state(false);
|
||||||
|
|
||||||
// Fetch notes when the modal opens for a (container, target) pair we haven't loaded.
|
// Fetch notes when the modal opens for a (container, target) pair we haven't loaded.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -72,6 +73,7 @@
|
|||||||
notes = [];
|
notes = [];
|
||||||
changelogUrl = null;
|
changelogUrl = null;
|
||||||
source = null;
|
source = null;
|
||||||
|
rateLimited = false;
|
||||||
try {
|
try {
|
||||||
const qs = `versions=${encodeURIComponent(versions.join(','))}`;
|
const qs = `versions=${encodeURIComponent(versions.join(','))}`;
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
@@ -82,6 +84,7 @@
|
|||||||
notes = data.notes ?? [];
|
notes = data.notes ?? [];
|
||||||
changelogUrl = data.changelogUrl ?? null;
|
changelogUrl = data.changelogUrl ?? null;
|
||||||
source = data.source ?? null;
|
source = data.source ?? null;
|
||||||
|
rateLimited = data.rateLimited ?? false;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// leave notes empty -> the "no notes" fallback renders
|
// leave notes empty -> the "no notes" fallback renders
|
||||||
@@ -90,11 +93,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Repo without the tag, e.g. `ghcr.io/owner/app` from `ghcr.io/owner/app:1.2`.
|
||||||
|
const repoBase = $derived.by(() => {
|
||||||
|
const img = container?.image ?? '';
|
||||||
|
return img.slice(0, img.lastIndexOf(':') > img.lastIndexOf('/') ? img.lastIndexOf(':') : img.length);
|
||||||
|
});
|
||||||
|
|
||||||
async function copyTag() {
|
async function copyTag() {
|
||||||
if (!container || !newerVersion) return;
|
if (!container || !newerVersion) return;
|
||||||
const img = container.image;
|
const ok = await copyToClipboard(`${repoBase}:${newerVersion.tag}`);
|
||||||
const base = img.slice(0, img.lastIndexOf(':') > img.lastIndexOf('/') ? img.lastIndexOf(':') : img.length);
|
|
||||||
const ok = await copyToClipboard(`${base}:${newerVersion.tag}`);
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
copied = true;
|
copied = true;
|
||||||
toast.success('New image tag copied');
|
toast.success('New image tag copied');
|
||||||
@@ -102,6 +109,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Digest-pinned form of the new tag (repo:tag@sha256).
|
||||||
|
let copiedPinned = $state(false);
|
||||||
|
async function copyPinnedTag() {
|
||||||
|
if (!container || !newerVersion?.digest) return;
|
||||||
|
const ok = await copyToClipboard(`${repoBase}:${newerVersion.tag}@${newerVersion.digest}`);
|
||||||
|
if (ok) {
|
||||||
|
copiedPinned = true;
|
||||||
|
toast.success('New tag with digest copied');
|
||||||
|
setTimeout(() => (copiedPinned = false), 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
container = null;
|
container = null;
|
||||||
}
|
}
|
||||||
@@ -110,6 +129,20 @@
|
|||||||
function noteFor(version: string): ReleaseNote | undefined {
|
function noteFor(version: string): ReleaseNote | undefined {
|
||||||
return notes.find((n) => n.version === version);
|
return notes.find((n) => n.version === version);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A DOM-safe id for a version's release-note <details>, so the Path links can
|
||||||
|
// open + scroll to it. Non-alphanumerics -> '-' (tags like `16.4-alpine` are fine).
|
||||||
|
function noteId(version: string): string {
|
||||||
|
return `note-${version.replace(/[^a-zA-Z0-9]+/g, '-')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clicking a version in the Path opens its note and scrolls it into view.
|
||||||
|
function jumpToNote(version: string) {
|
||||||
|
const el = document.getElementById(noteId(version));
|
||||||
|
if (!el) return;
|
||||||
|
if (el instanceof HTMLDetailsElement) el.open = true;
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root {open} onOpenChange={(v) => { if (!v) close(); }}>
|
<Dialog.Root {open} onOpenChange={(v) => { if (!v) close(); }}>
|
||||||
@@ -123,6 +156,18 @@
|
|||||||
<span class="text-muted-foreground font-normal">{currentTag}</span>
|
<span class="text-muted-foreground font-normal">{currentTag}</span>
|
||||||
<ArrowRight class="w-4 h-4 text-muted-foreground shrink-0" />
|
<ArrowRight class="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
<span class="font-semibold text-amber-400">{newerVersion.tag}</span>
|
<span class="font-semibold text-amber-400">{newerVersion.tag}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={copyTag}
|
||||||
|
title={copied ? 'Copied!' : 'Copy new tag'}
|
||||||
|
class="inline-flex items-center p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{#if copied}
|
||||||
|
<Check class="w-3.5 h-3.5 text-green-500" />
|
||||||
|
{:else}
|
||||||
|
<Copy class="w-3.5 h-3.5" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<span class="ml-auto flex items-center gap-2">
|
<span class="ml-auto flex items-center gap-2">
|
||||||
<span class="rounded-full border px-2 py-0.5 text-2xs font-semibold uppercase {bumpColor}">
|
<span class="rounded-full border px-2 py-0.5 text-2xs font-semibold uppercase {bumpColor}">
|
||||||
@@ -133,19 +178,44 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<Dialog.Description>
|
|
||||||
Advisory only. Dockhand never changes the tag in your compose - bump it yourself.
|
|
||||||
</Dialog.Description>
|
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
<!-- Version path -->
|
<!-- Digest-pinned reference for the new tag (repo:tag@sha256), inline copy. -->
|
||||||
|
{#if newerVersion.digest}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={copyPinnedTag}
|
||||||
|
title={copiedPinned ? 'Copied!' : 'Copy the new tag pinned to its digest (tag@sha256)'}
|
||||||
|
class="group flex items-center gap-1.5 w-full text-left px-2 py-1.5 rounded-md border border-border bg-muted/30 hover:bg-muted/60 transition-colors cursor-pointer focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<ShieldCheck class="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400 shrink-0" />
|
||||||
|
<code class="text-xs text-muted-foreground truncate min-w-0 flex-1">{repoBase}:{newerVersion.tag}@{newerVersion.digest}</code>
|
||||||
|
{#if copiedPinned}
|
||||||
|
<Check class="w-3.5 h-3.5 text-green-500 shrink-0" />
|
||||||
|
{:else}
|
||||||
|
<Copy class="w-3.5 h-3.5 text-muted-foreground group-hover:text-foreground shrink-0" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Version path. A version with release notes is a link that opens + scrolls to them. -->
|
||||||
{#if versionPath.length > 1}
|
{#if versionPath.length > 1}
|
||||||
<div class="flex items-center gap-1.5 flex-wrap text-xs px-1">
|
<div class="flex items-center gap-1.5 flex-wrap text-xs px-1">
|
||||||
<span class="text-muted-foreground">Path:</span>
|
<span class="text-muted-foreground">Path:</span>
|
||||||
<span class="font-mono text-muted-foreground">{currentTag}</span>
|
<span class="font-mono text-muted-foreground">{currentTag}</span>
|
||||||
{#each versionPath as v, i}
|
{#each versionPath as v, i}
|
||||||
|
{@const isTarget = i === versionPath.length - 1}
|
||||||
<ArrowRight class="w-3 h-3 text-muted-foreground/60 shrink-0" />
|
<ArrowRight class="w-3 h-3 text-muted-foreground/60 shrink-0" />
|
||||||
<span class="font-mono {i === versionPath.length - 1 ? 'text-amber-400 font-semibold' : 'text-foreground'}">{v}</span>
|
{#if noteFor(v)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => jumpToNote(v)}
|
||||||
|
title="Jump to release notes for {v}"
|
||||||
|
class="font-mono cursor-pointer transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-ring rounded {isTarget ? 'text-amber-400 font-semibold' : 'text-foreground hover:text-amber-400'}"
|
||||||
|
>{v}</button>
|
||||||
|
{:else}
|
||||||
|
<span class="font-mono {isTarget ? 'text-amber-400 font-semibold' : 'text-foreground'}">{v}</span>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -161,7 +231,7 @@
|
|||||||
{#each versionPath.slice().reverse() as version}
|
{#each versionPath.slice().reverse() as version}
|
||||||
{@const note = noteFor(version)}
|
{@const note = noteFor(version)}
|
||||||
{#if note}
|
{#if note}
|
||||||
<details class="group rounded-md border border-border bg-muted/30 [&_summary::-webkit-details-marker]:hidden" open={version === newerVersion.tag || versionPath.length <= 3}>
|
<details id={noteId(version)} class="group rounded-md border border-border bg-muted/30 [&_summary::-webkit-details-marker]:hidden" open={version === newerVersion.tag || versionPath.length <= 3}>
|
||||||
<summary class="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left hover:bg-muted/50 transition-colors rounded-md list-none">
|
<summary class="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left hover:bg-muted/50 transition-colors rounded-md list-none">
|
||||||
<ChevronDown class="w-4 h-4 text-muted-foreground shrink-0 transition-transform group-open:rotate-0 -rotate-90" />
|
<ChevronDown class="w-4 h-4 text-muted-foreground shrink-0 transition-transform group-open:rotate-0 -rotate-90" />
|
||||||
<span class="font-mono text-sm font-semibold">{note.name || note.githubTag}</span>
|
<span class="font-mono text-sm font-semibold">{note.name || note.githubTag}</span>
|
||||||
@@ -187,22 +257,40 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Fallback: no per-version notes found -->
|
<!-- Fallback: no per-version notes found -->
|
||||||
<div class="flex flex-col items-center justify-center gap-3 py-10 text-center">
|
{#if rateLimited}
|
||||||
<BookOpen class="w-8 h-8 text-muted-foreground/50" />
|
<div class="flex flex-col items-start gap-3 py-8">
|
||||||
<p class="text-sm text-muted-foreground max-w-sm">
|
<div class="flex items-start gap-2.5">
|
||||||
|
<Info class="w-5 h-5 shrink-0 text-amber-500 mt-0.5" />
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
GitHub's rate limit was hit while fetching release notes. Set a
|
||||||
|
<code class="text-xs">DOCKHAND_GITHUB_TOKEN</code> (a personal access token, no scopes needed)
|
||||||
|
to raise the limit from 60 to 5000 requests/hour.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{#if changelogUrl}
|
{#if changelogUrl}
|
||||||
Release notes for these versions aren't available inline.
|
<Button variant="outline" size="sm" href={changelogUrl} target="_blank" rel="noopener noreferrer">
|
||||||
{:else}
|
<ExternalLink class="w-3.5 h-3.5 mr-1.5" /> View changelog
|
||||||
This image doesn't publish release notes Dockhand can read
|
</Button>
|
||||||
(no <code class="text-xs">org.opencontainers.image.source</code> label pointing at a GitHub or Gitea/Forgejo repo).
|
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</div>
|
||||||
{#if changelogUrl}
|
{:else}
|
||||||
<Button variant="outline" size="sm" href={changelogUrl} target="_blank" rel="noopener noreferrer">
|
<div class="flex flex-col items-center justify-center gap-3 py-10 text-center">
|
||||||
<ExternalLink class="w-3.5 h-3.5 mr-1.5" /> View changelog
|
<BookOpen class="w-8 h-8 text-muted-foreground/50" />
|
||||||
</Button>
|
<p class="text-sm text-muted-foreground max-w-sm">
|
||||||
{/if}
|
{#if changelogUrl}
|
||||||
</div>
|
Release notes for these versions aren't available inline.
|
||||||
|
{:else}
|
||||||
|
This image doesn't publish release notes Dockhand can read
|
||||||
|
(no <code class="text-xs">org.opencontainers.image.source</code> label pointing at a GitHub or Gitea/Forgejo repo).
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{#if changelogUrl}
|
||||||
|
<Button variant="outline" size="sm" href={changelogUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink class="w-3.5 h-3.5 mr-1.5" /> View changelog
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -213,14 +301,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button variant="outline" size="sm" onclick={copyTag}>
|
<Button variant="outline" size="sm" onclick={close}>Close</Button>
|
||||||
{#if copied}
|
|
||||||
<Check class="w-3.5 h-3.5 mr-1.5 text-green-500" /> Copied
|
|
||||||
{:else}
|
|
||||||
<Copy class="w-3.5 h-3.5 mr-1.5" /> Copy new tag
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" onclick={close}>Close</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Footer>
|
</Dialog.Footer>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -8651,6 +8651,9 @@
|
|||||||
"source": {
|
"source": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"rateLimited": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ import { resolveNanoCpusConflict, resolvePodmanUsernsMode } from './hostconfig-r
|
|||||||
import { parseImageReference } from './registry/image-ref';
|
import { parseImageReference } from './registry/image-ref';
|
||||||
export { parseImageReference } from './registry/image-ref';
|
export { parseImageReference } from './registry/image-ref';
|
||||||
import { rebaseEnvOntoImage, rebaseLabelsOntoImage, rebaseCommand, describeEnvRebase, describeLabelRebase, type ImageEnvLabels } from './container-env-merge';
|
import { rebaseEnvOntoImage, rebaseLabelsOntoImage, rebaseCommand, describeEnvRebase, describeLabelRebase, type ImageEnvLabels } from './container-env-merge';
|
||||||
import { encodeRegistryAuth, fetchRegistryToken } from './registry-auth';
|
import { encodeRegistryAuth, fetchRegistryToken, isSafeRegistryHost } from './registry-auth';
|
||||||
|
import { classifyManifest, type ArtifactKind } from './semver/manifest-artifact';
|
||||||
import { isSystemContainer, classifyEmptyDigestImage, localDigestIsIndexChild } from './scheduler/tasks/update-utils';
|
import { isSystemContainer, classifyEmptyDigestImage, localDigestIsIndexChild } from './scheduler/tasks/update-utils';
|
||||||
import { deepDiff } from '../utils/diff.js';
|
import { deepDiff } from '../utils/diff.js';
|
||||||
import { getInstanceId } from './backups/identity';
|
import { getInstanceId } from './backups/identity';
|
||||||
@@ -3129,15 +3130,22 @@ export async function findRegistryCredentials(registryHost: string): Promise<{ u
|
|||||||
*/
|
*/
|
||||||
async function getRegistryBearerToken(registry: string, repo: string): Promise<string | null> {
|
async function getRegistryBearerToken(registry: string, repo: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
|
const hostSafety = isSafeRegistryHost(registry);
|
||||||
|
if (!hostSafety.ok) {
|
||||||
|
console.error(`[Registry] Refusing token request for ${registry}: ${hostSafety.reason}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const registryUrl = `https://${registry}`;
|
const registryUrl = `https://${registry}`;
|
||||||
|
|
||||||
// Look up stored credentials for this registry
|
// Look up stored credentials for this registry
|
||||||
const credentials = await findRegistryCredentials(registry);
|
const credentials = await findRegistryCredentials(registry);
|
||||||
|
|
||||||
// Step 1: Challenge request to /v2/
|
// Step 1: Challenge request to /v2/
|
||||||
|
// Do not follow redirects on the challenge; a 3xx is treated as a non-401 below.
|
||||||
const challengeResponse = await fetch(`${registryUrl}/v2/`, {
|
const challengeResponse = await fetch(`${registryUrl}/v2/`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'User-Agent': 'Dockhand/1.0' }
|
headers: { 'User-Agent': 'Dockhand/1.0' },
|
||||||
|
redirect: 'manual'
|
||||||
});
|
});
|
||||||
|
|
||||||
// If 200, no auth needed
|
// If 200, no auth needed
|
||||||
@@ -3258,12 +3266,19 @@ export async function getRegistryAuthHeader(
|
|||||||
try {
|
try {
|
||||||
// Parse URL to extract host (V2 API is always at the host root)
|
// Parse URL to extract host (V2 API is always at the host root)
|
||||||
const parsed = parseRegistryUrl(registryUrl);
|
const parsed = parseRegistryUrl(registryUrl);
|
||||||
|
const hostSafety = isSafeRegistryHost(parsed.host);
|
||||||
|
if (!hostSafety.ok) {
|
||||||
|
console.error(`[Registry] Refusing auth challenge for ${parsed.host}: ${hostSafety.reason}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const apiBaseUrl = `${parsed.protocol}://${parsed.host}`;
|
const apiBaseUrl = `${parsed.protocol}://${parsed.host}`;
|
||||||
|
|
||||||
// Step 1: Challenge request to /v2/ (always at registry root, not under org path)
|
// Step 1: Challenge request to /v2/ (always at registry root, not under org path).
|
||||||
|
// Do not follow redirects; a 3xx is treated as a non-401 below.
|
||||||
const challengeResponse = await fetch(`${apiBaseUrl}/v2/`, {
|
const challengeResponse = await fetch(`${apiBaseUrl}/v2/`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'User-Agent': 'Dockhand/1.0' }
|
headers: { 'User-Agent': 'Dockhand/1.0' },
|
||||||
|
redirect: 'manual'
|
||||||
});
|
});
|
||||||
|
|
||||||
// If 200, no auth needed
|
// If 200, no auth needed
|
||||||
@@ -3638,6 +3653,7 @@ export async function harborSearchRepositories(
|
|||||||
export async function getRegistryManifestDigest(imageName: string): Promise<string | null> {
|
export async function getRegistryManifestDigest(imageName: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { registry, repo, tag } = parseImageReference(imageName);
|
const { registry, repo, tag } = parseImageReference(imageName);
|
||||||
|
if (!isSafeRegistryHost(registry).ok) return null;
|
||||||
const token = await getRegistryBearerToken(registry, repo);
|
const token = await getRegistryBearerToken(registry, repo);
|
||||||
const manifestUrl = `https://${registry}/v2/${repo}/manifests/${tag}`;
|
const manifestUrl = `https://${registry}/v2/${repo}/manifests/${tag}`;
|
||||||
|
|
||||||
@@ -3679,6 +3695,54 @@ export async function getRegistryManifestDigest(imageName: string): Promise<stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify what a `registry/repo:tag` actually is (image vs Helm chart vs other
|
||||||
|
* OCI artifact) by GET-ing its manifest, and return its manifest digest from the
|
||||||
|
* same response. Used by the semver check so a Helm chart tag isn't offered as a
|
||||||
|
* newer version, and so the offered image tag can be shown/copied digest-pinned
|
||||||
|
* without a second request. Returns kind:'image' on any failure (fail-open - never
|
||||||
|
* hide a real update because a probe failed); digest is null when unavailable.
|
||||||
|
* One request per call; the caller probes only the candidate tag.
|
||||||
|
*/
|
||||||
|
export async function getTagArtifactKind(
|
||||||
|
registry: string,
|
||||||
|
repo: string,
|
||||||
|
tag: string
|
||||||
|
): Promise<{ kind: ArtifactKind; digest: string | null }> {
|
||||||
|
try {
|
||||||
|
if (!isSafeRegistryHost(registry).ok) return { kind: 'image', digest: null };
|
||||||
|
const token = await getRegistryBearerToken(registry, repo);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'User-Agent': 'Dockhand/1.0',
|
||||||
|
'Accept': [
|
||||||
|
'application/vnd.oci.image.index.v1+json',
|
||||||
|
'application/vnd.docker.distribution.manifest.list.v2+json',
|
||||||
|
'application/vnd.oci.image.manifest.v1+json',
|
||||||
|
'application/vnd.docker.distribution.manifest.v2+json'
|
||||||
|
].join(', ')
|
||||||
|
};
|
||||||
|
if (token) headers['Authorization'] = token;
|
||||||
|
|
||||||
|
const res = await fetch(`https://${registry}/v2/${repo}/manifests/${tag}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(8000)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
await drainResponse(res);
|
||||||
|
return { kind: 'image', digest: null };
|
||||||
|
}
|
||||||
|
const digest = res.headers.get('Docker-Content-Digest');
|
||||||
|
const topMediaType = res.headers.get('Content-Type');
|
||||||
|
const body = (await res.json().catch(() => null)) as
|
||||||
|
| { mediaType?: string; config?: { mediaType?: string }; manifests?: unknown[] }
|
||||||
|
| null;
|
||||||
|
return { kind: classifyManifest(body, topMediaType), digest };
|
||||||
|
} catch {
|
||||||
|
return { kind: 'image', digest: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A multi-arch tag is a manifest list / OCI image index: the list has one digest,
|
* A multi-arch tag is a manifest list / OCI image index: the list has one digest,
|
||||||
* and each per-architecture child manifest has its own. `docker pull` usually records
|
* and each per-architecture child manifest has its own. `docker pull` usually records
|
||||||
@@ -3699,6 +3763,7 @@ async function localDigestMatchesRegistryChild(
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const { registry, repo, tag } = parseImageReference(imageName);
|
const { registry, repo, tag } = parseImageReference(imageName);
|
||||||
|
if (!isSafeRegistryHost(registry).ok) return false;
|
||||||
const token = await getRegistryBearerToken(registry, repo);
|
const token = await getRegistryBearerToken(registry, repo);
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'User-Agent': 'Dockhand/1.0',
|
'User-Agent': 'Dockhand/1.0',
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
removeTempImage,
|
removeTempImage,
|
||||||
tagImage,
|
tagImage,
|
||||||
inspectImage,
|
inspectImage,
|
||||||
|
getTagArtifactKind,
|
||||||
} from '../../docker';
|
} from '../../docker';
|
||||||
import type { ImageEnvLabels } from '../../container-env-merge';
|
import type { ImageEnvLabels } from '../../container-env-merge';
|
||||||
import { sendEventNotification } from '../../notifications';
|
import { sendEventNotification } from '../../notifications';
|
||||||
@@ -227,7 +228,7 @@ export async function runEnvUpdateCheckJob(
|
|||||||
// A `dockhand.version.pattern` label lets a container teach the check
|
// A `dockhand.version.pattern` label lets a container teach the check
|
||||||
// how to read its own non-standard tags (CalVer+hash, etc.).
|
// how to read its own non-standard tags (CalVer+hash, etc.).
|
||||||
const versionPattern = getVersionPatternOverride(inspectData.Config?.Labels);
|
const versionPattern = getVersionPatternOverride(inspectData.Config?.Labels);
|
||||||
const newer = await checkNewerVersion(imageName, { ...semverOptions, versionPattern }).catch(() => null);
|
const newer = await checkNewerVersion(imageName, { ...semverOptions, versionPattern }, getTagArtifactKind).catch(() => null);
|
||||||
if (newer) {
|
if (newer) {
|
||||||
semverByContainer.set(container.id, newer);
|
semverByContainer.set(container.id, newer);
|
||||||
await log(` NEWER VERSION: ${newer.tag} (${newer.bump})`);
|
await log(` NEWER VERSION: ${newer.tag} (${newer.bump})`);
|
||||||
|
|||||||
@@ -6,18 +6,33 @@
|
|||||||
import { parseImageReference } from '../registry/image-ref';
|
import { parseImageReference } from '../registry/image-ref';
|
||||||
import { parseTag } from './tag-parser';
|
import { parseTag } from './tag-parser';
|
||||||
import { listVersionTags } from './tag-source';
|
import { listVersionTags } from './tag-source';
|
||||||
import { findNewerVersionTag, type FindNewerOptions, type NewerVersion } from './find-newer';
|
import { findNewerVersionTag, findNewerImageTag, type FindNewerOptions, type NewerVersion } from './find-newer';
|
||||||
|
import type { ArtifactKind } from './manifest-artifact';
|
||||||
|
|
||||||
|
/** Probe a single tag's artifact kind + manifest digest. Injected so check.ts stays unit-testable. */
|
||||||
|
export type TagKindProbe = (
|
||||||
|
registry: string,
|
||||||
|
repo: string,
|
||||||
|
tag: string
|
||||||
|
) => Promise<{ kind: ArtifactKind; digest: string | null }>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the newer-version suggestion for `imageRef`, or null when the current
|
* Returns the newer-version suggestion for `imageRef`, or null when the current
|
||||||
* tag isn't a version (floating) or nothing newer is published. Never throws —
|
* tag isn't a version (floating) or nothing newer is published. Never throws —
|
||||||
* a registry failure yields null, so the update check degrades gracefully.
|
* a registry failure yields null, so the update check degrades gracefully.
|
||||||
|
*
|
||||||
|
* When `probeTagKind` is supplied, the chosen candidate is verified to be a real
|
||||||
|
* container image, not a Helm chart / other OCI artifact published into the same
|
||||||
|
* repo (monorepos do this). A non-image target is dropped and the next-highest
|
||||||
|
* version is tried. Only the candidate is probed. A probe failure is treated as
|
||||||
|
* "is an image" (fail-open) so a registry hiccup never hides a real update.
|
||||||
*/
|
*/
|
||||||
export async function checkNewerVersion(
|
export async function checkNewerVersion(
|
||||||
imageRef: string,
|
imageRef: string,
|
||||||
options: FindNewerOptions = {}
|
options: FindNewerOptions = {},
|
||||||
|
probeTagKind?: TagKindProbe
|
||||||
): Promise<NewerVersion | null> {
|
): Promise<NewerVersion | null> {
|
||||||
const { tag } = parseImageReference(imageRef);
|
const { registry, repo, tag } = parseImageReference(imageRef);
|
||||||
|
|
||||||
// Floating tag -> nothing to compare, and we skip the registry call entirely.
|
// Floating tag -> nothing to compare, and we skip the registry call entirely.
|
||||||
// Parse with the same override find-newer will use, so a custom-scheme tag
|
// Parse with the same override find-newer will use, so a custom-scheme tag
|
||||||
@@ -25,5 +40,20 @@ export async function checkNewerVersion(
|
|||||||
if (!parseTag(tag, options.versionPattern ?? null)) return null;
|
if (!parseTag(tag, options.versionPattern ?? null)) return null;
|
||||||
|
|
||||||
const tags = await listVersionTags(imageRef);
|
const tags = await listVersionTags(imageRef);
|
||||||
return findNewerVersionTag(tag, tags, options);
|
|
||||||
|
if (!probeTagKind) return findNewerVersionTag(tag, tags, options);
|
||||||
|
|
||||||
|
return findNewerImageTag(
|
||||||
|
tag,
|
||||||
|
tags,
|
||||||
|
async (candidate) => {
|
||||||
|
try {
|
||||||
|
const { kind, digest } = await probeTagKind(registry, repo, candidate);
|
||||||
|
return { ok: kind === 'image', digest };
|
||||||
|
} catch {
|
||||||
|
return { ok: true }; // fail-open: never hide a real update on a probe error.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -40,6 +40,8 @@ export interface NewerVersion {
|
|||||||
bump: VersionBump;
|
bump: VersionBump;
|
||||||
/** Every newer version between current and target (exclusive of current, inclusive of target), ordered. */
|
/** Every newer version between current and target (exclusive of current, inclusive of target), ordered. */
|
||||||
skipped: string[];
|
skipped: string[];
|
||||||
|
/** The target tag's manifest digest (`sha256:...`), when probed. Lets the UI show/copy the new tag digest-pinned. */
|
||||||
|
digest?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Which segment first differs decides the bump: [0]=major, [1]=minor, else patch. */
|
/** Which segment first differs decides the bump: [0]=major, [1]=minor, else patch. */
|
||||||
@@ -122,3 +124,35 @@ export function findNewerVersionTag(
|
|||||||
skipped: deduped.map((c) => c.tag)
|
skipped: deduped.map((c) => c.tag)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like findNewerVersionTag, but verifies the chosen target is a real container
|
||||||
|
* image before offering it - dropping any tag that turns out to be a Helm chart or
|
||||||
|
* other non-image OCI artifact published into the same repo, and re-picking the
|
||||||
|
* next-highest version. `probe` returns `{ ok, digest }` where ok=true means a
|
||||||
|
* runnable image; the digest (when present) is attached to the result so the UI can
|
||||||
|
* offer the new tag digest-pinned. Async + injected so this stays pure/testable.
|
||||||
|
*
|
||||||
|
* Only the chosen candidate is probed, not every tag. `maxSkips` bounds the extra
|
||||||
|
* probes so a repo full of artifacts can't fan out unbounded. `probe` failures
|
||||||
|
* should resolve to `{ ok: true }` (fail-open) at the call site so a probe error
|
||||||
|
* never hides a real update.
|
||||||
|
*/
|
||||||
|
export async function findNewerImageTag(
|
||||||
|
currentTag: string,
|
||||||
|
allTags: string[],
|
||||||
|
probe: (tag: string) => Promise<{ ok: boolean; digest?: string | null }>,
|
||||||
|
options: FindNewerOptions = {},
|
||||||
|
maxSkips = 5
|
||||||
|
): Promise<NewerVersion | null> {
|
||||||
|
const excluded = new Set<string>();
|
||||||
|
for (let i = 0; i <= maxSkips; i++) {
|
||||||
|
const pool = excluded.size ? allTags.filter((t) => !excluded.has(t)) : allTags;
|
||||||
|
const newer = findNewerVersionTag(currentTag, pool, options);
|
||||||
|
if (!newer) return null;
|
||||||
|
const { ok, digest } = await probe(newer.tag);
|
||||||
|
if (ok) return digest ? { ...newer, digest } : newer;
|
||||||
|
excluded.add(newer.tag);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Classify a registry manifest as a runnable container image vs a non-image OCI
|
||||||
|
* artifact (Helm chart, SBOM, signature, ...). Monorepos increasingly publish
|
||||||
|
* Helm charts into the SAME repo as their images (e.g. `maximhq/bifrost` ships
|
||||||
|
* chart tag `2.1.29` alongside image tag `v1.6.11`), so the semver check must not
|
||||||
|
* offer a chart tag as a "newer version" of an image.
|
||||||
|
*
|
||||||
|
* Pure: takes the already-parsed manifest JSON, no I/O. The fetch lives in
|
||||||
|
* docker.ts; this just reads the media types.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ArtifactKind = 'image' | 'chart' | 'other';
|
||||||
|
|
||||||
|
/** config.mediaType values that mean "this is a real container image config". */
|
||||||
|
const IMAGE_CONFIG_TYPES = new Set([
|
||||||
|
'application/vnd.oci.image.config.v1+json',
|
||||||
|
'application/vnd.docker.container.image.v1+json'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Helm's OCI chart config media type. */
|
||||||
|
const HELM_CONFIG_TYPE = 'application/vnd.cncf.helm.config.v1+json';
|
||||||
|
|
||||||
|
/** Manifest LIST / image INDEX media types - a multi-arch image points here. */
|
||||||
|
const INDEX_TYPES = new Set([
|
||||||
|
'application/vnd.oci.image.index.v1+json',
|
||||||
|
'application/vnd.docker.distribution.manifest.list.v2+json'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Single-image manifest media types. */
|
||||||
|
const IMAGE_MANIFEST_TYPES = new Set([
|
||||||
|
'application/vnd.oci.image.manifest.v1+json',
|
||||||
|
'application/vnd.docker.distribution.manifest.v2+json'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a manifest body (parsed JSON). `topMediaType` is the manifest's own
|
||||||
|
* media type when known (from the response Content-Type or a `mediaType` field);
|
||||||
|
* an image index has no `config`, so it is recognized by that type alone.
|
||||||
|
*
|
||||||
|
* - A manifest INDEX / manifest LIST is always a multi-arch IMAGE.
|
||||||
|
* - Otherwise the `config.mediaType` decides: Helm config -> chart, image config
|
||||||
|
* -> image, anything else -> other (SBOM, signature, unknown artifact).
|
||||||
|
* - Unknown / missing info defaults to 'image' (fail-open: never hide a real
|
||||||
|
* update just because a registry omitted a field).
|
||||||
|
*/
|
||||||
|
export function classifyManifest(
|
||||||
|
manifest: { mediaType?: string; config?: { mediaType?: string }; manifests?: unknown[] } | null | undefined,
|
||||||
|
topMediaType?: string | null
|
||||||
|
): ArtifactKind {
|
||||||
|
if (!manifest) return 'image';
|
||||||
|
|
||||||
|
const top = manifest.mediaType || topMediaType || '';
|
||||||
|
if (INDEX_TYPES.has(top) || Array.isArray(manifest.manifests)) return 'image';
|
||||||
|
|
||||||
|
const cfg = manifest.config?.mediaType;
|
||||||
|
if (cfg === HELM_CONFIG_TYPE) return 'chart';
|
||||||
|
if (cfg && IMAGE_CONFIG_TYPES.has(cfg)) return 'image';
|
||||||
|
|
||||||
|
// A recognized single-image manifest type with no/other config is still an image.
|
||||||
|
if (IMAGE_MANIFEST_TYPES.has(top)) return 'image';
|
||||||
|
|
||||||
|
// A config media type we don't recognize, on a non-index manifest, is a
|
||||||
|
// non-image artifact (chart variants, SBOM, cosign signature, ...).
|
||||||
|
if (cfg) return 'other';
|
||||||
|
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True only for a runnable container image (multi-arch index or single image). */
|
||||||
|
export function isRunnableImage(
|
||||||
|
manifest: { mediaType?: string; config?: { mediaType?: string }; manifests?: unknown[] } | null | undefined,
|
||||||
|
topMediaType?: string | null
|
||||||
|
): boolean {
|
||||||
|
return classifyManifest(manifest, topMediaType) === 'image';
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ export interface NewerVersion {
|
|||||||
tag: string;
|
tag: string;
|
||||||
bump: 'major' | 'minor' | 'patch';
|
bump: 'major' | 'minor' | 'patch';
|
||||||
skipped: string[];
|
skipped: string[];
|
||||||
|
/** The target tag's manifest digest (`sha256:...`), when known. Lets the UI copy the new tag digest-pinned. */
|
||||||
|
digest?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContainerInfo {
|
export interface ContainerInfo {
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
* 1. `dockhand.changelog.url` label — explicit override set by the image
|
* 1. `dockhand.changelog.url` label — explicit override set by the image
|
||||||
* author or at runtime via `--label dockhand.changelog.url=…`. Wins over
|
* author or at runtime via `--label dockhand.changelog.url=…`. Wins over
|
||||||
* everything. Pattern matches the existing dockhand.* label convention.
|
* everything. Pattern matches the existing dockhand.* label convention.
|
||||||
|
* May contain `{{version}}` / `{{tag}}` placeholders (see interpolation
|
||||||
|
* below) to build a per-version link (e.g. a monorepo release stream like
|
||||||
|
* `.../releases/tag/transports/{{version}}`); a plain URL with no
|
||||||
|
* placeholders is returned verbatim, unchanged from before.
|
||||||
* 2. `org.opencontainers.image.source` label — the OCI standard. When it
|
* 2. `org.opencontainers.image.source` label — the OCI standard. When it
|
||||||
* points at github.com the canonical changelog page is `<source>/releases`.
|
* points at github.com the canonical changelog page is `<source>/releases`.
|
||||||
* 3. GHCR images — `ghcr.io/<owner>/<repo>` is always the same as
|
* 3. GHCR images — `ghcr.io/<owner>/<repo>` is always the same as
|
||||||
@@ -20,6 +24,18 @@
|
|||||||
const GITHUB_HOST = 'github.com';
|
const GITHUB_HOST = 'github.com';
|
||||||
const GHCR_PREFIX = 'ghcr.io/';
|
const GHCR_PREFIX = 'ghcr.io/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Substitute `{{version}}` / `{{tag}}` (case-insensitive, whitespace-tolerant)
|
||||||
|
* in a changelog-url override. Backward-compatible: a URL with NO placeholder is
|
||||||
|
* returned unchanged, and if `version` is not supplied (e.g. the generic
|
||||||
|
* "changelog" link on the container/stack list, which has no target version) the
|
||||||
|
* template is left as-is so callers can decide - the plain-URL case is unaffected.
|
||||||
|
*/
|
||||||
|
export function interpolateChangelogUrl(url: string, version?: string | null): string {
|
||||||
|
if (!version || !url.includes('{{')) return url;
|
||||||
|
return url.replace(/\{\{\s*(version|tag)\s*\}\}/gi, version);
|
||||||
|
}
|
||||||
|
|
||||||
function stripTrailingSlash(s: string): string {
|
function stripTrailingSlash(s: string): string {
|
||||||
return s.endsWith('/') ? s.slice(0, -1) : s;
|
return s.endsWith('/') ? s.slice(0, -1) : s;
|
||||||
}
|
}
|
||||||
@@ -38,12 +54,13 @@ function stripImageTag(image: string): string {
|
|||||||
|
|
||||||
export function resolveChangelogUrl(
|
export function resolveChangelogUrl(
|
||||||
imageName: string | null | undefined,
|
imageName: string | null | undefined,
|
||||||
labels?: Record<string, string> | null
|
labels?: Record<string, string> | null,
|
||||||
|
version?: string | null
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!imageName) return null;
|
if (!imageName) return null;
|
||||||
|
|
||||||
const override = labels?.['dockhand.changelog.url'];
|
const override = labels?.['dockhand.changelog.url'];
|
||||||
if (override && override.trim()) return override.trim();
|
if (override && override.trim()) return interpolateChangelogUrl(override.trim(), version);
|
||||||
|
|
||||||
const source = labels?.['org.opencontainers.image.source'];
|
const source = labels?.['org.opencontainers.image.source'];
|
||||||
if (source && source.includes(GITHUB_HOST)) {
|
if (source && source.includes(GITHUB_HOST)) {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Build a digest-pinned image reference from a `repo:tag` and the image's
|
||||||
|
* RepoDigests, for the "copy pinned reference" action. A supply-chain-hardened
|
||||||
|
* reference keeps the human-readable tag AND pins the immutable digest:
|
||||||
|
*
|
||||||
|
* nginx:1.25 + ["nginx@sha256:abc..."] -> nginx:1.25@sha256:abc...
|
||||||
|
*
|
||||||
|
* Pure, no I/O - unit-tested. Returns null when no digest matches the repo (an
|
||||||
|
* image built locally and never pushed has no RepoDigests), so the caller can
|
||||||
|
* hide/disable the button instead of copying a broken ref.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Split `repo:tag` (or a bare `repo`) into its repo and tag parts. A colon that
|
||||||
|
* is part of a registry `host:port` is not a tag separator. */
|
||||||
|
function splitRepoTag(fullRef: string): { repo: string; tag: string | null } {
|
||||||
|
const lastColon = fullRef.lastIndexOf(':');
|
||||||
|
if (lastColon === -1) return { repo: fullRef, tag: null };
|
||||||
|
// A tag never contains a slash; `host:port/repo` has the slash AFTER the colon.
|
||||||
|
if (fullRef.slice(lastColon + 1).includes('/')) return { repo: fullRef, tag: null };
|
||||||
|
return { repo: fullRef.slice(0, lastColon), tag: fullRef.slice(lastColon + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `sha256:...` part of a `repo@sha256:...` digest, or null. */
|
||||||
|
function digestOf(repoDigest: string): string | null {
|
||||||
|
const at = repoDigest.indexOf('@');
|
||||||
|
return at > 0 ? repoDigest.slice(at + 1) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return `repo:tag@sha256:...` for `fullRef`, picking the digest whose repo
|
||||||
|
* matches. Falls back to the single available digest when there is exactly one
|
||||||
|
* (common case: one repo, one digest). Returns null if no usable digest exists.
|
||||||
|
*/
|
||||||
|
export function buildPinnedRef(fullRef: string, repoDigests: string[] | undefined | null): string | null {
|
||||||
|
if (!repoDigests || repoDigests.length === 0) return null;
|
||||||
|
const { repo, tag } = splitRepoTag(fullRef);
|
||||||
|
|
||||||
|
// Prefer the digest published for the SAME repo as this tag (an image can carry
|
||||||
|
// digests for several repo names when it was tagged/pushed under more than one).
|
||||||
|
const match =
|
||||||
|
repoDigests.find((d) => d.slice(0, d.indexOf('@')) === repo) ??
|
||||||
|
(repoDigests.length === 1 ? repoDigests[0] : null);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const digest = digestOf(match);
|
||||||
|
if (!digest) return null;
|
||||||
|
|
||||||
|
return tag ? `${repo}:${tag}@${digest}` : `${repo}@${digest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The manifest digest (`sha256:...`) for `fullRef`, shortened for display, or null.
|
||||||
|
* Same matching rules as buildPinnedRef. `chars` counts hex digits shown after the
|
||||||
|
* `sha256:` prefix.
|
||||||
|
*/
|
||||||
|
export function shortDigest(
|
||||||
|
fullRef: string,
|
||||||
|
repoDigests: string[] | undefined | null,
|
||||||
|
chars = 12
|
||||||
|
): string | null {
|
||||||
|
const pinned = buildPinnedRef(fullRef, repoDigests);
|
||||||
|
if (!pinned) return null;
|
||||||
|
const at = pinned.lastIndexOf('@');
|
||||||
|
const digest = pinned.slice(at + 1); // sha256:....
|
||||||
|
const colon = digest.indexOf(':');
|
||||||
|
if (colon === -1) return digest.slice(0, chars);
|
||||||
|
return `${digest.slice(0, colon + 1)}${digest.slice(colon + 1, colon + 1 + chars)}`;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
|
import { listContainers, inspectContainer, checkImageUpdateAvailable, getTagArtifactKind } from '$lib/server/docker';
|
||||||
import { clearPendingContainerUpdates, addPendingContainerUpdate, getPendingContainerUpdates, getGlobalSemverConfig } from '$lib/server/db';
|
import { clearPendingContainerUpdates, addPendingContainerUpdate, getPendingContainerUpdates, getGlobalSemverConfig } from '$lib/server/db';
|
||||||
import { isSystemContainer, isPodmanInfraContainer } from '$lib/server/scheduler/tasks/update-utils';
|
import { isSystemContainer, isPodmanInfraContainer } from '$lib/server/scheduler/tasks/update-utils';
|
||||||
import { isUpdateDisabledByLabel, isHiddenByLabel, getVersionPatternOverride } from '$lib/server/container-labels';
|
import { isUpdateDisabledByLabel, isHiddenByLabel, getVersionPatternOverride } from '$lib/server/container-labels';
|
||||||
@@ -156,7 +156,7 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
|||||||
? await checkNewerVersion(imageName, {
|
? await checkNewerVersion(imageName, {
|
||||||
...semverOptions,
|
...semverOptions,
|
||||||
versionPattern: getVersionPatternOverride(inspectData.Config?.Labels)
|
versionPattern: getVersionPatternOverride(inspectData.Config?.Labels)
|
||||||
}).catch(() => null)
|
}, getTagArtifactKind).catch(() => null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
import { formatBytes } from '$lib/utils/format';
|
import { formatBytes } from '$lib/utils/format';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||||
|
import { buildPinnedRef, shortDigest } from '$lib/utils/pinned-ref';
|
||||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||||
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
||||||
import ImageHistoryModal from './ImageHistoryModal.svelte';
|
import ImageHistoryModal from './ImageHistoryModal.svelte';
|
||||||
@@ -63,6 +64,10 @@
|
|||||||
tag: string;
|
tag: string;
|
||||||
fullRef: string;
|
fullRef: string;
|
||||||
imageId: string;
|
imageId: string;
|
||||||
|
/** `repo:tag@sha256:...` for the "copy pinned reference" action, or null if the image has no digest. */
|
||||||
|
pinnedRef: string | null;
|
||||||
|
/** Shortened `sha256:...` shown next to the tag, or null. */
|
||||||
|
digestShort: string | null;
|
||||||
size: number;
|
size: number;
|
||||||
created: number;
|
created: number;
|
||||||
containers: number;
|
containers: number;
|
||||||
@@ -354,6 +359,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copy digest-pinned reference (repo:tag@sha256:...).
|
||||||
|
let copiedPinned = $state<string | null>(null);
|
||||||
|
async function copyPinnedRef(pinnedRef: string) {
|
||||||
|
const ok = await copyToClipboard(pinnedRef);
|
||||||
|
if (ok) {
|
||||||
|
copiedPinned = pinnedRef;
|
||||||
|
pendingTimeouts.push(setTimeout(() => copiedPinned = null, 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Export state
|
// Export state
|
||||||
let exportingId = $state<string | null>(null);
|
let exportingId = $state<string | null>(null);
|
||||||
|
|
||||||
@@ -414,6 +429,8 @@
|
|||||||
tag: repoName === '<none>' ? image.id.slice(7, 19) : '<none>',
|
tag: repoName === '<none>' ? image.id.slice(7, 19) : '<none>',
|
||||||
fullRef: image.id,
|
fullRef: image.id,
|
||||||
imageId: image.id,
|
imageId: image.id,
|
||||||
|
pinnedRef: buildPinnedRef(repoName, image.repoDigests),
|
||||||
|
digestShort: shortDigest(repoName, image.repoDigests),
|
||||||
size: image.size,
|
size: image.size,
|
||||||
created: image.created,
|
created: image.created,
|
||||||
containers: image.containers
|
containers: image.containers
|
||||||
@@ -446,6 +463,8 @@
|
|||||||
tag: tagPart,
|
tag: tagPart,
|
||||||
fullRef: fullTag,
|
fullRef: fullTag,
|
||||||
imageId: image.id,
|
imageId: image.id,
|
||||||
|
pinnedRef: buildPinnedRef(fullTag, image.repoDigests),
|
||||||
|
digestShort: shortDigest(fullTag, image.repoDigests),
|
||||||
size: image.size,
|
size: image.size,
|
||||||
created: image.created,
|
created: image.created,
|
||||||
containers: image.containers
|
containers: image.containers
|
||||||
@@ -1388,6 +1407,22 @@
|
|||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<Tag class="w-3 h-3 text-muted-foreground shrink-0" />
|
<Tag class="w-3 h-3 text-muted-foreground shrink-0" />
|
||||||
<span class="{tagInfo.tag === 'latest' ? 'text-blue-600 dark:text-blue-400' : ''}">{tagInfo.tag}</span>
|
<span class="{tagInfo.tag === 'latest' ? 'text-blue-600 dark:text-blue-400' : ''}">{tagInfo.tag}</span>
|
||||||
|
{#if tagInfo.digestShort && tagInfo.pinnedRef}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => copyPinnedRef(tagInfo.pinnedRef!)}
|
||||||
|
class="inline-flex items-center gap-1 hover:bg-muted px-1 py-0.5 rounded transition-colors cursor-pointer text-muted-foreground hover:text-foreground"
|
||||||
|
title={copiedPinned === tagInfo.pinnedRef ? 'Copied!' : 'Copy digest-pinned reference (tag@sha256)'}
|
||||||
|
>
|
||||||
|
<ShieldCheck class="w-3 h-3 text-emerald-600 dark:text-emerald-400 shrink-0" />
|
||||||
|
<code class="text-2xs">{tagInfo.digestShort}</code>
|
||||||
|
{#if copiedPinned === tagInfo.pinnedRef}
|
||||||
|
<Check class="w-3 h-3 text-green-500" />
|
||||||
|
{:else}
|
||||||
|
<Copy class="w-3 h-3" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if column.id === 'id'}
|
{:else if column.id === 'id'}
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in new issue
Block a user