-
-
Save jahands/c68b4786d1cea696e61714354c1d3019 to your computer and use it in GitHub Desktop.
Repeat for clearing out Pages deployments (https://repeat.dev)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// https://gist.github.com/jahands/c68b4786d1cea696e61714354c1d3019 | |
import { ThrottledQueue } from '@jahands/msc-utils'; | |
import PQueue from 'p-queue'; | |
// ===== SETUP ===== // | |
// Required ENV vars: | |
// CLOUDFLARE_ACCOUNT_ID | |
// CLOUDFLARE_API_TOKEN | |
// WEBHOOK_KEY | |
// Be sure to add a CRON that runs every 1 day at most | |
// Add a webhook event that you can trigger to update projects from your Cloudflare account. | |
// Or use AUTO_UPDATE_PROJECTS to auto-update every time the cron runs. | |
// Defaults are used when adding missing projects using webhook. | |
// These are configurable in the config file | |
const DEFAULT_RETENTION_DAYS = 60; // Keep last N days, also keeps latest 25 deployments | |
const ENABLED_BY_DEFAULT = true; // Whether newly added projects are enabled by default | |
const TASK_INTERVAL_SECONDS = 120; // Run tasks N seconds apart | |
const DELETE_BRANCH_ALIASES = true; // Could make this per-group or per-project if wanted | |
const AUTO_UPDATE_PROJECTS = false; // If true, every time the cron runs, it will update the project list from your CF account | |
const CF_API_BASE = 'https://api.cloudflare.com/client/v4'; | |
const CONFIG_STORAGE_PATH = 'config/pages_deployment_cleaner/config.json'; | |
// Config is stored here (substitute your Repeat.dev project ID (NOT Repeat ID!)): | |
// https://dash.repeat.dev/projects/[REPEAT_PROJECT_ID]/storage-file/config/pages_deployment_cleaner/projects.json | |
// Global API queue to throttle api calls to Pages | |
const queue = new ThrottledQueue({ | |
concurrency: 2, // Total concurrency limit (max=6 for Workers subrequests) | |
interval: 1000, // Interval to limit on (milliseconds) | |
limit: 3, // Limit to this many requests per the above interval | |
}); | |
// ThrottledQueue API: | |
// add(async fn) - returns a promise that will wait for | |
// this function to finish before returning. Don't await it if you want to add multiple | |
// things that run concurrently | |
// onIdle() - returns a promise that waits for all items in the queue to complete | |
export default { | |
async webhook(request: Request, env: Repeat.Env, ctx: ExecutionContext): Promise<Response | void> { | |
const url = new URL(request.url); | |
if (!url.searchParams.has('key') || url.searchParams.get('key') !== env.variables.WEBHOOK_KEY) { | |
return new Response('unauthorized :(', { status: 401 }); | |
} | |
// Use tasks for updating projects so that the webhook returns quickly. | |
await env.unstable.tasks.add({}, { id: 'update-projects' }); | |
}, | |
async cron(cron: Repeat.Cron, env: Repeat.Env): Promise<void> { | |
if (AUTO_UPDATE_PROJECTS) { | |
await updateConfigFromPagesAccount(env); | |
} | |
// Make sure there aren't already any scheduled tasks | |
const existingTasks = await env.unstable.tasks.list(); | |
if (existingTasks.length > 0) { | |
throw new Error(`Aborting cron, there are already ${existingTasks.length} tasks scheduled!`); | |
} | |
return handleDeleteDeploymentsCron(env); | |
}, | |
async task(task: Repeat.Task, env: Repeat.Env) { | |
if (task.id === 'update-projects') { | |
return updateConfigFromPagesAccount(env); | |
} | |
return handleDeleteDeploymentsTask(task, env); | |
}, | |
}; | |
// ============ Pages Deployment Cleaner ============= // | |
async function handleDeleteDeploymentsCron(env: Repeat.Env) { | |
const retentionConfig = (await env.storage.get<RetentionConfig>(CONFIG_STORAGE_PATH, 'json')).config; | |
if (!retentionConfig) { | |
throw new Error('No retention config in storage!'); | |
} | |
const now = Date.now(); | |
const taskQueue = new PQueue({ concurrency: 6 }); // Don't need to throttle this api at all | |
let jobCount = 0; | |
for (const groupName in retentionConfig) { | |
const groupConfig = retentionConfig[groupName]; | |
for (const projConfig of groupConfig.projects.filter(p => p.enabled)) { | |
const job: Job = { | |
project: projConfig.name, | |
days: groupConfig.days, | |
}; | |
const runAt = now + jobCount * TASK_INTERVAL_SECONDS * 1000; | |
taskQueue.add(async () => await env.unstable.tasks.add(job as any, { runAt })); | |
jobCount++; | |
} | |
} | |
await taskQueue.onIdle(); | |
} | |
async function handleDeleteDeploymentsTask(task: Repeat.Task, env: Repeat.Env) { | |
console.log(task.data); | |
const data = JSON.parse(task.data) as Job; | |
const endpoint = `${CF_API_BASE}/accounts/${env.variables.CLOUDFLARE_ACCOUNT_ID}/pages/projects/${data.project}/deployments`; | |
const expirationDays = data.days; | |
try { | |
const init = { | |
headers: { | |
'Content-Type': 'application/json;charset=UTF-8', | |
'Authorization': `Bearer ${env.variables.CLOUDFLARE_API_TOKEN}`, | |
}, | |
}; | |
let response: any; | |
let deployments: DeploymentsResponse; | |
// awaiting add() will wait until the passed in function resolves | |
await queue.add(async () => { | |
response = await fetch(endpoint, init); | |
}); | |
if (!response.ok) { | |
throw new Error( | |
`Failed to fetch deployments for '${data.project}', got ${response.status}: ${await response.text()}` | |
); | |
} | |
deployments = await response.json(); | |
const pages: number = Math.ceil(deployments.result_info.total_count / deployments.result_info.per_page); | |
console.log({ pages }); | |
for (let page = pages; page > 1; page--) { | |
console.log(page); | |
const pageOpts = `page=${page}&per_page=25`; // 25 is the default, but I like to be explicit :) | |
const sortOpts = 'sort_by=created_on&sort_order=desc'; // This should be the default, but just to be safe.. | |
const listDeploymentsEndpoint = `${endpoint}?${pageOpts}&${sortOpts}`; | |
await queue.add(async () => { | |
response = await fetch(listDeploymentsEndpoint, init); | |
}); | |
deployments = await response.json(); | |
if (deployments.result.length > 0) { | |
const hasDeploymentsBeforeExpiration = await clearOldDeploys( | |
deployments, | |
expirationDays, | |
env, | |
endpoint, | |
data.project | |
); | |
if (hasDeploymentsBeforeExpiration) { | |
break; // Some deployments were newer than expiration, so there shouldn't be any more pages to check | |
} | |
} | |
} | |
} catch (e) { | |
// log error | |
console.error('cron failed!', e.message); | |
} | |
} | |
/** | |
* @returns: Whether there were deployments newer than expiration (indicating we should stop) | |
*/ | |
async function clearOldDeploys( | |
deployments: DeploymentsResponse, | |
expirationDays: number, | |
env: Repeat.Env, | |
endpoint: string, | |
label: string | |
): Promise<boolean> { | |
let hasDeploymentsBeforeExpiration = false; | |
let count = 0; | |
for (const deployment of deployments.result) { | |
if ((Date.now() - new Date(deployment.created_on).getTime()) / 86400000 > expirationDays) { | |
// Delete the deployment | |
const forceDeleteOpt = `force=${DELETE_BRANCH_ALIASES}`; | |
const deleteEndpoint = `${endpoint}/${deployment.id}?${forceDeleteOpt}`; | |
queue.add(async () => { | |
const resp = await fetch(deleteEndpoint, { | |
method: 'DELETE', | |
headers: { | |
'Content-Type': 'application/json;charset=UTF-8', | |
'Authorization': `Bearer ${env.variables.CLOUDFLARE_API_TOKEN}`, | |
}, | |
}); | |
if (resp.status === 200) { | |
console.log(`deleted ${deployment.id} (${new Date(deployment.created_on).getTime()})`); | |
count += 1; | |
} else { | |
console.log(`${resp.status}: ${await resp.text()}`); | |
} | |
}); | |
} else { | |
hasDeploymentsBeforeExpiration = true; | |
} | |
} | |
await queue.onIdle(); // Wait until everything in the queue is done | |
if (count > 0) { | |
env.metrics.write('deployments_deleted', count, label); | |
} | |
return hasDeploymentsBeforeExpiration; | |
} | |
// ============ Update Projects config ============= // | |
// Note: I probably over-complicated the config file format. | |
// I just wanted the ability to set retention for an entire group | |
// of projects, rather than needing to specify it on every single one. | |
async function updateConfigFromPagesAccount(env: Repeat.Env): Promise<void> { | |
let retentionConfig: RetentionConfig; | |
const existing = await env.storage.get<RetentionConfig>(CONFIG_STORAGE_PATH, 'json'); | |
if (existing !== null) { | |
retentionConfig = existing; | |
} else { | |
retentionConfig = { config: {} }; | |
} | |
const projectConfigs = Object.entries(retentionConfig.config) | |
.map(([configName, config]) => flattenConfigs(configName, config.days, config.projects)) | |
.flat() | |
.map(project => project); | |
const projectConfigsMap = new Map<string, ProjectConfigWithGroup>(); | |
projectConfigs.forEach(p => projectConfigsMap.set(p.name, p)); | |
// Projects that actually exist | |
const liveProjects = await getAllProjects(env); | |
const liveProjectsMap = new Map<string, boolean>(); | |
liveProjects.forEach(p => liveProjectsMap.set(p.name, true)); | |
// Add default config for any that are missing | |
for (const [projName, _] of liveProjectsMap) { | |
if (!projectConfigsMap.has(projName)) { | |
const projConfig: ProjectConfigWithGroup = { | |
name: projName, | |
days: DEFAULT_RETENTION_DAYS, | |
enabled: ENABLED_BY_DEFAULT, | |
group: 'default', | |
}; | |
projectConfigsMap.set(projName, projConfig); | |
} | |
} | |
// Disable any projects that don't exist anymore | |
for (const [projName, projConfig] of projectConfigsMap) { | |
if (!liveProjectsMap.has(projName)) { | |
const disabledConfig: ProjectConfigWithGroup = { | |
...projConfig, | |
enabled: false, | |
}; | |
projectConfigsMap.set(projName, disabledConfig); | |
} | |
} | |
const newRetentionConfig = unflattenConfigs(projectConfigsMap); | |
// Save updated config to Storage | |
await env.storage.put(CONFIG_STORAGE_PATH, JSON.stringify(newRetentionConfig, null, 2), { | |
contentType: 'application/json', | |
}); | |
} | |
function flattenConfigs(group: string, days: number, projectConfigs: ProjectConfig[]): ProjectConfigWithGroup[] { | |
return projectConfigs.map(projConfig => { | |
return { | |
group, | |
days, | |
...projConfig, | |
}; | |
}); | |
} | |
function unflattenConfigs(projectConfigsMap: Map<string, ProjectConfigWithGroup>): RetentionConfig { | |
const projectConfigs: ProjectConfigWithGroup[] = []; | |
for (const [_, projConfig] of projectConfigsMap) { | |
projectConfigs.push(projConfig); | |
} | |
const retention: RetentionConfig = { config: {} }; | |
for (const projConfigWithGroup of projectConfigs) { | |
const group = projConfigWithGroup.group; | |
const days = projConfigWithGroup.days | |
const projConfig = { | |
name: projConfigWithGroup.name, | |
enabled: projConfigWithGroup.enabled, | |
}; | |
if (retention.config[group]) { | |
retention.config[group].projects.push(projConfig); | |
} else { | |
retention.config[group] = { | |
days: days, | |
projects: [projConfig], | |
}; | |
} | |
} | |
return retention; | |
} | |
async function getProjects(env: Repeat.Env, page: number, perPage: number): Promise<any> { | |
const res = await fetch( | |
`${CF_API_BASE}/accounts/${env.variables.CLOUDFLARE_ACCOUNT_ID}/pages/projects?page=${page}&per_page=${perPage}`, | |
{ | |
headers: { | |
Authorization: `Bearer ${env.variables.CLOUDFLARE_API_TOKEN}`, | |
}, | |
} | |
); | |
const body = (await res.json()) as any; | |
return body; | |
} | |
async function getAllProjects(env: Repeat.Env): Promise<GetAllProjectsResponse[]> { | |
// Listing is expensive, so let's throttle it a bit more | |
const listQueue = new ThrottledQueue({ | |
concurrency: 1, // Total concurrency limit (max=6 for Workers subrequests) | |
interval: 1000, // Interval to limit on (milliseconds) | |
limit: 1, // Limit to this many requests per the above interval | |
}); | |
const projects = []; | |
const perPage = 5 | |
let page = 1; | |
const data = await getProjects(env, page, perPage); | |
projects.push(...data.result); | |
while (projects.length < data.result_info.total_count) { | |
// listQueue has 1 concurrency, so may as well await it here for simplicity | |
await listQueue.add(async () => { | |
page++; | |
const data = await getProjects(env, page, perPage); | |
projects.push(...data.result); | |
}); | |
} | |
return projects as GetAllProjectsResponse[]; | |
} | |
// ============== TYPES ================ // | |
interface GetAllProjectsResponse { | |
id: string; | |
name: string; | |
} | |
interface DeploymentsResponse { | |
result: Deployment[]; | |
result_info: { | |
total_count: number; | |
per_page: number; | |
}; | |
} | |
interface Deployment { | |
id: string; | |
created_on: number; | |
} | |
interface RetentionConfig { | |
// string is the group name - projects are added to group "default" by default | |
config: Record<string, RetentionGroup>; | |
} | |
interface RetentionGroup { | |
days: number; | |
projects: ProjectConfig[]; | |
} | |
interface ProjectConfig { | |
name: string; | |
enabled: boolean; | |
} | |
// FLattened version of RetentionGroup | |
interface ProjectConfigWithGroup { | |
name: string; | |
enabled: boolean; | |
group: string; | |
days: number; | |
} | |
interface Job { | |
project: string; | |
days: number; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment