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 @@
- No Toolbar
+
+
+
+
+
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 @@
{{ $route.params.repo }}
+
Details
@@ -16,7 +17,10 @@
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 @@