Fix image deletion: per-tag delete, repository delete, daily GC.
- Deleting a tag now untags only that tag; the manifest is removed only when no other tag references the same digest (previously deleting by digest also removed every sibling tag). - Add repository deletion. The registry API cannot drop a repository from the catalog, so empty repositories stayed in the UI forever with nothing left to delete. The app now mounts registry storage and removes the repository's metadata directories after deleting its manifests. - Run `registry garbage-collect` daily (GC_HOUR_UTC) so deleted blobs free disk. - Disable Watchtower for the locally built app image. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f3d8d4c8ea
commit
89c6cbb5ad
9 files changed
+258
-41
No files matched your search
@@ -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' })
|
||||
}
|
||||
}
|
||||
@@ -95,3 +95,39 @@ export async function deleteManifest(name: string, digest: string): Promise<void
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a single tag. Other tags pointing at the same manifest are kept; the
|
||||
* manifest itself is deleted only when no tag references it anymore, so blobs
|
||||
* become reclaimable by `registry garbage-collect`.
|
||||
*/
|
||||
export async function deleteTag(
|
||||
name: string,
|
||||
tag: string,
|
||||
): Promise<{ digest: string; manifestDeleted: boolean }> {
|
||||
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 }
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
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<void> {
|
||||
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, '..')
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user