1
0
Fork 0

Deletion and more details
the build was successful Details

pull/2/head
Ambrose Chua 2018-09-29 15:39:45 +08:00
parent f9abc53a30
commit 1649afe535
13 changed files with 302 additions and 50 deletions

View File

@ -5,6 +5,7 @@ BASE_URL=${BASE_URL:=/}
REGISTRY_HOST=${REGISTRY_HOST:=""} REGISTRY_HOST=${REGISTRY_HOST:=""}
REGISTRY_API=${REGISTRY_API:=""} REGISTRY_API=${REGISTRY_API:=""}
[[ "$DELETE_ENABLED" != "true" ]] && [[ "$DELETE_ENABLED" != "false" ]] && DELETE_ENABLED=false
REPOSITORIES_PER_PAGE=${REPOSITORIES_PER_PAGE:=0} REPOSITORIES_PER_PAGE=${REPOSITORIES_PER_PAGE:=0}
TAGS_PER_PAGE=${TAGS_PER_PAGE:=30} TAGS_PER_PAGE=${TAGS_PER_PAGE:=30}
@ -27,6 +28,7 @@ cat > /srv/config.json << EOF
{ {
"registryHost": "$REGISTRY_HOST", "registryHost": "$REGISTRY_HOST",
"registryAPI": "$REGISTRY_API", "registryAPI": "$REGISTRY_API",
"deleteEnabled": $DELETE_ENABLED,
"repositoriesPerPage": $REPOSITORIES_PER_PAGE, "repositoriesPerPage": $REPOSITORIES_PER_PAGE,
"tagsPerPage": $TAGS_PER_PAGE, "tagsPerPage": $TAGS_PER_PAGE,
"usePortusExplore": $USE_PORTUS_EXPLORE "usePortusExplore": $USE_PORTUS_EXPLORE

View File

@ -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

View File

@ -1,6 +1,6 @@
import parseLink from 'parse-link-header'; import parseLink from 'parse-link-header';
import { registryAPI, repositoriesPerPage, tagsPerPage, usePortusExplore } from '@/options'; import { registryAPI, deleteEnabled, repositoriesPerPage, tagsPerPage, usePortusExplore } from '@/options';
function parseWWWAuthenticate(text) { function parseWWWAuthenticate(text) {
const result = {}; const result = {};
@ -86,29 +86,41 @@ async function paginatable(path, scope, n, last = null) {
return Object.assign(await response.json(), { nextLast }); 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 url = new URL(`${await registryAPI()}${path}`);
const headers = {}; const headers = {};
if (accept) {
headers.Accept = accept;
}
if (scope) { if (scope) {
const token = await doAuth(scope); const token = await doAuth(scope);
if (token) headers.Authorization = `Bearer ${token}`; if (token) headers.Authorization = `Bearer ${token}`;
} }
const response = await fetch(url, { headers }); const response = await fetch(url, { method, headers });
return response.json(); if (!response.ok) {
} if (method === 'HEAD') {
throw new Error(`${response.statusText}`);
async function head(path, scope) { } else if (response.headers.get('Content-Type').startsWith('application/json')) {
const url = new URL(`${await registryAPI()}${path}`); const r = await response.json();
const headers = {}; const firstError = r.errors[0];
if (scope) { if (firstError) {
const token = await doAuth(scope); throw new Error(`${firstError.code}: ${firstError.message}`);
if (token) headers.Authorization = `Bearer ${token}`; }
}
throw new Error(`${response.statusText}`);
} }
const response = await fetch(url, { method: 'HEAD', headers }); if (!response.headers.get('Content-Type').startsWith('application/json')) {
return response.headers; 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() { async function portus() {
// TODO: Use the Portus API when it enables anonymous access // TODO: Use the Portus API when it enables anonymous access
const response = await fetch(`${await registryAPI()}/explore?explore%5Bsearch%5D=`); 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); 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) { async function tags(name, last = null) {
if (await usePortusExplore()) { if (await usePortusExplore()) {
const p = await portus(); const p = await portus();
@ -151,20 +159,66 @@ async function tags(name, last = null) {
} }
async function tag(name, ref) { 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) { 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 { return {
dockerContentDigest: headers.get('Docker-Content-Digest'),
contentLength: parseInt(headers.get('Content-Length'), 10), contentLength: parseInt(headers.get('Content-Length'), 10),
}; };
} }
async function configBlob(name, digest) {
return request('GET', `/v2/${name}/blobs/${digest}`, `repository:${name}:pull`);
}
export { export {
repos, repos,
repo,
tags, tags,
tag, tag,
tagCanDelete,
tagDelete,
repoCanDelete,
repoDelete,
blob, blob,
configBlob,
}; };

View File

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

View File

@ -40,6 +40,8 @@ header, footer, main {
header, footer, .content { header, footer, .content {
padding: 1rem; padding: 1rem;
} }
.content {
width: fit-content;}
header, footer { header, footer {
max-width: 38rem; max-width: 38rem;
text-align: center; text-align: center;

View File

@ -1,10 +1,10 @@
<template> <template>
<LoadableText :text="size" /> <LoadableText :text="text" />
</template> </template>
<script> <script>
import filesize from 'filesize'; import filesize from 'filesize';
import { tag, blob } from '@/api'; import { tag } from '@/api';
import LoadableText from '@/components/LoadableText.vue'; import LoadableText from '@/components/LoadableText.vue';
export default { export default {
@ -14,10 +14,11 @@ export default {
props: { props: {
repo: String, repo: String,
tag: String, tag: String,
size: Number,
}, },
data() { data() {
return { return {
size: '', text: '',
}; };
}, },
async created() { async created() {
@ -27,13 +28,11 @@ export default {
return; return;
} }
if (r.schemaVersion === 1) { 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 => ( const total = r.layers.reduce((a, b) => a + b.size, 0);
await blob(this.repo, layer.digest) this.text = filesize(total);
).contentLength));
const total = sizes.reduce((a, b) => a + b, 0);
this.size = filesize(total);
}, },
}; };
</script> </script>

View File

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

View File

@ -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>

View File

@ -7,10 +7,11 @@ const defaultConfig = {
registryHost: process.env.VUE_APP_REGISTRY_HOST, registryHost: process.env.VUE_APP_REGISTRY_HOST,
registryAPI: process.env.VUE_APP_REGISTRY_API, registryAPI: process.env.VUE_APP_REGISTRY_API,
repositoriesPerPage: process.env.VUE_APP_REPOSITORIES_PER_PAGE, deleteEnabled: process.env.VUE_APP_DELETE_ENABLED === 'true',
tagsPerPage: process.env.VUE_APP_TAGS_PER_PAGE, 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() { async function config() {
@ -49,9 +50,9 @@ async function registryAPI() {
return `${window.location.protocol}//${host}`; return `${window.location.protocol}//${host}`;
} }
async function usePortusExplore() { async function deleteEnabled() {
const c = await config(); const c = await config();
if (c.usePortusExplore) { if (c.deleteEnabled) {
return true; return true;
} }
return false; return false;
@ -79,11 +80,20 @@ async function tagsPerPage() {
return 0; return 0;
} }
async function usePortusExplore() {
const c = await config();
if (c.usePortusExplore) {
return true;
}
return false;
}
export { export {
version, version,
source, source,
registryHost, registryHost,
registryAPI, registryAPI,
deleteEnabled,
repositoriesPerPage, repositoriesPerPage,
tagsPerPage, tagsPerPage,
usePortusExplore, usePortusExplore,

View File

@ -1,6 +1,7 @@
<template> <template>
<Layout> <Layout>
<h1 slot="title">{{ $route.params.repo }}</h1> <h1 slot="title">{{ $route.params.repo }}</h1>
<Error slot="error" :message='error' />
<h2>Details</h2> <h2>Details</h2>
<List> <List>
<ListItem> <ListItem>
@ -16,7 +17,10 @@
</template> </template>
<script> <script>
import { blob } from '@/api';
import Layout from '@/components/Layout.vue'; import Layout from '@/components/Layout.vue';
import Error from '@/components/Error.vue';
import List from '@/components/List.vue'; import List from '@/components/List.vue';
import ListItem from '@/components/ListItem.vue'; import ListItem from '@/components/ListItem.vue';
import BlobSize from '@/components/BlobSize.vue'; import BlobSize from '@/components/BlobSize.vue';
@ -24,9 +28,37 @@ import BlobSize from '@/components/BlobSize.vue';
export default { export default {
components: { components: {
Layout, Layout,
Error,
List, List,
ListItem, ListItem,
BlobSize, 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> </script>

View File

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

View File

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

View File

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