Skip to content

Instantly share code, notes, and snippets.

@jahands
Forked from Erisa/repeat.ts
Last active January 7, 2023 21:20
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jahands/c68b4786d1cea696e61714354c1d3019 to your computer and use it in GitHub Desktop.
Save jahands/c68b4786d1cea696e61714354c1d3019 to your computer and use it in GitHub Desktop.
Repeat for clearing out Pages deployments (https://repeat.dev)
// 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