Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions src/pages/api/v1/admin/ttl/cleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/**
* Copyright 2025 GoodRx, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { NextApiRequest, NextApiResponse } from 'next';
import rootLogger from 'server/lib/logger';
import GlobalConfigService from 'server/services/globalConfig';
import TTLCleanupService from 'server/services/ttlCleanup';

const logger = rootLogger.child({
filename: 'v1/admin/ttl/cleanup.ts',
});

/**
* @openapi
* /api/v1/admin/ttl/cleanup:
* get:
* summary: Get TTL cleanup configuration
* description: Retrieves the current TTL cleanup configuration from global config
* tags:
* - Admin
* - TTL Cleanup
* responses:
* 200:
* description: Successfully retrieved TTL cleanup configuration
* content:
* application/json:
* schema:
* type: object
* properties:
* config:
* type: object
* properties:
* enabled:
* type: boolean
* description: Whether TTL cleanup is enabled
* dryRun:
* type: boolean
* description: Whether cleanup runs in dry-run mode
* inactivityDays:
* type: number
* description: Number of days of inactivity before cleanup
* checkIntervalMinutes:
* type: number
* description: How often cleanup job runs (in minutes)
* commentTemplate:
* type: string
* description: Template for PR comments
* excludedRepositories:
* type: array
* items:
* type: string
* description: List of repositories excluded from cleanup
* 405:
* description: Method not allowed
* content:
* application/json:
* schema:
* type: object
* properties:
* error:
* type: string
* example: DELETE is not allowed.
* 500:
* description: Internal server error
* content:
* application/json:
* schema:
* type: object
* properties:
* error:
* type: string
* example: Unable to retrieve TTL cleanup configuration
* post:
* summary: Manually trigger TTL cleanup
* description: Manually triggers a TTL cleanup job with optional configuration override
* tags:
* - Admin
* - TTL Cleanup
* requestBody:
* required: false
* content:
* application/json:
* schema:
* type: object
* properties:
* dryRun:
* type: boolean
* description: Override dry-run mode for this execution (optional)
* responses:
* 200:
* description: Successfully triggered TTL cleanup job
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: TTL cleanup job triggered successfully
* jobId:
* type: string
* description: The ID of the queued job
* dryRun:
* type: boolean
* description: Whether this job will run in dry-run mode
* 400:
* description: Bad request - invalid parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* error:
* type: string
* example: dryRun must be a boolean value
* 405:
* description: Method not allowed
* content:
* application/json:
* schema:
* type: object
* properties:
* error:
* type: string
* example: DELETE is not allowed.
* 500:
* description: Internal server error
* content:
* application/json:
* schema:
* type: object
* properties:
* error:
* type: string
* example: Unable to trigger TTL cleanup job
*/
// eslint-disable-next-line import/no-anonymous-default-export
export default async (req: NextApiRequest, res: NextApiResponse) => {
try {
switch (req.method) {
case 'GET':
return getTTLConfig(res);
case 'POST':
return triggerTTLCleanup(req, res);
default:
res.setHeader('Allow', ['GET', 'POST']);
return res.status(405).json({ error: `${req.method} is not allowed.` });
}
} catch (error) {
logger.error(`Error occurred on TTL cleanup operation: \n ${error}`);
res.status(500).json({ error: 'An unexpected error occurred.' });
}
};

async function getTTLConfig(res: NextApiResponse) {
try {
const configService = GlobalConfigService.getInstance();
const globalConfig = await configService.getAllConfigs();
const ttlConfig = globalConfig.ttl_cleanup;

if (!ttlConfig) {
logger.warn('[API] TTL cleanup configuration not found in global config');
return res.status(404).json({ error: 'TTL cleanup configuration not found' });
}

return res.status(200).json({ config: ttlConfig });
} catch (error) {
logger.error(`[API] Error occurred retrieving TTL cleanup config: \n ${error}`);
return res.status(500).json({ error: 'Unable to retrieve TTL cleanup configuration' });
}
}

async function triggerTTLCleanup(req: NextApiRequest, res: NextApiResponse) {
try {
const { dryRun = false } = req.body || {};

// Validate dryRun parameter type
if (typeof dryRun !== 'boolean') {
return res.status(400).json({ error: 'dryRun must be a boolean value' });
}

// Create new service instance and add job to queue
const ttlCleanupService = new TTLCleanupService();
const job = await ttlCleanupService.ttlCleanupQueue.add('manual-ttl-cleanup', { dryRun });

logger.info(`[API] TTL cleanup job triggered manually (job ID: ${job.id}, dryRun: ${dryRun})`);

return res.status(200).json({
message: 'TTL cleanup job triggered successfully',
jobId: job.id,
dryRun,
});
} catch (error) {
logger.error(`[API] Error occurred triggering TTL cleanup: \n ${error}`);
return res.status(500).json({ error: 'Unable to trigger TTL cleanup job' });
}
}
81 changes: 81 additions & 0 deletions src/server/db/migrations/006_add_ttl_cleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Copyright 2025 GoodRx, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { Knex } from 'knex';

