Browse Source

Deletion and more details

pull/2/head
Ambrose Chua 3 years ago
parent
commit
1649afe535
  1. 2
      entrypoint.sh
  2. 24
      registry-config-testing.yml
  3. 98
      src/api.js
  4. 13
      src/components/BlobSize.vue
  5. 2
      src/components/Layout.vue
  6. 17
      src/components/TagSize.vue
  7. 12
      src/components/Toolbar.vue
  8. 47
      src/components/ToolbarButton.vue
  9. 20
      src/options.js
  10. 32
      src/views/Blob.vue
  11. 20
      src/views/Repo.vue
  12. 2
      src/views/Repos.vue
  13. 63
      src/views/Tag.vue

2
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

24
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

98
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 (scope) {
const token = await doAuth(scope);
if (token) headers.Authorization = `Bearer ${token}`;
if (accept) {
headers.Accept = accept;
}
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: 'HEAD', headers });
return response.headers;
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}`);
}
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,
};

13
src/components/BlobSize.vue

@ -1,5 +1,5 @@
<template>
<LoadableText :text="size" />
<LoadableText :text="text" />
</template>
<script>
@ -14,15 +14,22 @@ export default {
props: {
repo: String,
blob: String,
size: Number,
},
data() {
return {
size: '',
text: '',
};
},
async created() {
if (this.size) {
this.text = filesize(this.size);
return;
}
const size = await blob(this.repo, this.blob);
this.size = filesize(size.contentLength);
if (size.contentLength) {
this.text = filesize(size.contentLength);
}
},
};
</script>

2
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;

17
src/components/TagSize.vue

@ -1,10 +1,10 @@
<template>
<LoadableText :text="size" />
<LoadableText :text="text" />
</template>
<script>
import filesize from 'filesize';
import { tag, blob } from '@/api';
import { tag } from '@/api';
import LoadableText from '@/components/LoadableText.vue';
export default {
@ -14,10 +14,11 @@ export default {
props: {
repo: String,
tag: String,
size: Number,
},
data() {
return {
size: '',
text: '',
};
},
async created() {
@ -27,13 +28,11 @@ export default {
return;
}
if (r.schemaVersion === 1) {
r.layers = r.fsLayers.map(l => ({ digest: l.blobSum }));
console.error('V1 manifests not supported');
return;
}
const sizes = await Promise.all(r.layers.map(async layer => (
await blob(this.repo, layer.digest)
).contentLength));
const total = sizes.reduce((a, b) => a + b, 0);
this.size = filesize(total);
const total = r.layers.reduce((a, b) => a + b.size, 0);
this.text = filesize(total);
},
};
</script>

12
src/components/Toolbar.vue

@ -1,3 +1,13 @@
<template>
<span>No Toolbar</span>
<div>
<slot />
</div>
</template>
<style scoped>
div {
display: flex;
flex-direction: row;
justify-content: center;
}
</style>

47
src/components/ToolbarButton.vue

@ -0,0 +1,47 @@
<template>
<button :class="{ danger }" @click="$emit('click', $event)">
<slot />
</button>
</template>
<script>
export default {
props: {
danger: Boolean,
},
};
</script>
<style scoped>
button {
appearance: none;
border: none;
outline: none;
border-radius: 0.5rem;
background: #66f;
padding: 0.5rem 0.75rem;
color: #fff;
tap-highlight-color: transparent;
}
button:hover {
background: #44f;
}
button:active {
background: #00f;
}
button:focus {
box-shadow: 0 0 0 0.25rem #ccf;
}
button.danger {
background: #f66;
}
button.danger:hover {
background: #f44;
}
button.danger:active {
background: #f00;
}
button.danger:focus {
box-shadow: 0 0 0 0.25rem #fcc;
}
</style>

20
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,

32
src/views/Blob.vue

@ -1,6 +1,7 @@
<template>
<Layout>
<h1 slot="title">{{ $route.params.repo }}</h1>
<Error slot="error" :message='error' />
<h2>Details</h2>
<List>
<ListItem>
@ -16,7 +17,10 @@
</template>
<script>
import { blob } from '@/api';
import Layout from '@/components/Layout.vue';
import Error from '@/components/Error.vue';
import List from '@/components/List.vue';
import ListItem from '@/components/ListItem.vue';
import BlobSize from '@/components/BlobSize.vue';
@ -24,9 +28,37 @@ import BlobSize from '@/components/BlobSize.vue';
export default {
components: {
Layout,
Error,
List,
ListItem,
BlobSize,
},
data() {
return {
error: '',
digest: '',
};
},
async created() {
await this.fetchBlob();
},
methods: {
async fetchBlob() {
try {
const r = await blob(this.$route.params.repo, this.$route.params.digest);
if (r.dockerContentDigest) {
this.digest = r.dockerContentDigest;
}
} catch (e) {
console.error(e);
this.error = `Unable to fetch blob (${e.message})`;
}
},
},
watch: {
async $route() {
await this.fetchBlob();
},
},
};
</script>

20
src/views/Repo.vue

@ -2,6 +2,9 @@
<Layout>
<h1 slot="title">{{ $route.params.repo }}</h1>
<Error slot="error" :message='error' />
<Toolbar slot="toolbar">
<ToolbarButton v-if="repoCanDelete" @click="deleteRepo" danger>Delete</ToolbarButton>
</Toolbar>
<List>
<ListHeader slot="header">
<span slot="title">Tag</span>
@ -27,11 +30,12 @@
<script>
import { registryHost } from '@/options';
import { tags } from '@/api';
import { tags, repoCanDelete, repoDelete } from '@/api';
import Layout from '@/components/Layout.vue';
import Error from '@/components/Error.vue';
import Toolbar from '@/components/Toolbar.vue';
import ToolbarButton from '@/components/ToolbarButton.vue';
import List from '@/components/List.vue';
import ListHeader from '@/components/ListHeader.vue';
import ListItem from '@/components/ListItem.vue';
@ -43,6 +47,7 @@ export default {
Layout,
Error,
Toolbar,
ToolbarButton,
List,
ListHeader,
ListItem,
@ -54,6 +59,7 @@ export default {
error: '',
registryHost: '',
tags: [],
repoCanDelete: false,
nextLast: '',
};
},
@ -62,14 +68,24 @@ export default {
await this.fetchTags();
},
methods: {
async deleteRepo() {
try {
await repoDelete(this.$route.params.repo);
this.$router.push({ name: 'repos' });
} catch (e) {
console.error(e);
this.error = `Unable to delete repo (${e.message})`;
}
},
async fetchTags() {
try {
const r = await tags(this.$route.params.repo, this.$route.query.last);
this.tags = r.tags;
this.nextLast = r.nextLast;
this.repoCanDelete = await repoCanDelete(this.$route.params.repo);
} catch (e) {
console.error(e);
this.error = `Unable to fetch tags (${e.name})`;
this.error = `Unable to fetch tags (${e.message})`;
}
},
},

2
src/views/Repos.vue

@ -65,7 +65,7 @@ export default {
this.nextLast = r.nextLast;
} catch (e) {
console.error(e);
this.error = `Unable to fetch repositories (${e.name})`;
this.error = `Unable to fetch repositories (${e.message})`;
}
},
},

63
src/views/Tag.vue

@ -2,6 +2,8 @@
<Layout>
<h1 slot="title">{{ $route.params.repo }}:{{ $route.params.tag }}</h1>
<Error slot="error" :message='error' />
<Toolbar slot="toolbar">
<ToolbarButton v-if="tagCanDelete" @click="deleteTag" danger>Delete</ToolbarButton> </Toolbar>
<h2>Details</h2>
<List>
<ListItem>
@ -9,8 +11,32 @@
<span slot="detail">{{ tag.schemaVersion }}</span>
</ListItem>
<ListItem>
<span slot="title">Architecture</span>
<span slot="detail">{{ tag.architecture }}</span>
<span slot="title">Full Digest</span>
<span slot="detail">{{ tag.headers.get('Docker-Content-Digest') }}</span>
</ListItem>
<ListItem>
<span slot="title">Date Created</span>
<span slot="detail">{{ config.created }}</span>
</ListItem>
<ListItem>
<span slot="title">Platform</span>
<span slot="detail">{{ config.os }} {{ config.architecture }}</span>
</ListItem>
<ListItem>
<span slot="title">Entrypoint</span>
<span slot="detail"><pre>{{ config.config.Entrypoint.join(' ') }}</pre></span>
</ListItem>
<ListItem>
<span slot="title">Command</span>
<span slot="detail"><pre>{{ config.config.Cmd.join(' ') }}</pre></span>
</ListItem>
<ListItem>
<span slot="title">Labels</span>
<span slot="detail"><pre>{{ formatLabels(config.config.Labels) }}</pre></span>
</ListItem>
<ListItem>
<span slot="title">Layers</span>
<span slot="detail">{{ tag.layers.length }}</span>
</ListItem>
<ListItem>
<span slot="title">Size</span>
@ -32,7 +58,7 @@
:to="{ name: 'blob', params: { repo: $route.params.repo, digest: layer.digest }}">
<span slot="title" :title="layer.digest">{{ identifier(tag, i) }}</span>
<span slot="detail">{{ command(tag, i) }}</span>
<BlobSize slot="size" :repo="$route.params.repo" :blob="layer.digest" />
<BlobSize slot="size" :size="layer.size" />
</ListItem>
</List>
</Layout>
@ -40,11 +66,12 @@
<script>
import { registryHost } from '@/options';
import { tag } from '@/api';
import { tag, tagCanDelete, tagDelete, configBlob } from '@/api';
import Layout from '@/components/Layout.vue';
import Error from '@/components/Error.vue';
import Toolbar from '@/components/Toolbar.vue';
import ToolbarButton from '@/components/ToolbarButton.vue';
import List from '@/components/List.vue';
import ListHeader from '@/components/ListHeader.vue';
import ListItem from '@/components/ListItem.vue';
@ -56,6 +83,7 @@ export default {
Layout,
Error,
Toolbar,
ToolbarButton,
List,
ListHeader,
ListItem,
@ -66,7 +94,9 @@ export default {
return {
error: '',
registryHost: '',
tag: {},
tag: { },
config: {},
tagCanDelete: false,
layers: [],
};
},
@ -75,20 +105,36 @@ export default {
await this.fetchTag();
},
methods: {
async deleteTag() {
try {
await tagDelete(this.$route.params.repo, this.$route.params.tag);
this.$router.push({ name: 'repo', params: { repo: this.$route.params.repo } });
} catch (e) {
console.error(e);
this.error = `Unable to delete tag (${e.message})`;
}
},
async fetchTag() {
try {
const r = await tag(this.$route.params.repo, this.$route.params.tag);
if (r.mediaType === 'application/vnd.docker.distribution.manifest.list.v2+json') {
this.error = 'V2 manifest lists not supported yet';
return;
}
if (r.schemaVersion === 1) {
r.layers = r.fsLayers.map(l => ({ digest: l.blobSum }));
}
this.tag = r;
console.log(r);
this.tagCanDelete = await tagCanDelete(this.$route.params.repo, this.$route.params.tag);
// extract configuration
if (r.config.mediaType !== 'application/vnd.docker.container.image.v1+json') {
this.error = 'configuration mediaType not supported';
}
this.config = await configBlob(this.$route.params.repo, r.config.digest);
} catch (e) {
console.error(e);
this.error = `Unable to fetch tag (${e.name})`;
this.error = `Unable to fetch tag (${e.message})`;
}
},
identifier(t, n) {
@ -97,6 +143,9 @@ export default {
command() {
return 'not implemented';
},
formatLabels(l) {
return Object.keys(l).map(k => `${k}: ${l[k]}`).join('\n');
},
},
watch: {
async $route() {

Loading…
Cancel
Save