diff --git a/__tests__/ghcr-client.test.ts b/__tests__/ghcr-client.test.ts index acff01b..571b8e7 100644 --- a/__tests__/ghcr-client.test.ts +++ b/__tests__/ghcr-client.test.ts @@ -26,16 +26,18 @@ const headMockNoExistingBlobs = (): object => { // Simulate none of the blobs existing currently return { text() { - return 'Not Found' + return '{"errors": [{"code": "NOT_FOUND", "message": "blob not found."}]}' }, - status: 404 + status: 404, + statusText: 'Not Found' } } const headMockAllExistingBlobs = (): object => { // Simulate all of the blobs existing currently return { - status: 200 + status: 200, + statusText: 'OK' } } @@ -45,15 +47,17 @@ const headMockSomeExistingBlobs = (): object => { // report one as existing if (count === 1) { return { - status: 200 + status: 200, + statusText: 'OK' } } else { // report all others are missing return { text() { - return 'Not Found' + return '{"errors": [{"code": "NOT_FOUND", "message": "blob not found."}]}' }, - status: 404 + status: 404, + statusText: 'Not Found' } } } @@ -61,9 +65,11 @@ const headMockSomeExistingBlobs = (): object => { const headMockFailure = (): object => { return { text() { - return 'Failed the head request' + // In this case we'll simulate a response which does not use the expected error format + return '503 Service Unavailable' }, - status: 503 + status: 503, + statusText: 'Service Unavailable' } } @@ -85,9 +91,11 @@ const postMockFailure = (): object => { // Simulate failed initiation of uploads return { text() { - return 'Failed the post request' + // In this case we'll simulate a response which does not use the expected error format + return '503 Service Unavailable' }, - status: 503 + status: 503, + statusText: 'Service Unavailable' } } @@ -123,9 +131,10 @@ const putMockFailure = (): object => { // Simulate fails upload of all blobs & manifest return { text() { - return 'Failed the put request' + return '{"errors": [{"code": "BAD_REQUEST", "message": "tag already exists."}]}' }, - status: 500 + status: 400, + statusText: 'Bad Request' } } @@ -134,9 +143,10 @@ const putMockFailureManifestUpload = (url: string): object => { if (url.includes('manifest')) { return { text() { - return 'Failed the put request' + return '{"errors": [{"code": "BAD_REQUEST", "message": "tag already exists."}]}' }, - status: 500 + status: 400, + statusText: 'Bad Request' } } return { @@ -343,7 +353,9 @@ describe('publishOCIArtifact', () => { tarFile, testManifest ) - ).rejects.toThrow(/^Unexpected response from blob check for layer/) + ).rejects.toThrow( + /^Unexpected 503 Service Unavailable response from check blob/ + ) }) it('throws an error if initiating layer upload fails', async () => { @@ -362,7 +374,9 @@ describe('publishOCIArtifact', () => { tarFile, testManifest ) - ).rejects.toThrow('Unexpected response from POST upload 503') + ).rejects.toThrow( + 'Unexpected 503 Service Unavailable response from initiate layer upload. Response Body: 503 Service Unavailable.' + ) }) it('throws an error if the upload endpoint does not return a location', async () => { @@ -406,7 +420,7 @@ describe('publishOCIArtifact', () => { tarFile, testManifest ) - ).rejects.toThrow(/^Unexpected response from PUT upload 500/) + ).rejects.toThrow(/^Unexpected 400 Bad Request response from layer/) }) it('throws an error if a manifest upload fails', async () => { @@ -431,7 +445,9 @@ describe('publishOCIArtifact', () => { tarFile, testManifest ) - ).rejects.toThrow(/^Unexpected response from PUT manifest 500/) + ).rejects.toThrow( + 'Unexpected 400 Bad Request response from manifest upload. Errors: BAD_REQUEST - tag already exists.' + ) }) it('throws an error if reading one of the files fails', async () => { diff --git a/badges/coverage.svg b/badges/coverage.svg index 369b184..b75435b 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 97%Coverage97% \ No newline at end of file +Coverage: 97.06%Coverage97.06% \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index 04f9bec..03f6d02 100644 --- a/dist/index.js +++ b/dist/index.js @@ -104749,8 +104749,7 @@ async function uploadLayer(layer, file, registryURL, checkBlobEndpoint, uploadBl return; } if (checkExistsResponse.status !== 404) { - const responseBody = await checkExistsResponse.text(); - throw new Error(`Unexpected response from blob check for layer ${layer.digest}: ${checkExistsResponse.status}. Response Body: ${responseBody}.`); + throw new Error(await errorMessageForFailedRequest(`check blob (${layer.digest}) exists`, checkExistsResponse)); } core.info(`Uploading layer ${layer.digest}.`); const initiateUploadResponse = await fetchWithDebug(uploadBlobEndpoint, { @@ -104761,9 +104760,7 @@ async function uploadLayer(layer, file, registryURL, checkBlobEndpoint, uploadBl body: JSON.stringify(layer) }); if (initiateUploadResponse.status !== 202) { - const responseBody = await initiateUploadResponse.text(); - core.error(`Unexpected response from upload post ${uploadBlobEndpoint}: ${initiateUploadResponse.status}. Response Body: ${responseBody}.`); - throw new Error(`Unexpected response from POST upload ${initiateUploadResponse.status}. Response Body: ${responseBody}.`); + throw new Error(await errorMessageForFailedRequest(`initiate layer upload`, initiateUploadResponse)); } const locationResponseHeader = initiateUploadResponse.headers.get('location'); if (locationResponseHeader === undefined) { @@ -104790,8 +104787,7 @@ async function uploadLayer(layer, file, registryURL, checkBlobEndpoint, uploadBl body: data }); if (putResponse.status !== 201) { - const responseBody = await putResponse.text(); - throw new Error(`Unexpected response from PUT upload ${putResponse.status} for layer ${layer.digest}. Response Body: ${responseBody}.`); + throw new Error(await errorMessageForFailedRequest(`layer (${layer.digest}) upload`, putResponse)); } } // Uploads the manifest and returns the digest returned by GHCR @@ -104806,8 +104802,7 @@ async function uploadManifest(manifestJSON, manifestEndpoint, b64Token) { body: manifestJSON }); if (putResponse.status !== 201) { - const responseBody = await putResponse.text(); - throw new Error(`Unexpected response from PUT manifest ${putResponse.status}. Response Body: ${responseBody}.`); + throw new Error(await errorMessageForFailedRequest(`manifest upload`, putResponse)); } const digestResponseHeader = putResponse.headers.get('docker-content-digest'); if (digestResponseHeader === undefined || digestResponseHeader === null) { @@ -104815,6 +104810,40 @@ async function uploadManifest(manifestJSON, manifestEndpoint, b64Token) { } return digestResponseHeader; } +// Generate an error message for a failed HTTP request +async function errorMessageForFailedRequest(requestDescription, response) { + const bodyText = await response.text(); + // Try to parse the body as JSON and extract the expected fields returned from GHCR + // Expected format: { "errors": [{"code": "BAD_REQUEST", "message": "Something went wrong."}] } + // If the body does not match the expected format, just return the whole response body + let errorString = `Response Body: ${bodyText}.`; + try { + const body = JSON.parse(bodyText); + const errors = body.errors; + if (Array.isArray(errors) && + errors.length > 0 && + errors.every(isGHCRError)) { + const errorMessages = errors.map((error) => { + return `${error.code} - ${error.message}`; + }); + errorString = `Errors: ${errorMessages.join(', ')}`; + } + } + catch (error) { + // Ignore error + } + return `Unexpected ${response.status} ${response.statusText} response from ${requestDescription}. ${errorString}`; +} +// Runtime checks that parsed JSON object is in the expected format +// {"code": "BAD_REQUEST", "message": "Something went wrong."} +function isGHCRError(obj) { + return (typeof obj === 'object' && + obj !== null && + 'code' in obj && + typeof obj.code === 'string' && + 'message' in obj && + typeof obj.message === 'string'); +} 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 47536df..7aba4ef 100644 --- a/src/ghcr-client.ts +++ b/src/ghcr-client.ts @@ -107,10 +107,11 @@ async function uploadLayer( } if (checkExistsResponse.status !== 404) { - const responseBody = await checkExistsResponse.text() - throw new Error( - `Unexpected response from blob check for layer ${layer.digest}: ${checkExistsResponse.status}. Response Body: ${responseBody}.` + await errorMessageForFailedRequest( + `check blob (${layer.digest}) exists`, + checkExistsResponse + ) ) } @@ -125,13 +126,11 @@ async function uploadLayer( }) if (initiateUploadResponse.status !== 202) { - const responseBody = await initiateUploadResponse.text() - - core.error( - `Unexpected response from upload post ${uploadBlobEndpoint}: ${initiateUploadResponse.status}. Response Body: ${responseBody}.` - ) throw new Error( - `Unexpected response from POST upload ${initiateUploadResponse.status}. Response Body: ${responseBody}.` + await errorMessageForFailedRequest( + `initiate layer upload`, + initiateUploadResponse + ) ) } @@ -165,10 +164,11 @@ async function uploadLayer( }) if (putResponse.status !== 201) { - const responseBody = await putResponse.text() - throw new Error( - `Unexpected response from PUT upload ${putResponse.status} for layer ${layer.digest}. Response Body: ${responseBody}.` + await errorMessageForFailedRequest( + `layer (${layer.digest}) upload`, + putResponse + ) ) } } @@ -191,10 +191,8 @@ async function uploadManifest( }) if (putResponse.status !== 201) { - const responseBody = await putResponse.text() - throw new Error( - `Unexpected response from PUT manifest ${putResponse.status}. Response Body: ${responseBody}.` + await errorMessageForFailedRequest(`manifest upload`, putResponse) ) } @@ -208,6 +206,57 @@ async function uploadManifest( return digestResponseHeader } +interface ghcrError { + code: string + message: string +} + +// Generate an error message for a failed HTTP request +async function errorMessageForFailedRequest( + requestDescription: string, + response: Response +): Promise { + const bodyText = await response.text() + + // Try to parse the body as JSON and extract the expected fields returned from GHCR + // Expected format: { "errors": [{"code": "BAD_REQUEST", "message": "Something went wrong."}] } + // If the body does not match the expected format, just return the whole response body + let errorString = `Response Body: ${bodyText}.` + + try { + const body = JSON.parse(bodyText) + const errors = body.errors + + if ( + Array.isArray(errors) && + errors.length > 0 && + errors.every(isGHCRError) + ) { + const errorMessages = errors.map((error: ghcrError) => { + return `${error.code} - ${error.message}` + }) + errorString = `Errors: ${errorMessages.join(', ')}` + } + } catch (error) { + // Ignore error + } + + return `Unexpected ${response.status} ${response.statusText} response from ${requestDescription}. ${errorString}` +} + +// Runtime checks that parsed JSON object is in the expected format +// {"code": "BAD_REQUEST", "message": "Something went wrong."} +function isGHCRError(obj: unknown): boolean { + return ( + typeof obj === 'object' && + obj !== null && + 'code' in obj && + typeof (obj as { code: unknown }).code === 'string' && + 'message' in obj && + typeof (obj as { message: unknown }).message === 'string' + ) +} + const fetchWithDebug = async ( url: string, config: RequestInit = {}