add retries and fix up tests

This commit is contained in:
Conor Sloan
2024-08-23 13:17:07 +01:00
parent 72b670f356
commit 1b9faf628d
7 changed files with 654 additions and 419 deletions
Generated Vendored
+172 -128
View File
@@ -106669,113 +106669,181 @@ var __importStar = (this && this.__importStar) || function (mod) {
return result;
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.uploadOCIImageManifest = uploadOCIImageManifest;
exports.uploadOCIIndexManifest = uploadOCIIndexManifest;
exports.Client = void 0;
const core = __importStar(__nccwpck_require__(42186));
const ociContainer = __importStar(__nccwpck_require__(33207));
async function uploadOCIImageManifest(token, registry, repository, manifest, blobs, tag) {
const b64Token = Buffer.from(token).toString('base64');
const manifestSHA = ociContainer.sha256Digest(manifest);
if (tag) {
core.info(`Uploading manifest ${manifestSHA} with tag ${tag} to ${repository}.`);
const defaultRetries = 5;
const defaultBackoff = 1000;
const retryableStatusCodes = [408, 429, 500, 502, 503, 504];
class Client {
_b64Token;
_registry;
_retryOptions;
constructor(token, registry, retryOptions = {
retries: defaultRetries,
backoff: defaultBackoff
}) {
this._b64Token = Buffer.from(token).toString('base64');
this._registry = registry;
this._retryOptions = retryOptions;
}
else {
core.info(`Uploading manifest ${manifestSHA} to ${repository}.`);
}
// We must also upload the config layer
const layersToUpload = manifest.layers.concat(manifest.config);
const layerUploads = layersToUpload.map(async (layer) => {
const blob = blobs.get(layer.digest);
if (!blob) {
throw new Error(`Blob for layer ${layer.digest} not found`);
async uploadOCIImageManifest(repository, manifest, blobs, tag) {
const manifestSHA = ociContainer.sha256Digest(manifest);
if (tag) {
core.info(`Uploading manifest ${manifestSHA} with tag ${tag} to ${repository}.`);
}
return uploadLayer(layer, blob, registry, repository, b64Token);
});
await Promise.all(layerUploads);
const publishedDigest = await uploadManifest(JSON.stringify(manifest), manifest.mediaType, registry, repository, tag || manifestSHA, b64Token);
if (publishedDigest !== manifestSHA) {
throw new Error(`Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.`);
}
return manifestSHA;
}
async function uploadOCIIndexManifest(token, registry, repository, manifest, tag) {
const b64Token = Buffer.from(token).toString('base64');
const manifestSHA = ociContainer.sha256Digest(manifest);
core.info(`Uploading index manifest ${manifestSHA} with tag ${tag} to ${repository}.`);
const publishedDigest = await uploadManifest(JSON.stringify(manifest), manifest.mediaType, registry, repository, tag, b64Token);
if (publishedDigest !== manifestSHA) {
throw new Error(`Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.`);
}
return manifestSHA;
}
async function uploadLayer(layer, data, registryURL, repository, b64Token) {
const checkExistsResponse = await fetchWithDebug(checkBlobEndpoint(registryURL, repository, layer.digest), {
method: 'HEAD',
headers: {
Authorization: `Bearer ${b64Token}`
else {
core.info(`Uploading manifest ${manifestSHA} to ${repository}.`);
}
});
if (checkExistsResponse.status === 200 ||
checkExistsResponse.status === 202) {
core.info(`Layer ${layer.digest} already exists. Skipping upload.`);
return;
// We must also upload the config layer
const layersToUpload = manifest.layers.concat(manifest.config);
const layerUploads = layersToUpload.map(async (layer) => {
const blob = blobs.get(layer.digest);
if (!blob) {
throw new Error(`Blob for layer ${layer.digest} not found`);
}
return this.uploadLayer(layer, blob, repository);
});
await Promise.all(layerUploads);
const publishedDigest = await this.uploadManifest(JSON.stringify(manifest), manifest.mediaType, repository, tag || manifestSHA);
if (publishedDigest !== manifestSHA) {
throw new Error(`Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.`);
}
return manifestSHA;
}
if (checkExistsResponse.status !== 404) {
throw new Error(await errorMessageForFailedRequest(`check blob (${layer.digest}) exists`, checkExistsResponse));
async uploadOCIIndexManifest(repository, manifest, tag) {
const manifestSHA = ociContainer.sha256Digest(manifest);
core.info(`Uploading index manifest ${manifestSHA} with tag ${tag} to ${repository}.`);
const publishedDigest = await this.uploadManifest(JSON.stringify(manifest), manifest.mediaType, repository, tag);
if (publishedDigest !== manifestSHA) {
throw new Error(`Digest mismatch. Expected ${manifestSHA}, got ${publishedDigest}.`);
}
return manifestSHA;
}
core.info(`Uploading layer ${layer.digest}.`);
const initiateUploadBlobURL = uploadBlobEndpoint(registryURL, repository);
const initiateUploadResponse = await fetchWithDebug(initiateUploadBlobURL, {
method: 'POST',
headers: {
Authorization: `Bearer ${b64Token}`
},
body: JSON.stringify(layer)
});
if (initiateUploadResponse.status !== 202) {
throw new Error(await errorMessageForFailedRequest(`initiate layer upload`, initiateUploadResponse));
async uploadLayer(layer, data, repository) {
const checkExistsResponse = await this.fetchWithRetries(this.checkBlobEndpoint(repository, layer.digest), {
method: 'HEAD',
headers: {
Authorization: `Bearer ${this._b64Token}`
}
});
if (checkExistsResponse.status === 200 ||
checkExistsResponse.status === 202) {
core.info(`Layer ${layer.digest} already exists. Skipping upload.`);
return;
}
if (checkExistsResponse.status !== 404) {
throw new Error(await errorMessageForFailedRequest(`check blob (${layer.digest}) exists`, checkExistsResponse));
}
core.info(`Uploading layer ${layer.digest}.`);
const initiateUploadBlobURL = this.uploadBlobEndpoint(repository);
const initiateUploadResponse = await this.fetchWithRetries(initiateUploadBlobURL, {
method: 'POST',
headers: {
Authorization: `Bearer ${this._b64Token}`
},
body: JSON.stringify(layer)
});
if (initiateUploadResponse.status !== 202) {
throw new Error(await errorMessageForFailedRequest(`initiate layer upload`, initiateUploadResponse));
}
const locationResponseHeader = initiateUploadResponse.headers.get('location');
if (locationResponseHeader === undefined) {
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, this._registry).toString();
const putResponse = await this.fetchWithRetries(uploadBlobUrl, {
method: 'PUT',
headers: {
Authorization: `Bearer ${this._b64Token}`,
'Content-Type': 'application/octet-stream',
'Accept-Encoding': 'gzip',
'Content-Length': layer.size.toString()
},
body: data
});
if (putResponse.status !== 201) {
throw new Error(await errorMessageForFailedRequest(`layer (${layer.digest}) upload`, putResponse));
}
}
const locationResponseHeader = initiateUploadResponse.headers.get('location');
if (locationResponseHeader === undefined) {
throw new Error(`No location header in response from upload post ${initiateUploadBlobURL} for layer ${layer.digest}`);
// Uploads the manifest and returns the digest returned by GHCR
async uploadManifest(manifestJSON, manifestMediaType, repository, version) {
const manifestUrl = this.manifestEndpoint(repository, version);
core.info(`Uploading manifest to ${manifestUrl}.`);
const putResponse = await this.fetchWithRetries(manifestUrl, {
method: 'PUT',
headers: {
Authorization: `Bearer ${this._b64Token}`,
'Content-Type': manifestMediaType
},
body: manifestJSON
});
if (putResponse.status !== 201) {
throw new Error(await errorMessageForFailedRequest(`manifest upload`, putResponse));
}
const digestResponseHeader = putResponse.headers.get('docker-content-digest') || '';
return digestResponseHeader;
}
const pathname = `${locationResponseHeader}?digest=${layer.digest}`;
const uploadBlobUrl = new URL(pathname, registryURL).toString();
const putResponse = await fetchWithDebug(uploadBlobUrl, {
method: 'PUT',
headers: {
Authorization: `Bearer ${b64Token}`,
'Content-Type': 'application/octet-stream',
'Accept-Encoding': 'gzip',
'Content-Length': layer.size.toString()
},
body: data
});
if (putResponse.status !== 201) {
throw new Error(await errorMessageForFailedRequest(`layer (${layer.digest}) upload`, putResponse));
checkBlobEndpoint(repository, digest) {
return new URL(`v2/${repository}/blobs/${digest}`, this._registry).toString();
}
uploadBlobEndpoint(repository) {
return new URL(`v2/${repository}/blobs/uploads/`, this._registry).toString();
}
manifestEndpoint(repository, version) {
return new URL(`v2/${repository}/manifests/${version}`, this._registry).toString();
}
// TODO: Add retries with backoff
async fetchWithDebug(url, config = {}) {
core.debug(`Request from ${url} with config: ${JSON.stringify(config)}`);
try {
const response = await fetch(url, config);
core.debug(`Response with ${JSON.stringify(response)}`);
return response;
}
catch (error) {
core.debug(`Error with ${error}`);
throw error;
}
}
async fetchWithRetries(url, config = {}) {
const allowedAttempts = this._retryOptions.retries + 1; // Initial attempt + retries
for (let attemptNumber = 1; attemptNumber <= allowedAttempts; attemptNumber++) {
let backoff = this._retryOptions.backoff;
try {
const response = await this.fetchWithDebug(url, config);
// If this is the last attempt, just return it
if (attemptNumber === allowedAttempts) {
return response;
}
// If the response is retryable, backoff and retry
if (retryableStatusCodes.includes(response.status)) {
const retryAfter = response.headers.get('retry-after');
if (retryAfter) {
backoff = parseInt(retryAfter) * 1000; // convert to ms
}
core.info(`Received ${response.status} response. Retrying after ${backoff}ms...`);
await new Promise(resolve => setTimeout(resolve, backoff));
continue;
}
// Otherwise, just return the response
return response;
}
catch (error) {
// If this is the last attempt, throw the error
if (attemptNumber === allowedAttempts) {
throw error;
}
core.info(`Encountered error: ${error}. Retrying after ${backoff}ms...`);
await new Promise(resolve => setTimeout(resolve, backoff));
}
}
// Should be unreachable
throw new Error('Exhausted retries without a successful response');
}
}
// Uploads the manifest and returns the digest returned by GHCR
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': manifestMediaType
},
body: manifestJSON
});
if (putResponse.status !== 201) {
throw new Error(await errorMessageForFailedRequest(`manifest upload`, putResponse));
}
const digestResponseHeader = putResponse.headers.get('docker-content-digest');
if (digestResponseHeader === undefined || digestResponseHeader === null) {
throw new Error(`No digest header in response from PUT manifest ${manifestUrl}`);
}
return digestResponseHeader;
}
exports.Client = Client;
// Generate an error message for a failed HTTP request
async function errorMessageForFailedRequest(requestDescription, response) {
const bodyText = await response.text();
@@ -106810,28 +106878,6 @@ 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 {
const response = await fetch(url, config);
core.debug(`Response with ${JSON.stringify(response)}`);
return response;
}
catch (error) {
core.debug(`Error with ${error}`);
throw error;
}
};
/***/ }),
@@ -106895,13 +106941,14 @@ async function run() {
const archives = await fsHelper.createArchives(stagedActionFilesDir, archiveDir);
const manifest = ociContainer.createActionPackageManifest(archives.tarFile, archives.zipFile, options.nameWithOwner, options.repositoryId, options.repositoryOwnerId, options.sha, semverTag.raw, new Date());
const manifestDigest = ociContainer.sha256Digest(manifest);
const ghcrClient = new ghcr.Client(options.token, options.containerRegistryUrl);
// Attestations are not supported in GHES.
if (!options.isEnterprise) {
const { bundle, bundleDigest } = await generateAttestation(manifestDigest, semverTag.raw, options);
const attestationCreated = new Date();
const attestationManifest = ociContainer.createSigstoreAttestationManifest(bundle.length, bundleDigest, ociContainer.sizeInBytes(manifest), manifestDigest, attestationCreated);
const referrerIndexManifest = ociContainer.createReferrerTagManifest(ociContainer.sha256Digest(attestationManifest), ociContainer.sizeInBytes(attestationManifest), attestationCreated);
const { attestationSHA, referrerIndexSHA } = await publishAttestation(options, bundle, bundleDigest, manifest, attestationManifest, referrerIndexManifest);
const { attestationSHA, referrerIndexSHA } = await publishAttestation(ghcrClient, options.nameWithOwner, bundle, bundleDigest, manifest, attestationManifest, referrerIndexManifest);
if (attestationSHA !== undefined) {
core.info(`Uploaded attestation ${attestationSHA}`);
core.setOutput('attestation-manifest-sha', attestationSHA);
@@ -106911,10 +106958,7 @@ async function run() {
core.setOutput('referrer-index-manifest-sha', referrerIndexSHA);
}
}
const publishedDigest = await publishImmutableActionVersion(options, semverTag.raw, archives.zipFile, archives.tarFile, manifest);
if (manifestDigest !== publishedDigest) {
throw new Error(`Unexpected digest returned for manifest. Expected ${manifestDigest}, got ${publishedDigest}`);
}
const publishedDigest = await publishImmutableActionVersion(ghcrClient, options.nameWithOwner, semverTag.raw, archives.zipFile, archives.tarFile, manifest);
core.setOutput('package-manifest-sha', publishedDigest);
}
catch (error) {
@@ -106938,16 +106982,16 @@ function parseSemverTagFromRef(opts) {
}
return semverTag;
}
async function publishImmutableActionVersion(options, semverTag, zipFile, tarFile, manifest) {
async function publishImmutableActionVersion(client, nameWithOwner, semverTag, zipFile, tarFile, manifest) {
const manifestDigest = ociContainer.sha256Digest(manifest);
core.info(`Creating GHCR package ${manifestDigest} for release with semver: ${semver_1.default}.`);
const files = new Map();
files.set(zipFile.sha256, fsHelper.readFileContents(zipFile.path));
files.set(tarFile.sha256, fsHelper.readFileContents(tarFile.path));
files.set(ociContainer.emptyConfigSha, Buffer.from('{}'));
return await ghcr.uploadOCIImageManifest(options.token, options.containerRegistryUrl, options.nameWithOwner, manifest, files, semverTag);
return await client.uploadOCIImageManifest(nameWithOwner, manifest, files, semverTag);
}
async function publishAttestation(options, bundle, bundleDigest, subjectManifest, attestationManifest, referrerIndexManifest) {
async function publishAttestation(client, nameWithOwner, bundle, bundleDigest, subjectManifest, attestationManifest, referrerIndexManifest) {
const attestationManifestDigest = ociContainer.sha256Digest(attestationManifest);
const subjectManifestDigest = ociContainer.sha256Digest(subjectManifest);
const referrerIndexManifestDigest = ociContainer.sha256Digest(referrerIndexManifest);
@@ -106955,11 +106999,11 @@ async function publishAttestation(options, bundle, bundleDigest, subjectManifest
const files = new Map();
files.set(ociContainer.emptyConfigSha, Buffer.from('{}'));
files.set(bundleDigest, bundle);
const attestationSHA = await ghcr.uploadOCIImageManifest(options.token, options.containerRegistryUrl, options.nameWithOwner, attestationManifest, files);
const attestationSHA = await client.uploadOCIImageManifest(nameWithOwner, attestationManifest, files);
// The referrer index is tagged with the subject's digest in format sha256-<digest>
const referrerTag = subjectManifestDigest.replace(':', '-');
core.info(`Publishing referrer index ${referrerIndexManifestDigest} with tag ${referrerTag} for attestation ${attestationManifestDigest} and subject ${subjectManifestDigest}.`);
const referrerIndexSHA = await ghcr.uploadOCIIndexManifest(options.token, options.containerRegistryUrl, options.nameWithOwner, referrerIndexManifest, referrerTag);
const referrerIndexSHA = await client.uploadOCIIndexManifest(nameWithOwner, referrerIndexManifest, referrerTag);
return { attestationSHA, referrerIndexSHA };
}
async function generateAttestation(manifestDigest, semverTag, options) {