mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 08:27:42 +00:00
feat: link container images to registry and source metadata (#1358)
* feat: link container images to registry and source metadata Turn image references on the stack surfaces into actionable links so an operator can review what an image ships before approving an update. A new pure helper maps a reference to its registry page (Docker Hub for official and namespace images, the owner profile for GitHub Container Registry) and never guesses a link for unknown or private registries. A shared dropdown adds copy-image-reference plus source, homepage, documentation, and revision links read from the image OCI labels via a local inspect, so they need no internet access and only appear when the image ships them. Version and non-commit revisions render as plain text. The menu is wired into the stack header image row, each per-container health card, and the update-readiness cards. * fix: invalidate image-source inspect token synchronously on image change The request token that drops a stale image inspect response was bumped only in a passive effect cleanup, which runs after paint. A response for a superseded image could resolve in the gap after the image-id change committed and still pass the token check, writing stale source labels into the new menu. Move the invalidation to a layout effect so the token is bumped during commit, before any network response can interleave. Add a regression test that holds the first inspect open, changes the image id, then resolves the stale response and asserts it is discarded.
This commit is contained in:
@@ -186,6 +186,16 @@ The header answers three questions at a glance:
|
||||
|
||||
The action row to the right of the header keeps everyday lifecycle controls visible. The **More actions** overflow holds Rollback, Scan config, and Delete; the next sections cover both surfaces.
|
||||
|
||||
### Image source links
|
||||
|
||||
Next to the image line is a links button that turns the image reference into somewhere useful to go. It opens a small menu with:
|
||||
|
||||
- **Open on the registry.** Docker Hub images link to their Docker Hub page; GitHub Container Registry (`ghcr.io`) images link to the owner on GitHub. Images from a private or unrecognized registry show the registry host instead of a link, so you never land on a broken guessed URL.
|
||||
- **Copy image reference.** Grabs the full reference for use elsewhere.
|
||||
- **Source, homepage, documentation, and revision links** when the image declares them. These come from the image's own [OCI labels](https://github.com/opencontainers/image-spec/blob/main/annotations.md) (`org.opencontainers.image.source`, `.url`, `.documentation`, `.revision`, `.version`) and are read locally from the image, so they need no internet access and appear only when the image ships them. A revision links to its commit when the source is a GitHub repository; the version is shown as plain text.
|
||||
|
||||
The same links button appears on each container row and on the update cards in [Auto-Update Policies](/features/auto-update-policies), so you can review what an image ships and where it comes from before approving an update.
|
||||
|
||||
## Container health strip
|
||||
|
||||
Below the header, each container in the stack gets a single row that answers "is this piece working, and how do I reach it?" without expanding anything.
|
||||
@@ -201,7 +211,7 @@ Each row includes:
|
||||
- **Meta line.** Uptime (`up 12 hours`) and the primary port mapping (`8989 → 8989/tcp`).
|
||||
- **Open link.** When the container publishes a port, an `open ↗` link launches the app in a new tab using the active node's hostname.
|
||||
- **Live stat tiles.** Three tiles show CPU, memory, and network I/O with a rolling sparkline. The sparkline uses the cyan data color and refreshes roughly every 1.5 seconds.
|
||||
- **Action icons.** Shortcuts to **View logs**, open a bash shell, and the per-service kebab.
|
||||
- **Action icons.** The image source links button (see above), plus shortcuts to **View logs**, open a bash shell, and the per-service kebab.
|
||||
|
||||
## Logs viewer
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RefreshCw, Shield, AlertTriangle, ShieldAlert, CircleSlash, Clock, Play
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { ImageSourceMenu } from './ImageSourceMenu';
|
||||
import type { ScheduledTask } from '@/types/scheduling';
|
||||
|
||||
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
|
||||
@@ -208,6 +209,7 @@ function StackReadinessCard({
|
||||
· {updatingImageCount} services
|
||||
</span>
|
||||
)}
|
||||
<ImageSourceMenu imageRef={p.summary.primary_image} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-dashed border-card-border pt-3 text-xs text-stat-subtitle/90 leading-relaxed">
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
import { Sparkline } from '../ui/sparkline';
|
||||
import { ImageSourceMenu } from '../ImageSourceMenu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import ErrorBoundary from '../ErrorBoundary';
|
||||
@@ -212,6 +213,7 @@ export function StackIdentityHeader({
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<ImageSourceMenu imageRef={first.Image} imageId={first.ImageID} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
@@ -409,6 +411,11 @@ export function ContainersHealth({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<ImageSourceMenu
|
||||
imageRef={container.Image}
|
||||
imageId={container.ImageID}
|
||||
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Covers the interactive surface of the image-source menu: the deterministic
|
||||
* registry link, the lazy (and non-repeating) OCI label fetch, safe degradation
|
||||
* when the inspect fails, copy behavior, external-link safety attributes, and the
|
||||
* private-registry / absent-ref fallbacks.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { ImageSourceMenu } from './ImageSourceMenu';
|
||||
|
||||
function inspectRes(labels: Record<string, string> | null, ok = true) {
|
||||
return {
|
||||
ok,
|
||||
status: ok ? 200 : 404,
|
||||
json: async () => ({ inspect: { Config: { Labels: labels } } }),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
const trigger = () => screen.getByRole('button', { name: 'Image source links' });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(apiFetch).mockResolvedValue(inspectRes({
|
||||
'org.opencontainers.image.source': 'https://github.com/owner/repo',
|
||||
}));
|
||||
});
|
||||
|
||||
describe('ImageSourceMenu', () => {
|
||||
it('renders nothing when the image ref is absent', () => {
|
||||
const { container } = render(<ImageSourceMenu imageRef={null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('shows the deterministic registry link with safe external-link attributes', async () => {
|
||||
render(<ImageSourceMenu imageRef="ghcr.io/owner/app:1.2" />);
|
||||
await userEvent.click(trigger());
|
||||
const link = await screen.findByRole('menuitem', { name: /Open on GitHub Container Registry/ });
|
||||
expect(link).toHaveAttribute('href', 'https://github.com/owner');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('does not fetch labels when no imageId is provided', async () => {
|
||||
render(<ImageSourceMenu imageRef="ghcr.io/owner/app:1.2" />);
|
||||
await userEvent.click(trigger());
|
||||
await screen.findByRole('menuitem', { name: /Open on/ });
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches OCI labels once on first open and not again on reopen', async () => {
|
||||
render(<ImageSourceMenu imageRef="ghcr.io/owner/app:1.2" imageId="sha256:abc" />);
|
||||
await userEvent.click(trigger());
|
||||
expect(await screen.findByRole('menuitem', { name: 'Source repository' })).toHaveAttribute(
|
||||
'href', 'https://github.com/owner/repo',
|
||||
);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(apiFetch).mock.calls[0][0]).toContain('/system/images/');
|
||||
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await waitFor(() => expect(screen.queryByRole('menuitem', { name: 'Source repository' })).toBeNull());
|
||||
await userEvent.click(trigger());
|
||||
await screen.findByRole('menuitem', { name: 'Source repository' });
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-fetches and clears stale labels when the image id changes', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce(inspectRes({
|
||||
'org.opencontainers.image.source': 'https://github.com/owner/old',
|
||||
}));
|
||||
const { rerender } = render(<ImageSourceMenu imageRef="ghcr.io/owner/app:1" imageId="sha256:aaa" />);
|
||||
await userEvent.click(trigger());
|
||||
expect(await screen.findByRole('menuitem', { name: 'Source repository' })).toHaveAttribute(
|
||||
'href', 'https://github.com/owner/old',
|
||||
);
|
||||
await userEvent.keyboard('{Escape}');
|
||||
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce(inspectRes({
|
||||
'org.opencontainers.image.source': 'https://github.com/owner/new',
|
||||
}));
|
||||
rerender(<ImageSourceMenu imageRef="ghcr.io/owner/app:2" imageId="sha256:bbb" />);
|
||||
await userEvent.click(trigger());
|
||||
expect(await screen.findByRole('menuitem', { name: 'Source repository' })).toHaveAttribute(
|
||||
'href', 'https://github.com/owner/new',
|
||||
);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('drops a late inspect response from a superseded image after the id changes', async () => {
|
||||
// First open: an inspect that stays in flight until we resolve it by hand.
|
||||
let resolveOld!: (v: Response) => void;
|
||||
vi.mocked(apiFetch).mockReturnValueOnce(new Promise<Response>((res) => { resolveOld = res; }));
|
||||
|
||||
const { rerender } = render(<ImageSourceMenu imageRef="ghcr.io/owner/app:1" imageId="sha256:aaa" />);
|
||||
await userEvent.click(trigger());
|
||||
await screen.findByRole('menuitem', { name: /Loading source/ });
|
||||
|
||||
// The image id changes while the old inspect is still pending (e.g. node switch).
|
||||
rerender(<ImageSourceMenu imageRef="ghcr.io/owner/app:2" imageId="sha256:bbb" />);
|
||||
|
||||
// The stale response now resolves; the request-token guard must discard it.
|
||||
// Drain the full response chain (.then -> res.json() -> .then) inside act so a
|
||||
// missing guard would have written the OLD label before we assert.
|
||||
await act(async () => {
|
||||
resolveOld(inspectRes({ 'org.opencontainers.image.source': 'https://github.com/owner/OLD' }));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('menuitem', { name: 'Source repository' })).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces a toast when the copy fails', async () => {
|
||||
vi.mocked(copyToClipboard).mockRejectedValueOnce(new Error('no clipboard'));
|
||||
render(<ImageSourceMenu imageRef="ghcr.io/owner/app:1.2" />);
|
||||
await userEvent.click(trigger());
|
||||
await userEvent.click(await screen.findByRole('menuitem', { name: /Copy image reference/ }));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Copy failed.'));
|
||||
});
|
||||
|
||||
it('still renders deterministic links when the inspect fetch fails', async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new Error('boom'));
|
||||
render(<ImageSourceMenu imageRef="ghcr.io/owner/app:1.2" imageId="sha256:abc" />);
|
||||
await userEvent.click(trigger());
|
||||
await screen.findByRole('menuitem', { name: /Open on GitHub Container Registry/ });
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalled());
|
||||
expect(screen.queryByRole('menuitem', { name: 'Source repository' })).toBeNull();
|
||||
});
|
||||
|
||||
it('copies the image reference on the copy action', async () => {
|
||||
render(<ImageSourceMenu imageRef="ghcr.io/owner/app:1.2" />);
|
||||
await userEvent.click(trigger());
|
||||
await userEvent.click(await screen.findByRole('menuitem', { name: /Copy image reference/ }));
|
||||
expect(copyToClipboard).toHaveBeenCalledWith('ghcr.io/owner/app:1.2');
|
||||
});
|
||||
|
||||
it('shows the host with no Open link for an unknown private registry', async () => {
|
||||
render(<ImageSourceMenu imageRef="registry.example.com:5000/team/app:1.2" />);
|
||||
await userEvent.click(trigger());
|
||||
await screen.findByText(/Registry · registry.example.com:5000/);
|
||||
expect(screen.queryByRole('menuitem', { name: /Open on/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Link2, ExternalLink, Copy, Check } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from './ui/dropdown-menu';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buildImageLinks, extractImageSourceMeta } from '@/lib/imageLinks';
|
||||
|
||||
interface ImageSourceMenuProps {
|
||||
/** Full image reference, e.g. 'ghcr.io/owner/app:1.2'. Absent/empty renders nothing. */
|
||||
imageRef?: string | null;
|
||||
/** Resolved image id (sha256). When set, OCI source labels are fetched lazily. */
|
||||
imageId?: string;
|
||||
/** Sizing classes for the trigger button so each surface matches its row. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type LabelState = 'idle' | 'loading' | 'loaded' | 'error';
|
||||
|
||||
// A focused quick-action for an image: the deterministic registry page, a copy
|
||||
// action, and (when an image id is available) the image's OCI source/docs/revision
|
||||
// links read lazily from a local inspect. It complements the Resources image
|
||||
// deep-dive sheet rather than duplicating it.
|
||||
export function ImageSourceMenu({ imageRef, imageId, className = 'h-4 w-4' }: ImageSourceMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [labels, setLabels] = useState<Record<string, string> | null>(null);
|
||||
const [labelState, setLabelState] = useState<LabelState>('idle');
|
||||
const [copied, setCopied] = useState(false);
|
||||
// Monotonic id so a response from a superseded image (node switch) or an
|
||||
// unmounted menu is ignored instead of writing stale labels.
|
||||
const requestRef = useRef(0);
|
||||
const [trackedId, setTrackedId] = useState(imageId);
|
||||
|
||||
// A new image id means a different (or node-switched) image: reset so the next
|
||||
// open re-fetches against the active node. Adjusting state during render is the
|
||||
// supported way to react to a changed prop without an extra render pass.
|
||||
if (trackedId !== imageId) {
|
||||
setTrackedId(imageId);
|
||||
setLabelState('idle');
|
||||
setLabels(null);
|
||||
}
|
||||
|
||||
// Invalidate any in-flight fetch when the image id changes or the menu unmounts,
|
||||
// so a late response from a superseded image cannot write stale labels. This runs
|
||||
// in a layout effect (not a passive one) so the token is bumped synchronously
|
||||
// during commit, before a network response could resolve in the gap after the
|
||||
// image-id change re-render.
|
||||
useLayoutEffect(() => () => { requestRef.current += 1; }, [imageId]);
|
||||
|
||||
const loadLabels = (id: string) => {
|
||||
const reqId = (requestRef.current += 1);
|
||||
setLabelState('loading');
|
||||
apiFetch(`/system/images/${encodeURIComponent(id)}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error('inspect failed');
|
||||
return res.json() as Promise<{ inspect?: { Config?: { Labels?: Record<string, string> | null } } }>;
|
||||
})
|
||||
.then((data) => {
|
||||
if (requestRef.current !== reqId) return;
|
||||
setLabels(data?.inspect?.Config?.Labels ?? null);
|
||||
setLabelState('loaded');
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestRef.current === reqId) setLabelState('error');
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
if (next && imageId && labelState === 'idle') loadLabels(imageId);
|
||||
};
|
||||
|
||||
const trimmedRef = imageRef?.trim();
|
||||
if (!trimmedRef) return null;
|
||||
const links = buildImageLinks(trimmedRef);
|
||||
if (!links) return null;
|
||||
|
||||
const meta = extractImageSourceMeta(labels);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await copyToClipboard(trimmedRef);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
toast.success('Image reference copied');
|
||||
} catch {
|
||||
toast.error('Copy failed.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Image source links"
|
||||
title="Image source links"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded text-stat-subtitle hover:text-foreground hover:bg-muted/60 transition-colors',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
<DropdownMenuLabel className="font-mono text-[11px] font-normal text-stat-subtitle truncate">
|
||||
{trimmedRef}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{links.registryUrl ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={links.registryUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-3.5 w-3.5 mr-2" strokeWidth={1.5} />
|
||||
Open on {links.registryLabel}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem disabled>
|
||||
<span className="truncate">Registry · {links.registryHost}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onSelect={(e) => { e.preventDefault(); void handleCopy(); }}>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 mr-2" strokeWidth={2} />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 mr-2" strokeWidth={1.5} />
|
||||
)}
|
||||
Copy image reference
|
||||
</DropdownMenuItem>
|
||||
|
||||
{imageId && (labelState === 'loading' || meta.links.length > 0 || meta.version || meta.revision) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{labelState === 'loading' ? (
|
||||
<DropdownMenuItem disabled>Loading source…</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
{meta.links.map((link) => (
|
||||
<DropdownMenuItem key={link.id} asChild>
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-3.5 w-3.5 mr-2" strokeWidth={1.5} />
|
||||
{link.label}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{meta.revision && (
|
||||
<DropdownMenuItem disabled>
|
||||
<span className="font-mono truncate">Revision · {meta.revision}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{meta.version && (
|
||||
<DropdownMenuItem disabled>
|
||||
<span className="font-mono truncate">Version · {meta.version}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseImageRef, buildImageLinks, extractImageSourceMeta } from './imageLinks';
|
||||
|
||||
describe('parseImageRef', () => {
|
||||
it('parses an official Docker Hub image with a tag', () => {
|
||||
expect(parseImageRef('nginx:latest')).toEqual({
|
||||
registry: 'docker.io', namespace: null, repo: 'nginx', tag: 'latest', digest: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a bare image name with no tag', () => {
|
||||
expect(parseImageRef('nginx')).toEqual({
|
||||
registry: 'docker.io', namespace: null, repo: 'nginx', tag: null, digest: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a Docker Hub namespace image', () => {
|
||||
expect(parseImageRef('linuxserver/sonarr:latest')).toEqual({
|
||||
registry: 'docker.io', namespace: 'linuxserver', repo: 'sonarr', tag: 'latest', digest: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a GHCR image', () => {
|
||||
expect(parseImageRef('ghcr.io/owner/image:tag')).toEqual({
|
||||
registry: 'ghcr.io', namespace: 'owner', repo: 'image', tag: 'tag', digest: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a private registry with a port', () => {
|
||||
expect(parseImageRef('registry.example.com:5000/team/app:1.2')).toEqual({
|
||||
registry: 'registry.example.com:5000', namespace: 'team', repo: 'app', tag: '1.2', digest: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('captures the digest of a digest-pinned namespace image', () => {
|
||||
const digest = 'sha256:0000000000000000000000000000000000000000000000000000000000000000';
|
||||
expect(parseImageRef(`myrepo/app@${digest}`)).toEqual({
|
||||
registry: 'docker.io', namespace: 'myrepo', repo: 'app', tag: null, digest,
|
||||
});
|
||||
});
|
||||
|
||||
it('captures the digest of a digest-pinned official image', () => {
|
||||
const digest = 'sha256:1111111111111111111111111111111111111111111111111111111111111111';
|
||||
expect(parseImageRef(`nginx@${digest}`)).toEqual({
|
||||
registry: 'docker.io', namespace: null, repo: 'nginx', tag: null, digest,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps both the tag and the digest of a tag-and-digest pinned ref', () => {
|
||||
const digest = 'sha256:3333333333333333333333333333333333333333333333333333333333333333';
|
||||
expect(parseImageRef(`nginx:1.25@${digest}`)).toEqual({
|
||||
registry: 'docker.io', namespace: null, repo: 'nginx', tag: '1.25', digest,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mistake a registry port for a tag when no tag is present', () => {
|
||||
expect(parseImageRef('registry.example.com:5000/team/app')).toEqual({
|
||||
registry: 'registry.example.com:5000', namespace: 'team', repo: 'app', tag: null, digest: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for a bare digest and for empty input', () => {
|
||||
expect(parseImageRef('sha256:abc')).toBeNull();
|
||||
expect(parseImageRef(' ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildImageLinks', () => {
|
||||
it('links official Docker Hub images to the _/ page', () => {
|
||||
const links = buildImageLinks('nginx:latest');
|
||||
expect(links?.kind).toBe('dockerhub-official');
|
||||
expect(links?.registryUrl).toBe('https://hub.docker.com/_/nginx');
|
||||
});
|
||||
|
||||
it('treats library/ as an official image', () => {
|
||||
expect(buildImageLinks('library/nginx')?.registryUrl).toBe('https://hub.docker.com/_/nginx');
|
||||
});
|
||||
|
||||
it('treats docker.io / registry-1 / index aliases as Docker Hub', () => {
|
||||
for (const ref of [
|
||||
'docker.io/library/nginx',
|
||||
'registry-1.docker.io/library/nginx:latest',
|
||||
'index.docker.io/library/nginx',
|
||||
]) {
|
||||
const links = buildImageLinks(ref);
|
||||
expect(links?.kind, ref).toBe('dockerhub-official');
|
||||
expect(links?.registryUrl, ref).toBe('https://hub.docker.com/_/nginx');
|
||||
}
|
||||
});
|
||||
|
||||
it('links Docker Hub namespace images to the r/ page', () => {
|
||||
const links = buildImageLinks('linuxserver/sonarr:latest');
|
||||
expect(links?.kind).toBe('dockerhub-namespace');
|
||||
expect(links?.registryUrl).toBe('https://hub.docker.com/r/linuxserver/sonarr');
|
||||
});
|
||||
|
||||
it('links GHCR images to the owner profile', () => {
|
||||
const links = buildImageLinks('ghcr.io/owner/image:tag');
|
||||
expect(links?.kind).toBe('ghcr');
|
||||
expect(links?.registryHost).toBe('ghcr.io');
|
||||
expect(links?.registryUrl).toBe('https://github.com/owner');
|
||||
});
|
||||
|
||||
it('never guesses a link for an unknown/private registry', () => {
|
||||
const links = buildImageLinks('registry.example.com:5000/team/app:1.2');
|
||||
expect(links?.kind).toBe('other');
|
||||
expect(links?.registryHost).toBe('registry.example.com:5000');
|
||||
expect(links?.registryUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('still resolves the registry page for a digest-pinned ref', () => {
|
||||
const links = buildImageLinks('linuxserver/sonarr@sha256:2222222222222222222222222222222222222222222222222222222222222222');
|
||||
expect(links?.registryUrl).toBe('https://hub.docker.com/r/linuxserver/sonarr');
|
||||
});
|
||||
|
||||
it('returns null for an unparseable ref', () => {
|
||||
expect(buildImageLinks('sha256:abc')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractImageSourceMeta', () => {
|
||||
it('returns nothing for empty or missing labels', () => {
|
||||
expect(extractImageSourceMeta(null)).toEqual({ links: [], version: null, revision: null });
|
||||
expect(extractImageSourceMeta({})).toEqual({ links: [], version: null, revision: null });
|
||||
});
|
||||
|
||||
it('emits links only for valid absolute http(s) label values', () => {
|
||||
const meta = extractImageSourceMeta({
|
||||
'org.opencontainers.image.source': 'https://github.com/owner/repo',
|
||||
'org.opencontainers.image.url': 'https://example.com',
|
||||
'org.opencontainers.image.documentation': 'http://docs.example.com',
|
||||
});
|
||||
expect(meta.links.map(l => l.id)).toEqual(['source', 'url', 'documentation']);
|
||||
});
|
||||
|
||||
it('rejects relative, javascript:, and non-URL label values', () => {
|
||||
const meta = extractImageSourceMeta({
|
||||
'org.opencontainers.image.source': '/owner/repo',
|
||||
'org.opencontainers.image.url': 'javascript:alert(1)',
|
||||
'org.opencontainers.image.documentation': 'not a url',
|
||||
});
|
||||
expect(meta.links).toEqual([]);
|
||||
});
|
||||
|
||||
it('renders version as text only, never a derived release link', () => {
|
||||
const meta = extractImageSourceMeta({ 'org.opencontainers.image.version': '1.2.3' });
|
||||
expect(meta.version).toBe('1.2.3');
|
||||
expect(meta.links).toEqual([]);
|
||||
});
|
||||
|
||||
it('links a SHA-like revision to a GitHub commit when source is a github repo', () => {
|
||||
const meta = extractImageSourceMeta({
|
||||
'org.opencontainers.image.source': 'https://github.com/owner/repo.git',
|
||||
'org.opencontainers.image.revision': 'abcdef1234567890',
|
||||
});
|
||||
const rev = meta.links.find(l => l.id === 'revision');
|
||||
expect(rev?.url).toBe('https://github.com/owner/repo/commit/abcdef1234567890');
|
||||
expect(meta.revision).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a SHA-like revision as text when there is no source label to anchor it', () => {
|
||||
const meta = extractImageSourceMeta({
|
||||
'org.opencontainers.image.revision': 'abcdef1234567890',
|
||||
});
|
||||
expect(meta.links).toEqual([]);
|
||||
expect(meta.revision).toBe('abcdef1234567890');
|
||||
});
|
||||
|
||||
it('keeps revision as text when it is not SHA-like or source is not github', () => {
|
||||
const notSha = extractImageSourceMeta({
|
||||
'org.opencontainers.image.source': 'https://github.com/owner/repo',
|
||||
'org.opencontainers.image.revision': 'v1.2.3',
|
||||
});
|
||||
expect(notSha.links.find(l => l.id === 'revision')).toBeUndefined();
|
||||
expect(notSha.revision).toBe('v1.2.3');
|
||||
|
||||
const notGithub = extractImageSourceMeta({
|
||||
'org.opencontainers.image.source': 'https://gitlab.com/owner/repo',
|
||||
'org.opencontainers.image.revision': 'abcdef1234567890',
|
||||
});
|
||||
expect(notGithub.links.find(l => l.id === 'revision')).toBeUndefined();
|
||||
expect(notGithub.revision).toBe('abcdef1234567890');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
// Turns a Docker image reference into actionable external links so operators can
|
||||
// review an image upstream before approving an update. Pure and offline: parsing
|
||||
// and URL building never touch the network. OCI label handling is deliberately
|
||||
// conservative so untrusted image metadata can never produce a broken or unsafe
|
||||
// link (only absolute http(s) values become links; everything else stays text).
|
||||
|
||||
export interface ParsedImageRef {
|
||||
/** Normalized registry host actually present in the ref ('docker.io' when implicit). */
|
||||
registry: string;
|
||||
/** First path segment (Docker Hub namespace / GHCR owner); null for official Hub images. */
|
||||
namespace: string | null;
|
||||
/** Repository name without the namespace, e.g. 'nginx', 'sonarr'. */
|
||||
repo: string;
|
||||
/** Tag if present, else null. */
|
||||
tag: string | null;
|
||||
/** Full digest including algorithm (e.g. 'sha256:abc…') if pinned, else null. */
|
||||
digest: string | null;
|
||||
}
|
||||
|
||||
export type RegistryKind =
|
||||
| 'dockerhub-official'
|
||||
| 'dockerhub-namespace'
|
||||
| 'ghcr'
|
||||
| 'other';
|
||||
|
||||
interface ImageLinksBase {
|
||||
/** Host to display, e.g. 'docker.io', 'ghcr.io', 'registry.example.com:5000'. */
|
||||
registryHost: string;
|
||||
/** Human label for the registry, e.g. 'Docker Hub'. */
|
||||
registryLabel: string;
|
||||
}
|
||||
|
||||
// A recognized registry always yields a link; an unknown/private registry never
|
||||
// guesses one. Modeling that as a discriminated union makes the "no link for
|
||||
// 'other'" rule provable at the type level instead of a convention.
|
||||
export type ImageLinks =
|
||||
| (ImageLinksBase & { kind: Exclude<RegistryKind, 'other'>; registryUrl: string })
|
||||
| (ImageLinksBase & { kind: 'other'; registryUrl: null });
|
||||
|
||||
export interface ImageSourceLink {
|
||||
id: 'source' | 'url' | 'documentation' | 'revision';
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ImageSourceMeta {
|
||||
links: ImageSourceLink[];
|
||||
/** org.opencontainers.image.version, shown as plain text (never a derived link). */
|
||||
version: string | null;
|
||||
/** Short revision text, only when it could not be turned into a commit link. */
|
||||
revision: string | null;
|
||||
}
|
||||
|
||||
// docker.io is the public alias; registry-1.docker.io / index.docker.io are the
|
||||
// API/login hosts an explicit ref may carry. All three are Docker Hub for linking.
|
||||
const DOCKER_HUB_HOSTS = new Set(['docker.io', 'registry-1.docker.io', 'index.docker.io']);
|
||||
|
||||
function isRegistryHost(segment: string): boolean {
|
||||
return segment.includes('.') || segment.includes(':') || segment === 'localhost';
|
||||
}
|
||||
|
||||
export function parseImageRef(ref: string): ParsedImageRef | null {
|
||||
const trimmed = ref?.trim();
|
||||
if (!trimmed) return null;
|
||||
// A bare digest carries no repository, so there is nothing to link to.
|
||||
if (trimmed.startsWith('sha256:')) return null;
|
||||
|
||||
let rest = trimmed;
|
||||
|
||||
let digest: string | null = null;
|
||||
const atIdx = rest.indexOf('@');
|
||||
if (atIdx !== -1) {
|
||||
digest = rest.slice(atIdx + 1) || null;
|
||||
rest = rest.slice(0, atIdx);
|
||||
}
|
||||
|
||||
let registry = 'docker.io';
|
||||
const slashIdx = rest.indexOf('/');
|
||||
if (slashIdx !== -1) {
|
||||
const firstPart = rest.slice(0, slashIdx);
|
||||
if (isRegistryHost(firstPart)) {
|
||||
registry = firstPart;
|
||||
rest = rest.slice(slashIdx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
let tag: string | null = null;
|
||||
const colonIdx = rest.lastIndexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
tag = rest.slice(colonIdx + 1);
|
||||
rest = rest.slice(0, colonIdx);
|
||||
}
|
||||
|
||||
if (!rest) return null;
|
||||
|
||||
const segments = rest.split('/').filter(Boolean);
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
let namespace: string | null;
|
||||
let repo: string;
|
||||
if (segments.length === 1) {
|
||||
namespace = null;
|
||||
repo = segments[0];
|
||||
} else {
|
||||
namespace = segments[0];
|
||||
repo = segments.slice(1).join('/');
|
||||
}
|
||||
|
||||
return { registry, namespace, repo, tag, digest };
|
||||
}
|
||||
|
||||
export function buildImageLinks(ref: string): ImageLinks | null {
|
||||
const parsed = parseImageRef(ref);
|
||||
if (!parsed) return null;
|
||||
const { registry, namespace, repo } = parsed;
|
||||
|
||||
if (DOCKER_HUB_HOSTS.has(registry)) {
|
||||
const isOfficial = namespace === null || namespace === 'library';
|
||||
return {
|
||||
registryHost: 'docker.io',
|
||||
registryLabel: 'Docker Hub',
|
||||
kind: isOfficial ? 'dockerhub-official' : 'dockerhub-namespace',
|
||||
registryUrl: isOfficial
|
||||
? `https://hub.docker.com/_/${repo}`
|
||||
: `https://hub.docker.com/r/${namespace}/${repo}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (registry === 'ghcr.io') {
|
||||
// GitHub does not expose a reliable package URL from the ref alone (user vs org,
|
||||
// image name may differ from the repo). The owner profile always resolves; the
|
||||
// exact source repo is filled in from the OCI source label when available.
|
||||
const owner = namespace ?? repo;
|
||||
return {
|
||||
registryHost: 'ghcr.io',
|
||||
registryLabel: 'GitHub Container Registry',
|
||||
kind: 'ghcr',
|
||||
registryUrl: `https://github.com/${owner}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown / private registry: never guess a link, just expose the host.
|
||||
return {
|
||||
registryHost: registry,
|
||||
registryLabel: registry,
|
||||
kind: 'other',
|
||||
registryUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
function asHttpUrl(value: string | undefined | null): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:' ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function githubRepoRoot(sourceUrl: string): string | null {
|
||||
try {
|
||||
const u = new URL(sourceUrl);
|
||||
if (u.hostname !== 'github.com' && u.hostname !== 'www.github.com') return null;
|
||||
const parts = u.pathname.split('/').filter(Boolean);
|
||||
if (parts.length < 2) return null;
|
||||
const owner = parts[0];
|
||||
const repo = parts[1].replace(/\.git$/, '');
|
||||
return `https://github.com/${owner}/${repo}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const OCI = {
|
||||
source: 'org.opencontainers.image.source',
|
||||
url: 'org.opencontainers.image.url',
|
||||
documentation: 'org.opencontainers.image.documentation',
|
||||
revision: 'org.opencontainers.image.revision',
|
||||
version: 'org.opencontainers.image.version',
|
||||
} as const;
|
||||
|
||||
const SHA_LIKE = /^[0-9a-f]{7,40}$/i;
|
||||
|
||||
export function extractImageSourceMeta(
|
||||
labels: Record<string, string> | null | undefined,
|
||||
): ImageSourceMeta {
|
||||
const links: ImageSourceLink[] = [];
|
||||
if (!labels) return { links, version: null, revision: null };
|
||||
|
||||
const sourceUrl = asHttpUrl(labels[OCI.source]);
|
||||
if (sourceUrl) links.push({ id: 'source', label: 'Source repository', url: sourceUrl });
|
||||
|
||||
const homepage = asHttpUrl(labels[OCI.url]);
|
||||
if (homepage) links.push({ id: 'url', label: 'Project homepage', url: homepage });
|
||||
|
||||
const docs = asHttpUrl(labels[OCI.documentation]);
|
||||
if (docs) links.push({ id: 'documentation', label: 'Documentation', url: docs });
|
||||
|
||||
const rawRevision = labels[OCI.revision]?.trim() || null;
|
||||
let revisionText: string | null = rawRevision;
|
||||
if (rawRevision && SHA_LIKE.test(rawRevision) && sourceUrl) {
|
||||
const repoRoot = githubRepoRoot(sourceUrl);
|
||||
if (repoRoot) {
|
||||
links.push({
|
||||
id: 'revision',
|
||||
label: `Revision ${rawRevision.slice(0, 12)}`,
|
||||
url: `${repoRoot}/commit/${rawRevision}`,
|
||||
});
|
||||
revisionText = null;
|
||||
}
|
||||
}
|
||||
|
||||
const version = labels[OCI.version]?.trim() || null;
|
||||
|
||||
return { links, version, revision: revisionText };
|
||||
}
|
||||
Reference in New Issue
Block a user