Skip to content
Draft
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
*.node
.DS_Store
tools/*
!tools/*.ts
!tools/*/
!tools/**/*.ts
**/.aws-cfn-storage
/oss-attribution
/tmp-tst
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"test:integration": "cross-env NODE_ENV=test vitest run --config vitest.integration.config.ts",
"test:unit": "cross-env NODE_ENV=test vitest run --config vitest.unit.config.ts",
"test:leaks": "cross-env NODE_ENV=test vitest run --pool=forks --logHeapUsage",
"test:stability": "cross-env NODE_ENV=test tsx tools/stability/runStabilityTest.ts",
"lint": "eslint --cache --cache-location node_modules/.cache/eslint --max-warnings 0 .",
"lint:fix": "npm run lint -- --fix",
"build:go:dev": "GOPROXY=direct go build -C cfn-init/cmd -v -o ../../bundle/development/bin/cfn-init",
Expand Down
318 changes: 318 additions & 0 deletions tools/lspClient/LspClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
import { spawn, ChildProcess } from 'child_process';
import {
createMessageConnection,
MessageConnection,
StreamMessageReader,
StreamMessageWriter,
IPCMessageReader,
IPCMessageWriter,
TextDocumentContentChangeEvent,
} from 'vscode-languageserver-protocol/node';
import { randomBytes } from 'crypto';
import { CompactEncrypt } from 'jose';
import { LspClientConfig, LspConnection, InitializationFlags } from './LspConnection';
import { ExtendedInitializeParams } from '../../src/server/InitParams';
import { IamCredentials } from '../../src/auth/AwsLspAuthTypes';
import { WaitFor } from '../../tst/utils/Utils';

/**
* Common LSP client for CloudFormation Language Server testing.
* Handles server startup, LSP protocol communication, and external service initialization detection.
*/
export class LspClient implements LspConnection {
protected serverProcess?: ChildProcess;
protected connection?: MessageConnection;
protected initialization: InitializationFlags = {
cfnLint: false,
cfnGuard: false,
};

public readonly createdAt: number;
private readonly encryptionKey: Buffer;
protected isShutdown = false;
protected workspaceConfig: Record<string, unknown>[] = [{}];
protected availableRegions = new Set<string>();

constructor(protected config: LspClientConfig) {
this.createdAt = performance.now();
this.encryptionKey = randomBytes(32);
}

async initialize(): Promise<void> {
console.log('LspClient: Starting initialization...');

// 1. Start server process
const args = this.config.mode === 'ipc' ? ['--node-ipc'] : ['--stdio'];
console.log(`LspClient: Spawning server with args: node ${this.config.serverPath} ${args.join(' ')}`);

this.serverProcess = spawn('node', [this.config.serverPath, ...args], {
stdio: this.config.mode === 'ipc' ? ['pipe', 'pipe', 'pipe', 'ipc'] : ['pipe', 'pipe', 'pipe'],
env: { ...process.env, ...this.config.env },
});

console.log(`LspClient: Server process spawned with PID: ${this.serverProcess.pid}`);

// 2. Setup output monitoring for external service initialization detection
this.attachOutputListeners();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are creating the lsp handler for this, we should not add this code if it will just be deleted in the next couple of days

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code works without the lsp handler, although it is less robust and local testing takes longer since schemas are already downloaded. We can wait until #507 gets merged in, but the code can work independently of it. The code we would have to delete and modify to use the lsp handler is minimal relative to the rest of the PR.


// 3. Create LSP connection
console.log('LspClient: Creating LSP connection...');
const reader =
this.config.mode === 'ipc'
? new IPCMessageReader(this.serverProcess)
: new StreamMessageReader(this.serverProcess.stdout!);

const writer =
this.config.mode === 'ipc'
? new IPCMessageWriter(this.serverProcess)
: new StreamMessageWriter(this.serverProcess.stdin!);

this.connection = createMessageConnection(reader, writer);

// Handle workspace/configuration requests from server

this.connection.onRequest('workspace/configuration', (params: any) => {
// Extract the specific configuration section requested
if (params?.items?.length > 0) {
const results = params.items.map((item: any) => {
if (item.section === 'aws.cloudformation') {
// Return just the CloudFormation config part
const fullConfig = this.workspaceConfig[0] ?? {};
return (fullConfig as any)['aws.cloudformation'] ?? {};
}
return {};
});
return results;
}
return this.workspaceConfig;
});

this.connection.listen();
console.log('LspClient: LSP connection created and listening');

// 4. Perform LSP handshake
console.log('LspClient: Performing LSP handshake...');
try {
await this.performHandshake();
console.log('LspClient: LSP handshake completed');
} catch (error) {
console.error('LspClient: LSP handshake failed:', error);
throw error;
}
}

private readonly onServerOutput = (data: Buffer) => {
const output = data.toString().trim();

// external service initialization detection
if (output.includes('cfn-lint version')) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, the only way to determine if lint, guard, and the schemas are ready is through the logs. #507 Introduces a new LSP request which will return the guard, lint, and schema statuses. This addition will make testing more robust and speed up test time, but this PR is functional without this change

this.initialization.cfnLint = true;
}
if (output.includes('Loading rules from')) {
this.initialization.cfnGuard = true;
}

// Region-specific schema loading
const regionSchemaMatch = output.match(/public schemas downloaded for ([a-z0-9-]+)/);
if (regionSchemaMatch) {
this.availableRegions.add(regionSchemaMatch[1]);
}

// Log filtering
const suppressLevels = this.config.suppressLogLevels ?? ['INFO', 'DEBUG'];
const shouldSuppress = suppressLevels.some((level) => output.includes(`${level}:`));

if (!shouldSuppress) {
console.error(`[LSP Server]: ${output}`);
}
};

