diff --git a/.env.example b/.env.example index fbc4a0a..4424718 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,6 @@ APP_PORT=3080 # Internal registry URL used by the Nuxt app (compose service name) REGISTRY_INTERNAL_URL=http://registry:5000 + +# Daily registry garbage-collect hour in UTC (20 = 05:00 KST). Empty value disables it. +GC_HOUR_UTC=20 diff --git a/README.md b/README.md index 19f54f3..ab03734 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ docker push localhost:5050/demo/alpine:latest - 주의: registry 컨테이너에는 `REGISTRY_*` 환경변수를 직접 넣지 않습니다(공식 이미지가 설정 오버라이드로 해석함). `REGISTRY_PUBLIC_URL`은 app에만 전달됩니다. - `SESSION_SECRET` — 관리 UI 세션 서명 키 (운영에서 반드시 변경) - `ADMIN_USERNAME` / `ADMIN_PASSWORD` — 최초 admin 시드 +- `GC_HOUR_UTC` — 매일 blob 가비지 컬렉션을 돌릴 시각(UTC, 기본 `20` = 05:00 KST). 빈 값이면 끔 `AUTH_PUBLIC_URL`을 바꾼 뒤에는 registry 컨테이너를 재시작하세요. @@ -97,6 +98,21 @@ ACL 패턴 예: - `team/**` — 하위 전체 - `*` — 모든 저장소 +## 이미지 삭제 + +- **태그 삭제** — 해당 태그만 지웁니다. 같은 digest를 가리키는 다른 태그는 남고, 더 이상 어떤 태그도 가리키지 않을 때만 manifest까지 삭제합니다. +- **저장소 삭제** — 모든 manifest를 지운 뒤 저장소 메타데이터 디렉터리를 스토리지에서 직접 제거합니다. + Registry API에는 저장소 삭제가 없어서, 태그를 모두 지워도 `_catalog`에 빈 저장소가 계속 남기 때문입니다. + 이를 위해 app 컨테이너에 `registry-data` 볼륨을 함께 마운트합니다(`REGISTRY_STORAGE_PATH`). +- **디스크 회수** — 삭제는 참조만 끊고 blob은 남습니다. registry 컨테이너가 매일 `GC_HOUR_UTC`에 + `registry garbage-collect`를 실행해 참조 없는 blob을 정리합니다. 바로 정리하려면: + + ```bash + docker compose exec registry registry garbage-collect /etc/docker/registry/config.yml + ``` + + GC 중 push가 겹치면 업로드 중인 레이어가 지워질 수 있으니 push가 없는 시간에 실행하세요. + ## 로컬 개발 (앱만) ```bash diff --git a/app/app/pages/images.vue b/app/app/pages/images.vue index 42ff13e..66d5d58 100644 --- a/app/app/pages/images.vue +++ b/app/app/pages/images.vue @@ -34,16 +34,29 @@
- +
+ + +
@@ -94,7 +107,22 @@ -

태그가 없습니다.

+
+

+ 태그가 없는 저장소입니다. 레지스트리는 빈 저장소를 자동으로 지우지 않으므로 직접 삭제하세요. +

