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
233 changes: 233 additions & 0 deletions applications/chatops/slack-bot/src/shared/build-lock.ts
Original file line number Diff line number Diff line change
@@ -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();

Check failure

Code scanning / CodeQL

Invocation of non-function Error

Callee is not a function: it has type undefined.

Copilot Autofix

AI 2 months ago

In general, to fix an "invocation of non-function" issue, ensure that the value being called is definitely a function (or constructor) before invocation. This can be done by correcting the import/export so the right symbol is referenced, or by adding a runtime guard that checks the type before calling and handles the error clearly instead of allowing a TypeError.

For this specific case, we should guard the use of getConfig in the BuildLockManager constructor. We will assign getConfig to a local variable, check that it is a function, and if not, log an explicit error and throw. If it is a function, we call it and keep the existing behavior of building this.tableName from the returned configuration. This approach avoids changing how configuration is used when things are correct, while preventing the unsafe direct call on a potentially undefined value. No new imports are required; we can reuse the existing logger import for the error message.

Concretely, in applications/chatops/slack-bot/src/shared/build-lock.ts, we will replace the body of the constructor (lines around 40–43) with a safer version that checks typeof getConfig === 'function' before calling it and throws a descriptive error if not.

Suggested changeset 1
applications/chatops/slack-bot/src/shared/build-lock.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/shared/build-lock.ts b/applications/chatops/slack-bot/src/shared/build-lock.ts
--- a/applications/chatops/slack-bot/src/shared/build-lock.ts
+++ b/applications/chatops/slack-bot/src/shared/build-lock.ts
@@ -38,6 +38,11 @@
   private tableName: string;
 
   constructor() {
+    if (typeof getConfig !== 'function') {
+      logger.error('BuildLockManager: getConfig is not a function. Ensure ./config exports a getConfig() function.');
+      throw new Error('Configuration error: getConfig is not a function.');
+    }
+
     const config = getConfig();
     this.tableName = `${config.orgPrefix}-${config.environment}-chatbot-build-locks`;
   }
EOF
@@ -38,6 +38,11 @@
private tableName: string;

constructor() {
if (typeof getConfig !== 'function') {
logger.error('BuildLockManager: getConfig is not a function. Ensure ./config exports a getConfig() function.');
throw new Error('Configuration error: getConfig is not a function.');
}

const config = getConfig();
this.tableName = `${config.orgPrefix}-${config.environment}-chatbot-build-locks`;
}
Copilot is powered by AI and may make mistakes. Always verify output.
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<LockAcquisitionResult> {
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) {

Check warning on line 122 in applications/chatops/slack-bot/src/shared/build-lock.ts

View workflow job for this annotation

GitHub Actions / Lint & Type Check

Unexpected any. Specify a different type
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<BuildLock | null> {
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<void> {
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<boolean> {
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();
88 changes: 88 additions & 0 deletions applications/chatops/slack-bot/src/shared/command-config.ts
Original file line number Diff line number Diff line change
@@ -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<string, CommandConfig> = {
'/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;
}
Loading
Loading