protected attachOutputListeners(): void {
this.serverProcess!.stdout?.on('data', this.onServerOutput);
this.serverProcess!.stderr?.on('data', this.onServerOutput);

this.serverProcess!.on('exit', (code, signal) => {
if (signal) {
console.log(`[LSP Server]: Process terminated with signal ${signal}`);
} else {
console.log(`[LSP Server]: Process exited with code ${code}`);
}
});

this.serverProcess!.on('error', (error) => {
console.error(`[LSP Server]: Process error:`, error);
});
}

protected async performHandshake(): Promise<void> {
const initParams: ExtendedInitializeParams = {
processId: process.pid,
rootUri: 'file:///test/workspace',
capabilities: {
textDocument: {
hover: { dynamicRegistration: true },
completion: { dynamicRegistration: true },
},
},
clientInfo: this.config.clientInfo,
initializationOptions: {
aws: {
clientInfo: {
extension: this.config.extensionInfo,
clientId: this.config.clientId,
},
telemetryEnabled: this.config.telemetryEnabled,
storageDir: this.config.storageDir,
encryption: {
key: this.encryptionKey.toString('base64'),
mode: 'JWT',
},
featureFlags: this.config.featureFlags,
},
},
};

console.log('LspClient: Sending initialize request...');
try {
await Promise.race([
this.connection!.sendRequest('initialize', initParams),
new Promise((_resolve, reject) => setTimeout(() => reject(new Error('Initialize timeout')), 30_000)),
]);
console.log('LspClient: Initialize request completed');

console.log('LspClient: Sending initialized notification');
await this.connection!.sendNotification('initialized', {});
console.log('LspClient: Initialized notification sent');
} catch (error) {
console.error('LspClient: Handshake error:', error);
throw error;
}
}

async openDocument(uri: string, content: string): Promise<void> {
await this.connection!.sendNotification('textDocument/didOpen', {
textDocument: {
uri,
languageId: 'yaml',
version: 1,
text: content,
},
});
}