+ +
@@ -107,6 +135,7 @@ import { ChevronRight, CircleAlert, Copy, + LoaderCircle, Package, PackageOpen, RefreshCw, @@ -122,6 +151,7 @@ const open = ref(null) const tags = ref([]) const tagsPending = ref(false) const copiedKey = ref('') +const deleting = ref(null) const { data, @@ -169,17 +199,12 @@ async function refreshAll() { await refresh() } -async function toggle(repo: string) { - if (open.value === repo) { - open.value = null - return - } - open.value = repo +async function loadTags(repo: string) { tagsPending.value = true tags.value = [] try { const res = await $fetch<{ tags: string[] }>('/api/repo-tags', { query: { name: repo } }) - tags.value = res.tags + if (open.value === repo) tags.value = res.tags } catch (e: any) { error.value = e?.data?.statusMessage || '태그를 불러오지 못했습니다' } finally { @@ -187,13 +212,41 @@ async function toggle(repo: string) { } } +async function toggle(repo: string) { + if (open.value === repo) { + open.value = null + return + } + open.value = repo + await loadTags(repo) +} + async function removeTag(name: string, tag: string) { - if (!confirm(`${name}:${tag} 을(를) 삭제할까요?`)) return + if (!confirm(`${name}:${tag} 태그를 삭제할까요?\n같은 이미지를 가리키는 다른 태그는 유지됩니다.`)) return + error.value = '' try { await $fetch('/api/repo-tags', { method: 'DELETE', body: { name, tag } }) - tags.value = tags.value.filter((t) => t !== tag) + await loadTags(name) } catch (e: any) { error.value = e?.data?.statusMessage || '삭제에 실패했습니다' } } + +async function removeRepository(name: string) { + if (!confirm(`저장소 ${name} 을(를) 모든 태그와 함께 삭제할까요?\n되돌릴 수 없습니다.`)) return + error.value = '' + deleting.value = name + try { + await $fetch('/api/repositories', { method: 'DELETE', body: { name } }) + if (open.value === name) { + open.value = null + tags.value = [] + } + await refresh() + } catch (e: any) { + error.value = e?.data?.statusMessage || '저장소 삭제에 실패했습니다' + } finally { + deleting.value = null + } +} diff --git a/app/server/api/repo-tags.delete.ts b/app/server/api/repo-tags.delete.ts index f3d416f..8d0a2a0 100644 --- a/app/server/api/repo-tags.delete.ts +++ b/app/server/api/repo-tags.delete.ts @@ -10,26 +10,8 @@ export default defineEventHandler(async (event) => { await ensureBootstrap() const body = bodySchema.parse(await readBody(event)) + await requireDeletePermission(session, body.name) - if (session.role !== 'ADMIN') { - const user = await prisma.user.findUnique({ - where: { id: session.id }, - include: { acls: true }, - }) - if (!user) { - throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) - } - const allowed = allowedActionsForRepo(user, body.name) - if (!allowed.includes('delete') && !allowed.includes('*')) { - throw createError({ statusCode: 403, statusMessage: 'Delete not allowed' }) - } - } - - const digest = await getManifestDigest(body.name, body.tag) - if (!digest) { - throw createError({ statusCode: 404, statusMessage: 'Tag not found' }) - } - - await deleteManifest(body.name, digest) - return { ok: true, name: body.name, tag: body.tag, digest } + const { digest, manifestDeleted } = await deleteTag(body.name, body.tag) + return { ok: true, name: body.name, tag: body.tag, digest, manifestDeleted } }) diff --git a/app/server/api/repositories/index.delete.ts b/app/server/api/repositories/index.delete.ts new file mode 100644 index 0000000..368fc53 --- /dev/null +++ b/app/server/api/repositories/index.delete.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' + +const bodySchema = z.object({ + name: z.string().min(1), +}) + +export default defineEventHandler(async (event) => { + const session = await requireSession(event) + await ensureBootstrap() + + const { name } = bodySchema.parse(await readBody(event)) + await requireDeletePermission(session, name) + + // Delete manifests through the API first so blobs become unreferenced for GC + const tags = await listTags(name) + const digests = new Set() + for (const tag of tags) { + const digest = await getManifestDigest(name, tag) + if (digest) digests.add(digest) + } + for (const digest of digests) { + await deleteManifest(name, digest) + } + + await removeRepositoryFromStorage(name) + return { ok: true, name, deletedTags: tags.length } +}) diff --git a/app/server/utils/delete-permission.ts b/app/server/utils/delete-permission.ts new file mode 100644 index 0000000..f5cf9b4 --- /dev/null +++ b/app/server/utils/delete-permission.ts @@ -0,0 +1,17 @@ +import type { SessionUser } from './session' + +export async function requireDeletePermission(session: SessionUser, repository: string) { + if (session.role === 'ADMIN') return + + const user = await prisma.user.findUnique({ + where: { id: session.id }, + include: { acls: true }, + }) + if (!user) { + throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) + } + const allowed = allowedActionsForRepo(user, repository) + if (!allowed.includes('delete') && !allowed.includes('*')) { + throw createError({ statusCode: 403, statusMessage: 'Delete not allowed' }) + } +} diff --git a/app/server/utils/registry.ts b/app/server/utils/registry.ts index 00cb7b7..2e66e38 100644 --- a/app/server/utils/registry.ts +++ b/app/server/utils/registry.ts @@ -95,3 +95,39 @@ export async function deleteManifest(name: string, digest: string): Promise { + const digest = await getManifestDigest(name, tag) + if (!digest) { + throw createError({ statusCode: 404, statusMessage: 'Tag not found' }) + } + + const res = await registryFetch( + `/v2/${encodeURI(name)}/manifests/${encodeURIComponent(tag)}`, + { method: 'DELETE' }, + [{ type: 'repository', name, actions: ['delete', 'pull'] }], + ) + if (!res.ok && res.status !== 404) { + throw createError({ + statusCode: 502, + statusMessage: `Registry tag delete failed: ${res.status}`, + }) + } + + const remaining = await listTags(name) + const remainingDigests = await Promise.all(remaining.map((t) => getManifestDigest(name, t))) + if (remainingDigests.includes(digest)) { + return { digest, manifestDeleted: false } + } + + await deleteManifest(name, digest) + return { digest, manifestDeleted: true } +} diff --git a/app/server/utils/repository-storage.ts b/app/server/utils/repository-storage.ts new file mode 100644 index 0000000..5608944 --- /dev/null +++ b/app/server/utils/repository-storage.ts @@ -0,0 +1,61 @@ +import { readdir, rm, rmdir, stat } from 'node:fs/promises' +import { join, resolve, sep } from 'node:path' + +// Distribution repository name grammar (path components separated by '/') +const REPOSITORY_NAME = + /^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*$/ + +// Per-repository metadata directories; anything else is a nested repository (e.g. foo/bar under foo) +const REPOSITORY_META_DIRS = ['_manifests', '_layers', '_uploads'] + +function repositoriesRoot(): string { + const storage = process.env.REGISTRY_STORAGE_PATH || '/var/lib/registry' + return resolve(storage, 'docker/registry/v2/repositories') +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory() + } catch { + return false + } +} + +/** + * The registry API cannot remove a repository from the catalog — even with zero tags it + * keeps showing up. Remove its metadata directories directly from the shared storage + * volume. Blobs are left for `registry garbage-collect`. + */ +export async function removeRepositoryFromStorage(name: string): Promise { + if (!REPOSITORY_NAME.test(name)) { + throw createError({ statusCode: 400, statusMessage: 'Invalid repository name' }) + } + + const root = repositoriesRoot() + if (!(await isDirectory(root))) { + throw createError({ + statusCode: 501, + statusMessage: 'Registry storage is not mounted in the app container (REGISTRY_STORAGE_PATH)', + }) + } + + const dir = resolve(root, name) + if (!dir.startsWith(root + sep)) { + throw createError({ statusCode: 400, statusMessage: 'Invalid repository name' }) + } + if (!(await isDirectory(join(dir, '_manifests')))) { + throw createError({ statusCode: 404, statusMessage: 'Repository not found' }) + } + + for (const meta of REPOSITORY_META_DIRS) { + await rm(join(dir, meta), { recursive: true, force: true }) + } + + // Drop now-empty directories up to the repositories root (keeps nested repositories) + let current = dir + while (current !== root) { + if ((await readdir(current)).length) break + await rmdir(current) + current = resolve(current, '..') + } +} diff --git a/docker-compose.yml b/docker-compose.yml index d1375a8..280f6a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,8 @@ services: AUTH_PUBLIC_URL: ${AUTH_PUBLIC_URL:-http://localhost:3080} TOKEN_SERVICE: ${REGISTRY_AUTH_SERVICE:-registry} TOKEN_ISSUER: ${REGISTRY_AUTH_ISSUER:-registry-aio} + # Daily blob garbage collection hour (UTC, 00-23). Empty disables it. + GC_HOUR_UTC: ${GC_HOUR_UTC-20} volumes: - registry-data:/var/lib/registry - ./certs:/certs:ro @@ -51,6 +53,21 @@ services: /config/config.yml.template > /etc/docker/registry/config.yml echo "Rendered registry config:" cat /etc/docker/registry/config.yml + # Deleting tags/manifests only drops references; blobs stay on disk until GC runs. + if [ -n "$${GC_HOUR_UTC:-}" ]; then + ( + last="" + while sleep 600; do + today="$$(date -u +%F)" + if [ "$$(date -u +%H)" = "$${GC_HOUR_UTC}" ] && [ "$$today" != "$$last" ]; then + last="$$today" + echo "garbage-collect: start" + registry garbage-collect /etc/docker/registry/config.yml 2>&1 | tail -n 5 + echo "garbage-collect: done" + fi + done + ) & + fi exec registry serve /etc/docker/registry/config.yml healthcheck: test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:5000/v2/ || exit 0"] @@ -63,6 +80,8 @@ services: build: context: ./app dockerfile: Dockerfile + labels: + - com.centurylinklabs.watchtower.enable=false restart: unless-stopped depends_on: certs: @@ -91,9 +110,12 @@ services: SESSION_SECRET: ${SESSION_SECRET:-change-me-to-a-long-random-string} TOKEN_KEY_PATH: /certs/token.key TOKEN_CERT_PATH: /certs/token.crt + # Shared registry storage: lets the UI remove empty repositories from the catalog + REGISTRY_STORAGE_PATH: /var/lib/registry volumes: - app-data:/data - ./certs:/certs:ro + - registry-data:/var/lib/registry healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/health"] interval: 15s