Skip to content
Open
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
153 changes: 153 additions & 0 deletions applications/chatops/slack-bot/src/interactive/index.ts
Original file line number Diff line number Diff line change
@@ -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<APIGatewayProxyResult> {
try {
logger.info('Interactive handler invoked', {
headers: event.headers,
bodyLength: event.body?.length
});

// Verify Slack signature
if (!verifySlackSignature(event)) {

Check warning

Code scanning / CodeQL

Missing await Warning

Missing await. The call to 'verifySlackSignature' always returns a promise.

Copilot Autofix

AI 2 months ago

In general, when calling an async function whose resolved value is needed for a conditional, you must either await it or use .then to access the resolved value. Here, verifySlackSignature appears to return a promise that resolves to a boolean indicating whether the request is valid. The if statement is meant to short-circuit and return 401 for invalid signatures, so the correct behavior is to await the verification result before checking it.

The single best fix is to insert await before verifySlackSignature(event) inside the if condition in handler. This keeps the structure and behavior the same while ensuring the condition operates on the boolean result rather than on the promise object. No additional imports are required, and there is no need to change the signature of handler since it is already async. Concretely, in applications/chatops/slack-bot/src/interactive/index.ts, update the if on line 24 from if (!verifySlackSignature(event)) { to if (!(await verifySlackSignature(event))) {.

Suggested changeset 1
applications/chatops/slack-bot/src/interactive/index.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/applications/chatops/slack-bot/src/interactive/index.ts b/applications/chatops/slack-bot/src/interactive/index.ts
--- a/applications/chatops/slack-bot/src/interactive/index.ts
+++ b/applications/chatops/slack-bot/src/interactive/index.ts
@@ -21,7 +21,7 @@
     });
 
     // Verify Slack signature
-    if (!verifySlackSignature(event)) {
+    if (!(await verifySlackSignature(event))) {
       logger.warn('Invalid Slack signature');
       return {
         statusCode: 401,
EOF
@@ -21,7 +21,7 @@
});

// Verify Slack signature
if (!verifySlackSignature(event)) {
if (!(await verifySlackSignature(event))) {
logger.warn('Invalid Slack signature');
return {
statusCode: 401,
Copilot is powered by AI and may make mistakes. Always verify output.
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' })
};
}
}
198 changes: 198 additions & 0 deletions applications/chatops/slack-bot/src/shared/dynamodb-client.ts
Original file line number Diff line number Diff line change
@@ -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<DeploymentRequest, 'expires_at'>): Promise<void> {
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<DeploymentRequest>
): Promise<void> {
logger.info('Updating deployment status', { request_id, status });

const updateExpressions: string[] = ['#status = :status'];
const expressionAttributeNames: Record<string, string> = { '#status': 'status' };
const expressionAttributeValues: Record<string, any> = { ':status': { S: status } };

Check warning on line 81 in applications/chatops/slack-bot/src/shared/dynamodb-client.ts

View workflow job for this annotation

GitHub Actions / Lint & Type Check

Unexpected any. Specify a different type

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<void> {
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<DeploymentRequest | null> {
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<DeploymentRequest[]> {
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!) })
}));
}
Loading
Loading