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
@@ -0,0 +1,131 @@
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
vi.mock('../services/registry-api', async () => {
const actual = await vi.importActual<typeof import('../services/registry-api')>('../services/registry-api');
return {
...actual,
listRegistryTagsResult: vi.fn(),
};
});
import { listRegistryTagsResult } from '../services/registry-api';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
let RegistryService: typeof import('../services/RegistryService').RegistryService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ RegistryService } = await import('../services/RegistryService'));
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '10m' });
authHeader = `Bearer ${token}`;
});
afterAll(() => cleanupTestDb(tmpDir));
afterEach(() => {
vi.restoreAllMocks();
vi.mocked(listRegistryTagsResult).mockReset();
});
describe('GET /api/registries/:id/tags', () => {
it('returns tags for an exact registry id', async () => {
const id = RegistryService.getInstance().create({
name: 'Hub',
url: 'https://index.docker.io/v1/',
type: 'dockerhub',
username: 'user',
secret: 'token',
});
vi.mocked(listRegistryTagsResult).mockResolvedValue({ ok: true, tags: ['latest', '1.0'] });
const res = await request(app)
.get(`/api/registries/${id}/tags`)
.query({ repository: 'library/nginx' })
.set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.tags).toEqual(['latest', '1.0']);
expect(res.body.registryId).toBe(id);
expect(listRegistryTagsResult).toHaveBeenCalled();
});
it('maps upstream unauthorized to 424 with REGISTRY_UNAUTHORIZED (never 401)', async () => {
const id = RegistryService.getInstance().create({
name: 'Hub2',
url: 'https://index.docker.io/v1/',
type: 'dockerhub',
username: 'user',
secret: 'bad',
});
vi.mocked(listRegistryTagsResult).mockResolvedValue({
ok: false,
code: 'REGISTRY_UNAUTHORIZED',
message: 'Registry rejected credentials',
});
const res = await request(app)
.get(`/api/registries/${id}/tags`)
.query({ repository: 'library/nginx' })
.set('Authorization', authHeader);
expect(res.status).toBe(424);
expect(res.body.code).toBe('REGISTRY_UNAUTHORIZED');
expect(res.status).not.toBe(401);
});
it('rejects host-looking repository values', async () => {
const id = RegistryService.getInstance().create({
name: 'GHCR',
url: 'ghcr.io',
type: 'ghcr',
username: 'user',
secret: 'token',
});
const res = await request(app)
.get(`/api/registries/${id}/tags`)
.query({ repository: 'ghcr.io/org/app' })
.set('Authorization', authHeader);
expect(res.status).toBe(400);
expect(listRegistryTagsResult).not.toHaveBeenCalled();
});
it('maps REGISTRY_UPSTREAM from the client to HTTP 502', async () => {
const id = RegistryService.getInstance().create({
name: 'Down',
url: 'https://registry.example.invalid/',
type: 'custom',
username: 'user',
secret: 'token',
});
vi.mocked(listRegistryTagsResult).mockResolvedValue({
ok: false,
code: 'REGISTRY_UPSTREAM',
message: 'Registry unreachable',
});
const res = await request(app)
.get(`/api/registries/${id}/tags`)
.query({ repository: 'org/app' })
.set('Authorization', authHeader);
expect(res.status).toBe(502);
expect(res.body.code).toBe('REGISTRY_UPSTREAM');
});
it('returns 404 for unknown registry id', async () => {
const res = await request(app)
.get('/api/registries/999999/tags')
.query({ repository: 'org/app' })
.set('Authorization', authHeader);
expect(res.status).toBe(404);
});
});
+83 -1
View File
@@ -39,7 +39,7 @@ function fakeRequest(url: string, options: { method?: string }, cb: (res: EventE
vi.mock('https', () => ({ default: { request: (...args: unknown[]) => fakeRequest(...(args as Parameters<typeof fakeRequest>)) } }));
vi.mock('http', () => ({ default: { request: (...args: unknown[]) => fakeRequest(...(args as Parameters<typeof fakeRequest>)) } }));
import { repoDigestMatchesRef, getRemoteDigest, getRemoteDigestResult, getAuthToken, parseImageRef } from '../services/registry-api';
import { repoDigestMatchesRef, getRemoteDigest, getRemoteDigestResult, getAuthToken, listRegistryTagsResult, parseImageRef } from '../services/registry-api';
const TOKEN_BODY = JSON.stringify({ token: 'test-token' });
const REMOTE = 'sha256:remote000000000000000000000000000000000000000000000000000000';
@@ -234,4 +234,86 @@ describe('getRemoteDigestResult failure reasons', () => {
route = () => { throw new Error('ENOTFOUND'); };
expect(await get()).toEqual({ ok: false, reason: `Registry unreachable for ${REF} (ENOTFOUND)` });
});
it('falls back to anonymous manifest lookup when auth transport fails', async () => {
route = (url, method): FakeResp => {
if (url.includes('auth.docker.io/token')) {
throw Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' });
}
if (method === 'HEAD') return { statusCode: 200, headers: { 'docker-content-digest': REMOTE } };
return { statusCode: 500, headers: {} };
};
expect(await get()).toEqual({ ok: true, digest: REMOTE });
});
});
describe('listRegistryTagsResult', () => {
const creds = { username: 'u', password: 'p' };
const GHCR_CHALLENGE = 'Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"';
beforeEach(() => {
calls.length = 0;
});
function authThenTags(tagResp: FakeResp): (url: string, method: string) => FakeResp {
return (url) => {
if (url === 'https://ghcr.io/v2/') return { statusCode: 401, headers: { 'www-authenticate': GHCR_CHALLENGE } };
if (url.startsWith('https://ghcr.io/token')) return { statusCode: 200, headers: {}, body: TOKEN_BODY };
if (url.includes('/tags/list')) return tagResp;
return { statusCode: 500, headers: {} };
};
}
async function expectFailure(code: string, message?: string): Promise<void> {
const result = await listRegistryTagsResult('ghcr.io', 'acme/app', creds);
expect(result).toMatchObject(message ? { ok: false, code, message } : { ok: false, code });
}
it('returns tags on a successful list', async () => {
route = authThenTags({ statusCode: 200, headers: {}, body: JSON.stringify({ tags: ['latest', '1.0'] }) });
await expect(listRegistryTagsResult('ghcr.io', 'acme/app', creds)).resolves.toEqual({
ok: true,
tags: ['latest', '1.0'],
});
});
it('maps transport failure during auth ping to REGISTRY_UPSTREAM (not UNAUTHORIZED)', async () => {
route = () => { throw Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }); };
await expectFailure('REGISTRY_UPSTREAM', 'Registry unreachable');
});
it('maps a rejected token to REGISTRY_UNAUTHORIZED', async () => {
route = (url): FakeResp => {
if (url === 'https://ghcr.io/v2/') return { statusCode: 401, headers: { 'www-authenticate': GHCR_CHALLENGE } };
if (url.startsWith('https://ghcr.io/token')) return { statusCode: 401, headers: {} };
return { statusCode: 200, headers: {}, body: '{}' };
};
await expectFailure('REGISTRY_UNAUTHORIZED', 'Registry rejected credentials');
});
it('maps 403 on tags/list to REGISTRY_FORBIDDEN', async () => {
route = authThenTags({ statusCode: 403, headers: {} });
await expectFailure('REGISTRY_FORBIDDEN');
});
it('maps 429 on tags/list to REGISTRY_RATE_LIMITED', async () => {
route = authThenTags({ statusCode: 429, headers: {} });
await expectFailure('REGISTRY_RATE_LIMITED');
});
it('maps invalid JSON to REGISTRY_INVALID_RESPONSE', async () => {
route = authThenTags({ statusCode: 200, headers: {}, body: 'not-json' });
await expectFailure('REGISTRY_INVALID_RESPONSE', 'Registry returned invalid JSON');
});
it('maps a non-array tags field to REGISTRY_INVALID_RESPONSE', async () => {
route = authThenTags({ statusCode: 200, headers: {}, body: JSON.stringify({ tags: 'latest' }) });
await expectFailure('REGISTRY_INVALID_RESPONSE', 'Registry tag list was malformed');
});
it('maps an oversized body to REGISTRY_INVALID_RESPONSE', async () => {
const huge = '{"tags":["' + 'x'.repeat(2 * 1024 * 1024) + '"]}';
route = authThenTags({ statusCode: 200, headers: {}, body: huge });
await expectFailure('REGISTRY_INVALID_RESPONSE', 'Registry tag list response too large');
});
});
+110
View File
@@ -1,8 +1,10 @@
import { Router, type Request, type Response } from 'express';
import { RegistryService } from '../services/RegistryService';
import { listRegistryTagsResult, type TagListCode } from '../services/registry-api';
import { requireAdmin, requirePaid } from '../middleware/tierGates';
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
import { parseIntParam } from '../utils/parseIntParam';
import { sanitizeForLog } from '../utils/safeLog';
const VALID_REGISTRY_TYPES = ['dockerhub', 'ghcr', 'ecr', 'custom'] as const;
const REGISTRY_SCOPE_MESSAGE = 'API tokens cannot manage registry credentials.';
@@ -30,6 +32,40 @@ function allowRegistryType(type: string | undefined, req: Request, res: Response
return true;
}
const REPO_MAX_LEN = 255;
const REPO_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*$/i;
/** Path-safe repository name only (no scheme, host, or tag). */
function parseRepositoryParam(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const repo = raw.trim();
if (!repo || repo.length > REPO_MAX_LEN) return null;
if (repo.includes('://') || repo.includes('@') || repo.startsWith('/') || repo.includes(':')) return null;
const first = repo.split('/')[0];
// Host-looking first segment (e.g. ghcr.io/org/name) is rejected; host comes from the registry row.
if (first.includes('.')) return null;
if (!REPO_PATTERN.test(repo)) return null;
return repo;
}
function tagListHttpStatus(code: TagListCode): number {
switch (code) {
case 'REGISTRY_UNAUTHORIZED':
case 'REGISTRY_FORBIDDEN':
case 'REGISTRY_RATE_LIMITED':
case 'REGISTRY_UNSUPPORTED':
return 424;
case 'REGISTRY_NOT_FOUND':
return 404;
case 'REGISTRY_INVALID_RESPONSE':
return 400;
case 'REGISTRY_UPSTREAM':
default:
return 502;
}
}
export const registriesRouter = Router();
registriesRouter.get('/', (req: Request, res: Response): void => {
@@ -136,6 +172,80 @@ registriesRouter.delete('/:id', (req: Request, res: Response): void => {
}
});
// Browse tags for a configured registry + validated repository (hub-only).
// Upstream registry auth failures map to 424/502 — never HTTP 401 (that would
// log the browser session out via the frontend unauthorized handler).
registriesRouter.get('/:id/tags', async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'registry ID');
if (id === null) return;
const repository = parseRepositoryParam(req.query.repository);
if (!repository) {
res.status(400).json({ error: 'Query parameter repository is required and must be a path-safe repository name' });
return;
}
const limitRaw = req.query.limit;
let limit = 50;
if (limitRaw !== undefined) {
const n = typeof limitRaw === 'string' ? Number.parseInt(limitRaw, 10) : NaN;
if (!Number.isFinite(n) || n < 1 || n > 100) {
res.status(400).json({ error: 'limit must be an integer from 1 to 100' });
return;
}
limit = n;
}
const cursor = typeof req.query.cursor === 'string' && req.query.cursor.trim()
? req.query.cursor.trim()
: undefined;
const auth = await RegistryService.getInstance().getAuthForRegistryId(id);
if (!auth.ok) {
if (auth.code === 'missing') {
res.status(404).json({ error: auth.message, code: 'REGISTRY_NOT_FOUND' });
return;
}
const code = auth.code === 'ecr_failed' || auth.code === 'decrypt_failed'
? 'REGISTRY_UNAUTHORIZED'
: 'REGISTRY_UPSTREAM';
res.status(424).json({ error: auth.message, code });
return;
}
if (!allowRegistryType(auth.type, req, res)) return;
// Docker Hub official images are often referenced as library/<name>.
let repo = repository;
if (auth.type === 'dockerhub' && !repo.includes('/')) {
repo = `library/${repo}`;
}
const result = await listRegistryTagsResult(
auth.registryHost,
repo,
{ username: auth.username, password: auth.password },
{ limit, cursor },
);
if (!result.ok) {
res.status(tagListHttpStatus(result.code)).json({ error: result.message, code: result.code });
return;
}
res.json({
tags: result.tags,
nextCursor: result.nextCursor ?? null,
registryId: id,
registryName: auth.name,
repository: repo,
});
} catch (error) {
console.error('[Registries] Tag list error:', sanitizeForLog(error instanceof Error ? error.message : String(error)));
res.status(500).json({ error: 'Failed to list registry tags' });
}
});
registriesRouter.post('/:id/test', async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
+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 : [];
}
+8
View File
@@ -25,6 +25,14 @@ Sencho stores credentials for your private Docker registries and injects them au
AWS ECR requires a Sencho **Admiral** license; Docker Hub, GHCR, and custom registries are available on every tier.
## Browse tags from Resources
When you inspect an image on the Resources page, the detail sheet includes a **Registry tags** section for admins. Sencho lists tags from the configured registry that matches the image host (credentials come from that registry row only). Tag browsing always runs on the hub instance; local image digests still come from the active node.
If credentials are wrong, Sencho reports a registry error without signing you out of the dashboard.
## Where to find it
Open **Settings → Infrastructure → Registries** on the Sencho instance you are signed into directly. The section is hidden when you are viewing another node through the node switcher.
+5 -3
View File
@@ -83,13 +83,15 @@ Lists all Docker images on the host with their ID, repository tag, size, and sta
#### Inspect image
Click the eye icon on any image row to open a detail sheet with three sections:
Click the eye icon on any image row to open a detail sheet with these sections:
- **Overview** lists the ID, size, creation date, architecture and OS, author, all repository tags, and which Sencho stacks use the image.
- **Used by** lists every Sencho stack that references the image (chips open that stack). Sourced from the Resources inventory, so it stays visible while inspect loads or if inspect fails.
- **Overview** lists the ID, size, creation date, architecture and OS, author, and all repository tags.
- **Config** shows the default `Cmd`, `Entrypoint`, `WorkingDir`, `User`, exposed ports, environment variables, and labels. Env vars and labels are collapsible.
- **Layers** lists the layer history in build order. Each row shows the layer index, size, age, and the build command (`CreatedBy`). Empty layers (metadata-only, zero bytes) are dimmed.
- **Registry tags** (admins) lists remote tags from a matching configured registry. See [Private registries](/features/private-registries) for credential and failure behavior.
The inspect sheet is read-only and available to all roles.
The inspect sheet is available to all roles. Registry tag browsing is admin-only.
### Volumes
+61
View File
@@ -644,6 +644,26 @@ components:
description: Required when `type` is `ecr`.
example: us-east-1
RegistryTagList:
type: object
required: [tags, nextCursor, registryId, registryName, repository]
properties:
tags:
type: array
items: { type: string }
example: [latest, "1.0"]
nextCursor:
type: ["string", "null"]
description: Opaque cursor for the next page, or null when there are no more tags.
registryId:
type: integer
registryName:
type: string
repository:
type: string
description: Path-safe repository name after Docker Hub library/ normalization when applicable.
example: library/nginx
ImageUpdateStatus:
type: object
properties:
@@ -3605,3 +3625,44 @@ paths:
$ref: "#/components/responses/Unauthorized"
"403":
description: Non-admin users cannot change the schedule.
/api/registries/{id}/tags:
get:
tags: [Registries]
summary: List tags for a repository on a configured registry
description: |
Hub-only. Admin only. Credentials are resolved by exact registry ID.
Upstream registry auth failures return 424 with a REGISTRY_* code, never HTTP 401.
parameters:
- name: id
in: path
required: true
schema: { type: integer }
- name: repository
in: query
required: true
schema: { type: string }
description: Path-safe repository name (no host or scheme)
- name: cursor
in: query
required: false
schema: { type: string }
- name: limit
in: query
required: false
schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
responses:
'200':
description: Tag page
content:
application/json:
schema:
$ref: "#/components/schemas/RegistryTagList"
'400':
description: Invalid repository or response
'404':
description: Registry or repository not found
'424':
description: Upstream registry auth or policy failure (not a Sencho session 401)
'502':
description: Upstream registry transport failure
@@ -7,6 +7,8 @@ import { apiFetch } from '@/lib/api';
import { formatBytes } from '@/lib/utils';
import { copyToClipboard } from '@/lib/clipboard';
import { formatShortDigest } from '@/lib/formatDigest';
import { useAuth } from '@/context/AuthContext';
import { RegistryTagsPanel } from './RegistryTagsPanel';
import { Copy } from 'lucide-react';
interface ImageInspect {
@@ -76,6 +78,7 @@ function formatRelativeAge(timestampSec: number): string {
export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsSheetProps) {
const imageId = image?.Id ?? null;
const { isAdmin } = useAuth();
const [data, setData] = useState<ImageDetails | null>(null);
const [loading, setLoading] = useState(false);
@@ -113,7 +116,6 @@ export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsS
const inspect = data?.inspect;
const history = data?.history ?? [];
const totalLayers = history.length;
const usedByStacks = image?.usedByStacks ?? [];
const name = image?.RepoTags?.[0]
|| inspect?.RepoTags?.[0]
@@ -145,6 +147,33 @@ export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsS
</div>
)}
{image && (
<SheetSection title="Used by">
{(image.usedByStacks?.length ?? 0) === 0 ? (
<p className="text-xs text-muted-foreground">
{image.managedStatus === 'unused' ? 'No containers' : 'Not used by a Sencho stack'}
</p>
) : (
<div className="flex flex-wrap gap-1">
{image.usedByStacks.map((stack) => (
onOpenStack ? (
<button
key={stack}
type="button"
className="inline-flex items-center px-1.5 py-0.5 rounded border border-success/25 bg-success/8 text-success text-[10px] font-medium hover:bg-success/15 transition-colors"
onClick={() => onOpenStack(stack)}
>
{stack}
</button>
) : (
<Badge key={stack} variant="outline" className="text-[10px] h-5">{stack}</Badge>
)
))}
</div>
)}
</SheetSection>
)}
{!loading && inspect && (
<>
<SheetSection title="Overview">
@@ -191,30 +220,6 @@ export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsS
</div>
</Field>
)}
<Field label="Used by" span={2}>
{usedByStacks.length === 0 ? (
<p className="text-xs mt-0.5 text-muted-foreground">
{image?.managedStatus === 'unused' ? 'No containers' : 'Not used by a Sencho stack'}
</p>
) : (
<div className="flex flex-wrap gap-1 mt-1">
{usedByStacks.map((stack) => (
onOpenStack ? (
<button
key={stack}
type="button"
className="inline-flex items-center px-1.5 py-0.5 rounded border border-success/25 bg-success/8 text-success text-[10px] font-medium hover:bg-success/15 transition-colors"
onClick={() => onOpenStack(stack)}
>
{stack}
</button>
) : (
<Badge key={stack} variant="outline" className="text-[10px] h-5">{stack}</Badge>
)
))}
</div>
)}
</Field>
</div>
</SheetSection>
@@ -279,6 +284,15 @@ export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsS
</ol>
)}
</SheetSection>
<SheetSection title="Registry tags">
<RegistryTagsPanel
repoTags={inspect.RepoTags ?? image?.RepoTags ?? []}
repoDigests={inspect.RepoDigests ?? []}
nodeId={image?.nodeId ?? 0}
isAdmin={isAdmin}
/>
</SheetSection>
</>
)}
</SystemSheet>
@@ -0,0 +1,287 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { Loader2 } from 'lucide-react';
interface RegistryRow {
id: number;
name: string;
url: string;
type: 'dockerhub' | 'ghcr' | 'ecr' | 'custom';
has_secret: boolean;
}
interface TagListResponse {
tags: string[];
nextCursor: string | null;
registryId: number;
registryName: string;
repository: string;
}
export interface RegistryTagsPanelProps {
repoTags: string[];
repoDigests: string[];
nodeId: string | number;
isAdmin: boolean;
}
function parseRepoFromTag(tag: string): { host: string; repo: string; tagName: string } | null {
if (!tag || tag === '<none>:<none>') return null;
const at = tag.indexOf('@');
const ref = at === -1 ? tag : tag.slice(0, at);
let rest = ref;
let host = 'docker.io';
const slash = ref.indexOf('/');
if (slash !== -1) {
const first = ref.slice(0, slash);
if (first.includes('.') || first.includes(':') || first === 'localhost') {
host = first.toLowerCase();
rest = ref.slice(slash + 1);
}
}
let tagName = 'latest';
const colon = rest.lastIndexOf(':');
if (colon > 0) {
tagName = rest.slice(colon + 1);
rest = rest.slice(0, colon);
}
if (host === 'docker.io' && !rest.includes('/')) {
rest = `library/${rest}`;
}
return { host, repo: rest, tagName };
}
function registryMatchesHost(reg: RegistryRow, host: string): boolean {
const h = host.toLowerCase();
if (reg.type === 'dockerhub') {
return h === 'docker.io' || h === 'index.docker.io' || h === 'registry-1.docker.io' || h === '';
}
try {
const withProto = reg.url.startsWith('http') ? reg.url : `https://${reg.url}`;
return new URL(withProto).host.toLowerCase() === h;
} catch {
return reg.url.replace(/^https?:\/\//i, '').split('/')[0].toLowerCase() === h;
}
}
export function RegistryTagsPanel({
repoTags,
isAdmin,
}: RegistryTagsPanelProps) {
const candidates = useMemo(() => {
const seen = new Set<string>();
const out: { host: string; repo: string; tagName: string; label: string }[] = [];
for (const t of repoTags) {
const parsed = parseRepoFromTag(t);
if (!parsed) continue;
const key = `${parsed.host}/${parsed.repo}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ ...parsed, label: t });
}
return out;
}, [repoTags]);
const [registries, setRegistries] = useState<RegistryRow[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false);
const [regsError, setRegsError] = useState<string | null>(null);
const [selectedRepoIdx, setSelectedRepoIdx] = useState(0);
const [selectedRegistryId, setSelectedRegistryId] = useState<number | null>(null);
const [tags, setTags] = useState<string[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loadingTags, setLoadingTags] = useState(false);
const [error, setError] = useState<string | null>(null);
const selected = candidates[selectedRepoIdx] ?? null;
const matchingRegistries = useMemo(() => {
if (!selected) return [];
return registries.filter((r) => registryMatchesHost(r, selected.host));
}, [registries, selected]);
useEffect(() => {
if (!isAdmin) return;
let cancelled = false;
setLoadingRegs(true);
setRegsError(null);
apiFetch('/registries', { localOnly: true })
.then(async (res) => {
if (!res.ok) throw new Error('Failed to load registries');
return res.json() as Promise<RegistryRow[]>;
})
.then((rows) => {
if (!cancelled) setRegistries(Array.isArray(rows) ? rows : []);
})
.catch((err: unknown) => {
if (cancelled) return;
const msg = err instanceof Error ? err.message : 'Failed to load registries';
setRegsError(msg);
setRegistries([]);
toast.error(msg);
})
.finally(() => {
if (!cancelled) setLoadingRegs(false);
});
return () => { cancelled = true; };
}, [isAdmin]);
useEffect(() => {
if (matchingRegistries.length === 0) {
setSelectedRegistryId(null);
return;
}
setSelectedRegistryId((prev) =>
prev && matchingRegistries.some((r) => r.id === prev) ? prev : matchingRegistries[0].id,
);
}, [matchingRegistries]);
const loadTags = async (cursor?: string | null, append = false) => {
if (!selected || selectedRegistryId == null) return;
setLoadingTags(true);
setError(null);
try {
const params = new URLSearchParams({ repository: selected.repo, limit: '50' });
if (cursor) params.set('cursor', cursor);
const res = await apiFetch(`/registries/${selectedRegistryId}/tags?${params}`, { localOnly: true });
const data = await res.json().catch(() => null) as (TagListResponse & { error?: string }) | null;
if (!res.ok) {
throw new Error(data?.error || `Failed to list tags (${res.status})`);
}
const page = Array.isArray(data?.tags) ? data!.tags : [];
setTags((prev) => (append ? [...prev, ...page] : page));
setNextCursor(data?.nextCursor ?? null);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to list tags';
setError(msg);
if (!append) setTags([]);
toast.error(msg);
} finally {
setLoadingTags(false);
}
};
useEffect(() => {
setTags([]);
setNextCursor(null);
setError(null);
if (selected && selectedRegistryId != null) {
void loadTags(null, false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected?.host, selected?.repo, selectedRegistryId]);
if (!isAdmin) {
return (
<p className="text-xs text-muted-foreground">
Registry tag browsing is available to admins with a configured registry.
</p>
);
}
if (candidates.length === 0) {
return <p className="text-xs text-muted-foreground">No repository tags to browse.</p>;
}
let registryBody: ReactNode;
if (loadingRegs) {
registryBody = (
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Loader2 className="w-3 h-3 animate-spin" /> Loading registries
</p>
);
} else if (regsError) {
registryBody = <p className="text-xs text-destructive">{regsError}</p>;
} else if (matchingRegistries.length === 0) {
registryBody = (
<p className="text-xs text-muted-foreground">
No configured registry matches {selected?.host}. Add credentials under Settings Registries.
</p>
);
} else {
registryBody = (
<>
{matchingRegistries.length > 1 && (
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Registry
<select
className="h-8 rounded-md border border-input bg-background px-2 text-foreground text-xs"
value={selectedRegistryId ?? ''}
onChange={(e) => setSelectedRegistryId(Number(e.target.value))}
>
{matchingRegistries.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
</label>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
{loadingTags && tags.length === 0 ? (
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Loader2 className="w-3 h-3 animate-spin" /> Loading tags
</p>
) : tags.length === 0 && !error ? (
<p className="text-xs text-muted-foreground">No tags returned.</p>
) : (
<ul className="max-h-48 overflow-y-auto space-y-1 font-mono text-[11px]">
{tags.map((tag) => {
const isCurrent = selected?.tagName === tag;
return (
<li key={tag} className="flex items-center gap-2 truncate">
<span className={isCurrent ? 'text-foreground font-medium' : 'text-stat-subtitle/90'}>
{tag}
</span>
{isCurrent && (
<Badge variant="outline" className="text-[9px] h-4">current</Badge>
)}
</li>
);
})}
</ul>
)}
{nextCursor && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
disabled={loadingTags}
onClick={() => void loadTags(nextCursor, true)}
>
{loadingTags ? 'Loading…' : 'Load more'}
</Button>
)}
</>
);
}
return (
<div className="space-y-3 text-sm">
{candidates.length > 1 && (
<div className="flex flex-wrap gap-1">
{candidates.map((c, i) => (
<button
key={c.label}
type="button"
className={`px-1.5 py-0.5 rounded border text-[10px] font-mono transition-colors ${
i === selectedRepoIdx
? 'border-foreground/30 bg-muted text-foreground'
: 'border-border text-muted-foreground hover:text-foreground'
}`}
onClick={() => setSelectedRepoIdx(i)}
>
{c.label}
</button>
))}
</div>
)}
{registryBody}
</div>
);
}
@@ -5,6 +5,7 @@ import { ImageDetailsSheet } from '../ImageDetailsSheet';
const apiFetch = vi.fn();
vi.mock('@/lib/api', () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) }));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true }) }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
@@ -0,0 +1,45 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { RegistryTagsPanel } from '../RegistryTagsPanel';
const apiFetch = vi.fn();
const toastError = vi.fn();
vi.mock('@/lib/api', () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: (...args: unknown[]) => toastError(...args), success: vi.fn() },
}));
describe('RegistryTagsPanel', () => {
beforeEach(() => {
apiFetch.mockReset();
toastError.mockReset();
});
const panelProps = {
repoTags: ['ghcr.io/acme/app:latest'],
repoDigests: [] as string[],
nodeId: 1,
isAdmin: true as const,
};
it('surfaces registry list failures instead of a no-match message', async () => {
apiFetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({ error: 'boom' }) });
render(<RegistryTagsPanel {...panelProps} />);
expect(await screen.findByText('Failed to load registries')).toBeInTheDocument();
expect(screen.queryByText(/No configured registry matches/i)).toBeNull();
expect(toastError).toHaveBeenCalled();
});
it('shows no-match copy when registries load but none match the host', async () => {
apiFetch.mockResolvedValue({
ok: true,
json: async () => ([{ id: 1, name: 'Hub', url: 'https://index.docker.io/v1/', type: 'dockerhub', has_secret: true }]),
});
render(<RegistryTagsPanel {...panelProps} />);
expect(await screen.findByText(/No configured registry matches ghcr.io/i)).toBeInTheDocument();
});
});