diff --git a/applications/chatops/slack-bot/src/interactive/index.ts b/applications/chatops/slack-bot/src/interactive/index.ts new file mode 100644 index 0000000..52668d5 --- /dev/null +++ b/applications/chatops/slack-bot/src/interactive/index.ts @@ -0,0 +1,153 @@ +// Slack Interactive Handler - Handles button clicks for deployment approval + +import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge'; +import { logger } from '../shared/logger'; +import { updateDeploymentStatus, getDeploymentRequest } from '../shared/dynamodb-client'; +import { createApprovedMessage, createDeniedMessage } from '../shared/slack-blocks'; +import { verifySlackSignature } from '../shared/slack-verify'; + +const eventBridge = new EventBridgeClient({}); +const EVENT_BUS_NAME = process.env.EVENTBRIDGE_BUS_NAME || 'laco-plt-chatbot'; + +/** + * Main handler for Slack interactive messages (button clicks) + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + try { + logger.info('Interactive handler invoked', { + headers: event.headers, + bodyLength: event.body?.length + }); + + // Verify Slack signature + if (!verifySlackSignature(event)) { + logger.warn('Invalid Slack signature'); + return { + statusCode: 401, + body: JSON.stringify({ error: 'Invalid signature' }) + }; + } + + // Parse payload from form-encoded body + const body = decodeURIComponent(event.body || ''); + const payloadMatch = body.match(/payload=(.+)/); + + if (!payloadMatch) { + logger.error('No payload found in request'); + return { + statusCode: 400, + body: JSON.stringify({ error: 'No payload found' }) + }; + } + + const payload = JSON.parse(payloadMatch[1]); + + logger.info('Parsed payload', { + type: payload.type, + user: payload.user?.id, + actions: payload.actions?.length + }); + + // Handle block actions (button clicks) + if (payload.type === 'block_actions' && payload.actions && payload.actions.length > 0) { + const action = payload.actions[0]; + const actionId = action.action_id; + const requestId = action.value; + const userId = payload.user.id; + + logger.info('Processing action', { actionId, requestId, userId }); + + // Get deployment request from DynamoDB + const deploymentRequest = await getDeploymentRequest(requestId); + + if (!deploymentRequest) { + logger.error('Deployment request not found', { requestId }); + return { + statusCode: 200, + body: JSON.stringify({ + replace_original: true, + text: `āŒ Deployment request not found: ${requestId}` + }) + }; + } + + // Handle approval + if (actionId === 'approve_deployment') { + logger.info('Deployment approved', { requestId, userId }); + + // Update DynamoDB status + await updateDeploymentStatus(requestId, 'approved', { + approval_metadata: { + approved_by: userId, + approved_at: new Date().toISOString() + } + }); + + // Publish event to EventBridge to trigger deployment + await eventBridge.send(new PutEventsCommand({ + Entries: [{ + Source: 'eks.deployment', + DetailType: 'Approval Completed', + Detail: JSON.stringify({ + request_id: requestId, + approved_by: userId, + deployment_type: deploymentRequest.deployment_type, + cluster_config: deploymentRequest.cluster_config + }), + EventBusName: EVENT_BUS_NAME + }] + })); + + logger.info('Approval event published to EventBridge', { requestId }); + + // Return updated message + const action = deploymentRequest.deployment_type === 'create_cluster' ? 'Cluster Creation' : 'Cluster Deletion'; + return { + statusCode: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(createApprovedMessage(userId, action)) + }; + } + + // Handle denial + if (actionId === 'deny_deployment') { + logger.info('Deployment denied', { requestId, userId }); + + // Update DynamoDB status + await updateDeploymentStatus(requestId, 'denied', { + approval_metadata: { + approved_by: userId, + approved_at: new Date().toISOString() + } + }); + + // Return updated message + return { + statusCode: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(createDeniedMessage(userId)) + }; + } + + logger.warn('Unknown action ID', { actionId }); + return { + statusCode: 400, + body: JSON.stringify({ error: 'Unknown action' }) + }; + } + + logger.warn('Unhandled payload type', { type: payload.type }); + return { + statusCode: 400, + body: JSON.stringify({ error: 'Unhandled payload type' }) + }; + + } catch (error) { + logger.error('Interactive handler error', error as Error); + return { + statusCode: 500, + body: JSON.stringify({ error: 'Internal server error' }) + }; + } +} diff --git a/applications/chatops/slack-bot/src/shared/dynamodb-client.ts b/applications/chatops/slack-bot/src/shared/dynamodb-client.ts new file mode 100644 index 0000000..de8cd5a --- /dev/null +++ b/applications/chatops/slack-bot/src/shared/dynamodb-client.ts @@ -0,0 +1,198 @@ +// DynamoDB client for EKS deployment requests + +import { DynamoDBClient, PutItemCommand, UpdateItemCommand, GetItemCommand, QueryCommand } from '@aws-sdk/client-dynamodb'; +import { logger } from './logger'; + +const client = new DynamoDBClient({}); + +export interface DeploymentRequest { + request_id: string; + created_at: string; + status: 'pending_approval' | 'approved' | 'denied' | 'in_progress' | 'completed' | 'failed' | 'expired'; + deployment_type: 'create_cluster' | 'delete_cluster'; + cluster_config: { + cluster_name: string; + environment: string; + version?: string; + region?: string; + }; + retry_count: number; + max_retries: number; + retry_interval_minutes: number; + last_retry_at?: string; + scheduled_time: string; + approval_metadata?: { + approved_by: string; + approved_at: string; + denial_reason?: string; + }; + execution_metadata?: { + codebuild_id?: string; + logs_url?: string; + duration_seconds?: number; + }; + expires_at: number; +} + +const TABLE_NAME = process.env.DYNAMODB_TABLE_NAME || 'laco-plt-eks-deployment-requests'; + +/** + * Create a new deployment request + */ +export async function createDeploymentRequest(request: Omit): Promise { + const expires_at = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60; // 30 days TTL + + logger.info('Creating deployment request', { request_id: request.request_id }); + + await client.send(new PutItemCommand({ + TableName: TABLE_NAME, + Item: { + request_id: { S: request.request_id }, + created_at: { S: request.created_at }, + status: { S: request.status }, + deployment_type: { S: request.deployment_type }, + cluster_config: { S: JSON.stringify(request.cluster_config) }, + retry_count: { N: String(request.retry_count) }, + max_retries: { N: String(request.max_retries) }, + retry_interval_minutes: { N: String(request.retry_interval_minutes) }, + scheduled_time: { S: request.scheduled_time }, + expires_at: { N: String(expires_at) }, + ...(request.last_retry_at && { last_retry_at: { S: request.last_retry_at } }), + ...(request.approval_metadata && { approval_metadata: { S: JSON.stringify(request.approval_metadata) } }), + ...(request.execution_metadata && { execution_metadata: { S: JSON.stringify(request.execution_metadata) } }) + } + })); + + logger.info('Deployment request created successfully'); +} + +/** + * Update deployment request status + */ +export async function updateDeploymentStatus( + request_id: string, + status: DeploymentRequest['status'], + metadata?: Partial +): Promise { + logger.info('Updating deployment status', { request_id, status }); + + const updateExpressions: string[] = ['#status = :status']; + const expressionAttributeNames: Record = { '#status': 'status' }; + const expressionAttributeValues: Record = { ':status': { S: status } }; + + if (metadata?.approval_metadata) { + updateExpressions.push('approval_metadata = :approval'); + expressionAttributeValues[':approval'] = { S: JSON.stringify(metadata.approval_metadata) }; + } + + if (metadata?.execution_metadata) { + updateExpressions.push('execution_metadata = :execution'); + expressionAttributeValues[':execution'] = { S: JSON.stringify(metadata.execution_metadata) }; + } + + await client.send(new UpdateItemCommand({ + TableName: TABLE_NAME, + Key: { + request_id: { S: request_id } + }, + UpdateExpression: `SET ${updateExpressions.join(', ')}`, + ExpressionAttributeNames: expressionAttributeNames, + ExpressionAttributeValues: expressionAttributeValues + })); + + logger.info('Deployment status updated successfully'); +} + +/** + * Increment retry count + */ +export async function incrementRetryCount(request_id: string): Promise { + logger.info('Incrementing retry count', { request_id }); + + await client.send(new UpdateItemCommand({ + TableName: TABLE_NAME, + Key: { + request_id: { S: request_id } + }, + UpdateExpression: 'SET retry_count = retry_count + :inc, last_retry_at = :now', + ExpressionAttributeValues: { + ':inc': { N: '1' }, + ':now': { S: new Date().toISOString() } + } + })); +} + +/** + * Get deployment request by ID + */ +export async function getDeploymentRequest(request_id: string): Promise { + logger.info('Getting deployment request', { request_id }); + + const result = await client.send(new GetItemCommand({ + TableName: TABLE_NAME, + Key: { + request_id: { S: request_id } + } + })); + + if (!result.Item) { + logger.warn('Deployment request not found', { request_id }); + return null; + } + + return { + request_id: result.Item.request_id.S!, + created_at: result.Item.created_at.S!, + status: result.Item.status.S as DeploymentRequest['status'], + deployment_type: result.Item.deployment_type.S as DeploymentRequest['deployment_type'], + cluster_config: JSON.parse(result.Item.cluster_config.S!), + retry_count: parseInt(result.Item.retry_count.N!), + max_retries: parseInt(result.Item.max_retries.N!), + retry_interval_minutes: parseInt(result.Item.retry_interval_minutes.N!), + scheduled_time: result.Item.scheduled_time.S!, + expires_at: parseInt(result.Item.expires_at.N!), + ...(result.Item.last_retry_at && { last_retry_at: result.Item.last_retry_at.S }), + ...(result.Item.approval_metadata && { approval_metadata: JSON.parse(result.Item.approval_metadata.S!) }), + ...(result.Item.execution_metadata && { execution_metadata: JSON.parse(result.Item.execution_metadata.S!) }) + }; +} + +/** + * Query deployment requests by status + */ +export async function queryDeploymentsByStatus(status: DeploymentRequest['status']): Promise { + logger.info('Querying deployments by status', { status }); + + const result = await client.send(new QueryCommand({ + TableName: TABLE_NAME, + IndexName: 'status-scheduled_time-index', + KeyConditionExpression: '#status = :status', + ExpressionAttributeNames: { + '#status': 'status' + }, + ExpressionAttributeValues: { + ':status': { S: status } + } + })); + + if (!result.Items || result.Items.length === 0) { + logger.info('No deployment requests found', { status }); + return []; + } + + return result.Items.map(item => ({ + request_id: item.request_id.S!, + created_at: item.created_at.S!, + status: item.status.S as DeploymentRequest['status'], + deployment_type: item.deployment_type.S as DeploymentRequest['deployment_type'], + cluster_config: JSON.parse(item.cluster_config.S!), + retry_count: parseInt(item.retry_count.N!), + max_retries: parseInt(item.max_retries.N!), + retry_interval_minutes: parseInt(item.retry_interval_minutes.N!), + scheduled_time: item.scheduled_time.S!, + expires_at: parseInt(item.expires_at.N!), + ...(item.last_retry_at && { last_retry_at: item.last_retry_at.S }), + ...(item.approval_metadata && { approval_metadata: JSON.parse(item.approval_metadata.S!) }), + ...(item.execution_metadata && { execution_metadata: JSON.parse(item.execution_metadata.S!) }) + })); +} diff --git a/applications/chatops/slack-bot/src/shared/slack-blocks.ts b/applications/chatops/slack-bot/src/shared/slack-blocks.ts new file mode 100644 index 0000000..6c0c3ff --- /dev/null +++ b/applications/chatops/slack-bot/src/shared/slack-blocks.ts @@ -0,0 +1,343 @@ +// Slack Block Kit templates for EKS deployment approval workflow + +import { DeploymentRequest } from './dynamodb-client'; + +export interface SlackMessage { + channel?: string; + text: string; + blocks?: any[]; + replace_original?: boolean; + response_type?: 'in_channel' | 'ephemeral'; +} + +/** + * Create approval request message with interactive buttons + */ +export function createApprovalMessage(request: DeploymentRequest): SlackMessage { + const action = request.deployment_type === 'create_cluster' ? 'Creation' : 'Deletion'; + const emoji = request.deployment_type === 'create_cluster' ? 'šŸš€' : 'šŸ—‘ļø'; + const { cluster_name, environment, version } = request.cluster_config; + + return { + text: `EKS Cluster ${action} Approval Request`, + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: `${emoji} EKS Cluster ${action} Request`, + emoji: true + } + }, + { + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: `*Cluster:*\n\`${cluster_name}\`` + }, + { + type: 'mrkdwn', + text: `*Environment:*\n\`${environment}\`` + }, + { + type: 'mrkdwn', + text: `*Action:*\n${action}` + }, + { + type: 'mrkdwn', + text: `*Scheduled:*\n${new Date(request.scheduled_time).toLocaleString('en-US', { timeZone: 'America/Toronto' })}` + } + ] + }, + ...(version ? [{ + type: 'section', + text: { + type: 'mrkdwn', + text: `*Kubernetes Version:* \`${version}\`` + } + }] : []), + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Request ID: \`${request.request_id}\`` + } + ] + }, + { + type: 'divider' + }, + { + type: 'actions', + block_id: `approval_${request.request_id}`, + elements: [ + { + type: 'button', + text: { + type: 'plain_text', + text: 'Approve āœ“', + emoji: true + }, + style: 'primary', + value: request.request_id, + action_id: 'approve_deployment' + }, + { + type: 'button', + text: { + type: 'plain_text', + text: 'Deny āœ—', + emoji: true + }, + style: 'danger', + value: request.request_id, + action_id: 'deny_deployment' + } + ] + } + ] + }; +} + +/** + * Create retry reminder message + */ +export function createRetryMessage(request: DeploymentRequest): SlackMessage { + const action = request.deployment_type === 'create_cluster' ? 'Creation' : 'Deletion'; + const { cluster_name, environment } = request.cluster_config; + + return { + text: `šŸ”” [Reminder ${request.retry_count}/${request.max_retries}] EKS Cluster ${action} Approval Needed`, + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: `šŸ”” Reminder: EKS Cluster ${action} Approval Needed`, + emoji: true + } + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Cluster:* \`${cluster_name}\`\n*Environment:* \`${environment}\`\n*Retry:* ${request.retry_count}/${request.max_retries}` + } + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `ā° Will remind again in ${request.retry_interval_minutes} minutes.` + } + ] + }, + { + type: 'actions', + block_id: `approval_${request.request_id}`, + elements: [ + { + type: 'button', + text: { + type: 'plain_text', + text: 'Approve āœ“', + emoji: true + }, + style: 'primary', + value: request.request_id, + action_id: 'approve_deployment' + }, + { + type: 'button', + text: { + type: 'plain_text', + text: 'Deny āœ—', + emoji: true + }, + style: 'danger', + value: request.request_id, + action_id: 'deny_deployment' + } + ] + } + ] + }; +} + +/** + * Create approval completed message (replaces original) + */ +export function createApprovedMessage(user_id: string, action: string): SlackMessage { + return { + replace_original: true, + text: `āœ… Deployment Approved by <@${user_id}>`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `āœ… *Deployment Approved*\n${action} will start shortly.\nApproved by: <@${user_id}>` + } + } + ] + }; +} + +/** + * Create denial message (replaces original) + */ +export function createDeniedMessage(user_id: string): SlackMessage { + return { + replace_original: true, + text: `āŒ Deployment Denied by <@${user_id}>`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `āŒ *Deployment Denied*\nOperation has been cancelled.\nDenied by: <@${user_id}>` + } + } + ] + }; +} + +/** + * Create expired message + */ +export function createExpiredMessage(request: DeploymentRequest): SlackMessage { + const { cluster_name } = request.cluster_config; + + return { + text: `ā° Deployment Approval Request Expired: ${cluster_name}`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `ā° *Deployment Approval Request Expired*\n\nCluster: \`${cluster_name}\`\nMaximum retry attempts (${request.max_retries}) exceeded.\n\nRequest ID: \`${request.request_id}\`` + } + } + ] + }; +} + +/** + * Create success notification + */ +export function createSuccessMessage(request: DeploymentRequest, logs_url?: string, duration?: number): SlackMessage { + const action = request.deployment_type === 'create_cluster' ? 'Creation' : 'Deletion'; + const { cluster_name, environment } = request.cluster_config; + + return { + text: `āœ… EKS Cluster ${action} Completed: ${cluster_name}`, + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: `āœ… EKS Cluster ${action} Completed`, + emoji: true + } + }, + { + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: `*Cluster:*\n\`${cluster_name}\`` + }, + { + type: 'mrkdwn', + text: `*Environment:*\n\`${environment}\`` + }, + { + type: 'mrkdwn', + text: `*Status:*\nāœ… Success` + }, + ...(duration ? [{ + type: 'mrkdwn', + text: `*Duration:*\n${Math.round(duration / 60)} minutes` + }] : []) + ] + }, + ...(logs_url ? [{ + type: 'section', + text: { + type: 'mrkdwn', + text: `<${logs_url}|šŸ“‹ View CloudWatch Logs>` + } + }] : []), + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Request ID: \`${request.request_id}\` | ${new Date().toLocaleString('en-US', { timeZone: 'America/Toronto' })}` + } + ] + } + ] + }; +} + +/** + * Create failure notification + */ +export function createFailureMessage(request: DeploymentRequest, error_message: string, logs_url?: string): SlackMessage { + const action = request.deployment_type === 'create_cluster' ? 'Creation' : 'Deletion'; + const { cluster_name, environment } = request.cluster_config; + + return { + text: `āŒ EKS Cluster ${action} Failed: ${cluster_name}`, + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: `āŒ EKS Cluster ${action} Failed`, + emoji: true + } + }, + { + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: `*Cluster:*\n\`${cluster_name}\`` + }, + { + type: 'mrkdwn', + text: `*Environment:*\n\`${environment}\`` + } + ] + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Error:*\n\`\`\`${error_message}\`\`\`` + } + }, + ...(logs_url ? [{ + type: 'section', + text: { + type: 'mrkdwn', + text: `<${logs_url}|šŸ“‹ View CloudWatch Logs>` + } + }] : []), + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Request ID: \`${request.request_id}\` | ${new Date().toLocaleString('en-US', { timeZone: 'America/Toronto' })}` + } + ] + } + ] + }; +} diff --git a/applications/chatops/slack-bot/src/workers/approval-retry/index.ts b/applications/chatops/slack-bot/src/workers/approval-retry/index.ts new file mode 100644 index 0000000..85bb092 --- /dev/null +++ b/applications/chatops/slack-bot/src/workers/approval-retry/index.ts @@ -0,0 +1,135 @@ +// Approval Retry Worker - Checks pending approvals and sends reminders + +import { ScheduledEvent } from 'aws-lambda'; +import { logger } from '../../shared/logger'; +import { queryDeploymentsByStatus, incrementRetryCount, updateDeploymentStatus } from '../../shared/dynamodb-client'; +import { createRetryMessage, createExpiredMessage } from '../../shared/slack-blocks'; +import { getParameter } from '../../shared/secrets'; +import axios from 'axios'; + +/** + * Handler for scheduled retry checks + * Runs every 15 minutes to check for pending approvals + */ +export async function handler(event: ScheduledEvent): Promise { + logger.info('Approval retry check started', { + time: event.time, + resources: event.resources + }); + + try { + // Query all pending approval requests + const pendingRequests = await queryDeploymentsByStatus('pending_approval'); + + logger.info('Found pending approval requests', { + count: pendingRequests.length + }); + + if (pendingRequests.length === 0) { + logger.info('No pending approval requests found'); + return; + } + + // Get Slack credentials + const slackBotToken = await getParameter('/laco/plt/aws/secrets/slack/bot-token'); + const slackChannelId = process.env.SLACK_CHANNEL_ID || 'C06XXXXXXXXX'; + + const now = Date.now(); + + // Process each pending request + for (const request of pendingRequests) { + logger.info('Processing pending request', { + request_id: request.request_id, + retry_count: request.retry_count, + max_retries: request.max_retries + }); + + // Calculate time since last retry (or creation) + const lastRetryTime = request.last_retry_at + ? new Date(request.last_retry_at).getTime() + : new Date(request.created_at).getTime(); + + const retryIntervalMs = request.retry_interval_minutes * 60 * 1000; + const timeSinceLastRetry = now - lastRetryTime; + + // Check if retry interval has elapsed + if (timeSinceLastRetry < retryIntervalMs) { + logger.info('Retry interval not elapsed yet', { + request_id: request.request_id, + timeSinceLastRetry: Math.round(timeSinceLastRetry / 1000), + retryInterval: request.retry_interval_minutes * 60 + }); + continue; + } + + // Check if max retries exceeded + if (request.retry_count >= request.max_retries) { + logger.warn('Max retries exceeded, marking as expired', { + request_id: request.request_id, + retry_count: request.retry_count, + max_retries: request.max_retries + }); + + // Mark as expired + await updateDeploymentStatus(request.request_id, 'expired'); + + // Send expiration message + const expiredMessage = createExpiredMessage(request); + await axios.post('https://slack.com/api/chat.postMessage', { + channel: slackChannelId, + ...expiredMessage + }, { + headers: { + 'Authorization': `Bearer ${slackBotToken}`, + 'Content-Type': 'application/json' + } + }); + + logger.info('Expiration message sent', { + request_id: request.request_id + }); + continue; + } + + // Increment retry count + await incrementRetryCount(request.request_id); + + // Update request object for message + request.retry_count += 1; + + // Send retry reminder + const retryMessage = createRetryMessage(request); + + try { + await axios.post('https://slack.com/api/chat.postMessage', { + channel: slackChannelId, + ...retryMessage + }, { + headers: { + 'Authorization': `Bearer ${slackBotToken}`, + 'Content-Type': 'application/json' + } + }); + + logger.info('Retry reminder sent', { + request_id: request.request_id, + retry_count: request.retry_count, + max_retries: request.max_retries + }); + } catch (error) { + logger.error('Failed to send retry reminder', error as Error, { + request_id: request.request_id + }); + // Continue processing other requests even if one fails + } + } + + logger.info('Approval retry check completed', { + processed: pendingRequests.length + }); + + } catch (error) { + logger.error('Approval retry check failed', error as Error); + throw error; + } +} diff --git a/applications/chatops/slack-bot/src/workers/deploy/index.ts b/applications/chatops/slack-bot/src/workers/deploy/index.ts index c747da4..3c9aff6 100644 --- a/applications/chatops/slack-bot/src/workers/deploy/index.ts +++ b/applications/chatops/slack-bot/src/workers/deploy/index.ts @@ -1,12 +1,36 @@ -// Deploy Worker Lambda - Handles deployment commands +// Deploy Worker Lambda - Handles both manual and scheduled deployment requests -import { SQSEvent, SQSBatchResponse } from 'aws-lambda'; +import { SQSEvent, SQSBatchResponse, EventBridgeEvent } from 'aws-lambda'; import { logger } from '../../shared/logger'; import { sendSlackResponse } from '../../shared/slack-client'; import { WorkerMessage } from '../../shared/types'; +import { handleScheduledDeployment } from './scheduled-handler'; +import { handleManualDeployment } from './manual-handler'; + +type LambdaEvent = SQSEvent | EventBridgeEvent; + +/** + * Main handler - Routes to appropriate sub-handler based on event type + */ +export async function handler(event: LambdaEvent): Promise { + logger.info('Deploy worker invoked', { eventSource: getEventSource(event) }); + + // Check if this is an EventBridge scheduled event + if (isEventBridgeEvent(event)) { + logger.info('Handling scheduled deployment from EventBridge'); + await handleScheduledDeployment(event as EventBridgeEvent); + return; + } + + // Otherwise, handle as SQS event (manual deployment) + return await handleSQSEvent(event as SQSEvent); +} -export async function handler(event: SQSEvent): Promise { - logger.info('Deploy worker invoked', { +/** + * Handle SQS events (manual deployments from Slack) + */ +async function handleSQSEvent(event: SQSEvent): Promise { + logger.info('Handling manual deployment from SQS', { recordCount: event.Records.length }); @@ -15,76 +39,7 @@ export async function handler(event: SQSEvent): Promise { for (const record of event.Records) { try { const message: WorkerMessage = JSON.parse(record.body); - - logger.info('Processing deploy command', { - text: message.text, - user: message.user_name - }); - - // Parse deployment target from text - const target = message.text.trim() || 'default'; - - // Send initial response - await sendSlackResponse(message.response_url, { - response_type: 'in_channel', - text: `:rocket: Starting deployment to \`${target}\`...`, - blocks: [ - { - type: 'section', - text: { - type: 'mrkdwn', - text: `:rocket: *Deployment Started*\nTarget: \`${target}\`\nRequested by: <@${message.user_id}>` - } - } - ] - }); - - // Simulate deployment process - const steps = [ - { name: 'Validating configuration', duration: 2000 }, - { name: 'Building application', duration: 3000 }, - { name: 'Running tests', duration: 2000 }, - { name: 'Deploying to AWS', duration: 4000 }, - { name: 'Verifying deployment', duration: 2000 } - ]; - - for (const [index, step] of steps.entries()) { - logger.info(`Deployment step: ${step.name}`); - await new Promise(resolve => setTimeout(resolve, step.duration)); - - // Send progress update - const progress = Math.round(((index + 1) / steps.length) * 100); - await sendSlackResponse(message.response_url, { - response_type: 'in_channel', - text: `[${progress}%] ${step.name}...` - }); - } - - // Send final success message - await sendSlackResponse(message.response_url, { - response_type: 'in_channel', - text: `:white_check_mark: Deployment to \`${target}\` completed successfully!`, - blocks: [ - { - type: 'section', - text: { - type: 'mrkdwn', - text: `:white_check_mark: *Deployment Successful*\nTarget: \`${target}\`\nDuration: ${steps.reduce((sum, s) => sum + s.duration, 0) / 1000}s` - } - }, - { - type: 'context', - elements: [ - { - type: 'mrkdwn', - text: `Deployed by <@${message.user_id}> | ${new Date().toISOString()}` - } - ] - } - ] - }); - - logger.info('Deployment completed'); + await handleManualDeployment(message); } catch (error) { logger.error('Failed to process deploy command', error as Error, { messageId: record.messageId @@ -108,3 +63,20 @@ export async function handler(event: SQSEvent): Promise { return { batchItemFailures }; } + +/** + * Type guard to check if event is from EventBridge + */ +function isEventBridgeEvent(event: LambdaEvent): boolean { + return 'source' in event && 'detail-type' in event; +} + +/** + * Get event source for logging + */ +function getEventSource(event: LambdaEvent): string { + if (isEventBridgeEvent(event)) { + return 'EventBridge'; + } + return 'SQS'; +} diff --git a/applications/chatops/slack-bot/src/workers/deploy/manual-handler.ts b/applications/chatops/slack-bot/src/workers/deploy/manual-handler.ts new file mode 100644 index 0000000..3602e8d --- /dev/null +++ b/applications/chatops/slack-bot/src/workers/deploy/manual-handler.ts @@ -0,0 +1,81 @@ +// Manual deployment handler - Handles /deploy Slack command + +import { logger } from '../../shared/logger'; +import { sendSlackResponse } from '../../shared/slack-client'; +import { WorkerMessage } from '../../shared/types'; + +/** + * Handle manual deployment from Slack /deploy command + * This is the original deployment simulation logic + */ +export async function handleManualDeployment(message: WorkerMessage): Promise { + logger.info('Processing manual deploy command', { + text: message.text, + user: message.user_name + }); + + // Parse deployment target from text + const target = message.text.trim() || 'default'; + + // Send initial response + await sendSlackResponse(message.response_url, { + response_type: 'in_channel', + text: `:rocket: Starting deployment to \`${target}\`...`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `:rocket: *Deployment Started*\nTarget: \`${target}\`\nRequested by: <@${message.user_id}>` + } + } + ] + }); + + // Simulate deployment process + const steps = [ + { name: 'Validating configuration', duration: 2000 }, + { name: 'Building application', duration: 3000 }, + { name: 'Running tests', duration: 2000 }, + { name: 'Deploying to AWS', duration: 4000 }, + { name: 'Verifying deployment', duration: 2000 } + ]; + + for (const [index, step] of steps.entries()) { + logger.info(`Deployment step: ${step.name}`); + await new Promise(resolve => setTimeout(resolve, step.duration)); + + // Send progress update + const progress = Math.round(((index + 1) / steps.length) * 100); + await sendSlackResponse(message.response_url, { + response_type: 'in_channel', + text: `[${progress}%] ${step.name}...` + }); + } + + // Send final success message + await sendSlackResponse(message.response_url, { + response_type: 'in_channel', + text: `:white_check_mark: Deployment to \`${target}\` completed successfully!`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `:white_check_mark: *Deployment Successful*\nTarget: \`${target}\`\nDuration: ${steps.reduce((sum, s) => sum + s.duration, 0) / 1000}s` + } + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Deployed by <@${message.user_id}> | ${new Date().toISOString()}` + } + ] + } + ] + }); + + logger.info('Manual deployment completed'); +} diff --git a/applications/chatops/slack-bot/src/workers/deploy/scheduled-handler.ts b/applications/chatops/slack-bot/src/workers/deploy/scheduled-handler.ts new file mode 100644 index 0000000..56b1d0d --- /dev/null +++ b/applications/chatops/slack-bot/src/workers/deploy/scheduled-handler.ts @@ -0,0 +1,76 @@ +// Scheduled deployment handler - Creates approval requests for scheduled EKS deployments + +import { EventBridgeEvent } from 'aws-lambda'; +import { v4 as uuidv4 } from 'uuid'; +import { logger } from '../../shared/logger'; +import { createDeploymentRequest } from '../../shared/dynamodb-client'; +import { createApprovalMessage } from '../../shared/slack-blocks'; +import axios from 'axios'; +import { getParameter } from '../../shared/secrets'; + +interface ScheduledDeploymentEvent { + deployment_type: 'create_cluster' | 'delete_cluster'; + cluster_config: { + cluster_name: string; + environment: string; + version?: string; + region?: string; + }; +} + +/** + * Handle scheduled deployment events from EventBridge + * Creates approval request in DynamoDB and sends Slack message + */ +export async function handleScheduledDeployment( + event: EventBridgeEvent +): Promise { + logger.info('Scheduled deployment triggered', { + source: event.source, + detailType: event['detail-type'], + deployment_type: event.detail.deployment_type + }); + + const request_id = uuidv4(); + const created_at = new Date().toISOString(); + const { deployment_type, cluster_config } = event.detail; + + // Create deployment request in DynamoDB + const deploymentRequest = { + request_id, + created_at, + status: 'pending_approval' as const, + deployment_type, + cluster_config, + retry_count: 0, + max_retries: 3, + retry_interval_minutes: 15, + scheduled_time: created_at + }; + + await createDeploymentRequest(deploymentRequest); + logger.info('Deployment request created', { request_id }); + + // Send Slack approval message + const slackBotToken = await getParameter('/laco/plt/aws/secrets/slack/bot-token'); + const slackChannelId = process.env.SLACK_CHANNEL_ID || 'C06XXXXXXXXX'; + + const approvalMessage = createApprovalMessage(deploymentRequest); + + try { + await axios.post('https://slack.com/api/chat.postMessage', { + channel: slackChannelId, + ...approvalMessage + }, { + headers: { + 'Authorization': `Bearer ${slackBotToken}`, + 'Content-Type': 'application/json' + } + }); + + logger.info('Slack approval message sent', { request_id, channel: slackChannelId }); + } catch (error) { + logger.error('Failed to send Slack approval message', error as Error, { request_id }); + throw error; + } +}