diff --git a/applications/chatops/slack-bot/src/shared/build-lock.ts b/applications/chatops/slack-bot/src/shared/build-lock.ts new file mode 100644 index 0000000..198ff3a --- /dev/null +++ b/applications/chatops/slack-bot/src/shared/build-lock.ts @@ -0,0 +1,233 @@ +// Distributed Lock Manager for Build/Deploy Operations +// Prevents duplicate builds when multiple users trigger the same command + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { + DynamoDBDocumentClient, + PutCommand, + GetCommand, + UpdateCommand, +} from '@aws-sdk/lib-dynamodb'; +import { logger } from './logger'; +import { getConfig } from './config'; + +const client = new DynamoDBClient({ region: 'ca-central-1' }); +const docClient = DynamoDBDocumentClient.from(client); + +interface BuildLock { + lockKey: string; + lockedBy: string; + lockedByName: string; + lockedAt: string; + status: 'IN_PROGRESS' | 'COMPLETED' | 'FAILED'; + ttl: number; + component: string; + environment: string; + correlationId?: string; +} + +interface LockAcquisitionResult { + acquired: boolean; + lockedBy?: string; + lockedByName?: string; + lockedAt?: string; + existingLock?: BuildLock; +} + +export class BuildLockManager { + private tableName: string; + + constructor() { + const config = getConfig(); + this.tableName = `${config.orgPrefix}-${config.environment}-chatbot-build-locks`; + } + + /** + * Generate lock key from command parameters + */ + private generateLockKey( + command: 'build' | 'deploy', + component: string, + environment: string + ): string { + return `${command}-${component}-${environment}`; + } + + /** + * Attempt to acquire a lock for a build/deploy operation + * Returns true if lock acquired, false if already locked + */ + async acquireLock(params: { + command: 'build' | 'deploy'; + component: string; + environment: string; + userId: string; + userName: string; + correlationId?: string; + }): Promise { + const lockKey = this.generateLockKey( + params.command, + params.component, + params.environment + ); + + // TTL: 10 minutes for builds, 30 minutes for deploys + const ttlMinutes = params.command === 'build' ? 10 : 30; + const ttl = Math.floor(Date.now() / 1000) + ttlMinutes * 60; + + const lock: BuildLock = { + lockKey, + lockedBy: params.userId, + lockedByName: params.userName, + lockedAt: new Date().toISOString(), + status: 'IN_PROGRESS', + ttl, + component: params.component, + environment: params.environment, + correlationId: params.correlationId, + }; + + try { + // Attempt to create lock with conditional write + // Succeeds only if: + // 1. Lock doesn't exist, OR + // 2. Lock status is COMPLETED or FAILED, OR + // 3. Lock TTL has expired + await docClient.send( + new PutCommand({ + TableName: this.tableName, + Item: lock, + ConditionExpression: + 'attribute_not_exists(lockKey) OR #status IN (:completed, :failed) OR #ttl < :now', + ExpressionAttributeNames: { + '#status': 'status', + '#ttl': 'ttl', + }, + ExpressionAttributeValues: { + ':completed': 'COMPLETED', + ':failed': 'FAILED', + ':now': Math.floor(Date.now() / 1000), + }, + }) + ); + + logger.info('Lock acquired successfully', { + lockKey, + userId: params.userId, + userName: params.userName, + ttl: ttlMinutes, + }); + + return { acquired: true }; + } catch (error: any) { + if (error.name === 'ConditionalCheckFailedException') { + // Lock already exists and is active + const existingLock = await this.getLock(lockKey); + + logger.info('Lock acquisition failed - already locked', { + lockKey, + requestedBy: params.userId, + lockedBy: existingLock?.lockedBy, + lockedByName: existingLock?.lockedByName, + }); + + return { + acquired: false, + lockedBy: existingLock?.lockedBy, + lockedByName: existingLock?.lockedByName, + lockedAt: existingLock?.lockedAt, + existingLock, + }; + } + + // Unexpected error + logger.error('Unexpected error acquiring lock', error); + throw error; + } + } + + /** + * Get current lock status + */ + async getLock(lockKey: string): Promise { + try { + const result = await docClient.send( + new GetCommand({ + TableName: this.tableName, + Key: { lockKey }, + }) + ); + + if (!result.Item) { + return null; + } + + return result.Item as BuildLock; + } catch (error) { + logger.error('Error getting lock', error as Error, { lockKey }); + return null; + } + } + + /** + * Release lock by updating status + */ + async releaseLock( + command: 'build' | 'deploy', + component: string, + environment: string, + status: 'COMPLETED' | 'FAILED' + ): Promise { + const lockKey = this.generateLockKey(command, component, environment); + + try { + await docClient.send( + new UpdateCommand({ + TableName: this.tableName, + Key: { lockKey }, + UpdateExpression: 'SET #status = :status, completedAt = :now', + ExpressionAttributeNames: { + '#status': 'status', + }, + ExpressionAttributeValues: { + ':status': status, + ':now': new Date().toISOString(), + }, + }) + ); + + logger.info('Lock released', { lockKey, status }); + } catch (error) { + logger.error('Error releasing lock', error as Error, { lockKey, status }); + // Don't throw - lock will expire via TTL + } + } + + /** + * Check if a build/deploy is currently in progress + */ + async isLocked( + command: 'build' | 'deploy', + component: string, + environment: string + ): Promise { + const lockKey = this.generateLockKey(command, component, environment); + const lock = await this.getLock(lockKey); + + if (!lock) { + return false; + } + + // Check if lock is expired + const now = Math.floor(Date.now() / 1000); + if (lock.ttl < now) { + return false; + } + + // Check if lock is in progress + return lock.status === 'IN_PROGRESS'; + } +} + +// Singleton instance +export const buildLockManager = new BuildLockManager(); diff --git a/applications/chatops/slack-bot/src/shared/command-config.ts b/applications/chatops/slack-bot/src/shared/command-config.ts new file mode 100644 index 0000000..b6835e1 --- /dev/null +++ b/applications/chatops/slack-bot/src/shared/command-config.ts @@ -0,0 +1,88 @@ +// Command Configuration +// Defines behavior and requirements for each chatbot command + +export interface CommandConfig { + command: string; + description: string; + requiresLock: boolean; + lockScope?: 'component-environment' | 'global'; + lockTTL?: number; // TTL in minutes + enableCache?: boolean; + cacheTTL?: number; // TTL in seconds + cacheStrategy?: 'request-dedup' | 'response-cache' | 'data-cache'; +} + +export const COMMAND_CONFIG: Record = { + '/echo': { + command: '/echo', + description: 'Echo command for testing', + requiresLock: false, + enableCache: false, + }, + + '/status': { + command: '/status', + description: 'Check build/deploy status', + requiresLock: false, + enableCache: true, + cacheTTL: 30, // 30 seconds + cacheStrategy: 'response-cache', + }, + + '/build': { + command: '/build', + description: 'Trigger GitHub Actions build', + requiresLock: true, + lockScope: 'component-environment', + lockTTL: 10, // 10 minutes + enableCache: false, + }, + + '/deploy': { + command: '/deploy', + description: 'Deploy to environment', + requiresLock: true, + lockScope: 'component-environment', + lockTTL: 30, // 30 minutes (deploys take longer) + enableCache: false, + }, +} as const; + +/** + * Get configuration for a command + */ +export function getCommandConfig(command: string): CommandConfig | null { + return COMMAND_CONFIG[command] || null; +} + +/** + * Check if command requires distributed lock + */ +export function requiresLock(command: string): boolean { + const config = getCommandConfig(command); + return config?.requiresLock ?? false; +} + +/** + * Check if command supports caching + */ +export function supportsCache(command: string): boolean { + const config = getCommandConfig(command); + return config?.enableCache ?? false; +} + +/** + * Get cache TTL for command + */ +export function getCacheTTL(command: string): number { + const config = getCommandConfig(command); + return config?.cacheTTL ?? 0; +} + +/** + * Get lock TTL for command + */ +export function getLockTTL(command: string): number { + const config = getCommandConfig(command); + return config?.lockTTL ?? 10; +} diff --git a/applications/chatops/slack-bot/src/shared/secrets.ts b/applications/chatops/slack-bot/src/shared/secrets.ts index 4877c10..b8aa60e 100644 --- a/applications/chatops/slack-bot/src/shared/secrets.ts +++ b/applications/chatops/slack-bot/src/shared/secrets.ts @@ -74,7 +74,7 @@ export async function getSlackSigningSecret(): Promise { export async function getGitHubToken(): Promise { const cacheKey = 'github-pat'; - + // Check cache first const cached = secretCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { @@ -82,6 +82,15 @@ export async function getGitHubToken(): Promise { return cached.value; } + // For local development, use environment variable + if (config.get().isLocal) { + const value = process.env.GITHUB_PAT_CLOUD_APPS; + if (value) { + logger.debug('GitHub PAT retrieved from environment'); + return value; + } + } + // GitHub PAT is stored in common environment, not environment-specific // Use direct parameter path instead of getSecret() which adds environment prefix const parameterPath = '/laco/cmn/github/pat/cloud-apps'; diff --git a/applications/chatops/slack-bot/src/workers/build/index.ts b/applications/chatops/slack-bot/src/workers/build/index.ts index 4f5fa95..2779209 100644 --- a/applications/chatops/slack-bot/src/workers/build/index.ts +++ b/applications/chatops/slack-bot/src/workers/build/index.ts @@ -6,6 +6,7 @@ import { logger } from '../../shared/logger'; import { sendSlackResponse } from '../../shared/slack-client'; import { WorkerMessage } from '../../shared/types'; import { getGitHubToken } from '../../shared/secrets'; +import { buildLockManager } from '../../shared/build-lock'; interface BuildCommand { component: string; // router, echo, deploy, status, all @@ -136,43 +137,116 @@ export async function handler(event: SQSEvent): Promise { environment }); - // Send immediate acknowledgment - await sendSlackResponse(message.response_url, { - response_type: 'in_channel', - text: `🔨 Building ${component}...`, - blocks: [ - { - type: 'section', - text: { - type: 'mrkdwn', - text: `🔨 *Building ${component}*\n\nEnvironment: \`${environment}\`\nRequested by: <@${message.user_id}>\n\nTriggering GitHub Actions workflow...\nThis will take ~2 minutes` - } - }, - { - type: 'context', - elements: [ - { + // Attempt to acquire distributed lock + const lockResult = await buildLockManager.acquireLock({ + command: 'build', + component, + environment, + userId: message.user_id, + userName: message.user_name, + correlationId, + }); + + if (!lockResult.acquired) { + // Lock already held by another user - notify and skip + const lockedSince = lockResult.lockedAt + ? new Date(lockResult.lockedAt) + : null; + const timeSince = lockedSince + ? Math.floor((Date.now() - lockedSince.getTime()) / 1000) + : null; + + await sendSlackResponse(message.response_url, { + response_type: 'ephemeral', + text: `⚠️ Build already in progress`, + blocks: [ + { + type: 'section', + text: { type: 'mrkdwn', - text: '⏳ Build in progress...' + text: `⚠️ *Build Already In Progress*\n\nA build for \`${component}\` (${environment}) is already running.\n\n` + + `Started by: ${lockResult.lockedByName || 'Unknown'}\n` + + (timeSince ? `Started: ${timeSince}s ago\n` : '') + + `\nPlease wait for the current build to complete.` } - ] - } - ] - }); + } + ] + }); + + messageLogger.info('Build skipped - lock held by another user', { + component, + environment, + requestedBy: message.user_id, + lockedBy: lockResult.lockedBy, + lockedByName: lockResult.lockedByName, + }); + + // Don't add to failures - this is expected behavior + continue; + } - // Trigger GitHub Actions workflow - await triggerGitHubWorkflow({ + // Lock acquired - proceed with build + messageLogger.info('Lock acquired - proceeding with build', { component, environment, - response_url: message.response_url, - user: message.user_name }); - messageLogger.info('Build command processed successfully', { - duration: Date.now() - startTime, - component, - environment - }); + try { + // Send immediate acknowledgment + await sendSlackResponse(message.response_url, { + response_type: 'in_channel', + text: `🔨 Building ${component}...`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `🔨 *Building ${component}*\n\nEnvironment: \`${environment}\`\nRequested by: <@${message.user_id}>\n\nTriggering GitHub Actions workflow...\nThis will take ~2 minutes` + } + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: '⏳ Build in progress...' + } + ] + } + ] + }); + + // Trigger GitHub Actions workflow + await triggerGitHubWorkflow({ + component, + environment, + response_url: message.response_url, + user: message.user_name + }); + + // Release lock on success + await buildLockManager.releaseLock( + 'build', + component, + environment, + 'COMPLETED' + ); + + messageLogger.info('Build command processed successfully', { + duration: Date.now() - startTime, + component, + environment + }); + } catch (buildError) { + // Release lock on failure + await buildLockManager.releaseLock( + 'build', + component, + environment, + 'FAILED' + ); + throw buildError; // Re-throw to be caught by outer catch + } } catch (error) { const duration = Date.now() - startTime;