Add [Docker] image size and version badges (#4562)
* Add [Docker] image size badge * Add [Docker] version/tag badge * [Docker] allow version badge to retrieve results from multiple pages * [Docker] allow size badge to retrieve results from multiple pages * [Docker] clean up size and version badges before squash * [Docker] Size badge change API call to explicit tag * [Docker] Conditionally include tag route param for badges * [Docker] Implement feedback for size and version badges * [Docker] Implement feedback round 2 for size and version badges * [Docker] Optimise API lookups and remove date sorting on tag badge * [Docker] Implement feedback round 3 for version badge * [Docker] Implement feedback round 4 for version badge * [Docker] Adjust unit and service tests for version badge * [Docker] Move unit test data into fixtures * [Docker] Fix Docker version badge route prefix * [Docker] Add date and semver lookup for size badge * [Docker] Implement feedback round 5 for version badge * [Docker] Implement feedback round 6 * [Docker] Tweak error messaging for consistent wording * [Docker] Adjust badge titles * [Docker] Guard and treat images with missing digest * [Docker] Guard and treat images with missing digest
This commit is contained in:
2944
services/docker/docker-fixtures.js
Normal file
2944
services/docker/docker-fixtures.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,19 @@
|
||||
'use strict'
|
||||
|
||||
const dockerBlue = '066da5' // see https://github.com/badges/shields/pull/1690
|
||||
const { NotFound } = require('..')
|
||||
|
||||
function buildDockerUrl(badgeName) {
|
||||
return {
|
||||
base: `docker/${badgeName}`,
|
||||
pattern: ':user/:repo',
|
||||
function buildDockerUrl(badgeName, includeTagRoute) {
|
||||
if (includeTagRoute) {
|
||||
return {
|
||||
base: `docker/${badgeName}`,
|
||||
pattern: ':user/:repo/:tag*',
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
base: `docker/${badgeName}`,
|
||||
pattern: ':user/:repo',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,4 +21,44 @@ function getDockerHubUser(user) {
|
||||
return user === '_' ? 'library' : user
|
||||
}
|
||||
|
||||
module.exports = { dockerBlue, buildDockerUrl, getDockerHubUser }
|
||||
async function getMultiPageData({ user, repo, fetch }) {
|
||||
const data = await fetch({ user, repo })
|
||||
|
||||
if (data.count === 0) {
|
||||
throw new NotFound({ prettyMessage: 'repository not found' })
|
||||
}
|
||||
|
||||
const numberOfPages = Math.ceil(data.count / 100) // Maximum of 100 results can be returned per page
|
||||
|
||||
if (numberOfPages === 1) {
|
||||
return data.results
|
||||
}
|
||||
|
||||
const pageData = await Promise.all(
|
||||
[...Array(numberOfPages - 1).keys()].map((_, i) =>
|
||||
fetch({ user, repo, page: ++i + 1 })
|
||||
)
|
||||
)
|
||||
return [...data.results].concat(...pageData.map(p => p.results))
|
||||
}
|
||||
|
||||
function getDigestSemVerMatches({ data, digest }) {
|
||||
const matches = data
|
||||
.filter(d => d.images.some(i => i.digest === digest))
|
||||
.map(d => d.name)
|
||||
let version = matches[0]
|
||||
matches.forEach(name => {
|
||||
const dots = (name.match(/\./g) || []).length
|
||||
const olddots = (version.match(/\./g) || []).length
|
||||
version = dots >= olddots && name !== 'latest' ? name : version
|
||||
})
|
||||
return version
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
dockerBlue,
|
||||
buildDockerUrl,
|
||||
getDockerHubUser,
|
||||
getMultiPageData,
|
||||
getDigestSemVerMatches,
|
||||
}
|
||||
|
||||
135
services/docker/docker-size.service.js
Normal file
135
services/docker/docker-size.service.js
Normal file
@@ -0,0 +1,135 @@
|
||||
'use strict'
|
||||
|
||||
const Joi = require('@hapi/joi')
|
||||
const prettyBytes = require('pretty-bytes')
|
||||
const { nonNegativeInteger } = require('../validators')
|
||||
const { latest } = require('../version')
|
||||
const {
|
||||
buildDockerUrl,
|
||||
getDockerHubUser,
|
||||
getMultiPageData,
|
||||
} = require('./docker-helpers')
|
||||
const { NotFound } = require('..')
|
||||
const { BaseJsonService } = require('..')
|
||||
|
||||
const buildSchema = Joi.object({
|
||||
name: Joi.string().required(),
|
||||
full_size: nonNegativeInteger.required(),
|
||||
}).required()
|
||||
|
||||
const pagedSchema = Joi.object({
|
||||
count: nonNegativeInteger.required(),
|
||||
results: Joi.array().items(
|
||||
Joi.object({
|
||||
name: Joi.string().required(),
|
||||
full_size: nonNegativeInteger.required(),
|
||||
})
|
||||
),
|
||||
}).required()
|
||||
|
||||
const queryParamSchema = Joi.object({
|
||||
sort: Joi.string()
|
||||
.valid('date', 'semver')
|
||||
.default('date'),
|
||||
}).required()
|
||||
|
||||
module.exports = class DockerSize extends BaseJsonService {
|
||||
static get category() {
|
||||
return 'size'
|
||||
}
|
||||
|
||||
static get route() {
|
||||
return { ...buildDockerUrl('image-size', true), queryParamSchema }
|
||||
}
|
||||
|
||||
static get examples() {
|
||||
return [
|
||||
{
|
||||
title: 'Docker Image Size (latest by date)',
|
||||
pattern: ':user/:repo',
|
||||
namedParams: { user: 'fedora', repo: 'apache' },
|
||||
queryParams: { sort: 'date' },
|
||||
staticPreview: this.render({ size: 126000000 }),
|
||||
},
|
||||
{
|
||||
title: 'Docker Image Size (latest semver)',
|
||||
pattern: ':user/:repo',
|
||||
namedParams: { user: 'fedora', repo: 'apache' },
|
||||
queryParams: { sort: 'semver' },
|
||||
staticPreview: this.render({ size: 136000000 }),
|
||||
},
|
||||
{
|
||||
title: 'Docker Image Size (tag)',
|
||||
pattern: ':user/:repo/:tag',
|
||||
namedParams: { user: 'fedora', repo: 'apache', tag: 'latest' },
|
||||
staticPreview: this.render({ size: 103000000 }),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
static get defaultBadgeData() {
|
||||
return {
|
||||
label: 'image size',
|
||||
color: 'blue',
|
||||
}
|
||||
}
|
||||
|
||||
static render({ size }) {
|
||||
return { message: prettyBytes(size) }
|
||||
}
|
||||
|
||||
async fetch({ user, repo, tag, page }) {
|
||||
page = page ? `&page=${page}` : ''
|
||||
return this._requestJson({
|
||||
schema: tag ? buildSchema : pagedSchema,
|
||||
url: `https://registry.hub.docker.com/v2/repositories/${getDockerHubUser(
|
||||
user
|
||||
)}/${repo}/tags${
|
||||
tag ? `/${tag}` : '?page_size=100&ordering=last_updated'
|
||||
}${page}`,
|
||||
errorMessages: { 404: 'repository or tag not found' },
|
||||
})
|
||||
}
|
||||
|
||||
transform({ tag, sort, data }) {
|
||||
if (!tag && sort === 'date') {
|
||||
if (data.count === 0) {
|
||||
throw new NotFound({ prettyMessage: 'repository not found' })
|
||||
} else {
|
||||
return { size: data.results[0].full_size }
|
||||
}
|
||||
} else if (!tag && sort === 'semver') {
|
||||
const [matches, versions] = data.reduce(
|
||||
([m, v], d) => {
|
||||
m[d.name] = d.full_size
|
||||
v.push(d.name)
|
||||
return [m, v]
|
||||
},
|
||||
[{}, []]
|
||||
)
|
||||
const version = latest(versions)
|
||||
return { size: matches[version] }
|
||||
} else {
|
||||
return { size: data.full_size }
|
||||
}
|
||||
}
|
||||
|
||||
async handle({ user, repo, tag }, { sort }) {
|
||||
let data
|
||||
|
||||
if (!tag && sort === 'date') {
|
||||
data = await this.fetch({ user, repo })
|
||||
} else if (!tag && sort === 'semver') {
|
||||
data = await getMultiPageData({
|
||||
user,
|
||||
repo,
|
||||
fetch: this.fetch.bind(this),
|
||||
})
|
||||
} else {
|
||||
data = await this.fetch({ user, repo, tag })
|
||||
}
|
||||
|
||||
const { size } = await this.transform({ tag, sort, data })
|
||||
return this.constructor.render({ size })
|
||||
}
|
||||
}
|
||||
44
services/docker/docker-size.spec.js
Normal file
44
services/docker/docker-size.spec.js
Normal file
@@ -0,0 +1,44 @@
|
||||
'use strict'
|
||||
|
||||
const { test, given } = require('sazerac')
|
||||
const DockerSize = require('./docker-size.service')
|
||||
const { sizeDataNoTagSemVerSort } = require('./docker-fixtures')
|
||||
|
||||
describe('DockerSize', function() {
|
||||
test(DockerSize.prototype.transform, () => {
|
||||
given({
|
||||
tag: '',
|
||||
sort: 'date',
|
||||
data: { results: [{ name: 'next', full_size: 219939484 }] },
|
||||
}).expect({
|
||||
size: 219939484,
|
||||
})
|
||||
given({
|
||||
tag: '',
|
||||
sort: 'date',
|
||||
data: {
|
||||
results: [
|
||||
{ name: 'latest', full_size: 74661264 },
|
||||
{ name: 'arm64v8-latest', full_size: 76310416 },
|
||||
{ name: 'arm32v7-latest', full_size: 68001970 },
|
||||
{ name: 'amd64-latest', full_size: 74661264 },
|
||||
],
|
||||
},
|
||||
}).expect({
|
||||
size: 74661264,
|
||||
})
|
||||
given({
|
||||
tag: '',
|
||||
sort: 'semver',
|
||||
data: sizeDataNoTagSemVerSort,
|
||||
}).expect({
|
||||
size: 13448411,
|
||||
})
|
||||
given({
|
||||
tag: 'latest',
|
||||
data: { name: 'latest', full_size: 13448411 },
|
||||
}).expect({
|
||||
size: 13448411,
|
||||
})
|
||||
})
|
||||
})
|
||||
46
services/docker/docker-size.tester.js
Normal file
46
services/docker/docker-size.tester.js
Normal file
@@ -0,0 +1,46 @@
|
||||
'use strict'
|
||||
|
||||
const { isFileSize } = require('../test-validators')
|
||||
const t = (module.exports = require('../tester').createServiceTester())
|
||||
|
||||
t.create('docker image size (valid, library)')
|
||||
.get('/_/alpine.json')
|
||||
.expectBadge({
|
||||
label: 'image size',
|
||||
message: isFileSize,
|
||||
})
|
||||
|
||||
t.create('docker image size (valid, library with tag)')
|
||||
.get('/_/alpine/latest.json')
|
||||
.expectBadge({
|
||||
label: 'image size',
|
||||
message: isFileSize,
|
||||
})
|
||||
|
||||
t.create('docker image size (valid, user)')
|
||||
.get('/jrottenberg/ffmpeg.json')
|
||||
.expectBadge({
|
||||
label: 'image size',
|
||||
message: isFileSize,
|
||||
})
|
||||
|
||||
t.create('docker image size (valid, user with tag)')
|
||||
.get('/jrottenberg/ffmpeg/3.2-alpine.json')
|
||||
.expectBadge({
|
||||
label: 'image size',
|
||||
message: isFileSize,
|
||||
})
|
||||
|
||||
t.create('docker image size (invalid, incorrect tag)')
|
||||
.get('/_/alpine/wrong-tag.json')
|
||||
.expectBadge({
|
||||
label: 'image size',
|
||||
message: 'repository or tag not found',
|
||||
})
|
||||
|
||||
t.create('docker image size (invalid, unknown repository)')
|
||||
.get('/_/not-a-real-repo.json')
|
||||
.expectBadge({
|
||||
label: 'image size',
|
||||
message: 'repository not found',
|
||||
})
|
||||
151
services/docker/docker-version.service.js
Normal file
151
services/docker/docker-version.service.js
Normal file
@@ -0,0 +1,151 @@
|
||||
'use strict'
|
||||
|
||||
const Joi = require('@hapi/joi')
|
||||
const { nonNegativeInteger } = require('../validators')
|
||||
const { latest, renderVersionBadge } = require('../version')
|
||||
const {
|
||||
buildDockerUrl,
|
||||
getDockerHubUser,
|
||||
getMultiPageData,
|
||||
getDigestSemVerMatches,
|
||||
} = require('./docker-helpers')
|
||||
const { NotFound, InvalidResponse } = require('..')
|
||||
const { BaseJsonService } = require('..')
|
||||
|
||||
const buildSchema = Joi.object({
|
||||
count: nonNegativeInteger.required(),
|
||||
results: Joi.array().items(
|
||||
Joi.object({
|
||||
name: Joi.string().required(),
|
||||
images: Joi.array().items(
|
||||
Joi.object({
|
||||
digest: Joi.string(),
|
||||
architecture: Joi.string().required(),
|
||||
})
|
||||
),
|
||||
})
|
||||
),
|
||||
}).required()
|
||||
|
||||
const queryParamSchema = Joi.object({
|
||||
sort: Joi.string()
|
||||
.valid('date', 'semver')
|
||||
.default('date'),
|
||||
}).required()
|
||||
|
||||
module.exports = class DockerVersion extends BaseJsonService {
|
||||
static get category() {
|
||||
return 'version'
|
||||
}
|
||||
|
||||
static get route() {
|
||||
return { ...buildDockerUrl('v', true), queryParamSchema }
|
||||
}
|
||||
|
||||
static get examples() {
|
||||
return [
|
||||
{
|
||||
title: 'Docker Image Version (latest by date)',
|
||||
pattern: ':user/:repo',
|
||||
namedParams: { user: '_', repo: 'alpine' },
|
||||
queryParams: { sort: 'date' },
|
||||
staticPreview: this.render({ version: '3.9.5' }),
|
||||
},
|
||||
{
|
||||
title: 'Docker Image Version (latest semver)',
|
||||
pattern: ':user/:repo',
|
||||
namedParams: { user: '_', repo: 'alpine' },
|
||||
queryParams: { sort: 'semver' },
|
||||
staticPreview: this.render({ version: '3.11.3' }),
|
||||
},
|
||||
{
|
||||
title: 'Docker Image Version (tag latest semver)',
|
||||
pattern: ':user/:repo/:tag',
|
||||
namedParams: { user: '_', repo: 'alpine', tag: '3.6' },
|
||||
staticPreview: this.render({ version: '3.6.5' }),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
static get defaultBadgeData() {
|
||||
return {
|
||||
label: 'version',
|
||||
color: 'blue',
|
||||
}
|
||||
}
|
||||
|
||||
static render({ version }) {
|
||||
return renderVersionBadge({ version })
|
||||
}
|
||||
|
||||
async fetch({ user, repo, page }) {
|
||||
page = page ? `&page=${page}` : ''
|
||||
return this._requestJson({
|
||||
schema: buildSchema,
|
||||
url: `https://registry.hub.docker.com/v2/repositories/${getDockerHubUser(
|
||||
user
|
||||
)}/${repo}/tags?page_size=100&ordering=last_updated${page}`,
|
||||
errorMessages: { 404: 'repository or tag not found' },
|
||||
})
|
||||
}
|
||||
|
||||
transform({ tag, sort, data, pagedData }) {
|
||||
let version
|
||||
|
||||
if (!tag && sort === 'date') {
|
||||
version = data.results[0].name
|
||||
if (version !== 'latest') {
|
||||
return { version }
|
||||
}
|
||||
if (Object.keys(data.results[0].images).length === 0) {
|
||||
throw new InvalidResponse({
|
||||
prettyMessage: 'digest not found for latest tag',
|
||||
})
|
||||
}
|
||||
const { digest } = data.results[0].images.find(
|
||||
i => i.architecture === 'amd64'
|
||||
) // Digest is the unique field that we utilise to match images
|
||||
return { version: getDigestSemVerMatches({ data: pagedData, digest }) }
|
||||
} else if (!tag && sort === 'semver') {
|
||||
const matches = data.map(d => d.name)
|
||||
return { version: latest(matches) }
|
||||
} else {
|
||||
version = data.find(d => d.name === tag)
|
||||
if (!version) {
|
||||
throw new NotFound({ prettyMessage: 'tag not found' })
|
||||
}
|
||||
if (Object.keys(version.images).length === 0) {
|
||||
return { version: version.name }
|
||||
}
|
||||
const { digest } = version.images.find(i => i.architecture === 'amd64')
|
||||
return { version: getDigestSemVerMatches({ data, digest }) }
|
||||
}
|
||||
}
|
||||
|
||||
async handle({ user, repo, tag }, { sort }) {
|
||||
let data, pagedData
|
||||
|
||||
if (!tag && sort === 'date') {
|
||||
data = await this.fetch({ user, repo })
|
||||
if (data.count === 0) {
|
||||
throw new NotFound({ prettyMessage: 'repository not found' })
|
||||
}
|
||||
if (data.results[0].name === 'latest') {
|
||||
pagedData = await getMultiPageData({
|
||||
user,
|
||||
repo,
|
||||
fetch: this.fetch.bind(this),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
data = await getMultiPageData({
|
||||
user,
|
||||
repo,
|
||||
fetch: this.fetch.bind(this),
|
||||
})
|
||||
}
|
||||
|
||||
const { version } = await this.transform({ tag, sort, data, pagedData })
|
||||
return this.constructor.render({ version })
|
||||
}
|
||||
}
|
||||
50
services/docker/docker-version.spec.js
Normal file
50
services/docker/docker-version.spec.js
Normal file
@@ -0,0 +1,50 @@
|
||||
'use strict'
|
||||
|
||||
const { test, given } = require('sazerac')
|
||||
const DockerVersion = require('./docker-version.service')
|
||||
const {
|
||||
versionDataNoTagDateSort,
|
||||
versionPagedDataNoTagDateSort,
|
||||
versionDataNoTagSemVerSort,
|
||||
versionDataWithTag,
|
||||
} = require('./docker-fixtures')
|
||||
|
||||
describe('DockerVersion', function() {
|
||||
test(DockerVersion.prototype.transform, () => {
|
||||
given({
|
||||
tag: '',
|
||||
sort: 'date',
|
||||
data: { results: [{ name: 'stable' }] },
|
||||
}).expect({
|
||||
version: 'stable',
|
||||
})
|
||||
given({
|
||||
tag: '',
|
||||
sort: 'date',
|
||||
data: { results: [{ name: '3.9.5' }] },
|
||||
}).expect({
|
||||
version: '3.9.5',
|
||||
})
|
||||
given({
|
||||
tag: '',
|
||||
sort: 'date',
|
||||
data: versionDataNoTagDateSort,
|
||||
pagedData: versionPagedDataNoTagDateSort,
|
||||
}).expect({
|
||||
version: 'amd64-latest',
|
||||
})
|
||||
given({
|
||||
tag: '',
|
||||
sort: 'semver',
|
||||
data: versionDataNoTagSemVerSort,
|
||||
}).expect({
|
||||
version: '3.11.3',
|
||||
})
|
||||
given({
|
||||
tag: '3.10',
|
||||
data: versionDataWithTag,
|
||||
}).expect({
|
||||
version: '3.10.4',
|
||||
})
|
||||
})
|
||||
})
|
||||
46
services/docker/docker-version.tester.js
Normal file
46
services/docker/docker-version.tester.js
Normal file
@@ -0,0 +1,46 @@
|
||||
'use strict'
|
||||
|
||||
const { isSemVer } = require('../test-validators')
|
||||
const t = (module.exports = require('../tester').createServiceTester())
|
||||
|
||||
t.create('docker version (valid, library)')
|
||||
.get('/_/alpine.json')
|
||||
.expectBadge({
|
||||
label: 'version',
|
||||
message: isSemVer,
|
||||
})
|
||||
|
||||
t.create('docker version (valid, library with tag)')
|
||||
.get('/_/alpine/latest.json')
|
||||
.expectBadge({
|
||||
label: 'version',
|
||||
message: isSemVer,
|
||||
})
|
||||
|
||||
t.create('docker version (valid, user)')
|
||||
.get('/jrottenberg/ffmpeg.json')
|
||||
.expectBadge({
|
||||
label: 'version',
|
||||
message: isSemVer,
|
||||
})
|
||||
|
||||
t.create('docker version (valid, user with tag)')
|
||||
.get('/jrottenberg/ffmpeg/3.2-alpine.json')
|
||||
.expectBadge({
|
||||
label: 'version',
|
||||
message: isSemVer,
|
||||
})
|
||||
|
||||
t.create('docker version (invalid, incorrect tag)')
|
||||
.get('/_/alpine/wrong-tag.json')
|
||||
.expectBadge({
|
||||
label: 'version',
|
||||
message: 'tag not found',
|
||||
})
|
||||
|
||||
t.create('docker version (invalid, unknown repository)')
|
||||
.get('/_/not-a-real-repo.json')
|
||||
.expectBadge({
|
||||
label: 'version',
|
||||
message: 'repository not found',
|
||||
})
|
||||
Reference in New Issue
Block a user