async updateDocument(
uri: string,
version: number,
changes: string | TextDocumentContentChangeEvent[],
): Promise<void> {
const contentChanges =
typeof changes === 'string'
? [{ text: changes }] // Full replacement
: changes; // Incremental changes

await this.connection!.sendNotification('textDocument/didChange', {
textDocument: {
uri,
version,
},
contentChanges,
});
}

async closeDocument(uri: string): Promise<void> {
await this.connection!.sendNotification('textDocument/didClose', {
textDocument: { uri },
});
}

async hover(uri: string, line: number, character: number): Promise<any> {
return await this.connection!.sendRequest('textDocument/hover', {
textDocument: { uri },
position: { line, character },
});
}

async completion(uri: string, line: number, character: number): Promise<any> {
return await this.connection!.sendRequest('textDocument/completion', {
textDocument: { uri },
position: { line, character },
});
}

async changeConfiguration(params: { settings: any }): Promise<void> {
// Store the new configuration
if (params.settings) {
const currentConfig = this.workspaceConfig[0] ?? {};
this.workspaceConfig = [{ ...currentConfig, ...params.settings }];
}

// Send the configuration change notification
await this.sendNotification('workspace/didChangeConfiguration', params);
}

async sendRequest(method: string, params: any): Promise<any> {
return await this.connection!.sendRequest(method, params);
}

async sendNotification(method: string, params: any): Promise<void> {
return await this.connection!.sendNotification(method, params);
}

onNotification(method: string, handler: (params: any) => void): void {
this.connection!.onNotification(method, handler);
}

onRequest(method: string, handler: (params: any) => any): void {
this.connection!.onRequest(method, handler);
}

async waitForExternalServiceInitialization(): Promise<void> {
console.log('Waiting for lint and guard initialization');

await WaitFor.waitFor(
() => {
if (!this.initialization.cfnLint || !this.initialization.cfnGuard) {
throw new Error('Lint and Guard services not initialized');
}
console.log('Lint and Guard services are initialized');
},
30_000,
500, // Check every 500ms
);
}

getAvailableRegions(): ReadonlySet<string> {
return this.availableRegions;
}

async updateCredentials(credentials: IamCredentials): Promise<void> {
const payload = new TextEncoder().encode(JSON.stringify({ data: credentials }));
const jwt = await new CompactEncrypt(payload)
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.encrypt(this.encryptionKey);

await this.connection!.sendRequest('aws/credentials/iam/update', {
data: jwt,
encrypted: true,
});
}

async shutdown(): Promise<void> {
if (this.isShutdown) return;
this.isShutdown = true;

try {
if (this.connection) {
await this.connection.sendRequest('shutdown', {});
await this.connection.sendNotification('exit', {});
}
} catch (e) {
console.warn('Error during LSP shutdown:', e);
}

if (this.serverProcess) {
this.serverProcess.kill();
}
}
}
28 changes: 28 additions & 0 deletions tools/lspClient/LspConnection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { ClientInfo, AwsMetadata } from '../../src/server/InitParams';

export interface LspConnection {
initialize(): Promise<void>;
sendRequest(method: string, params: any): Promise<any>;
sendNotification(method: string, params: any): Promise<void>;
onNotification(method: string, handler: (params: any) => void): void;
onRequest(method: string, handler: (params: any) => any): void;
shutdown(): Promise<void>;
}

export type LspClientConfig = {
serverPath: string;
mode: 'stdio' | 'ipc';
clientId: string;
clientInfo: ClientInfo;
extensionInfo: ClientInfo;
telemetryEnabled: boolean;
featureFlags: NonNullable<AwsMetadata['featureFlags']>;
storageDir?: string;
env?: NodeJS.ProcessEnv;
suppressLogLevels?: string[];
};

export type InitializationFlags = {
cfnLint: boolean;
cfnGuard: boolean;
};
Loading
Loading