diff --git a/package.json b/package.json index d42e39d0..74800f8b 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "build-preview": "yarn build && scripts/preview.sh", "serve": "http-server --cors='*' dist", "preview": "yarn build-preview && yarn serve", - "translate": "node --loader ts-node/esm/transpile-only scripts/translate.ts" + "translate": "node --loader ts-node/esm/transpile-only scripts/translate.ts", + "cloudflare_clean": "node scripts/cloudflare_clean.js" }, "dependencies": { "@andreekeberg/imagedata": "^1.0.2", @@ -39,6 +40,8 @@ "devDependencies": { "@octokit/core": "^4.1.0", "@types/js-yaml": "^4.0.5", - "http-server": "^14.1.1" + "http-server": "^14.1.1", + "exponential-backoff": "^3.1.0", + "node-fetch": "^2.6.7" } } diff --git a/scripts/cloudflare_clean.js b/scripts/cloudflare_clean.js new file mode 100644 index 00000000..5258d1f0 --- /dev/null +++ b/scripts/cloudflare_clean.js @@ -0,0 +1,147 @@ +import fetch from 'node-fetch' +import { backOff } from 'exponential-backoff' + +const CF_API_TOKEN = process.env.CF_API_TOKEN +const CF_ACCOUNT_ID = process.env.CF_ACCOUNT_ID +const CF_PAGES_PROJECT_NAME = process.env.CF_PAGES_PROJECT_NAME +const CF_DELETE_ALIASED_DEPLOYMENTS = process.env.CF_DELETE_ALIASED_DEPLOYMENTS + +const MAX_ATTEMPTS = 5 + +const sleep = (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) + +const headers = { + Authorization: `Bearer ${CF_API_TOKEN}`, +} + +/** Get the cononical deployment (the live deployment) */ +async function getProductionDeploymentId() { + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/pages/projects/${CF_PAGES_PROJECT_NAME}`, + { + method: 'GET', + headers, + } + ) + const body = await response.json() + if (!body.success) { + throw new Error(body.errors[0].message) + } + const prodDeploymentId = body.result.canonical_deployment.id + if (!prodDeploymentId) { + throw new Error('Unable to fetch production deployment ID') + } + return prodDeploymentId +} + +async function deleteDeployment(id) { + let params = '' + if (CF_DELETE_ALIASED_DEPLOYMENTS === 'true') { + params = '?force=true' // Forces deletion of aliased deployments + } + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/pages/projects/${CF_PAGES_PROJECT_NAME}/deployments/${id}${params}`, + { + method: 'DELETE', + headers, + } + ) + const body = await response.json() + if (!body.success) { + throw new Error(body.errors[0].message) + } + console.log(`Deleted deployment ${id} for project ${CF_PAGES_PROJECT_NAME}`) +} + +async function listDeploymentsPerPage(page) { + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/pages/projects/${CF_PAGES_PROJECT_NAME}/deployments?per_page=10&page=${page}`, + { + method: 'GET', + headers, + } + ) + const body = await response.json() + if (!body.success) { + throw new Error(`Could not fetch deployments for ${CF_PAGES_PROJECT_NAME}`) + } + return body.result +} + +async function listAllDeployments() { + let page = 1 + const deploymentIds = [] + + while (true) { + let result + try { + result = await backOff(() => listDeploymentsPerPage(page), { + numOfAttempts: 5, + startingDelay: 1000, // 1s, 2s, 4s, 8s, 16s + retry: (_, attempt) => { + console.warn( + `Failed to list deployments on page ${page}... retrying (${attempt}/${MAX_ATTEMPTS})` + ) + return true + }, + }) + } catch (err) { + console.warn(`Failed to list deployments on page ${page}.`) + console.warn(err) + + process.exit(1) + } + + for (const deployment of result) { + deploymentIds.push(deployment.id) + } + + if (result.length) { + page = page + 1 + await sleep(500) + } else { + return deploymentIds + } + } +} + +async function main() { + if (!CF_API_TOKEN) { + throw new Error('Please set CF_API_TOKEN as an env variable to your API Token') + } + + if (!CF_ACCOUNT_ID) { + throw new Error('Please set CF_ACCOUNT_ID as an env variable to your Account ID') + } + + if (!CF_PAGES_PROJECT_NAME) { + throw new Error( + 'Please set CF_PAGES_PROJECT_NAME as an env variable to your Pages project name' + ) + } + + const productionDeploymentId = await getProductionDeploymentId() + console.log( + `Found live production deployment to exclude from deletion: ${productionDeploymentId}` + ) + + console.log('Listing all deployments, this may take a while...') + const deploymentIds = await listAllDeployments() + + for (const id of deploymentIds) { + if (id === productionDeploymentId) { + console.log(`Skipping production deployment: ${id}`) + } else { + try { + await deleteDeployment(id) + } catch (error) { + console.log(error) + } + } + } +} + +main() diff --git a/yarn.lock b/yarn.lock index ce370804..9565d814 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1093,6 +1093,11 @@ exif-parser@^0.1.12: resolved "https://registry.yarnpkg.com/exif-parser/-/exif-parser-0.1.12.tgz#58a9d2d72c02c1f6f02a0ef4a9166272b7760922" integrity sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw== +exponential-backoff@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.1.tgz#64ac7526fe341ab18a39016cd22c787d01e00bf6" + integrity sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw== + extend@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"