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.
This commit is contained in:
Anso
2026-07-11 13:21:38 -04:00
committed by GitHub
parent 362a18e91a
commit ce699864c1
12 changed files with 977 additions and 76 deletions
+62 -1
View File
@@ -42,6 +42,23 @@ export interface ResolvedDockerConfig {
warnings: string[];
}
export type RegistryAuthForIdResult =
| {
ok: true;
username: string;
password: string;
registryHost: string;
type: RegistryType;
name: string;
}
| { ok: false; code: 'missing' | 'decrypt_failed' | 'ecr_failed'; message: string };
/** Host used for Docker Registry HTTP API calls (/v2/...). */
export function registryApiHost(reg: Pick<Registry, 'url' | 'type'>): string {
if (reg.type === 'dockerhub') return 'registry-1.docker.io';
return hostFromStoredRegistry(reg);
}
interface HttpResult {
statusCode: number;
headers: Record<string, string | string[] | undefined>;
@@ -83,7 +100,7 @@ function toProbeUrl(url: string, type: RegistryType): string {
}
/** Canonical host for matching (image ref → stored credential). */
function hostFromStoredRegistry(reg: Pick<Registry, 'url' | 'type'>): string {
export function hostFromStoredRegistry(reg: Pick<Registry, 'url' | 'type'>): string {
if (reg.type === 'dockerhub') return 'index.docker.io';
try {
const withProtocol = reg.url.startsWith('http') ? reg.url : `https://${reg.url}`;
@@ -375,6 +392,50 @@ export class RegistryService {
return { config: { auths }, warnings };
}
/**
* Resolve credentials for a specific registry row by ID only.
* Never falls back to host-based matching (duplicate hosts must use the requested ID).
*/
public async getAuthForRegistryId(id: number): Promise<RegistryAuthForIdResult> {
const reg = DatabaseService.getInstance().getRegistry(id);
if (!reg) {
return { ok: false, code: 'missing', message: 'Registry not found' };
}
try {
const registryHost = registryApiHost(reg);
if (reg.type === 'ecr') {
const creds = await this.getEcrCredentials(reg);
return {
ok: true,
username: creds.username,
password: creds.password,
registryHost,
type: reg.type,
name: reg.name,
};
}
return {
ok: true,
username: reg.username,
password: this.crypto.decrypt(reg.secret),
registryHost,
type: reg.type,
name: reg.name,
};
} catch (e) {
const detail = e instanceof Error ? e.message : String(e);
console.warn(
`[RegistryService] getAuthForRegistryId(${id}) failed:`,
sanitizeForLog(detail),
);
if (reg.type === 'ecr') {
return { ok: false, code: 'ecr_failed', message: 'Failed to refresh ECR credentials' };
}
return { ok: false, code: 'decrypt_failed', message: 'Failed to decrypt registry credentials' };
}
}
// ─── Registry auth for ImageUpdateService ────────────────────────────────
public async getAuthForRegistry(registryHost: string): Promise<{ username: string; password: string } | null> {
+145 -46
View File
@@ -98,39 +98,41 @@ export async function getAuthToken(
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 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;
const parsed = JSON.parse(tokenRes.body);
return parsed.token ?? parsed.access_token ?? null;
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;
}
@@ -210,7 +212,21 @@ export async function getRemoteDigestResult(
): Promise<RemoteDigestResult> {
const ref = `${registry}/${repo}:${tag}`;
try {
const token = await getAuthToken(registry, repo, credentials);
// 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}`;
@@ -260,22 +276,105 @@ export async function getRemoteDigest(
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[]> {
try {
const token = await getAuthToken(registry, repo, credentials);
const headers: Record<string, string> = { Accept: 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await httpGet(`https://${registry}/v2/${repo}/tags/list`, headers);
if (res.statusCode !== 200) return [];
const parsed = JSON.parse(res.body) as { tags?: string[] };
return Array.isArray(parsed.tags) ? parsed.tags : [];
} catch {
return [];
}
if (!credentials) return [];
const result = await listRegistryTagsResult(registry, repo, credentials);
return result.ok ? result.tags : [];
}