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 8 months ago

In general, to fix “invocation of non‑function” errors, you either (1) ensure the imported/assigned value is actually a function, or (2) guard before calling it and handle the error path explicitly. Since we are constrained to changes within this file and cannot alter the ./config module or change existing imports, the appropriate approach is to validate getConfig at the call site and avoid calling it if it is not a function.

Concretely, in applications/chatops/slack-bot/src/shared/build-lock.ts, update the BuildLockManager constructor to:

  1. Check that getConfig is a function before invoking it.
  2. If it is not a function, throw a descriptive error (or log and throw) instead of calling it, preventing a cryptic “is not a function” crash.
  3. Optionally give a type-safe fallback (e.g., never after throwing) so TypeScript remains happy, but that is not strictly necessary if we just throw and then use the result.

The minimal change is to replace:

constructor() {
  const config = getConfig();
  this.tableName = `${config.orgPrefix}-${config.environment}-chatbot-build-locks`;
}

with a constructor that:

  • Validates typeof getConfig === 'function'.
  • Throws an Error if the validation fails.
  • Calls getConfig() only in the safe branch and uses the returned config as before.

No new imports are required; we can use the built‑in Error and existing logger if desired (but using Error alone is sufficient and keeps changes minimal).


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,9 @@
   private tableName: string;
 
   constructor() {
+    if (typeof getConfig !== 'function') {
+      throw new Error('getConfig is not a function. Ensure ./config exports a callable getConfig.');
+    }
     const config = getConfig();
     this.tableName = `${config.orgPrefix}-${config.environment}-chatbot-build-locks`;
   }
EOF
@@ -38,6 +38,9 @@
private tableName: string;

constructor() {
if (typeof getConfig !== 'function') {
throw new Error('getConfig is not a function. Ensure ./config exports a callable getConfig.');
}
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;
}
11 changes: 10 additions & 1 deletion applications/chatops/slack-bot/src/shared/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,23 @@ export async function getSlackSigningSecret(): Promise<string> {

export async function getGitHubToken(): Promise<string> {
const cacheKey = 'github-pat';

// Check cache first
const cached = secretCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
logger.debug('GitHub PAT retrieved from cache');
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';
Expand Down
Loading
Loading