From bafa38ff944e5154e8e85b248f988abe37a4682c Mon Sep 17 00:00:00 2001 From: Conor Sloan Date: Thu, 22 Aug 2024 18:40:02 +0100 Subject: [PATCH] refactor ghcr client for reusable upload functions --- badges/coverage.svg | 2 +- dist/index.js | 49 ++++++++++++------------ src/ghcr-client.ts | 90 ++++++++++++++++++++++++--------------------- 3 files changed, 74 insertions(+), 67 deletions(-) diff --git a/badges/coverage.svg b/badges/coverage.svg index f6ea690..8918069 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 97.39%Coverage97.39% \ No newline at end of file +Coverage: 97.38%Coverage97.38% \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index 8f74b27..571e965 100644 --- a/dist/index.js +++ b/dist/index.js @@ -107430,31 +107430,28 @@ const fsHelper = __importStar(__nccwpck_require__(76642)); // Publish the OCI artifact and return the URL where it can be downloaded async function publishOCIArtifact(token, registry, repository, semver, zipFile, tarFile, manifest) { const b64Token = Buffer.from(token).toString('base64'); - const checkBlobEndpoint = new URL(`v2/${repository}/blobs/`, registry).toString(); - const uploadBlobEndpoint = new URL(`v2/${repository}/blobs/uploads/`, registry).toString(); - const manifestEndpoint = new URL(`v2/${repository}/manifests/${semver}`, registry).toString(); core.info(`Creating GHCR package for release with semver:${semver} with path:"${zipFile.path}" and "${tarFile.path}".`); const layerUploads = manifest.layers.map(async (layer) => { switch (layer.mediaType) { case 'application/vnd.github.actions.package.layer.v1.tar+gzip': - return uploadLayer(layer, tarFile, registry, checkBlobEndpoint, uploadBlobEndpoint, b64Token); + return uploadLayer(layer, fsHelper.readFileContents(zipFile.path), registry, repository, b64Token); case 'application/vnd.github.actions.package.layer.v1.zip': - return uploadLayer(layer, zipFile, registry, checkBlobEndpoint, uploadBlobEndpoint, b64Token); + return uploadLayer(layer, fsHelper.readFileContents(zipFile.path), registry, repository, b64Token); case 'application/vnd.oci.empty.v1+json': - return uploadLayer(layer, { path: '', size: 2, sha256: layer.digest }, registry, checkBlobEndpoint, uploadBlobEndpoint, b64Token); + return uploadLayer(layer, Buffer.from('{}'), registry, repository, b64Token); default: throw new Error(`Unknown media type ${layer.mediaType}`); } }); await Promise.all(layerUploads); - const digest = await uploadManifest(JSON.stringify(manifest), manifestEndpoint, b64Token); + const digest = await uploadManifest(JSON.stringify(manifest), manifest.mediaType, registry, repository, semver, b64Token); return { packageURL: new URL(`${repository}:${semver}`, registry), publishedDigest: digest }; } -async function uploadLayer(layer, file, registryURL, checkBlobEndpoint, uploadBlobEndpoint, b64Token) { - const checkExistsResponse = await fetchWithDebug(checkBlobEndpoint + layer.digest, { +async function uploadLayer(layer, data, registryURL, repository, b64Token) { + const checkExistsResponse = await fetchWithDebug(checkBlobEndpoint(registryURL, repository, layer.digest), { method: 'HEAD', headers: { Authorization: `Bearer ${b64Token}` @@ -107469,7 +107466,8 @@ async function uploadLayer(layer, file, registryURL, checkBlobEndpoint, uploadBl throw new Error(await errorMessageForFailedRequest(`check blob (${layer.digest}) exists`, checkExistsResponse)); } core.info(`Uploading layer ${layer.digest}.`); - const initiateUploadResponse = await fetchWithDebug(uploadBlobEndpoint, { + const initiateUploadBlobURL = uploadBlobEndpoint(registryURL, repository); + const initiateUploadResponse = await fetchWithDebug(initiateUploadBlobURL, { method: 'POST', headers: { Authorization: `Bearer ${b64Token}` @@ -107481,18 +107479,10 @@ async function uploadLayer(layer, file, registryURL, checkBlobEndpoint, uploadBl } const locationResponseHeader = initiateUploadResponse.headers.get('location'); if (locationResponseHeader === undefined) { - throw new Error(`No location header in response from upload post ${uploadBlobEndpoint} for layer ${layer.digest}`); + throw new Error(`No location header in response from upload post ${initiateUploadBlobURL} for layer ${layer.digest}`); } const pathname = `${locationResponseHeader}?digest=${layer.digest}`; const uploadBlobUrl = new URL(pathname, registryURL).toString(); - // TODO: must we handle the empty config layer? Maybe we can just skip calling this at all - let data; - if (layer.mediaType === 'application/vnd.oci.empty.v1+json') { - data = Buffer.from('{}'); - } - else { - data = fsHelper.readFileContents(file.path); - } const putResponse = await fetchWithDebug(uploadBlobUrl, { method: 'PUT', headers: { @@ -107508,13 +107498,14 @@ async function uploadLayer(layer, file, registryURL, checkBlobEndpoint, uploadBl } } // Uploads the manifest and returns the digest returned by GHCR -async function uploadManifest(manifestJSON, manifestEndpoint, b64Token) { - core.info(`Uploading manifest to ${manifestEndpoint}.`); - const putResponse = await fetchWithDebug(manifestEndpoint, { +async function uploadManifest(manifestJSON, manifestMediaType, registry, repository, version, b64Token) { + const manifestUrl = manifestEndpoint(registry, repository, version); + core.info(`Uploading manifest to ${manifestUrl}.`); + const putResponse = await fetchWithDebug(manifestUrl, { method: 'PUT', headers: { Authorization: `Bearer ${b64Token}`, - 'Content-Type': 'application/vnd.oci.image.manifest.v1+json' + 'Content-Type': manifestMediaType }, body: manifestJSON }); @@ -107523,7 +107514,7 @@ async function uploadManifest(manifestJSON, manifestEndpoint, b64Token) { } const digestResponseHeader = putResponse.headers.get('docker-content-digest'); if (digestResponseHeader === undefined || digestResponseHeader === null) { - throw new Error(`No digest header in response from PUT manifest ${manifestEndpoint}`); + throw new Error(`No digest header in response from PUT manifest ${manifestUrl}`); } return digestResponseHeader; } @@ -107561,6 +107552,16 @@ function isGHCRError(obj) { 'message' in obj && typeof obj.message === 'string'); } +function checkBlobEndpoint(registry, repository, digest) { + return new URL(`v2/${repository}/blobs/${digest}`, registry).toString(); +} +function uploadBlobEndpoint(registry, repository) { + return new URL(`v2/${repository}/blobs/uploads/`, registry).toString(); +} +function manifestEndpoint(registry, repository, version) { + return new URL(`v2/${repository}/manifests/${version}`, registry).toString(); +} +// TODO: Add retries with backoff const fetchWithDebug = async (url, config = {}) => { core.debug(`Request from ${url} with config: ${JSON.stringify(config)}`); try { diff --git a/src/ghcr-client.ts b/src/ghcr-client.ts index cf8c570..e943bbe 100644 --- a/src/ghcr-client.ts +++ b/src/ghcr-client.ts @@ -15,19 +15,6 @@ export async function publishOCIArtifact( ): Promise<{ packageURL: URL; publishedDigest: string }> { const b64Token = Buffer.from(token).toString('base64') - const checkBlobEndpoint = new URL( - `v2/${repository}/blobs/`, - registry - ).toString() - const uploadBlobEndpoint = new URL( - `v2/${repository}/blobs/uploads/`, - registry - ).toString() - const manifestEndpoint = new URL( - `v2/${repository}/manifests/${semver}`, - registry - ).toString() - core.info( `Creating GHCR package for release with semver:${semver} with path:"${zipFile.path}" and "${tarFile.path}".` ) @@ -37,28 +24,25 @@ export async function publishOCIArtifact( case 'application/vnd.github.actions.package.layer.v1.tar+gzip': return uploadLayer( layer, - tarFile, + fsHelper.readFileContents(zipFile.path), registry, - checkBlobEndpoint, - uploadBlobEndpoint, + repository, b64Token ) case 'application/vnd.github.actions.package.layer.v1.zip': return uploadLayer( layer, - zipFile, + fsHelper.readFileContents(zipFile.path), registry, - checkBlobEndpoint, - uploadBlobEndpoint, + repository, b64Token ) case 'application/vnd.oci.empty.v1+json': return uploadLayer( layer, - { path: '', size: 2, sha256: layer.digest }, + Buffer.from('{}'), registry, - checkBlobEndpoint, - uploadBlobEndpoint, + repository, b64Token ) default: @@ -70,7 +54,10 @@ export async function publishOCIArtifact( const digest = await uploadManifest( JSON.stringify(manifest), - manifestEndpoint, + manifest.mediaType, + registry, + repository, + semver, b64Token ) @@ -82,14 +69,13 @@ export async function publishOCIArtifact( async function uploadLayer( layer: ociContainer.Descriptor, - file: FileMetadata, + data: Buffer, registryURL: URL, - checkBlobEndpoint: string, - uploadBlobEndpoint: string, + repository: string, b64Token: string ): Promise { const checkExistsResponse = await fetchWithDebug( - checkBlobEndpoint + layer.digest, + checkBlobEndpoint(registryURL, repository, layer.digest), { method: 'HEAD', headers: { @@ -117,7 +103,9 @@ async function uploadLayer( core.info(`Uploading layer ${layer.digest}.`) - const initiateUploadResponse = await fetchWithDebug(uploadBlobEndpoint, { + const initiateUploadBlobURL = uploadBlobEndpoint(registryURL, repository) + + const initiateUploadResponse = await fetchWithDebug(initiateUploadBlobURL, { method: 'POST', headers: { Authorization: `Bearer ${b64Token}` @@ -137,21 +125,13 @@ async function uploadLayer( const locationResponseHeader = initiateUploadResponse.headers.get('location') if (locationResponseHeader === undefined) { throw new Error( - `No location header in response from upload post ${uploadBlobEndpoint} for layer ${layer.digest}` + `No location header in response from upload post ${initiateUploadBlobURL} for layer ${layer.digest}` ) } const pathname = `${locationResponseHeader}?digest=${layer.digest}` const uploadBlobUrl = new URL(pathname, registryURL).toString() - // TODO: must we handle the empty config layer? Maybe we can just skip calling this at all - let data: Buffer - if (layer.mediaType === 'application/vnd.oci.empty.v1+json') { - data = Buffer.from('{}') - } else { - data = fsHelper.readFileContents(file.path) - } - const putResponse = await fetchWithDebug(uploadBlobUrl, { method: 'PUT', headers: { @@ -176,16 +156,21 @@ async function uploadLayer( // Uploads the manifest and returns the digest returned by GHCR async function uploadManifest( manifestJSON: string, - manifestEndpoint: string, + manifestMediaType: string, + registry: URL, + repository: string, + version: string, b64Token: string ): Promise { - core.info(`Uploading manifest to ${manifestEndpoint}.`) + const manifestUrl = manifestEndpoint(registry, repository, version) - const putResponse = await fetchWithDebug(manifestEndpoint, { + core.info(`Uploading manifest to ${manifestUrl}.`) + + const putResponse = await fetchWithDebug(manifestUrl, { method: 'PUT', headers: { Authorization: `Bearer ${b64Token}`, - 'Content-Type': 'application/vnd.oci.image.manifest.v1+json' + 'Content-Type': manifestMediaType }, body: manifestJSON }) @@ -199,7 +184,7 @@ async function uploadManifest( const digestResponseHeader = putResponse.headers.get('docker-content-digest') if (digestResponseHeader === undefined || digestResponseHeader === null) { throw new Error( - `No digest header in response from PUT manifest ${manifestEndpoint}` + `No digest header in response from PUT manifest ${manifestUrl}` ) } @@ -257,6 +242,27 @@ function isGHCRError(obj: unknown): boolean { ) } +function checkBlobEndpoint( + registry: URL, + repository: string, + digest: string +): string { + return new URL(`v2/${repository}/blobs/${digest}`, registry).toString() +} + +function uploadBlobEndpoint(registry: URL, repository: string): string { + return new URL(`v2/${repository}/blobs/uploads/`, registry).toString() +} + +function manifestEndpoint( + registry: URL, + repository: string, + version: string +): string { + return new URL(`v2/${repository}/manifests/${version}`, registry).toString() +} + +// TODO: Add retries with backoff const fetchWithDebug = async ( url: string, config: RequestInit = {}