diff --git a/controlplane/package.json b/controlplane/package.json index 936a848baa..211d758e06 100644 --- a/controlplane/package.json +++ b/controlplane/package.json @@ -65,7 +65,7 @@ "@wundergraph/protographic": "workspace:*", "axios": "1.13.5", "axios-retry": "^4.5.0", - "bullmq": "^5.10.0", + "bullmq": "5.66.4", "cookie": "^0.7.2", "date-fns": "^3.6.0", "dotenv": "^16.4.5", @@ -80,7 +80,7 @@ "graphql": "^16.9.0", "http-proxy-agent": "8.0.0", "https-proxy-agent": "8.0.0", - "ioredis": "^5.4.1", + "ioredis": "5.8.2", "isomorphic-dompurify": "^2.33.0", "jose": "^5.2.4", "lodash": "^4.17.21", diff --git a/controlplane/src/bin/delete-inactive-orgs.ts b/controlplane/src/bin/delete-inactive-orgs.ts new file mode 100644 index 0000000000..7eb3f7b74b --- /dev/null +++ b/controlplane/src/bin/delete-inactive-orgs.ts @@ -0,0 +1,184 @@ +import 'dotenv/config'; +import process from 'node:process'; +import { and, count, eq, gte, isNull, lt, or, sql } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import { pino } from 'pino'; +import { addDays, startOfMonth, subDays } from 'date-fns'; +import * as schema from '../db/schema.js'; +import { buildDatabaseConnectionConfig } from '../core/plugins/database.js'; +import { createRedisConnections } from '../core/plugins/redis.js'; +import { OrganizationRepository } from '../core/repositories/OrganizationRepository.js'; +import { DeleteOrganizationQueue } from '../core/workers/DeleteOrganizationWorker.js'; +import { NotifyOrganizationDeletionQueuedQueue } from '../core/workers/NotifyOrganizationDeletionQueuedWorker.js'; +import Keycloak from '../core/services/Keycloak.js'; +import { getConfig } from './get-config.js'; + +// The number of days the organization needs to be inactive for before we consider it for deletion +const MIN_INACTIVITY_DAYS = 90; + +// How long should we wait before deleting the organization? +const DELAY_FOR_ORG_DELETION_IN_DAYS = 7; + +const { + realm, + loginRealm, + apiUrl, + adminUser, + adminPassword, + clientId, + databaseConnectionUrl, + databaseTlsCa, + databaseTlsCert, + databaseTlsKey, + redis, +} = getConfig(); + +// Create the redis connection. +const { redisQueue, redisWorker } = await createRedisConnections({ + host: redis.host!, + port: Number(redis.port), + password: redis.password, + tls: redis.tls, +}); + +// Create the database connection. TLS is optional. +const connectionConfig = await buildDatabaseConnectionConfig({ + tls: + databaseTlsCa || databaseTlsCert || databaseTlsKey + ? { ca: databaseTlsCa, cert: databaseTlsCert, key: databaseTlsKey } + : undefined, +}); + +const queryConnection = postgres(databaseConnectionUrl, { ...connectionConfig }); + +// Initialize all required services +const logger = pino(); +const db = drizzle(queryConnection, { schema: { ...schema } }); +const keycloak = new Keycloak({ + apiUrl, + realm: loginRealm, + clientId, + adminUser, + adminPassword, + logger: pino(), +}); + +const orgRepo = new OrganizationRepository(logger, db); +const deleteOrganizationQueue = new DeleteOrganizationQueue(logger, redisQueue); +const notifyOrganizationDeletionQueuedQueue = new NotifyOrganizationDeletionQueuedQueue(logger, redisQueue); + +// Do the work! +try { + const now = new Date(); + const inactivityThreshold = startOfMonth(subDays(now, MIN_INACTIVITY_DAYS)); + const deletesAt = addDays(now, DELAY_FOR_ORG_DELETION_IN_DAYS); + + // Retrieve all the organizations that only have a single user + const orgsWithSingleUser = await retrieveOrganizationsWithSingleUser(inactivityThreshold); + if (orgsWithSingleUser.length === 0) { + console.log('No organizations with single user found'); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); + } + + // Process all the organizations with a single user + await keycloak.authenticateClient(); + for (const org of orgsWithSingleUser) { + if (!org.userId) { + // Should never be the case but to prevent TypeScript from complaining, we still need to ensure + // that the value exists + continue; + } + + // First, we check whether the organization has had any activity registered in the audit logs in the + // last `MIN_INACTIVITY_DAYS` days + const auditLogs = await db + .select({ count: count() }) + .from(schema.auditLogs) + .where(and(eq(schema.auditLogs.organizationId, org.id), gte(schema.auditLogs.createdAt, inactivityThreshold))) + .execute(); + + if (auditLogs.length > 0 && auditLogs[0].count > 0) { + // The organization has had activity registered in the audit, at least once in the last `MIN_INACTIVITY_DAYS` days, + // so we don't need to consider it for deletion + continue; + } + + // If the organization hasn't had any activity, we should check the last time the user logged in + try { + const userSessions = await keycloak.client.users.listSessions({ + id: org.userId, + realm, + }); + + const numberOfSessionsRecentlyActive = userSessions.filter( + (sess) => (sess.lastAccess || sess.start) && new Date(sess.lastAccess || sess.start!) >= inactivityThreshold, + ).length; + + if (numberOfSessionsRecentlyActive > 0) { + // The user has been active at least once in the last `MIN_INACTIVITY_DAYS` days, so we don't need + // to consider it for deletion + continue; + } + } catch (error) { + // Failed to fetch the user sessions, skip for now + console.error(error, `Failed to retrieve sessions for user: ${org.userId}`); + continue; + } + + // It seems like the organization (and the user) hasn't been active recently, flag the organization for deletion + console.log(`Queuing organization "${org.slug}" for deletion at ${deletesAt.toISOString()}`); + await queueForDeletion(org.id, now, deletesAt); + } +} catch (err: unknown) { + console.error(err); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} finally { + redisQueue.disconnect(); + redisWorker.disconnect(); + + await queryConnection.end({ timeout: 1 }); +} + +async function queueForDeletion(orgId: string, queuedAt: Date, deletesAt: Date) { + // Enqueue the organization deletion job + await orgRepo.queueOrganizationDeletion({ + organizationId: orgId, + queuedBy: undefined, + deleteOrganizationQueue, + deleteDelayInDays: DELAY_FOR_ORG_DELETION_IN_DAYS, + }); + + // Queue the organization deletion notification job + await notifyOrganizationDeletionQueuedQueue.addJob({ + organizationId: orgId, + queuedAt: Number(queuedAt), + deletesAt: Number(deletesAt), + }); +} + +function retrieveOrganizationsWithSingleUser(createdBefore: Date) { + return db + .select({ + id: schema.organizations.id, + slug: schema.organizations.slug, + userId: schema.organizations.createdBy, + plan: schema.organizationBilling.plan, + }) + .from(schema.organizations) + .innerJoin(schema.organizationsMembers, eq(schema.organizationsMembers.organizationId, schema.organizations.id)) + .leftJoin(schema.organizationBilling, eq(schema.organizationBilling.organizationId, schema.organizations.id)) + .where( + and( + isNull(schema.organizations.queuedForDeletionAt), + eq(schema.organizations.isDeactivated, false), + lt(schema.organizations.createdAt, createdBefore), + or(isNull(schema.organizationBilling.plan), eq(schema.organizationBilling.plan, 'developer')), + ), + ) + .groupBy(schema.organizations.id, schema.organizationBilling.plan) + .having(sql`COUNT(${schema.organizationsMembers.id}) = 1`) + .execute(); +} diff --git a/controlplane/src/core/bufservices/organization/deleteOrganization.ts b/controlplane/src/core/bufservices/organization/deleteOrganization.ts index f9b36b759f..81a9881f96 100644 --- a/controlplane/src/core/bufservices/organization/deleteOrganization.ts +++ b/controlplane/src/core/bufservices/organization/deleteOrganization.ts @@ -25,116 +25,118 @@ export function deleteOrganization( const authContext = await opts.authenticator.authenticate(ctx.requestHeader); logger = enrichLogger(ctx, logger, authContext); - const orgRepo = new OrganizationRepository(logger, opts.db, opts.billingDefaultPlanId); - const auditLogRepo = new AuditLogRepository(opts.db); - const billingRepo = new BillingRepository(opts.db); - - const memberships = await orgRepo.memberships({ userId: authContext.userId }); - const orgCount = memberships.length; - - const org = await orgRepo.byId(authContext.organizationId); - if (!org) { - return { - response: { - code: EnumStatusCode.ERR_NOT_FOUND, - details: `Organization not found`, - }, - }; - } - - const subscription = await billingRepo.getActiveSubscriptionOfOrganization(org.id); - if (subscription?.id) { - return { - response: { - code: EnumStatusCode.ERR, - details: 'The organization subscription must be cancelled before the organization is deleted.', - }, - }; - } - - const user = await orgRepo.getOrganizationMember({ - organizationID: authContext.organizationId, - userID: authContext.userId || req.userID, - }); + return opts.db.transaction(async (tx) => { + const orgRepo = new OrganizationRepository(logger, tx, opts.billingDefaultPlanId); + const auditLogRepo = new AuditLogRepository(tx); + const billingRepo = new BillingRepository(tx); + + const memberships = await orgRepo.memberships({ userId: authContext.userId }); + const orgCount = memberships.length; + + const org = await orgRepo.byId(authContext.organizationId); + if (!org) { + return { + response: { + code: EnumStatusCode.ERR_NOT_FOUND, + details: `Organization not found`, + }, + }; + } + + const subscription = await billingRepo.getActiveSubscriptionOfOrganization(org.id); + if (subscription?.id) { + return { + response: { + code: EnumStatusCode.ERR, + details: 'The organization subscription must be cancelled before the organization is deleted.', + }, + }; + } + + const user = await orgRepo.getOrganizationMember({ + organizationID: authContext.organizationId, + userID: authContext.userId || req.userID, + }); - if (!user) { - return { - response: { - code: EnumStatusCode.ERR, - details: 'User is not a part of this organization.', - }, - }; - } + if (!user) { + return { + response: { + code: EnumStatusCode.ERR, + details: 'User is not a part of this organization.', + }, + }; + } + + // non admins cannot delete the organization + if (!user.rbac.isOrganizationAdmin) { + throw new UnauthorizedError(); + } + + // Minimum one organization is required for a user + if (orgCount <= 1) { + return { + response: { + code: EnumStatusCode.ERR_NOT_FOUND, + details: 'Minimum one organization is required for a user.', + }, + }; + } + + // If the organization deletion have already been queued we shouldn't do it again + if (org.deletion) { + return { + response: { + code: EnumStatusCode.OK, + }, + }; + } + + const organizationMembers = await orgRepo.getMembers({ organizationID: org.id }); + const orgAdmins = organizationMembers.filter((m) => m.rbac.isOrganizationAdmin); + + const now = new Date(); + const oneMonthFromNow = addDays(now, delayForManualOrgDeletionInDays); + + await orgRepo.queueOrganizationDeletion({ + organizationId: org.id, + queuedBy: authContext.userDisplayName, + deleteOrganizationQueue: opts.queues.deleteOrganizationQueue, + }); - // non admins cannot delete the organization - if (!user.rbac.isOrganizationAdmin) { - throw new UnauthorizedError(); - } + await auditLogRepo.addAuditLog({ + organizationId: org.id, + organizationSlug: authContext.organizationSlug, + auditAction: 'organization.deletion_queued', + action: 'queued_deletion', + actorId: authContext.userId, + auditableType: 'organization', + auditableDisplayName: org.name, + actorDisplayName: authContext.userDisplayName, + apiKeyName: authContext.apiKeyName, + actorType: authContext.auth === 'api_key' ? 'api_key' : 'user', + }); - // Minimum one organization is required for a user - if (orgCount <= 1) { - return { - response: { - code: EnumStatusCode.ERR_NOT_FOUND, - details: 'Minimum one organization is required for a user.', - }, - }; - } + if (opts.mailerClient && orgAdmins.length > 0) { + const intl = Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }); + + await opts.mailerClient.sendOrganizationDeletionQueuedEmail({ + receiverEmails: orgAdmins.map((m) => m.email), + organizationName: org.name, + userDisplayName: authContext.userDisplayName, + queuedOnDate: intl.format(now), + deletionDate: intl.format(oneMonthFromNow), + restoreLink: `${process.env.WEB_BASE_URL}/${org.slug}/settings`, + }); + } - // If the organization deletion have already been queued we shouldn't do it again - if (org.deletion) { return { response: { code: EnumStatusCode.OK, }, }; - } - - const organizationMembers = await orgRepo.getMembers({ organizationID: org.id }); - const orgAdmins = organizationMembers.filter((m) => m.rbac.isOrganizationAdmin); - - const now = new Date(); - const oneMonthFromNow = addDays(now, delayForManualOrgDeletionInDays); - - await orgRepo.queueOrganizationDeletion({ - organizationId: org.id, - queuedBy: authContext.userDisplayName, - deleteOrganizationQueue: opts.queues.deleteOrganizationQueue, - }); - - await auditLogRepo.addAuditLog({ - organizationId: org.id, - organizationSlug: authContext.organizationSlug, - auditAction: 'organization.deletion_queued', - action: 'queued_deletion', - actorId: authContext.userId, - auditableType: 'organization', - auditableDisplayName: org.name, - actorDisplayName: authContext.userDisplayName, - apiKeyName: authContext.apiKeyName, - actorType: authContext.auth === 'api_key' ? 'api_key' : 'user', }); - - if (opts.mailerClient && orgAdmins.length > 0) { - const intl = Intl.DateTimeFormat(undefined, { - dateStyle: 'medium', - timeStyle: 'short', - }); - - await opts.mailerClient.sendOrganizationDeletionQueuedEmail({ - receiverEmails: orgAdmins.map((m) => m.email), - organizationName: org.name, - userDisplayName: authContext.userDisplayName, - queuedOnDate: intl.format(now), - deletionDate: intl.format(oneMonthFromNow), - restoreLink: `${process.env.WEB_BASE_URL}/${org.slug}/settings`, - }); - } - - return { - response: { - code: EnumStatusCode.OK, - }, - }; }); } diff --git a/controlplane/src/core/build-server.ts b/controlplane/src/core/build-server.ts index cfc5ab6e3f..ded425e459 100644 --- a/controlplane/src/core/build-server.ts +++ b/controlplane/src/core/build-server.ts @@ -11,7 +11,7 @@ import { App } from 'octokit'; import { Worker } from 'bullmq'; import routes from './routes.js'; import fastifyHealth from './plugins/health.js'; -import fastifyMetrics, { MetricsPluginOptions } from './plugins/metrics.js'; +import fastifyMetrics from './plugins/metrics.js'; import fastifyDatabase from './plugins/database.js'; import fastifyClickHouse from './plugins/clickhouse.js'; import fastifyRedis from './plugins/redis.js'; @@ -60,6 +60,7 @@ import { createReactivateOrganizationWorker, ReactivateOrganizationQueue, } from './workers/ReactivateOrganizationWorker.js'; +import { createNotifyOrganizationDeletionQueuedWorker } from './workers/NotifyOrganizationDeletionQueuedWorker.js'; import { configureComposeGraphsPool, destroyComposeGraphsPool } from './composition/composeGraphs.pool.js'; export interface BuildConfig { @@ -463,6 +464,15 @@ export default async function build(opts: BuildConfig) { }), ); + bullWorkers.push( + createNotifyOrganizationDeletionQueuedWorker({ + redisConnection: fastify.redisForWorker, + db: fastify.db, + logger, + mailer: mailerClient, + }), + ); + // required to verify webhook payloads await fastify.register(import('fastify-raw-body'), { field: 'rawBody', diff --git a/controlplane/src/core/repositories/OrganizationRepository.ts b/controlplane/src/core/repositories/OrganizationRepository.ts index c132f647e7..a5e4f4191a 100644 --- a/controlplane/src/core/repositories/OrganizationRepository.ts +++ b/controlplane/src/core/repositories/OrganizationRepository.ts @@ -960,33 +960,32 @@ export class OrganizationRepository { return result[0]; } - public queueOrganizationDeletion(input: { + public async queueOrganizationDeletion(input: { organizationId: string; queuedBy?: string; deleteOrganizationQueue: DeleteOrganizationQueue; + deleteDelayInDays?: number; }) { - return this.db.transaction(async (tx) => { - const now = new Date(); - await tx - .update(schema.organizations) - .set({ - queuedForDeletionAt: now, - queuedForDeletionBy: input.queuedBy, - }) - .where(eq(schema.organizations.id, input.organizationId)); + const now = new Date(); + await this.db + .update(schema.organizations) + .set({ + queuedForDeletionAt: now, + queuedForDeletionBy: input.queuedBy, + }) + .where(eq(schema.organizations.id, input.organizationId)); - const deleteAt = addDays(now, delayForManualOrgDeletionInDays); - const delay = Number(deleteAt) - Number(now); + const deleteAt = addDays(now, input.deleteDelayInDays || delayForManualOrgDeletionInDays); + const delay = Number(deleteAt) - Number(now); - return await input.deleteOrganizationQueue.addJob( - { - organizationId: input.organizationId, - }, - { - delay, - }, - ); - }); + return await input.deleteOrganizationQueue.addJob( + { + organizationId: input.organizationId, + }, + { + delay, + }, + ); } public restoreOrganization(input: { organizationId: string; deleteOrganizationQueue: DeleteOrganizationQueue }) { diff --git a/controlplane/src/core/workers/CacheWarmerWorker.ts b/controlplane/src/core/workers/CacheWarmerWorker.ts index cb170f77e0..6e5edfd6ec 100644 --- a/controlplane/src/core/workers/CacheWarmerWorker.ts +++ b/controlplane/src/core/workers/CacheWarmerWorker.ts @@ -131,7 +131,7 @@ export const createCacheWarmerWorker = (input: { concurrency: 10, }); worker.on('stalled', (job) => { - log.warn({ joinId: job }, `Job stalled`); + log.warn({ jobId: job }, `Job stalled`); }); worker.on('error', (err) => { log.error(err, 'Worker error'); diff --git a/controlplane/src/core/workers/DeactivateOrganizationWorker.ts b/controlplane/src/core/workers/DeactivateOrganizationWorker.ts index d1ee93cb3e..b403ea7034 100644 --- a/controlplane/src/core/workers/DeactivateOrganizationWorker.ts +++ b/controlplane/src/core/workers/DeactivateOrganizationWorker.ts @@ -125,7 +125,7 @@ export const createDeactivateOrganizationWorker = (input: { }, ); worker.on('stalled', (job) => { - log.warn({ joinId: job }, `Job stalled`); + log.warn({ jobId: job }, `Job stalled`); }); worker.on('error', (err) => { log.error(err, 'Worker error'); diff --git a/controlplane/src/core/workers/DeleteOrganizationAuditLogsWorker.ts b/controlplane/src/core/workers/DeleteOrganizationAuditLogsWorker.ts index 77465438c4..3f158bc01c 100644 --- a/controlplane/src/core/workers/DeleteOrganizationAuditLogsWorker.ts +++ b/controlplane/src/core/workers/DeleteOrganizationAuditLogsWorker.ts @@ -99,7 +99,7 @@ export const createDeleteOrganizationAuditLogsWorker = (input: { ); worker.on('stalled', (job) => { - log.warn({ joinId: job }, 'Job stalled'); + log.warn({ jobId: job }, 'Job stalled'); }); worker.on('error', (err) => { log.error(err, 'Worker error'); diff --git a/controlplane/src/core/workers/DeleteOrganizationWorker.ts b/controlplane/src/core/workers/DeleteOrganizationWorker.ts index 2846b1a0d0..182ec6c76c 100644 --- a/controlplane/src/core/workers/DeleteOrganizationWorker.ts +++ b/controlplane/src/core/workers/DeleteOrganizationWorker.ts @@ -151,7 +151,7 @@ export const createDeleteOrganizationWorker = (input: { }, ); worker.on('stalled', (job) => { - log.warn({ joinId: job }, `Job stalled`); + log.warn({ jobId: job }, `Job stalled`); }); worker.on('error', (err) => { log.error(err, 'Worker error'); diff --git a/controlplane/src/core/workers/DeleteUserQueue.ts b/controlplane/src/core/workers/DeleteUserQueue.ts index e03e29a35b..0b5634c5e5 100644 --- a/controlplane/src/core/workers/DeleteUserQueue.ts +++ b/controlplane/src/core/workers/DeleteUserQueue.ts @@ -140,7 +140,7 @@ export const createDeleteUserWorker = (input: { concurrency: 10, }); worker.on('stalled', (job) => { - log.warn({ joinId: job }, `Job stalled`); + log.warn({ jobId: job }, `Job stalled`); }); worker.on('error', (err) => { log.error(err, 'Worker error'); diff --git a/controlplane/src/core/workers/NotifyOrganizationDeletionQueuedWorker.ts b/controlplane/src/core/workers/NotifyOrganizationDeletionQueuedWorker.ts new file mode 100644 index 0000000000..953f791dcc --- /dev/null +++ b/controlplane/src/core/workers/NotifyOrganizationDeletionQueuedWorker.ts @@ -0,0 +1,139 @@ +import { ConnectionOptions, Job, JobsOptions, Queue, Worker } from 'bullmq'; +import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import pino from 'pino'; +import * as schema from '../../db/schema.js'; +import Mailer from '../services/Mailer.js'; +import { OrganizationRepository } from '../repositories/OrganizationRepository.js'; +import { IQueue, IWorker } from './Worker.js'; + +const QueueName = 'organization.notify-organization-deletion-queued-queue'; +const WorkerName = 'NotifyOrganizationDeletionQueuedQueue'; + +export interface NotifyOrganizationDeletionQueuedInput { + organizationId: string; + queuedAt: number; + deletesAt: number; +} + +export class NotifyOrganizationDeletionQueuedQueue implements IQueue { + private readonly queue: Queue; + private readonly logger: pino.Logger; + + constructor(log: pino.Logger, conn: ConnectionOptions) { + this.logger = log.child({ queue: QueueName }); + this.queue = new Queue(QueueName, { + connection: conn, + defaultJobOptions: { + removeOnComplete: { + age: 90 * 86_400, + }, + removeOnFail: { + age: 90 * 86_400, + }, + attempts: 6, + backoff: { + type: 'exponential', + delay: 112_000, + }, + }, + }); + + this.queue.on('error', (err) => { + this.logger.error(err, 'Queue error'); + }); + } + + public addJob(job: NotifyOrganizationDeletionQueuedInput, opts?: Omit) { + return this.queue.add(job.organizationId, job, { + ...opts, + jobId: job.organizationId, + }); + } + + public removeJob(job: NotifyOrganizationDeletionQueuedInput) { + return this.queue.remove(job.organizationId); + } + + public getJob(job: NotifyOrganizationDeletionQueuedInput) { + return this.queue.getJob(job.organizationId); + } +} + +class NotifyOrganizationDeletionQueuedWorker implements IWorker { + constructor( + private input: { + redisConnection: ConnectionOptions; + db: PostgresJsDatabase; + logger: pino.Logger; + mailer: Mailer | undefined; + }, + ) { + this.input.logger = input.logger.child({ worker: WorkerName }); + } + + public async handler(job: Job) { + try { + if (!this.input.mailer) { + throw new Error('Mailer service not configured'); + } + + const orgRepo = new OrganizationRepository(this.input.logger, this.input.db); + const org = await orgRepo.byId(job.data.organizationId); + if (!org) { + // The organization has already been deleted + return; + } + + const organizationMembers = await orgRepo.getMembers({ organizationID: org.id }); + const orgAdmins = organizationMembers.filter((m) => m.rbac.isOrganizationAdmin); + if (orgAdmins.length === 0) { + this.input.logger.warn({ organizationId: org.id }, 'No admins found for organization, skipping notification'); + return; + } + + const intl = Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }); + + await this.input.mailer.sendOrganizationDeletionQueuedEmail({ + receiverEmails: orgAdmins.map((m) => m.email), + organizationName: org.name, + userDisplayName: 'System', + queuedOnDate: intl.format(new Date(job.data.queuedAt)), + deletionDate: intl.format(new Date(job.data.deletesAt)), + restoreLink: `${process.env.WEB_BASE_URL}/${org.slug}/settings`, + }); + } catch (err) { + this.input.logger.error( + { jobId: job.id, organizationId: job.data.organizationId, err }, + `Failed to send organization deletion queued notification`, + ); + throw err; + } + } +} + +export const createNotifyOrganizationDeletionQueuedWorker = (input: { + redisConnection: ConnectionOptions; + db: PostgresJsDatabase; + logger: pino.Logger; + mailer: Mailer | undefined; +}) => { + const log = input.logger.child({ worker: WorkerName }); + const worker = new Worker( + QueueName, + (job) => new NotifyOrganizationDeletionQueuedWorker(input).handler(job), + { + connection: input.redisConnection, + concurrency: 100, + }, + ); + worker.on('stalled', (job) => { + log.warn({ jobId: job }, `Job stalled`); + }); + worker.on('error', (err) => { + log.error(err, 'Worker error'); + }); + return worker; +}; diff --git a/controlplane/src/core/workers/ReactivateOrganizationWorker.ts b/controlplane/src/core/workers/ReactivateOrganizationWorker.ts index 24c20fdf1a..a6c5ffacbc 100644 --- a/controlplane/src/core/workers/ReactivateOrganizationWorker.ts +++ b/controlplane/src/core/workers/ReactivateOrganizationWorker.ts @@ -113,7 +113,7 @@ export const createReactivateOrganizationWorker = (input: { }, ); worker.on('stalled', (job) => { - log.warn({ joinId: job }, `Job stalled`); + log.warn({ jobId: job }, `Job stalled`); }); worker.on('error', (err) => { log.error(err, 'Worker error'); diff --git a/helm/cosmo/charts/controlplane/README.md b/helm/cosmo/charts/controlplane/README.md index b526e9e193..665f6b1edd 100644 --- a/helm/cosmo/charts/controlplane/README.md +++ b/helm/cosmo/charts/controlplane/README.md @@ -81,7 +81,7 @@ WunderGraph Cosmo Controlplane | imagePullSecrets | list | `[]` | | | ingress.hosts | string | `nil` | | | ingress.tls | list | `[]` | | -| jobs | object | `{"activateOrganization":{"additionalLabels":{},"enabled":false,"id":"123","slug":"foo"},"clickhouseMigration":{"additionalLabels":{}},"databaseMigration":{"additionalLabels":{}},"deactivateOrganization":{"additionalLabels":{},"enabled":false,"id":"123","reason":"","slug":"foo"},"deleteUser":{"additionalLabels":{},"email":"foo@wundergraph.com","enabled":false,"id":"123"},"seedOrganization":{"additionalLabels":{}}}` | Configure jobs to be executed in the control plane | +| jobs | object | `{"activateOrganization":{"additionalLabels":{},"enabled":false,"id":"123","slug":"foo"},"clickhouseMigration":{"additionalLabels":{}},"databaseMigration":{"additionalLabels":{}},"deactivateOrganization":{"additionalLabels":{},"enabled":false,"id":"123","reason":"","slug":"foo"},"deleteInactiveOrgs":{"additionalLabels":{},"enabled":true,"schedule":"0 0 1 * *"},"deleteUser":{"additionalLabels":{},"email":"foo@wundergraph.com","enabled":false,"id":"123"},"seedOrganization":{"additionalLabels":{}}}` | Configure jobs to be executed in the control plane | | jobs.activateOrganization | object | `{"additionalLabels":{},"enabled":false,"id":"123","slug":"foo"}` | Used to activate an organization and remove the scheduled deletion | | jobs.activateOrganization.additionalLabels | object | `{}` | Adds additional labels to the job | | jobs.activateOrganization.enabled | bool | `false` | Enables the job to be run | @@ -95,6 +95,9 @@ WunderGraph Cosmo Controlplane | jobs.deactivateOrganization.id | string | `"123"` | The unique identifier of the organization | | jobs.deactivateOrganization.reason | string | `""` | The reason for deactivation | | jobs.deactivateOrganization.slug | string | `"foo"` | The slug of the organization | +| jobs.deleteInactiveOrgs.additionalLabels | object | `{}` | Adds additional labels to the job (see: .Values.global.seed) | +| jobs.deleteInactiveOrgs.enabled | bool | `true` | Enables the job to be run | +| jobs.deleteInactiveOrgs.schedule | string | `"0 0 1 * *"` | Cron schedule (default: 1st of every month at midnight UTC) | | jobs.deleteUser | object | `{"additionalLabels":{},"email":"foo@wundergraph.com","enabled":false,"id":"123"}` | Used to delete the user | | jobs.deleteUser.additionalLabels | object | `{}` | Adds additional labels to the job | | jobs.deleteUser.email | string | `"foo@wundergraph.com"` | The email of the user | diff --git a/helm/cosmo/charts/controlplane/templates/delete-inactive-orgs.yaml b/helm/cosmo/charts/controlplane/templates/delete-inactive-orgs.yaml new file mode 100644 index 0000000000..1d0c7e910c --- /dev/null +++ b/helm/cosmo/charts/controlplane/templates/delete-inactive-orgs.yaml @@ -0,0 +1,133 @@ +{{ if .Values.jobs.deleteInactiveOrgs.enabled }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: "{{ include "controlplane.fullname" . }}-delete-inactive-orgs" + labels: + {{- include "controlplane.job.labels" (dict "additionalLabels" .Values.jobs.deleteInactiveOrgs.additionalLabels "context" .) | nindent 4 }} +spec: + schedule: {{ .Values.jobs.deleteInactiveOrgs.schedule | quote }} + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 3 + parallelism: 1 + template: + metadata: + name: "{{ include "controlplane.fullname" . }}-delete-inactive-orgs" + labels: + {{- include "controlplane.job.labels" (dict "additionalLabels" .Values.jobs.deleteInactiveOrgs.additionalLabels "context" .) | nindent 12 }} + spec: + restartPolicy: OnFailure + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 12 }} + {{- end }} + containers: + - name: delete-inactive-orgs + securityContext: + {{- toYaml .Values.securityContext | nindent 16 }} + image: "{{ include "controlplane.image" . }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + env: + - name: REDIS_HOST + valueFrom: + configMapKeyRef: + name: {{ include "controlplane.fullname" . }}-configmap + key: redisHost + - name: REDIS_PORT + valueFrom: + configMapKeyRef: + name: {{ include "controlplane.fullname" . }}-configmap + key: redisPort + {{- if .Values.configuration.redisPassword }} + - name: REDIS_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "controlplane.secretName" . }} + key: redisPassword + {{- end }} + {{- if .Values.configuration.redisTlsCert }} + - name: REDIS_TLS_CERT + valueFrom: + secretKeyRef: + name: {{ include "controlplane.secretName" . }} + key: redisTlsCert + {{- end }} + {{- if .Values.configuration.redisTlsKey }} + - name: REDIS_TLS_KEY + valueFrom: + secretKeyRef: + name: {{ include "controlplane.secretName" . }} + key: redisTlsKey + {{- end }} + {{- if .Values.configuration.redisTlsCa }} + - name: REDIS_TLS_CA + valueFrom: + secretKeyRef: + name: {{ include "controlplane.secretName" . }} + key: redisTlsCa + {{- end }} + - name: DB_URL + valueFrom: + secretKeyRef: + name: {{ include "controlplane.secretName" . }} + key: databaseUrl + {{- if .Values.configuration.databaseTlsCert }} + - name: DB_TLS_CERT + valueFrom: + secretKeyRef: + name: {{ include "controlplane.secretName" . }} + key: databaseTlsCert + {{- end }} + {{- if .Values.configuration.databaseTlsCa }} + - name: DB_TLS_CA + valueFrom: + secretKeyRef: + name: {{ include "controlplane.secretName" . }} + key: databaseTlsCa + {{- end }} + {{- if .Values.configuration.databaseTlsKey }} + - name: DB_TLS_KEY + valueFrom: + secretKeyRef: + name: {{ include "controlplane.secretName" . }} + key: databaseTlsKey + {{- end }} + - name: KC_REALM + valueFrom: + configMapKeyRef: + name: {{ include "controlplane.fullname" . }}-configmap + key: keycloakRealm + - name: KC_LOGIN_REALM + valueFrom: + configMapKeyRef: + name: {{ include "controlplane.fullname" . }}-configmap + key: keycloakLoginRealm + - name: KC_API_URL + valueFrom: + configMapKeyRef: + name: {{ include "controlplane.fullname" . }}-configmap + key: keycloakApiUrl + - name: KC_ADMIN_USER + valueFrom: + secretKeyRef: + name: {{ include "controlplane.secretName" . }} + + key: keycloakAdminUser + - name: KC_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "controlplane.secretName" . }} + + key: keycloakAdminPassword + - name: KC_CLIENT_ID + valueFrom: + configMapKeyRef: + name: {{ include "controlplane.fullname" . }}-configmap + key: keycloakClientId + args: + - "/app/dist/bin/delete-inactive-orgs.js" +{{- end }} \ No newline at end of file diff --git a/helm/cosmo/charts/controlplane/values.yaml b/helm/cosmo/charts/controlplane/values.yaml index 9adad77e1c..86589ab6a9 100644 --- a/helm/cosmo/charts/controlplane/values.yaml +++ b/helm/cosmo/charts/controlplane/values.yaml @@ -290,3 +290,10 @@ jobs: seedOrganization: # -- Adds additional labels to the job (see: .Values.global.seed) additionalLabels: {} + deleteInactiveOrgs: + # -- Enables the job to be run + enabled: true + # -- Cron schedule (default: 1st of every month at midnight UTC) + schedule: '0 0 1 * *' + # -- Adds additional labels to the job (see: .Values.global.seed) + additionalLabels: {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e8088855d..943010cdc5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -534,8 +534,8 @@ importers: specifier: ^4.5.0 version: 4.5.0(axios@1.13.5) bullmq: - specifier: ^5.10.0 - version: 5.10.0 + specifier: 5.66.4 + version: 5.66.4 cookie: specifier: ^0.7.2 version: 0.7.2 @@ -579,8 +579,8 @@ importers: specifier: 8.0.0 version: 8.0.0 ioredis: - specifier: ^5.4.1 - version: 5.4.1 + specifier: 5.8.2 + version: 5.8.2 isomorphic-dompurify: specifier: ^2.33.0 version: 2.33.0 @@ -4126,8 +4126,8 @@ packages: '@types/node': optional: true - '@ioredis/commands@1.2.0': - resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} + '@ioredis/commands@1.4.0': + resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} @@ -8595,8 +8595,8 @@ packages: builtins@5.0.1: resolution: {integrity: sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==} - bullmq@5.10.0: - resolution: {integrity: sha512-bu8lGvgXLwMhBrKeE0ntH+IWBZFVISJBm5njYtPL8u9Qm3AekEONinKRftiIP66r/Y45ljxg9dp9EahoJ6L9wA==} + bullmq@5.66.4: + resolution: {integrity: sha512-y2VRk2z7d1YNI2JQDD7iThoD0X/0iZZ3VEp8lqT5s5U0XDl9CIjXp1LQgmE9EKy6ReHtzmYXS1f328PnUbZGtQ==} bun-types@1.2.12: resolution: {integrity: sha512-tvWMx5vPqbRXgE8WUZI94iS1xAYs8bkqESR9cxBB1Wi+urvfTrF1uzuDgBHFAdO0+d2lmsbG3HmeKMvUyj6pWA==} @@ -10961,8 +10961,8 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - ioredis@5.4.1: - resolution: {integrity: sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==} + ioredis@5.8.2: + resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} engines: {node: '>=12.22.0'} ip-address@10.1.0: @@ -12001,8 +12001,8 @@ packages: resolution: {integrity: sha512-SdzXp4kD/Qf8agZ9+iTu6eql0m3kWm1A2y1hkpTeVNENutaB0BwHlSvAIaMxwntmRUAUjon2V4L8Z/njd0Ct8A==} hasBin: true - msgpackr@1.10.1: - resolution: {integrity: sha512-r5VRLv9qouXuLiIBrLpl2d5ZvPt8svdQTl5/vMvE4nzDMyEX4sgW5yWhuBBj5UmgwOTWj8CIdSXn5sAfsHAWIQ==} + msgpackr@1.11.5: + resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} msw@2.2.11: resolution: {integrity: sha512-XtIoewF7XWLT0a39Ftkazt9PprBA1bxHZ4CSlomN74sCBJOJU2w5VwLmGlswwsOBhGoF7jovt6bxrSIESxA1KA==} @@ -13542,6 +13542,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -14551,6 +14556,10 @@ packages: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + uuid@13.0.0: resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} hasBin: true @@ -17977,7 +17986,7 @@ snapshots: dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.9.0(patch_hash=hafdlc54qtxpqvetpefk646rly)) graphql: 16.9.0(patch_hash=hafdlc54qtxpqvetpefk646rly) - tslib: 2.6.2 + tslib: 2.8.1 '@graphql-tools/wrap@9.4.2(graphql@16.9.0(patch_hash=hafdlc54qtxpqvetpefk646rly))': dependencies: @@ -18198,7 +18207,7 @@ snapshots: optionalDependencies: '@types/node': 22.17.0 - '@ioredis/commands@1.2.0': {} + '@ioredis/commands@1.4.0': {} '@isaacs/cliui@8.0.2': dependencies: @@ -23883,15 +23892,15 @@ snapshots: dependencies: semver: 7.7.1 - bullmq@5.10.0: + bullmq@5.66.4: dependencies: cron-parser: 4.9.0 - ioredis: 5.4.1 - msgpackr: 1.10.1 + ioredis: 5.8.2 + msgpackr: 1.11.5 node-abort-controller: 3.1.1 - semver: 7.7.1 - tslib: 2.6.2 - uuid: 9.0.1 + semver: 7.7.3 + tslib: 2.8.1 + uuid: 11.1.0 transitivePeerDependencies: - supports-color @@ -26731,11 +26740,11 @@ snapshots: dependencies: loose-envify: 1.4.0 - ioredis@5.4.1: + ioredis@5.8.2: dependencies: - '@ioredis/commands': 1.2.0 + '@ioredis/commands': 1.4.0 cluster-key-slot: 1.1.2 - debug: 4.3.7 + debug: 4.4.1 denque: 2.1.0 lodash.defaults: 4.2.0 lodash.isarguments: 3.1.0 @@ -27960,7 +27969,7 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.2 optional: true - msgpackr@1.10.1: + msgpackr@1.11.5: optionalDependencies: msgpackr-extract: 3.0.2 @@ -29721,6 +29730,8 @@ snapshots: semver@7.7.1: {} + semver@7.7.3: {} + semver@7.7.4: {} send@1.2.0: @@ -30891,6 +30902,8 @@ snapshots: uuid@10.0.0: {} + uuid@11.1.0: {} + uuid@13.0.0: {} uuid@9.0.1: {}