Files
sencho/backend/src/services/registry-api.ts
T
Anso ce699864c1 feat(registries): add exact-ID tag browser with non-401 failures (#1613)
* feat(resources): show multi-stack usedByStacks on images

Classify images with a deduped sorted stack reverse index, surface chips in the Images table and inspect sheet, and clear node-bound sheet selection on active-node change.

* feat(registries): add exact-ID tag browser with non-401 failures

Add GET /api/registries/:id/tags using credentials for that registry row only, map upstream auth failures to 424, and surface a Registry tags section on the image inspect sheet.

* fix(registries): distinguish unreachable hosts from auth failures

Map auth transport errors to REGISTRY_UPSTREAM (502), surface registry list-load failures in the tag panel, document Used by and Registry tags, and add parser coverage.

* fix(registries): drop unused RegistryTagsPanel __test export

The non-component export tripped react-refresh/only-export-components and failed Frontend lint in CI.
2026-07-11 13:21:38 -04:00

381 lines
16 KiB
TypeScript

import https from 'https';
import http from 'http';
import { sanitizeForLog } from '../utils/safeLog';
export interface ParsedRef {
registry: string;
repo: string;
tag: string;
}
export interface HttpResult {
statusCode: number;
headers: Record<string, string | string[] | undefined>;
body: string;
}
export interface RegistryCredentials {
username: string;
password: string;
}
export function parseImageRef(imageRef: string): ParsedRef | null {
if (imageRef.startsWith('sha256:')) return null;
const atIdx = imageRef.indexOf('@');
if (atIdx !== -1) imageRef = imageRef.slice(0, atIdx);
let registry = 'registry-1.docker.io';
let rest = imageRef;
const slashIdx = imageRef.indexOf('/');
if (slashIdx !== -1) {
const firstPart = imageRef.slice(0, slashIdx);
if (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost') {
registry = firstPart;
rest = imageRef.slice(slashIdx + 1);
}
}
let tag = 'latest';
const colonIdx = rest.lastIndexOf(':');
if (colonIdx > 0) {
tag = rest.slice(colonIdx + 1);
rest = rest.slice(0, colonIdx);
}
if (registry === 'registry-1.docker.io' && !rest.includes('/')) {
rest = `library/${rest}`;
}
return { registry, repo: rest, tag };
}
export function httpRequest(
url: string,
method: 'GET' | 'HEAD',
headers: Record<string, string> = {},
timeoutMs = 10000,
): Promise<HttpResult> {
return new Promise((resolve, reject) => {
const lib = url.startsWith('https:') ? https : http;
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
fn();
};
const req = lib.request(url, { method, headers }, (res) => {
let body = '';
res.on('data', (chunk: Buffer) => { body += chunk.toString(); });
res.on('end', () => finish(() => resolve({
statusCode: res.statusCode ?? 0,
headers: res.headers as Record<string, string | string[] | undefined>,
body,
})));
res.on('error', (err) => finish(() => reject(err)));
});
req.on('error', (err) => finish(() => reject(err)));
req.setTimeout(timeoutMs, () => {
const err = new Error('Request timed out');
req.destroy(err);
finish(() => reject(err));
});
req.end();
});
}
export function httpGet(
url: string,
headers: Record<string, string> = {},
timeoutMs = 10000,
): Promise<HttpResult> {
return httpRequest(url, 'GET', headers, timeoutMs);
}
export async function getAuthToken(
registry: string,
repo: string,
credentials?: RegistryCredentials | null,
): Promise<string | null> {
// Transport errors propagate (callers map to REGISTRY_UPSTREAM). null = auth/token failure only.
const basicHeaders: Record<string, string> = {};
if (credentials) {
basicHeaders['Authorization'] = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
let tokenUrl: string;
if (registry === 'registry-1.docker.io') {
tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`;
} else {
const ping = await httpGet(`https://${registry}/v2/`, basicHeaders);
const wwwAuth = ping.headers['www-authenticate'] as string | undefined;
if (!wwwAuth) return null;
const realmMatch = wwwAuth.match(/realm="([^"]+)"/);
const serviceMatch = wwwAuth.match(/service="([^"]+)"/);
if (!realmMatch) return null;
const params = new URLSearchParams();
if (serviceMatch) params.set('service', serviceMatch[1]);
// The /v2/ ping carries no repository context, so any scope it echoes is a
// placeholder (ghcr.io returns repository:user/image:pull). Always request
// the scope for the repository we actually want; reusing the echoed scope
// makes ghcr.io mint a token for the wrong repo and then reject the pull.
params.set('scope', `repository:${repo}:pull`);
tokenUrl = `${realmMatch[1]}?${params.toString()}`;
}
const tokenRes = await httpGet(tokenUrl, basicHeaders);
if (tokenRes.statusCode !== 200) return null;
try {
const parsed = JSON.parse(tokenRes.body) as { token?: unknown; access_token?: unknown };
const token = parsed.token ?? parsed.access_token;
return typeof token === 'string' ? token : null;
} catch {
return null;
}
}
const MANIFEST_ACCEPT = [
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.docker.distribution.manifest.v2+json',
'application/vnd.oci.image.index.v1+json',
'application/vnd.oci.image.manifest.v1+json',
].join(', ');
/** docker.io has three hostnames that all address the same registry. */
function canonicalRegistry(host: string): string {
if (host === 'docker.io' || host === 'index.docker.io' || host === 'registry-1.docker.io') {
return 'docker.io';
}
return host;
}
/**
* True when a local RepoDigest entry ("name@sha256:...") refers to the same
* registry + repository as the parsed image ref. Parses the name side through
* the same normalization as the image ref (Docker Hub's implicit `library/`
* namespace and default registry), replacing a fragile substring check that
* missed `library/*` official images: their RepoDigests read `nginx@sha256:...`,
* never `library/nginx@...`, so `name.includes('library/nginx')` was false.
*/
export function repoDigestMatchesRef(repoDigest: string, parsed: ParsedRef): boolean {
const at = repoDigest.indexOf('@');
if (at === -1) return false;
const parsedName = parseImageRef(repoDigest.slice(0, at));
if (!parsedName) return false;
return canonicalRegistry(parsedName.registry) === canonicalRegistry(parsed.registry)
&& parsedName.repo === parsed.repo;
}
/** Outcome of a remote-digest lookup: the digest, or a human-readable reason it failed. */
export type RemoteDigestResult =
| { ok: true; digest: string }
| { ok: false; reason: string };
/**
* Map a non-success manifest status to a specific reason so a caller can tell an auth
* failure from a rate limit, a missing image, or a server error, rather than collapsing
* them all into "unreachable". `ref` is the resolved "<registry>/<repo>:<tag>" for the
* image after Docker Hub normalization, not necessarily the literal string the user wrote.
*/
function manifestFailureReason(statusCode: number, ref: string, headers: HttpResult['headers']): string {
if (statusCode === 401 || statusCode === 403) return `Authentication failed for ${ref}`;
if (statusCode === 429) {
const retry = headers['retry-after'];
const retryStr = Array.isArray(retry) ? retry[0] : retry;
return retryStr
? `Rate limited by registry for ${ref} (retry after ${retryStr})`
: `Rate limited by registry for ${ref}`;
}
if (statusCode === 404) return `Image not found: ${ref}`;
if (statusCode >= 500) return `Registry error (${statusCode}) for ${ref}`;
return `Registry returned status ${statusCode} for ${ref}`;
}
/**
* Resolve the remote manifest digest for an image, returning either the digest or the
* reason the lookup failed. Same HEAD-first/GET-fallback transport as before (HEAD
* returns docker-content-digest without transferring the body, so it does not draw down
* Docker Hub's anonymous pull-rate budget the way a GET can); only the failure handling
* is richer. A 401/403/404/429/5xx HEAD reports its specific reason without a GET retry,
* since the bearer token is fetched up-front, so a 401 here is a real auth failure rather
* than a token-scope challenge to retry.
*/
export async function getRemoteDigestResult(
registry: string,
repo: string,
tag: string,
credentials?: RegistryCredentials | null,
): Promise<RemoteDigestResult> {
const ref = `${registry}/${repo}:${tag}`;
try {
// Auth transport failures used to collapse to null inside getAuthToken.
// Tag listing now needs those errors to propagate (REGISTRY_UPSTREAM), so
// digest lookup keeps anonymous fallback here when the token endpoint is down.
let token: string | null = null;
try {
token = await getAuthToken(registry, repo, credentials);
} catch (authErr) {
const cause = authErr instanceof Error
? ((authErr as NodeJS.ErrnoException).code ?? authErr.message)
: String(authErr);
console.error(
`[registry-api] Auth for ${sanitizeForLog(ref)} failed; trying anonymous:`,
sanitizeForLog(cause),
);
}
const headers: Record<string, string> = { Accept: MANIFEST_ACCEPT };
if (token) headers['Authorization'] = `Bearer ${token}`;
const url = `https://${registry}/v2/${repo}/manifests/${tag}`;
const head = await httpRequest(url, 'HEAD', headers);
if (head.statusCode === 200) {
const digest = head.headers['docker-content-digest'];
if (typeof digest === 'string') return { ok: true, digest };
// 200 without the digest header: fall through to GET to read it from there.
} else if (head.statusCode !== 405 && head.statusCode !== 501) {
return { ok: false, reason: manifestFailureReason(head.statusCode, ref, head.headers) };
}
const res = await httpRequest(url, 'GET', headers);
if (res.statusCode === 200) {
const digest = res.headers['docker-content-digest'];
if (typeof digest === 'string') return { ok: true, digest };
// 200 on both HEAD and GET but no digest header: a spec-violating registry.
return { ok: false, reason: `Registry returned no digest for ${ref}` };
}
return { ok: false, reason: manifestFailureReason(res.statusCode, ref, res.headers) };
} catch (e) {
// Bind and log the cause: a bare catch here would flatten DNS, TLS, connection-
// refused, and timeout failures into one opaque string with nothing in the logs,
// the silent-failure mode this function exists to remove. Prefer the errno code
// (ENOTFOUND/ECONNREFUSED/ETIMEDOUT/...) over a verbose message so the reason
// stays short in the sidebar tooltip; fall back to the message otherwise.
const cause = e instanceof Error ? ((e as NodeJS.ErrnoException).code ?? e.message) : String(e);
// ref and cause derive from the compose-authored image string and upstream error
// text, so neutralize control characters before they reach the log line.
console.error(`[registry-api] Remote digest lookup for ${sanitizeForLog(ref)} failed:`, sanitizeForLog(cause));
return { ok: false, reason: `Registry unreachable for ${ref} (${cause})` };
}
}
/**
* Digest-or-null view of {@link getRemoteDigestResult} for callers that only need the
* digest and treat any failure as "unknown" (e.g. the update-preview tag/digest diff).
*/
export async function getRemoteDigest(
registry: string,
repo: string,
tag: string,
credentials?: RegistryCredentials | null,
): Promise<string | null> {
const result = await getRemoteDigestResult(registry, repo, tag, credentials);
return result.ok ? result.digest : null;
}
export type TagListCode =
| 'REGISTRY_UNAUTHORIZED'
| 'REGISTRY_FORBIDDEN'
| 'REGISTRY_NOT_FOUND'
| 'REGISTRY_RATE_LIMITED'
| 'REGISTRY_UNSUPPORTED'
| 'REGISTRY_UPSTREAM'
| 'REGISTRY_INVALID_RESPONSE';
export type TagListResult =
| { ok: true; tags: string[]; nextCursor?: string }
| { ok: false; code: TagListCode; message: string };
const TAG_LIST_BODY_CAP = 2 * 1024 * 1024; // 2 MiB
function tagListFailure(statusCode: number): TagListResult {
if (statusCode === 401) {
return { ok: false, code: 'REGISTRY_UNAUTHORIZED', message: 'Registry rejected credentials' };
}
if (statusCode === 403) {
return { ok: false, code: 'REGISTRY_FORBIDDEN', message: 'Registry denied access to this repository' };
}
if (statusCode === 404) {
return { ok: false, code: 'REGISTRY_NOT_FOUND', message: 'Repository not found on registry' };
}
if (statusCode === 429) {
return { ok: false, code: 'REGISTRY_RATE_LIMITED', message: 'Registry rate limit exceeded' };
}
if (statusCode >= 500) {
return { ok: false, code: 'REGISTRY_UPSTREAM', message: `Registry error (${statusCode})` };
}
return { ok: false, code: 'REGISTRY_UPSTREAM', message: `Registry returned status ${statusCode}` };
}
function parseNextCursor(linkHeader: string | string[] | undefined): string | undefined {
const raw = Array.isArray(linkHeader) ? linkHeader.join(',') : linkHeader;
if (!raw) return undefined;
// Rel=next Link: </v2/repo/tags/list?n=50&last=foo>; rel="next"
const match = raw.match(/<[^>]*[?&]last=([^&>]+)[^>]*>\s*;\s*rel="?next"?/i);
if (!match) return undefined;
try {
return decodeURIComponent(match[1]);
} catch {
return match[1];
}
}
/**
* Typed tag list for the Resources registry browser. Never collapses auth
* failures into an empty array (that would hide credential problems).
*/
export async function listRegistryTagsResult(
registry: string,
repo: string,
credentials: RegistryCredentials,
opts: { limit?: number; cursor?: string } = {},
): Promise<TagListResult> {
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);
try {
const token = await getAuthToken(registry, repo, credentials);
if (!token) {
return { ok: false, code: 'REGISTRY_UNAUTHORIZED', message: 'Registry rejected credentials' };
}
const headers: Record<string, string> = { Accept: 'application/json', Authorization: `Bearer ${token}` };
const params = new URLSearchParams({ n: String(limit) });
if (opts.cursor) params.set('last', opts.cursor);
const url = `https://${registry}/v2/${repo}/tags/list?${params.toString()}`;
const res = await httpGet(url, headers);
if (res.statusCode !== 200) return tagListFailure(res.statusCode);
if (res.body.length > TAG_LIST_BODY_CAP) {
return { ok: false, code: 'REGISTRY_INVALID_RESPONSE', message: 'Registry tag list response too large' };
}
let parsed: { tags?: unknown };
try {
parsed = JSON.parse(res.body) as { tags?: unknown };
} catch {
return { ok: false, code: 'REGISTRY_INVALID_RESPONSE', message: 'Registry returned invalid JSON' };
}
if (!Array.isArray(parsed.tags) || !parsed.tags.every((t) => typeof t === 'string')) {
return { ok: false, code: 'REGISTRY_INVALID_RESPONSE', message: 'Registry tag list was malformed' };
}
const nextCursor = parseNextCursor(res.headers['link']);
return nextCursor
? { ok: true, tags: parsed.tags as string[], nextCursor }
: { ok: true, tags: parsed.tags as string[] };
} catch (e) {
const cause = e instanceof Error ? ((e as NodeJS.ErrnoException).code ?? e.message) : String(e);
console.error('[registry-api] Tag list failed:', sanitizeForLog(cause));
return { ok: false, code: 'REGISTRY_UPSTREAM', message: 'Registry unreachable' };
}
}
/** Compatibility wrapper for update-preview: empty list on any failure. */
export async function listRegistryTags(
registry: string,
repo: string,
credentials?: RegistryCredentials | null,
): Promise<string[]> {
if (!credentials) return [];
const result = await listRegistryTagsResult(registry, repo, credentials);
return result.ok ? result.tags : [];
}