diff --git a/entrypoint.sh b/entrypoint.sh index 3f80c65..8143ff5 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -5,6 +5,7 @@ BASE_URL=${BASE_URL:=/} REGISTRY_HOST=${REGISTRY_HOST:=""} REGISTRY_API=${REGISTRY_API:=""} +[[ "$DELETE_ENABLED" != "true" ]] && [[ "$DELETE_ENABLED" != "false" ]] && DELETE_ENABLED=false REPOSITORIES_PER_PAGE=${REPOSITORIES_PER_PAGE:=0} TAGS_PER_PAGE=${TAGS_PER_PAGE:=30} @@ -27,6 +28,7 @@ cat > /srv/config.json << EOF { "registryHost": "$REGISTRY_HOST", "registryAPI": "$REGISTRY_API", + "deleteEnabled": $DELETE_ENABLED, "repositoriesPerPage": $REPOSITORIES_PER_PAGE, "tagsPerPage": $TAGS_PER_PAGE, "usePortusExplore": $USE_PORTUS_EXPLORE diff --git a/registry-config-testing.yml b/registry-config-testing.yml new file mode 100644 index 0000000..b6a12cc --- /dev/null +++ b/registry-config-testing.yml @@ -0,0 +1,24 @@ +version: 0.1 +log: + fields: + service: registry +storage: + cache: + blobdescriptor: inmemory + filesystem: + rootdirectory: /var/lib/registry + delete: + enabled: true +http: + addr: :5000 + headers: + X-Content-Type-Options: [nosniff] + Access-Control-Allow-Origin: ['*'] + Access-Control-Allow-Methods: ['GET, DELETE'] + Access-Control-Allow-Headers: ['Authorization'] + Access-Control-Expose-Headers: ['Content-Length, Www-Authenticate', 'Docker-Content-Digest'] +health: + storagedriver: + enabled: true + interval: 10s + threshold: 3 diff --git a/src/api.js b/src/api.js index 6ac4cd0..3850bfc 100644 --- a/src/api.js +++ b/src/api.js @@ -1,6 +1,6 @@ import parseLink from 'parse-link-header'; -import { registryAPI, repositoriesPerPage, tagsPerPage, usePortusExplore } from '@/options'; +import { registryAPI, deleteEnabled, repositoriesPerPage, tagsPerPage, usePortusExplore } from '@/options'; function parseWWWAuthenticate(text) { const result = {}; @@ -86,29 +86,41 @@ async function paginatable(path, scope, n, last = null) { return Object.assign(await response.json(), { nextLast }); } -async function get(path, scope) { +async function request(method, path, scope, accept) { const url = new URL(`${await registryAPI()}${path}`); const headers = {}; + if (accept) { + headers.Accept = accept; + } if (scope) { const token = await doAuth(scope); if (token) headers.Authorization = `Bearer ${token}`; } - const response = await fetch(url, { headers }); - return response.json(); -} - -async function head(path, scope) { - const url = new URL(`${await registryAPI()}${path}`); - const headers = {}; - if (scope) { - const token = await doAuth(scope); - if (token) headers.Authorization = `Bearer ${token}`; + const response = await fetch(url, { method, headers }); + if (!response.ok) { + if (method === 'HEAD') { + throw new Error(`${response.statusText}`); + } else if (response.headers.get('Content-Type').startsWith('application/json')) { + const r = await response.json(); + const firstError = r.errors[0]; + if (firstError) { + throw new Error(`${firstError.code}: ${firstError.message}`); + } + } + throw new Error(`${response.statusText}`); } - const response = await fetch(url, { method: 'HEAD', headers }); - return response.headers; + if (!response.headers.get('Content-Type').startsWith('application/json')) { + console.warn('response returned was not JSON, parsing may fail'); + } + if (method === 'HEAD' || parseInt(response.headers.get('Content-Length'), 10) < 1) { + return { headers: response.headers }; + } + return { + ...(await response.json()), + headers: response.headers, + }; } - async function portus() { // TODO: Use the Portus API when it enables anonymous access const response = await fetch(`${await registryAPI()}/explore?explore%5Bsearch%5D=`); @@ -135,10 +147,6 @@ async function repos(last = null) { return paginatable('/v2/_catalog', null, await repositoriesPerPage(), last); } -async function repo(name) { - return get(`/v2/${name}`, `repository:${name}:pull`); -} - async function tags(name, last = null) { if (await usePortusExplore()) { const p = await portus(); @@ -151,20 +159,66 @@ async function tags(name, last = null) { } async function tag(name, ref) { - return get(`/v2/${name}/manifests/${ref}`, `repository:${name}:pull`); + return request('GET', `/v2/${name}/manifests/${ref}`, `repository:${name}:pull`, 'application/vnd.docker.distribution.manifest.v2+json'); +} + +async function tagCanDelete(name, ref) { + if (!await deleteEnabled()) { + return false; + } + try { + const { headers } = await request('HEAD', `/v2/${name}/manifests/${ref}`, `repository:${name}:delete`, 'application/vnd.docker.distribution.manifest.v2+json'); + request('HEAD', `/v2/${name}/manifests/${headers.get('Docker-Content-Digest')}`, `repository:${name}:delete`); + return true; + } catch (e) { + return false; + } +} + +async function tagDelete(name, ref) { + const tagManifest = await tag(name, ref); + // delete each blob + // await Promise.all(tagManifest.layers.map(l => + // request('DELETE', `/v2/${name}/blobs/${l.digest}`, `repository:${name}:delete`))); + return request('DELETE', `/v2/${name}/manifests/${tagManifest.headers.get('Docker-Content-Digest')}`, `repository:${name}:delete`); +} + +async function repoCanDelete(name) { + if (!await deleteEnabled()) { + return false; + } + const r = await request('GET', `/v2/${name}/tags/list`, `repository:${name}:delete`); + if (!r.tags) { + return false; + } + return Promise.race(r.tags.map(t => tagCanDelete(name, t))); +} + +async function repoDelete(name) { + const r = await request('GET', `/v2/${name}/tags/list`, `repository:${name}:delete`); + return Promise.all(r.tags.map(t => tagDelete(name, t))); } async function blob(name, digest) { - const headers = await head(`/v2/${name}/blobs/${digest}`, `repository:${name}:pull`); + const { headers } = await request('HEAD', `/v2/${name}/blobs/${digest}`, `repository:${name}:pull`); return { + dockerContentDigest: headers.get('Docker-Content-Digest'), contentLength: parseInt(headers.get('Content-Length'), 10), }; } +async function configBlob(name, digest) { + return request('GET', `/v2/${name}/blobs/${digest}`, `repository:${name}:pull`); +} + export { repos, - repo, tags, tag, + tagCanDelete, + tagDelete, + repoCanDelete, + repoDelete, blob, + configBlob, }; diff --git a/src/components/BlobSize.vue b/src/components/BlobSize.vue index 20f9b71..ae14b08 100644 --- a/src/components/BlobSize.vue +++ b/src/components/BlobSize.vue @@ -1,5 +1,5 @@ diff --git a/src/components/Layout.vue b/src/components/Layout.vue index bc83974..07a6f0b 100644 --- a/src/components/Layout.vue +++ b/src/components/Layout.vue @@ -40,6 +40,8 @@ header, footer, main { header, footer, .content { padding: 1rem; } +.content { +width: fit-content;} header, footer { max-width: 38rem; text-align: center; diff --git a/src/components/TagSize.vue b/src/components/TagSize.vue index 782e883..1b1fd90 100644 --- a/src/components/TagSize.vue +++ b/src/components/TagSize.vue @@ -1,10 +1,10 @@ diff --git a/src/components/Toolbar.vue b/src/components/Toolbar.vue index 92c798e..90f5d21 100644 --- a/src/components/Toolbar.vue +++ b/src/components/Toolbar.vue @@ -1,3 +1,13 @@ + + diff --git a/src/components/ToolbarButton.vue b/src/components/ToolbarButton.vue new file mode 100644 index 0000000..2778ff5 --- /dev/null +++ b/src/components/ToolbarButton.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/src/options.js b/src/options.js index 7813fb4..e4f99c3 100644 --- a/src/options.js +++ b/src/options.js @@ -7,10 +7,11 @@ const defaultConfig = { registryHost: process.env.VUE_APP_REGISTRY_HOST, registryAPI: process.env.VUE_APP_REGISTRY_API, - repositoriesPerPage: process.env.VUE_APP_REPOSITORIES_PER_PAGE, - tagsPerPage: process.env.VUE_APP_TAGS_PER_PAGE, + deleteEnabled: process.env.VUE_APP_DELETE_ENABLED === 'true', + repositoriesPerPage: parseInt(process.env.VUE_APP_REPOSITORIES_PER_PAGE, 10), + tagsPerPage: parseInt(process.env.VUE_APP_TAGS_PER_PAGE, 10), - usePortusExplore: process.env.VUE_APP_USE_PORTUS_EXPLORE, + usePortusExplore: process.env.VUE_APP_USE_PORTUS_EXPLORE === 'true', }; async function config() { @@ -49,9 +50,9 @@ async function registryAPI() { return `${window.location.protocol}//${host}`; } -async function usePortusExplore() { +async function deleteEnabled() { const c = await config(); - if (c.usePortusExplore) { + if (c.deleteEnabled) { return true; } return false; @@ -79,11 +80,20 @@ async function tagsPerPage() { return 0; } +async function usePortusExplore() { + const c = await config(); + if (c.usePortusExplore) { + return true; + } + return false; +} + export { version, source, registryHost, registryAPI, + deleteEnabled, repositoriesPerPage, tagsPerPage, usePortusExplore, diff --git a/src/views/Blob.vue b/src/views/Blob.vue index 758f767..45ad8bf 100644 --- a/src/views/Blob.vue +++ b/src/views/Blob.vue @@ -1,6 +1,7 @@ diff --git a/src/views/Repo.vue b/src/views/Repo.vue index c68722f..1b0839b 100644 --- a/src/views/Repo.vue +++ b/src/views/Repo.vue @@ -2,6 +2,9 @@

{{ $route.params.repo }}

+ + Delete + Tag @@ -27,11 +30,12 @@