export async function up(knex: Knex): Promise<any> {
const existingLabels = await knex('global_config').where('key', 'labels').first();

const defaultLabelsConfig = {
deploy: ['lifecycle-deploy!'],
disabled: ['lifecycle-disabled!'],
keep: ['lifecycle-keep!'],
statusComments: ['lifecycle-status-comments!'],
defaultStatusComments: true,
defaultControlComments: true,
};

if (existingLabels) {
const mergedConfig = { ...defaultLabelsConfig, ...existingLabels.config };

await knex('global_config').where('key', 'labels').update({
config: mergedConfig,
updatedAt: knex.fn.now(),
});
} else {
await knex('global_config').insert({
key: 'labels',
config: defaultLabelsConfig,
createdAt: knex.fn.now(),
updatedAt: knex.fn.now(),
deletedAt: null,
description: 'Configurable PR labels for deploy, disabled, keep, and status comments',
});
}

await knex('global_config').insert({
key: 'ttl_cleanup',
config: {
enabled: false,
dryRun: true,
inactivityDays: 14,
checkIntervalMinutes: 240,
commentTemplate:
'This environment has been inactive for {inactivityDays} days and will be automatically cleaned up. Add the {keepLabel} label to prevent cleanup.',
excludedRepositories: [],
},
createdAt: knex.fn.now(),
updatedAt: knex.fn.now(),
deletedAt: null,
description:
'TTL-based automatic cleanup configuration for inactive PR environments. Set enabled to true to activate cleanup (starts in dryRun mode). Environments with the keep label are excluded.',
});
}

export async function down(knex: Knex): Promise<any> {
const existingLabels = await knex('global_config').where('key', 'labels').first();

if (existingLabels && existingLabels.config?.keep) {
const { keep: _keep, ...restConfig } = existingLabels.config;

await knex('global_config').where('key', 'labels').update({
config: restConfig,
updatedAt: knex.fn.now(),
});
}

await knex('global_config').where('key', 'ttl_cleanup').delete();
}
8 changes: 8 additions & 0 deletions src/server/jobs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ export default function bootstrapJobs(services: IServices) {
concurrency: 1,
});

/* Setup TTL cleanup job */
services.TTLCleanupService.setupTTLCleanupJob();

queueManager.registerWorker(QUEUE_NAMES.TTL_CLEANUP, services.TTLCleanupService.processTTLCleanupQueue, {
connection: redisClient.getConnection(),
concurrency: 1,
});

services.PullRequest.cleanupClosedPRQueue.add('cleanup', {}, {});

queueManager.registerWorker(QUEUE_NAMES.INGRESS_MANIFEST, services.Ingress.createOrUpdateIngressForBuild, {
Expand Down
8 changes: 8 additions & 0 deletions src/server/lib/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ jest.mock('server/services/globalConfig', () => {
getLabels: jest.fn().mockResolvedValue({
deploy: ['lifecycle-deploy!', 'custom-deploy!'],
disabled: ['lifecycle-disabled!', 'no-deploy!'],
keep: ['lifecycle-keep!'],
statusComments: ['lifecycle-status-comments!', 'show-status!'],
defaultStatusComments: true,
defaultControlComments: true,
Expand Down Expand Up @@ -355,6 +356,7 @@ describe('hasDeployLabel', () => {
const mockService = GlobalConfigService.getInstance() as jest.Mocked<GlobalConfigService>;
mockService.getLabels.mockResolvedValueOnce({
disabled: ['lifecycle-disabled!'],
keep: ['lifecycle-keep!'],
statusComments: ['lifecycle-status-comments!'],
defaultStatusComments: true,
} as any);
Expand All @@ -367,6 +369,7 @@ describe('hasDeployLabel', () => {
mockService.getLabels.mockResolvedValueOnce({
deploy: [],
disabled: ['lifecycle-disabled!'],
keep: ['lifecycle-keep!'],
statusComments: ['lifecycle-status-comments!'],
defaultStatusComments: true,
defaultControlComments: true,
Expand Down Expand Up @@ -433,6 +436,7 @@ describe('getDeployLabel', () => {
const mockService = GlobalConfigService.getInstance() as jest.Mocked<GlobalConfigService>;
mockService.getLabels.mockResolvedValueOnce({
disabled: ['lifecycle-disabled!'],
keep: ['lifecycle-keep!'],
statusComments: ['lifecycle-status-comments!'],
defaultStatusComments: true,
} as any);
Expand All @@ -445,6 +449,7 @@ describe('getDeployLabel', () => {
mockService.getLabels.mockResolvedValueOnce({
deploy: [],
disabled: ['lifecycle-disabled!'],
keep: ['lifecycle-keep!'],
statusComments: ['lifecycle-status-comments!'],
defaultStatusComments: true,
defaultControlComments: true,
Expand Down Expand Up @@ -494,6 +499,7 @@ describe('isDefaultStatusCommentsEnabled', () => {
mockService.getLabels.mockResolvedValueOnce({
deploy: ['lifecycle-deploy!'],
disabled: ['lifecycle-disabled!'],
keep: ['lifecycle-keep!'],
statusComments: ['lifecycle-status-comments!'],
} as any);
const result = await isDefaultStatusCommentsEnabled();
Expand All @@ -519,6 +525,7 @@ describe('isControlCommentsEnabled', () => {
mockService.getLabels.mockResolvedValueOnce({
deploy: ['lifecycle-deploy!'],
disabled: ['lifecycle-disabled!'],
keep: ['lifecycle-keep!'],
statusComments: ['lifecycle-status-comments!'],
defaultStatusComments: true,
} as any);
Expand All @@ -532,6 +539,7 @@ describe('isControlCommentsEnabled', () => {
mockService.getLabels.mockResolvedValueOnce({
deploy: ['lifecycle-deploy!'],
disabled: ['lifecycle-disabled!'],
keep: ['lifecycle-keep!'],
statusComments: ['lifecycle-status-comments!'],
defaultStatusComments: true,
defaultControlComments: false,
Expand Down
Loading