diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9992cd16dcf..04e90660dec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -527,6 +527,11 @@ Unlike the user setting overrides, not all of these environment variables have t - `SSMDOCUMENT_LANGUAGESERVER_PORT`: The port the ssm document language server should start debugging on +#### CloudFormation LSP + +- `__CLOUDFORMATIONLSP_PATH`: for aws.dev.cloudformationLsp.path +- `__CLOUDFORMATIONLSP_CLOUDFORMATION_ENDPOINT`: for aws.dev.cloudformationLsp.cloudformationEndpoint + #### CI/Testing - `GITHUB_ACTION`: The name of the current GitHub Action workflow step that is running diff --git a/packages/core/src/awsService/cloudformation/auth/credentials.ts b/packages/core/src/awsService/cloudformation/auth/credentials.ts new file mode 100644 index 00000000000..58a732807d6 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/auth/credentials.ts @@ -0,0 +1,80 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Disposable } from 'vscode' +import { LanguageClient } from 'vscode-languageclient/node' +import { StacksManager } from '../stacks/stacksManager' +import { ResourcesManager } from '../resources/resourcesManager' +import { CloudFormationRegionManager } from '../explorer/regionManager' +import globals from '../../../shared/extensionGlobals' +import * as jose from 'jose' +import * as crypto from 'crypto' + +export const encryptionKey = crypto.randomBytes(32) + +export class AwsCredentialsService implements Disposable { + private authChangeListener: Disposable + private client: LanguageClient | undefined + + constructor( + private stacksManager: StacksManager, + private resourcesManager: ResourcesManager, + private regionManager: CloudFormationRegionManager + ) { + this.authChangeListener = globals.awsContext.onDidChangeContext(() => { + void this.updateCredentialsFromActiveConnection() + }) + } + + async initialize(client: LanguageClient): Promise { + this.client = client + await this.updateCredentialsFromActiveConnection() + } + + private async updateCredentialsFromActiveConnection(): Promise { + if (!this.client) { + return + } + + const credentials = await globals.awsContext.getCredentials() + const profileName = globals.awsContext.getCredentialProfileName() + + if (credentials && profileName) { + const encryptedRequest = await this.createEncryptedCredentialsRequest({ + profile: profileName.replaceAll('profile:', ''), + region: this.regionManager.getSelectedRegion(), + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + }) + + await this.client.sendRequest('aws/credentials/iam/update', encryptedRequest) + } + + void this.stacksManager.reload() + void this.resourcesManager.reload() + } + + async updateRegion(): Promise { + await this.updateCredentialsFromActiveConnection() + } + + private async createEncryptedCredentialsRequest(data: any): Promise { + const payload = new TextEncoder().encode(JSON.stringify({ data })) + + const jwt = await new jose.CompactEncrypt(payload) + .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }) + .encrypt(encryptionKey) + + return { + data: jwt, + encrypted: true, + } + } + + dispose(): void { + this.authChangeListener.dispose() + } +} diff --git a/packages/core/src/awsService/cloudformation/cfn-init/cfnEnvironmentApi.ts b/packages/core/src/awsService/cloudformation/cfn-init/cfnEnvironmentApi.ts new file mode 100644 index 00000000000..1df272c175e --- /dev/null +++ b/packages/core/src/awsService/cloudformation/cfn-init/cfnEnvironmentApi.ts @@ -0,0 +1,19 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { LanguageClient } from 'vscode-languageclient/node' +import { + ParsedCfnEnvironmentFile, + ParseCfnEnvironmentFilesParams, + ParseCfnEnvironmentFilesRequest, +} from './cfnEnvironmentRequestType' + +export async function parseCfnEnvironmentFiles( + client: LanguageClient, + params: ParseCfnEnvironmentFilesParams +): Promise { + const result = await client.sendRequest(ParseCfnEnvironmentFilesRequest, params) + return result.parsedFiles +} diff --git a/packages/core/src/awsService/cloudformation/cfn-init/cfnEnvironmentManager.ts b/packages/core/src/awsService/cloudformation/cfn-init/cfnEnvironmentManager.ts new file mode 100644 index 00000000000..ac850de830d --- /dev/null +++ b/packages/core/src/awsService/cloudformation/cfn-init/cfnEnvironmentManager.ts @@ -0,0 +1,273 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Disposable, Uri, window, workspace, commands } from 'vscode' +import { Auth } from '../../../auth/auth' +import { commandKey, extractErrorMessage, formatMessage, toString } from '../utils' +import { + CfnConfig, + CfnEnvironmentConfig, + CfnEnvironmentLookup, + DeploymentConfig, + CfnEnvironmentFileSelectorItem as DeploymentFileDetail, + CfnEnvironmentFileSelectorItem, +} from './cfnProjectTypes' +import path from 'path' +import fs from '../../../shared/fs/fs' +import { CfnEnvironmentSelector } from '../ui/cfnEnvironmentSelector' +import { CfnEnvironmentFileSelector } from '../ui/cfnEnvironmentFileSelector' +import globals from '../../../shared/extensionGlobals' +import { TemplateParameter } from '../stacks/actions/stackActionRequestType' +import { validateParameterValue } from '../stacks/actions/stackActionInputValidation' +import { getLogger } from '../../../shared/logger/logger' +import { DocumentInfo } from './cfnEnvironmentRequestType' +import { parseCfnEnvironmentFiles } from './cfnEnvironmentApi' +import { LanguageClient } from 'vscode-languageclient/node' +import { Parameter } from '@aws-sdk/client-cloudformation' +import { convertRecordToParameters, convertRecordToTags } from './utils' + +export class CfnEnvironmentManager implements Disposable { + private readonly cfnProjectPath = 'cfn-project' + private readonly configFile = 'cfn-config.json' + private readonly environmentsDirectory = 'environments' + private readonly selectedEnvironmentKey = 'aws.cloudformation.selectedEnvironment' + private readonly auth = Auth.instance + private listeners: (() => void)[] = [] + + private readonly initializeOption = 'Initialize Project' + + constructor( + private readonly client: LanguageClient, + private readonly environmentSelector: CfnEnvironmentSelector, + private readonly environmentFileSelector: CfnEnvironmentFileSelector + ) {} + + public addListener(listener: () => void): void { + this.listeners.push(listener) + } + + public getSelectedEnvironmentName(): string | undefined { + return globals.context.workspaceState.get(this.selectedEnvironmentKey) + } + + private notifyListeners(): void { + for (const listener of this.listeners) { + listener() + } + } + + public async promptInitializeIfNeeded(operation: string): Promise { + if (!(await this.isProjectInitialized())) { + const choice = await window.showWarningMessage( + `You must initialize your CFN Project to perform ${operation}`, + this.initializeOption + ) + + if (choice === this.initializeOption) { + void commands.executeCommand(commandKey('init.initializeProject')) + } + return true + } + + return false + } + + public async selectEnvironment(): Promise { + if (await this.promptInitializeIfNeeded('Environment Selection')) { + return + } + + let environmentLookup: CfnEnvironmentLookup + + try { + environmentLookup = await this.fetchAvailableEnvironments() + } catch (error) { + void window.showErrorMessage( + formatMessage(`Failed to retrieve environments from configuration: ${toString(error)}`) + ) + return + } + + const environmentName = await this.environmentSelector.selectEnvironment(environmentLookup) + + if (environmentName) { + await this.setSelectedEnvironment(environmentName, environmentLookup) + } + } + + private async isProjectInitialized(): Promise { + const configPath = await this.getConfigPath() + const projectDirectory = await this.getProjectDir() + + return (await fs.existsFile(configPath)) && (await fs.existsDir(projectDirectory)) + } + + private async setSelectedEnvironment( + environmentName: string, + environmentLookup: CfnEnvironmentLookup + ): Promise { + const environment = environmentLookup[environmentName] + + if (environment) { + await globals.context.workspaceState.update(this.selectedEnvironmentKey, environmentName) + + await this.syncEnvironmentWithProfile(environment) + } + + this.notifyListeners() + } + + private async syncEnvironmentWithProfile(environment: CfnEnvironmentConfig) { + const profileName = environment.profile + + const currentConnection = await this.auth.getConnection({ id: `profile:${profileName}` }) + + if (!currentConnection) { + void window.showErrorMessage(formatMessage(`No connection found for profile: ${profileName}`)) + return + } + + await this.auth.useConnection(currentConnection) + } + + public async fetchAvailableEnvironments(): Promise { + const configPath = await this.getConfigPath() + const config = JSON.parse(await fs.readFileText(configPath)) as CfnConfig + + return config.environments + } + + public async selectEnvironmentFile( + templateUri: string, + requiredParameters: TemplateParameter[] + ): Promise { + const environmentName = this.getSelectedEnvironmentName() + const selectorItems: CfnEnvironmentFileSelectorItem[] = [] + + if (!environmentName) { + return undefined + } + + try { + const environmentDir = await this.getEnvironmentDir(environmentName) + const files = await fs.readdir(environmentDir) + + const filesToParse: DocumentInfo[] = await Promise.all( + files + .filter( + ([fileName]) => + fileName.endsWith('.json') || fileName.endsWith('.yaml') || fileName.endsWith('.yml') + ) + .map(async ([fileName]) => { + const filePath = path.join(environmentDir, fileName) + const content = await fs.readFileText(filePath) + const type = fileName.endsWith('.json') ? 'JSON' : 'YAML' + + return { + type, + content, + fileName, + } + }) + ) + + const environmentFiles = await parseCfnEnvironmentFiles(this.client, { documents: filesToParse }) + + for (const deploymentFile of environmentFiles) { + const item = await this.createEnvironmentFileSelectorItem( + deploymentFile.fileName, + deploymentFile.deploymentConfig, + requiredParameters, + templateUri + ) + if (item) { + selectorItems.push(item) + } + } + } catch (error) { + void window.showErrorMessage(`Error loading deployment files: ${extractErrorMessage(error)}`) + return undefined + } + + return await this.environmentFileSelector.selectEnvironmentFile(selectorItems, requiredParameters.length) + } + + private async createEnvironmentFileSelectorItem( + fileName: string, + deploymentConfig: DeploymentConfig, + requiredParameters: TemplateParameter[], + templateUri: string + ): Promise { + try { + return { + fileName: fileName, + hasMatchingTemplatePath: + workspace.asRelativePath(Uri.parse(templateUri)) === deploymentConfig.templateFilePath, + compatibleParameters: this.getCompatibleParams(deploymentConfig, requiredParameters), + optionalFlags: { + tags: deploymentConfig.tags ? convertRecordToTags(deploymentConfig.tags) : undefined, + includeNestedStacks: deploymentConfig.includeNestedStacks, + importExistingResources: deploymentConfig.importExistingResources, + onStackFailure: deploymentConfig.onStackFailure, + }, + } + } catch (error) { + getLogger().warn(`Failed to create selector item ${fileName}:`, error) + } + } + + private getCompatibleParams( + deploymentConfig: DeploymentConfig, + requiredParameters: TemplateParameter[] + ): Parameter[] | undefined { + if (deploymentConfig.parameters && requiredParameters.length > 0) { + const parameters = deploymentConfig.parameters + + // Filter only parameters that are in template and are valid + const validParams = requiredParameters.filter((templateParam) => { + if (!(templateParam.name in parameters)) { + return false + } + const value = parameters[templateParam.name] + return validateParameterValue(value, templateParam) === undefined + }) + + const validParameterNames = validParams.map((p) => p.name) + const filteredParameters = Object.fromEntries( + Object.entries(parameters).filter(([key]) => validParameterNames.includes(key)) + ) + + return convertRecordToParameters(filteredParameters) + } + } + + public async getEnvironmentDir(environmentName: string): Promise { + const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath + if (!workspaceRoot) { + throw new Error('No workspace folder found') + } + return path.join(workspaceRoot, this.cfnProjectPath, this.environmentsDirectory, environmentName) + } + + private async getConfigPath(): Promise { + const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath + if (!workspaceRoot) { + throw new Error('No workspace folder found') + } + return path.join(workspaceRoot, this.cfnProjectPath, this.configFile) + } + + private async getProjectDir(): Promise { + const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath + if (!workspaceRoot) { + throw new Error('No workspace folder found') + } + return path.join(workspaceRoot, this.cfnProjectPath) + } + + dispose(): void { + // No resources to dispose + } +} diff --git a/packages/core/src/awsService/cloudformation/cfn-init/cfnEnvironmentRequestType.ts b/packages/core/src/awsService/cloudformation/cfn-init/cfnEnvironmentRequestType.ts new file mode 100644 index 00000000000..cb1e930109d --- /dev/null +++ b/packages/core/src/awsService/cloudformation/cfn-init/cfnEnvironmentRequestType.ts @@ -0,0 +1,32 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { RequestType } from 'vscode-languageserver-protocol' +import { DeploymentConfig } from './cfnProjectTypes' + +export type DocumentInfo = { + type: 'JSON' | 'YAML' + content: string + fileName: string +} + +export type ParsedCfnEnvironmentFile = { + deploymentConfig: DeploymentConfig + fileName: string +} + +export type ParseCfnEnvironmentFilesParams = { + documents: DocumentInfo[] +} + +export type ParseCfnEnvironmentFilesResult = { + parsedFiles: ParsedCfnEnvironmentFile[] +} + +export const ParseCfnEnvironmentFilesRequest = new RequestType< + ParseCfnEnvironmentFilesParams, + ParseCfnEnvironmentFilesResult, + void +>('aws/cfn/environment/files/parse') diff --git a/packages/core/src/awsService/cloudformation/cfn-init/cfnInitCliCaller.ts b/packages/core/src/awsService/cloudformation/cfn-init/cfnInitCliCaller.ts new file mode 100644 index 00000000000..3a99d0b622a --- /dev/null +++ b/packages/core/src/awsService/cloudformation/cfn-init/cfnInitCliCaller.ts @@ -0,0 +1,73 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'path' +import * as vscode from 'vscode' +import { ChildProcess } from '../../../shared/utilities/processUtils' + +export interface EnvironmentOption { + name: string + awsProfile: string + parametersFiles?: string[] +} + +export class CfnInitCliCaller { + private binaryPath: string + + constructor(serverRootDir: string) { + this.binaryPath = path.join(serverRootDir, 'bin', 'cfn-init') + } + + async createProject( + projectName: string, + options?: { + projectPath?: string + environments?: EnvironmentOption[] + } + ) { + const args = ['create', projectName] + + if (options?.projectPath) { + args.push('--project-path', options.projectPath) + } + + if (options?.environments && options.environments.length > 0) { + const environmentConfig = { + environments: options.environments, + } + args.push('--environments', JSON.stringify(environmentConfig)) + } + + return this.executeCommand(args) + } + + async addEnvironments(environments: EnvironmentOption[]) { + const args = ['environment', 'add', '--environments', JSON.stringify({ environments })] + return this.executeCommand(args) + } + + async removeEnvironment(envName: string) { + const args = ['environment', 'remove', envName] + return this.executeCommand(args) + } + + private async executeCommand(args: string[]) { + const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd() + + try { + const result = await ChildProcess.run(this.binaryPath, args, { + spawnOptions: { + cwd, + }, + }) + + return result.exitCode === 0 + ? { success: true, output: result.stdout || undefined } + : { success: false, error: result.stderr || `Process exited with code ${result.exitCode}` } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } +} diff --git a/packages/core/src/awsService/cloudformation/cfn-init/cfnInitUiInterface.ts b/packages/core/src/awsService/cloudformation/cfn-init/cfnInitUiInterface.ts new file mode 100644 index 00000000000..24e7904c03f --- /dev/null +++ b/packages/core/src/awsService/cloudformation/cfn-init/cfnInitUiInterface.ts @@ -0,0 +1,240 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { CfnInitCliCaller, EnvironmentOption } from './cfnInitCliCaller' +import { Auth } from '../../../auth/auth' +import { promptForConnection } from '../../../auth/utils' +import { getEnvironmentName, getProjectName, getProjectPath } from '../ui/inputBox' + +interface FormState { + projectName?: string + projectPath?: string + environments: EnvironmentOption[] +} + +export class CfnInitUiInterface { + private state: FormState = { environments: [] } + + constructor(private cfnInitService: CfnInitCliCaller) {} + + async promptForCreate() { + try { + // Set default project path + this.state.projectPath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd() + await this.showForm() + } catch (error) { + void vscode.window.showErrorMessage(`CFN Init failed: ${error}`) + } + } + + private async showForm(): Promise { + const quickPick = vscode.window.createQuickPick() + quickPick.title = 'CFN Init: Initialize Project' + quickPick.placeholder = 'Configure your CloudFormation project' + quickPick.buttons = [{ iconPath: new vscode.ThemeIcon('check'), tooltip: 'Create Project' }] + + return new Promise((resolve) => { + const updateItems = () => { + const items = [ + { + label: `${this.state.projectName ? '[✓]' : '[ ]'} Project Name`, + detail: this.state.projectName || 'Click to set project name', + }, + { + label: `${this.state.projectPath ? '[✓]' : '[ ]'} Project Path`, + detail: this.state.projectPath || 'Click to set project path', + }, + ] + + // Add environment items + for (const [_index, env] of this.state.environments.entries()) { + items.push({ + label: `Adding Environment: ${env.name}`, + detail: `AWS Profile: ${env.awsProfile}`, + }) + } + + items.push({ + label: '$(plus) Add Environment (At least one required)', + detail: 'Configure a new deployment environment', + }) + + if (this.state.environments.length > 0) { + items.push({ + label: '$(trash) Delete Environment', + detail: 'Remove an existing environment', + }) + } + + quickPick.items = items + } + + updateItems() + + quickPick.onDidAccept(async () => { + const selected = quickPick.selectedItems[0] + if (!selected) { + return + } + + if (selected.label.includes('Project Name')) { + const name = await getProjectName(this.state.projectName) + + if (name) { + this.state.projectName = name + } + } else if (selected.label.includes('Project Path')) { + const currentPath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '.' + + const pathInput = await getProjectPath(this.state.projectPath || currentPath) + + if (pathInput !== undefined) { + this.state.projectPath = pathInput.trim() || currentPath + } + } else if (selected.label.includes('Add Environment')) { + await this.addEnvironment() + } else if (selected.label.includes('Delete Environment')) { + await this.deleteEnvironment() + } + + updateItems() + quickPick.show() + }) + + quickPick.onDidTriggerButton(async () => { + if (!this.state.projectName) { + void vscode.window.showWarningMessage('Project name is required') + return + } + if (!this.state.projectPath) { + void vscode.window.showWarningMessage('Project path is required') + return + } + if (this.state.environments.length === 0) { + void vscode.window.showWarningMessage('At least one environment is required') + return + } + quickPick.hide() + resolve(true) + await this.executeProject() + }) + + quickPick.onDidHide(() => resolve(false)) + quickPick.show() + }) + } + + async collectEnvironmentConfig(): Promise { + const envName = await getEnvironmentName() + + if (!envName) { + return undefined + } + + const connection = await promptForConnection(Auth.instance, 'iam-only') + if (!connection) { + return undefined + } + + if (connection.type !== 'iam') { + void vscode.window.showErrorMessage('Must select a valid IAM Profile for environment setup') + return undefined + } + + const selectedProfile = connection.id.replace('profile:', '') + + const addParamsFile = await vscode.window.showQuickPick(['Yes', 'No'], { + placeHolder: 'Import parameters files?', + }) + + const environment: EnvironmentOption = { + name: envName, + awsProfile: selectedProfile, + } + + if (addParamsFile === 'Yes') { + const result = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectMany: true, + filters: { 'Parameters Files': ['json', 'yaml', 'yml'] }, + }) + if (result && result.length > 0) { + environment.parametersFiles = result.map((uri) => uri.fsPath) + } + } + + return environment + } + + private async addEnvironment() { + const environment = await this.collectEnvironmentConfig() + if (!environment) { + return + } + + // Check for duplicate names + if (this.state.environments.some((e) => e.name === environment.name)) { + void vscode.window.showErrorMessage('Environment name already exists') + return + } + + this.state.environments.push(environment) + } + + private async deleteEnvironment() { + if (this.state.environments.length === 0) { + return + } + + const envNames = this.state.environments.map((env) => env.name) + const selected = await vscode.window.showQuickPick(envNames, { + placeHolder: 'Select environment to delete', + }) + + if (selected) { + this.state.environments = this.state.environments.filter((env) => env.name !== selected) + } + } + + private async executeProject() { + const progress = vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Creating CFN project...', + cancellable: false, + }, + async (progress) => { + progress.report({ increment: 25, message: 'Creating project...' }) + + const projectPath = this.state.projectPath || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '.' + + const result = await this.cfnInitService.createProject(this.state.projectName!, { + projectPath, + environments: this.state.environments, + }) + + if (!result.success) { + throw new Error(result.error) + } + + progress.report({ increment: 100, message: 'Complete!' }) + } + ) + + await progress + void vscode.window.showInformationMessage(`CFN project '${this.state.projectName}' created!`) + + const openProject = await vscode.window.showQuickPick(['Yes', 'No'], { + placeHolder: 'Open project folder in new window?', + }) + + if (openProject === 'Yes') { + const finalPath = this.state.projectPath || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '.' + const uri = vscode.Uri.file(finalPath) + await vscode.commands.executeCommand('vscode.openFolder', uri, true) + } + } +} diff --git a/packages/core/src/awsService/cloudformation/cfn-init/cfnProjectTypes.ts b/packages/core/src/awsService/cloudformation/cfn-init/cfnProjectTypes.ts new file mode 100644 index 00000000000..1f886d5bd15 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/cfn-init/cfnProjectTypes.ts @@ -0,0 +1,39 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OnStackFailure, Parameter } from '@aws-sdk/client-cloudformation' +import { ChangeSetOptionalFlags } from '../stacks/actions/stackActionRequestType' + +export type CfnEnvironmentConfig = { + name: string + profile: string +} + +export type CfnEnvironmentLookup = Record + +export type CfnConfig = { + version: string + project: { + name: string + created: string + } + environments: CfnEnvironmentLookup +} + +export type DeploymentConfig = { + templateFilePath?: string + parameters?: Record + tags?: Record + includeNestedStacks?: boolean + importExistingResources?: boolean + onStackFailure?: OnStackFailure +} + +export type CfnEnvironmentFileSelectorItem = { + fileName: string + hasMatchingTemplatePath?: boolean + compatibleParameters?: Parameter[] + optionalFlags?: ChangeSetOptionalFlags +} diff --git a/packages/core/src/awsService/cloudformation/cfn-init/utils.ts b/packages/core/src/awsService/cloudformation/cfn-init/utils.ts new file mode 100644 index 00000000000..a37725c465b --- /dev/null +++ b/packages/core/src/awsService/cloudformation/cfn-init/utils.ts @@ -0,0 +1,32 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Parameter, Tag } from '@aws-sdk/client-cloudformation' + +export function convertRecordToParameters(parameters: Record): Parameter[] { + return Object.entries(parameters).map(([key, value]) => ({ + ParameterKey: key, + ParameterValue: value, + })) +} + +export function convertRecordToTags(tags: Record): Tag[] { + return Object.entries(tags).map(([key, value]) => ({ + Key: key, + Value: value, + })) +} + +export function convertParametersToRecord(parameters: Parameter[]): Record { + return Object.fromEntries( + parameters + .filter((param) => param.ParameterKey && param.ParameterValue) + .map((param) => [param.ParameterKey!, param.ParameterValue!]) + ) +} + +export function convertTagsToRecord(tags: Tag[]): Record { + return Object.fromEntries(tags.filter((tag) => tag.Key && tag.Value).map((tag) => [tag.Key!, tag.Value!])) +} diff --git a/packages/core/src/awsService/cloudformation/cfn/resourceRequestTypes.ts b/packages/core/src/awsService/cloudformation/cfn/resourceRequestTypes.ts new file mode 100644 index 00000000000..9b64782526f --- /dev/null +++ b/packages/core/src/awsService/cloudformation/cfn/resourceRequestTypes.ts @@ -0,0 +1,102 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { RequestType, CompletionItem, TextDocumentIdentifier } from 'vscode-languageserver-protocol' + +export interface ResourceRequest { + resourceType: string + nextToken?: string +} + +export interface ListResourcesParams { + resources?: ResourceRequest[] +} + +export interface ResourceTypesParams {} + +export interface ResourceTypesResult { + resourceTypes: string[] +} + +export interface ResourceList { + typeName: string + resourceIdentifiers: string[] + nextToken?: string +} + +export interface ListResourcesResult { + resources: ResourceList[] +} + +export const ListResourcesRequest = new RequestType( + 'aws/cfn/resources/list' +) + +export const RefreshResourcesRequest = new RequestType( + 'aws/cfn/resources/refresh' +) + +export const ResourceTypesRequest = new RequestType( + 'aws/cfn/resources/types' +) + +export const RemoveResourceTypeRequest = new RequestType('aws/cfn/resources/list/remove') + +export type ResourceSelection = { + resourceType: string + resourceIdentifiers: string[] +} + +export enum ResourceStatePurpose { + Import = 'Import', + Clone = 'Clone', +} + +export interface ResourceStateParams { + textDocument: TextDocumentIdentifier + resourceSelections?: ResourceSelection[] + purpose: ResourceStatePurpose + parentResourceType?: string +} + +export type ResourceType = string +export type ResourceIdentifier = string + +export interface ResourceStateResult { + completionItem?: CompletionItem + successfulImports: Map + failedImports: Map + warning?: string +} + +export const ResourceStateRequest = new RequestType( + 'aws/cfn/resources/state' +) + +export type ResourceStackManagementResult = { + physicalResourceId: string + managedByStack: boolean | undefined + stackName?: string + stackId?: string + error?: string +} + +export const StackMgmtInfoRequest = new RequestType( + 'aws/cfn/resources/stackMgmtInfo' +) + +export type SearchResourceParams = { + resourceType: string + identifier: string +} + +export type SearchResourceResult = { + found: boolean + resource?: ResourceList +} + +export const SearchResourceRequest = new RequestType( + 'aws/cfn/resources/search' +) diff --git a/packages/core/src/awsService/cloudformation/codelens/stackActionCodeLensProvider.ts b/packages/core/src/awsService/cloudformation/codelens/stackActionCodeLensProvider.ts new file mode 100644 index 00000000000..bcf366890cc --- /dev/null +++ b/packages/core/src/awsService/cloudformation/codelens/stackActionCodeLensProvider.ts @@ -0,0 +1,34 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CancellationToken, CodeLens, CodeLensProvider, Event, EventEmitter, TextDocument } from 'vscode' +import { LanguageClient } from 'vscode-languageclient/node' + +const codeLensRequest = 'textDocument/codeLens' + +export class StackActionCodeLensProvider implements CodeLensProvider { + private readonly _onDidChangeCodeLenses = new EventEmitter() + public readonly onDidChangeCodeLenses: Event = this._onDidChangeCodeLenses.event + + constructor(private readonly client: LanguageClient) {} + + async provideCodeLenses(document: TextDocument, token: CancellationToken): Promise { + if (token.isCancellationRequested) { + return [] + } + + const result = await this.client.sendRequest( + codeLensRequest, + { textDocument: { uri: document.uri.toString() } }, + token + ) + + return result || [] + } + + refresh(): void { + this._onDidChangeCodeLenses.fire() + } +} diff --git a/packages/core/src/awsService/cloudformation/commands/cfnCommands.ts b/packages/core/src/awsService/cloudformation/commands/cfnCommands.ts new file mode 100644 index 00000000000..91e1f3b1eac --- /dev/null +++ b/packages/core/src/awsService/cloudformation/commands/cfnCommands.ts @@ -0,0 +1,974 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { commands, env, Uri, window, workspace, Range, Selection, TextEditorRevealType, ProgressLocation } from 'vscode' +import { commandKey, extractErrorMessage, findParameterDescriptionPosition, isStackInTransientState } from '../utils' +import { LanguageClient } from 'vscode-languageclient/node' +import { Command } from 'vscode-languageclient/node' +import * as yaml from 'js-yaml' + +import { Deployment } from '../stacks/actions/deploymentWorkflow' +import { Parameter, Capability, OnStackFailure, Stack } from '@aws-sdk/client-cloudformation' +import { + getParameterValues, + getStackName, + getTemplatePath, + confirmCapabilities, + shouldImportResources, + getResourcesToImport, + getEnvironmentName, + getChangeSetName, + chooseOptionalFlagSuggestion as chooseOptionalFlagMode, + getTags, + getOnStackFailure, + getIncludeNestedStacks, + getImportExistingResources, + getDeploymentMode, + shouldUploadToS3, + getS3Bucket, + getS3Key, + shouldSaveFlagsToFile, + getFilePath, +} from '../ui/inputBox' +import { DiffWebviewProvider } from '../ui/diffWebviewProvider' +import { showErrorMessage } from '../ui/message' +import { getLastValidation, setLastValidation, Validation } from '../stacks/actions/validationWorkflow' +import { + getParameters, + getCapabilities, + getTemplateResources, + getTemplateArtifacts, + describeChangeSet, +} from '../stacks/actions/stackActionApi' +import { + ChangeSetOptionalFlags, + OptionalFlagMode, + TemplateParameter, + ResourceToImport, + ChangeSetReference, + DeploymentMode, +} from '../stacks/actions/stackActionRequestType' +import { ResourceNode } from '../explorer/nodes/resourceNode' +import { ResourcesManager } from '../resources/resourcesManager' +import { RelatedResourcesManager } from '../relatedResources/relatedResourcesManager' +import { DocumentManager } from '../documents/documentManager' +import { CfnEnvironmentManager } from '../cfn-init/cfnEnvironmentManager' + +import { StackOverviewWebviewProvider } from '../ui/stackOverviewWebviewProvider' +import { StackOutputsWebviewProvider } from '../ui/stackOutputsWebviewProvider' +import { StackResourcesWebviewProvider } from '../ui/stackResourcesWebviewProvider' +import { StackViewCoordinator } from '../ui/stackViewCoordinator' +import { ResourceContextValue } from '../explorer/contextValue' +import { getLogger } from '../../../shared/logger/logger' +import { CloudFormationExplorer } from '../explorer/explorer' +import { StacksNode } from '../explorer/nodes/stacksNode' +import { StackNode } from '../explorer/nodes/stackNode' +import { ResourcesNode } from '../explorer/nodes/resourcesNode' +import { ResourceTypeNode } from '../explorer/nodes/resourceTypeNode' +import { StackChangeSetsNode } from '../explorer/nodes/stackChangeSetsNode' +import { CfnInitCliCaller } from '../cfn-init/cfnInitCliCaller' +import { CfnInitUiInterface } from '../cfn-init/cfnInitUiInterface' +import { ChangeSetDeletion } from '../stacks/actions/changeSetDeletionWorkflow' +import { fs } from '../../../shared/fs/fs' +import { convertParametersToRecord, convertTagsToRecord } from '../cfn-init/utils' +import { DescribeStackRequest } from '../stacks/actions/stackActionProtocol' + +export function validateDeploymentCommand( + client: LanguageClient, + diffProvider: DiffWebviewProvider, + documentManager: DocumentManager, + environmentManager: CfnEnvironmentManager +) { + return commands.registerCommand( + commandKey('api.validateDeployment'), + async (changeSetParams: string | StackNode | StacksNode) => { + try { + const result = await changeSetSteps( + client, + documentManager, + environmentManager, + true, + typeof changeSetParams === 'string' ? changeSetParams : undefined, + changeSetParams instanceof StackNode ? changeSetParams?.stack.StackName : undefined + ) + if (!result) { + return + } + + const validation = new Validation( + result.templateUri, + result.stackName, + client, + diffProvider, + result.parameters, + result.capabilities, + result.resourcesToImport, + false, + result.optionalFlags, + result.s3Bucket, + result.s3Key + ) + + setLastValidation(validation) + + await validation.validate() + } catch (error) { + showErrorMessage(`Error validating template: ${extractErrorMessage(error)}`) + } + } + ) +} + +export function deployTemplateFromStacksMenuCommand() { + return commands.registerCommand(commandKey('api.deployTemplateFromStacksMenu'), async () => { + return commands.executeCommand(commandKey('api.deployTemplate')) + }) +} + +export function executeChangeSetCommand(client: LanguageClient, coordinator: StackViewCoordinator) { + return commands.registerCommand( + commandKey('api.executeChangeSet'), + async (stackName: string, changeSetName: string) => { + try { + const deployment = new Deployment(stackName, changeSetName, client, coordinator) + + await deployment.deploy() + } catch (error) { + showErrorMessage(`Error executing change set: ${extractErrorMessage(error)}`) + } + } + ) +} + +export function deleteChangeSetCommand(client: LanguageClient) { + return commands.registerCommand(commandKey('stacks.deleteChangeSet'), async (params?: ChangeSetReference) => { + try { + params = params ?? (await promptForChangeSetReference()) + + if (!params) { + return + } + + const changeSetDeletion = new ChangeSetDeletion(params.stackName, params.changeSetName, client) + + await changeSetDeletion.delete() + } catch (error) { + showErrorMessage(`Error deleting change set: ${extractErrorMessage(error)}`) + } + }) +} + +export function viewChangeSetCommand(client: LanguageClient, diffProvider: DiffWebviewProvider) { + return commands.registerCommand(commandKey('stacks.viewChangeSet'), async (params?: ChangeSetReference) => { + try { + params = params ?? (await promptForChangeSetReference()) + + if (!params) { + return + } + + const describeChangeSetResult = await describeChangeSet(client, { + changeSetName: params.changeSetName, + stackName: params.stackName, + }) + + void diffProvider.updateData(params.stackName, describeChangeSetResult.changes, params.changeSetName, true) + void commands.executeCommand(commandKey('diff.focus')) + } catch (error) { + showErrorMessage(`Error viewing change set: ${extractErrorMessage(error)}`) + } + }) +} + +async function promptForChangeSetReference(): Promise { + const stackName = await getStackName() + const changeSetName = await getChangeSetName() + if (!stackName || !changeSetName) { + return undefined + } + + return { stackName: stackName, changeSetName: changeSetName } +} + +export function deployTemplateCommand( + client: LanguageClient, + diffProvider: DiffWebviewProvider, + documentManager: DocumentManager, + environmentManager: CfnEnvironmentManager +) { + return commands.registerCommand(commandKey('api.deployTemplate'), async (changeSetParams?: string | StackNode) => { + try { + const result = await changeSetSteps( + client, + documentManager, + environmentManager, + false, + typeof changeSetParams === 'string' ? changeSetParams : undefined, + typeof changeSetParams === 'object' ? changeSetParams?.stack.StackName : undefined + ) + if (!result) { + return + } + + const validation = new Validation( + result.templateUri, + result.stackName, + client, + diffProvider, + result.parameters, + result.capabilities, + result.resourcesToImport, + true, // Confirm deployment following successful validation + result.optionalFlags, + result.s3Bucket, + result.s3Key + ) + + setLastValidation(validation) + + await validation.validate() + } catch (error) { + showErrorMessage(`Error deploying template ${extractErrorMessage(error)}`) + } + }) +} + +async function promptForResourceImport(client: LanguageClient, templateUri: string) { + const importMode = await shouldImportResources() + let resourcesToImport + if (importMode) { + const templateResources = await getTemplateResources(client, templateUri) + if (!templateResources || templateResources.length === 0) { + showErrorMessage('No resources found in template to import') + return + } + + resourcesToImport = await getResourcesToImport(templateResources) + if (!resourcesToImport || resourcesToImport.length === 0) { + return + } + } + return resourcesToImport +} + +type OptionalFlagSelection = ChangeSetOptionalFlags & { + shouldSaveOptions?: boolean +} + +function shouldPromptForDeploymentMode( + stackDetails: Stack | undefined, + importExistingResources: boolean | undefined, + includeNestedStacks: boolean | undefined, + onStackFailure: OnStackFailure | undefined +): boolean { + const isCreate = !stackDetails + const hasDisableRollback = onStackFailure === OnStackFailure.DO_NOTHING + + return !isCreate && !importExistingResources && !includeNestedStacks && !hasDisableRollback +} + +export async function promptForOptionalFlags( + fileFlags?: ChangeSetOptionalFlags, + stackDetails?: Stack +): Promise { + if (fileFlags && Object.values(fileFlags).every((v) => v !== undefined)) { + return { + ...fileFlags, + shouldSaveOptions: false, + } + } + + let optionalFlags: OptionalFlagSelection | undefined + + const optionSelection = await chooseOptionalFlagMode() + + switch (optionSelection) { + case OptionalFlagMode.Skip: + optionalFlags = { + onStackFailure: fileFlags?.onStackFailure, + includeNestedStacks: fileFlags?.includeNestedStacks, + tags: fileFlags?.tags, + importExistingResources: fileFlags?.importExistingResources, + // default to REVERT_DRIFT if possible because it's generally useful + deploymentMode: + fileFlags?.deploymentMode ?? + (shouldPromptForDeploymentMode( + stackDetails, + fileFlags?.importExistingResources, + fileFlags?.includeNestedStacks, + fileFlags?.onStackFailure + ) + ? DeploymentMode.REVERT_DRIFT + : undefined), + shouldSaveOptions: false, + } + + break + case OptionalFlagMode.Input: { + const onStackFailure = fileFlags?.onStackFailure ?? (await getOnStackFailure(!!stackDetails)) + const includeNestedStacks = fileFlags?.includeNestedStacks ?? (await getIncludeNestedStacks()) + const importExistingResources = fileFlags?.importExistingResources ?? (await getImportExistingResources()) + + let deploymentMode = fileFlags?.deploymentMode + if ( + !deploymentMode && + shouldPromptForDeploymentMode( + stackDetails, + importExistingResources, + includeNestedStacks, + onStackFailure + ) + ) { + deploymentMode = await getDeploymentMode() + } + + optionalFlags = { + onStackFailure, + includeNestedStacks, + tags: fileFlags?.tags ?? (await getTags(stackDetails?.Tags)), + importExistingResources, + deploymentMode, + } + + if (!fileFlags && Object.values(optionalFlags).some((val) => val !== undefined)) { + optionalFlags.shouldSaveOptions = true + } + + break + } + case OptionalFlagMode.DevFriendly: + optionalFlags = { + onStackFailure: OnStackFailure.DO_NOTHING, + includeNestedStacks: true, + tags: fileFlags?.tags ?? (await getTags(stackDetails?.Tags)), + importExistingResources: true, + deploymentMode: undefined, + } + + if (!fileFlags && optionalFlags.tags) { + optionalFlags.shouldSaveOptions = true + } + + break + default: + optionalFlags = undefined + } + + return optionalFlags +} + +export async function promptToSaveToFile( + environmentDir: string, + optionalFlags?: ChangeSetOptionalFlags, + parameters?: Parameter[] +): Promise { + const shouldSave = await shouldSaveFlagsToFile() + + if (!shouldSave) { + return + } + + const filePath = await getFilePath(environmentDir) + + if (!filePath) { + return + } + + const data = { + parameters: parameters ? convertParametersToRecord(parameters) : undefined, + tags: optionalFlags?.tags ? convertTagsToRecord(optionalFlags?.tags) : undefined, + 'on-stack-failure': optionalFlags?.onStackFailure, + 'include-nested-stacks': optionalFlags?.includeNestedStacks, + 'import-existing-resources': optionalFlags?.importExistingResources, + 'deployment-mode': optionalFlags?.deploymentMode, + } + + // Determine file type and format accordingly + const isJsonFile = filePath.endsWith('.json') + const config = workspace.getConfiguration('editor') + const tabSize = config.get('tabSize', 2) + const insertSpaces = config.get('insertSpaces', true) + let content: string + + try { + if (isJsonFile) { + // JSON allows both tabs and spaces - respect user preference + const indent = insertSpaces ? tabSize : '\t' + content = JSON.stringify(data, undefined, indent) + } else { + // YAML spec requires spaces for indentation - always use spaces + content = yaml.dump(data, { indent: tabSize, noRefs: true, sortKeys: true }) + } + } catch (error) { + showErrorMessage(`Failed to format deployment options: ${extractErrorMessage(error)}`) + return + } + + try { + await fs.writeFile(filePath, content) + void window.showInformationMessage(`options saved to: ${filePath}`) + } catch (error) { + showErrorMessage(`Failed to save deployment options file: ${extractErrorMessage(error)}`) + } +} + +async function validateArtifactPaths(client: LanguageClient, templateUri: string): Promise { + try { + const artifactsResult = await getTemplateArtifacts(client, templateUri) + if (artifactsResult.artifacts.length === 0) { + return false + } + + for (const artifact of artifactsResult.artifacts) { + const artifactPath = artifact.filePath.startsWith('/') + ? artifact.filePath + : Uri.joinPath(Uri.parse(templateUri), '..', artifact.filePath).fsPath + + if (!(await fs.exists(artifactPath))) { + showErrorMessage(`Artifact path does not exist: ${artifact.filePath}`) + return undefined + } + } + return true + } catch (error) { + getLogger().warn(`Failed to check for artifacts: ${error}`) + return false + } +} + +type UserInputtedTemplateParameters = { + templateUri: string + stackName: string + parameters: Parameter[] | undefined + capabilities: Capability[] + resourcesToImport: ResourceToImport[] | undefined + optionalFlags: ChangeSetOptionalFlags | undefined + s3Bucket?: string + s3Key?: string +} + +async function changeSetSteps( + client: LanguageClient, + documentManager: DocumentManager, + environmentManager: CfnEnvironmentManager, + isValidation: boolean, + templateUri: string | undefined, + stackName: string | undefined +): Promise { + templateUri ??= await getTemplatePath(documentManager) + if (!templateUri) { + return + } + + await ensureFileIsOpen(templateUri) + + // Check for artifacts first + const hasArtifacts = await validateArtifactPaths(client, templateUri) + if (hasArtifacts === undefined) { + return // Error occurred during validation + } + + // Ask user if they want to upload to S3 + let s3Bucket: string | undefined + let s3Key: string | undefined + const uploadChoice = await shouldUploadToS3() + if (uploadChoice === undefined) { + return // User chose to configure settings, exit command + } + if (uploadChoice) { + s3Bucket = await getS3Bucket() + if (!s3Bucket) { + return + } + + const fileName = templateUri.split('/').pop() + const timestamp = Date.now() + const fileNameWithTimestamp = fileName + ? `${fileName.split('.')[0]}-${timestamp}.${fileName.split('.').pop()}` + : `template-${timestamp}.yaml` + s3Key = await getS3Key(fileNameWithTimestamp) + if (!s3Key) { + return + } + } else if (hasArtifacts) { + s3Bucket = await getS3Bucket( + 'S3 bucket is required because template contains artifacts that need to be uploaded to S3' + ) + if (!s3Bucket) { + return + } + } + + if (!stackName) { + if (isValidation) { + stackName = await getStackName(getLastValidation()?.stackName) + } else { + stackName = await getStackName() + } + // User cancelled + if (!stackName) { + return + } + } + + const stackDetails = await getStackDetails(client, stackName) + + const resourcesToImport = await promptForResourceImport(client, templateUri) + + const paramDefinition = await getTemplateParameters(client, templateUri) + let parameters: Parameter[] | undefined + + const environmentFile = await environmentManager.selectEnvironmentFile(templateUri, paramDefinition) + + if (paramDefinition.length > 0) { + parameters = environmentFile?.compatibleParameters + + // Prompt for any remaining parameters not provided by file + const providedParamNames = parameters?.map((p) => p.ParameterKey) ?? [] + const remainingParams = paramDefinition.filter((p) => !providedParamNames.includes(p.name)) + + if (remainingParams.length > 0) { + let prefilledParams: Parameter[] | undefined + + if (stackDetails) { + prefilledParams = stackDetails.Parameters + } else if (isValidation) { + prefilledParams = getLastValidation()?.parameters + } + + const additionalParams = await getParameterValues(remainingParams, prefilledParams) + + if (!additionalParams) { + return + } + + parameters = [...(parameters ?? []), ...additionalParams] + } + } + if (paramDefinition.length > 0 && !parameters) { + return + } + + const optionalFlags = await promptForOptionalFlags(environmentFile?.optionalFlags, stackDetails) + const shouldSaveParameters = parameters && parameters.length > 0 && !environmentFile + const selectedEnvironment = environmentManager.getSelectedEnvironmentName() + + if (selectedEnvironment && (shouldSaveParameters || optionalFlags?.shouldSaveOptions)) { + await promptToSaveToFile( + await environmentManager.getEnvironmentDir(selectedEnvironment), + optionalFlags, + parameters + ) + } + + const capabilitiesResult = await getCapabilities(client, templateUri) + const capabilities = await confirmCapabilities(capabilitiesResult.capabilities) + if (capabilities === undefined) { + return + } // User cancelled + return { templateUri, stackName, parameters, capabilities, resourcesToImport, optionalFlags, s3Bucket, s3Key } +} + +export function rerunLastValidationCommand() { + return commands.registerCommand(commandKey('api.rerunLastValidation'), async () => { + try { + const lastValidation = getLastValidation() + if (!lastValidation) { + showErrorMessage('No previous validation to rerun') + return + } + await lastValidation.validate() + } catch (error) { + showErrorMessage(`Error rerunning validation: ${error instanceof Error ? error.message : String(error)}`) + } + }) +} + +async function ensureFileIsOpen(templateUri: string): Promise { + const uri = Uri.parse(templateUri) + const openEditors = window.visibleTextEditors + const isFileOpen = openEditors.some((editor) => editor.document.uri.toString() === uri.toString()) + + if (!isFileOpen) { + try { + const document = await workspace.openTextDocument(uri) + await window.showTextDocument(document) + } catch (error) { + getLogger().warn(`Could not open file: ${error}`) + throw error + } + } +} + +async function getStackDetails(client: LanguageClient, stackName: string) { + let stackDetails: Stack | undefined + + try { + stackDetails = ( + await client.sendRequest(DescribeStackRequest, { + stackName: stackName, + }) + ).stack + } catch (error) { + const errorMessage = extractErrorMessage(error) + + if (!errorMessage.toLowerCase().includes('does not exist')) { + showErrorMessage(`Encountered error while extracting stack details: ${errorMessage}`) + } + } + + return stackDetails +} + +async function getTemplateParameters(client: LanguageClient, templateUri: string): Promise { + try { + const result = await getParameters(client, templateUri) + return result.parameters + } catch (error) { + showErrorMessage(`Error getting template parameters: ${error instanceof Error ? error.message : String(error)}`) + return [] + } +} + +export function addResourceTypesCommand(resourcesManager: ResourcesManager) { + return commands.registerCommand( + commandKey('api.addResourceTypes'), + async () => await resourcesManager.selectResourceTypes() + ) +} + +export function removeResourceTypeCommand(resourcesManager: ResourcesManager) { + return commands.registerCommand( + commandKey('removeResourceType'), + async (node: ResourceTypeNode) => await resourcesManager.removeResourceType(node.typeName) + ) +} + +export function importResourceStateCommand(resourcesManager: ResourcesManager) { + return commands.registerCommand( + commandKey('api.importResourceState'), + async (node?: ResourceNode, selectedNodes?: ResourceNode[]) => { + const nodes = selectedNodes ?? (node ? [node] : []) + const resourceNodes = nodes.filter((n) => n.contextValue === ResourceContextValue) + await resourcesManager.importResourceStates(resourceNodes) + } + ) +} + +export function cloneResourceStateCommand(resourcesManager: ResourcesManager) { + return commands.registerCommand( + commandKey('api.cloneResourceState'), + async (node?: ResourceNode, selectedNodes?: ResourceNode[]) => { + const nodes = selectedNodes ?? (node ? [node] : []) + const resourceNodes = nodes.filter((n) => n.contextValue === ResourceContextValue) + await resourcesManager.cloneResourceStates(resourceNodes) + } + ) +} + +export const RefreshResourceListCommand: Command = { + title: 'Refresh Resource List', + command: commandKey('api.refreshResourceList'), + arguments: [], +} + +export function copyResourceIdentifierCommand() { + return commands.registerCommand(commandKey('api.copyResourceIdentifier'), async (resourceNode?: ResourceNode) => { + if (resourceNode?.resourceIdentifier) { + await env.clipboard.writeText(resourceNode.resourceIdentifier) + window.setStatusBarMessage(`Resource identifier copied to clipboard`, 3000) + } + }) +} + +export function refreshAllResourcesCommand(resourcesManager: ResourcesManager) { + return commands.registerCommand(commandKey('api.refreshAllResources'), () => { + resourcesManager.refreshAllResources() + }) +} + +export function refreshResourceListCommand(resourcesManager: ResourcesManager, explorer: CloudFormationExplorer) { + return commands.registerCommand(RefreshResourceListCommand.command, async (resourceTypeNode?: ResourceTypeNode) => { + if (!resourceTypeNode) { + const children = await explorer.getChildren() + const resourcesNode = children.find((child) => child instanceof ResourcesNode) as ResourcesNode | undefined + if (!resourcesNode) { + return + } + + const resourceTypeNodes = (await resourcesNode.getChildren()) as ResourceTypeNode[] + if (resourceTypeNodes.length === 0) { + void window.showInformationMessage('No resource types selected') + return + } + + const selected = await window.showQuickPick( + resourceTypeNodes.map((n) => ({ label: n.typeName, node: n })), + { placeHolder: 'Select resource type to refresh' } + ) + + if (!selected) { + return + } + + resourceTypeNode = selected.node + } + + resourcesManager.refreshResourceList(resourceTypeNode.typeName) + }) +} + +export function focusDiffCommand() { + return commands.registerCommand(commandKey('diff.focus'), () => { + void commands.executeCommand('workbench.view.extension.cfn-diff') + }) +} + +export function getStackManagementInfoCommand(resourcesManager: ResourcesManager) { + return commands.registerCommand(commandKey('api.getStackManagementInfo'), async (resourceNode?: ResourceNode) => { + await resourcesManager.getStackManagementInfo(resourceNode) + }) +} + +export function extractToParameterPositionCursorCommand(client: LanguageClient) { + return commands.registerCommand( + 'aws.cloudformation.extractToParameter.positionCursor', + async ( + documentUri: string, + parameterName: string, + documentType: string, + trackingCommand?: string, + actionType?: string + ) => { + try { + // Track code action acceptance on the server if tracking parameters provided + if (trackingCommand && actionType) { + await client.sendRequest('workspace/executeCommand', { + command: trackingCommand, + arguments: [actionType], + }) + } + + const uri = Uri.parse(documentUri) + const document = await workspace.openTextDocument(uri) + const editor = await window.showTextDocument(document) + + const text = document.getText() + const position = findParameterDescriptionPosition(text, parameterName, documentType) + + if (position) { + editor.selection = new Selection(position, position) + editor.revealRange(new Range(position, position), TextEditorRevealType.InCenter) + } + } catch (error) { + getLogger().error(`Error positioning cursor in parameter description: ${error}`) + } + } + ) +} + +export function loadMoreResourcesCommand(explorer: CloudFormationExplorer) { + return commands.registerCommand(commandKey('api.loadMoreResources'), async (node?: ResourceTypeNode) => { + if (!node) { + const children = await explorer.getChildren() + const resourcesNode = children.find((child) => child instanceof ResourcesNode) as ResourcesNode | undefined + if (!resourcesNode) { + return + } + + const resourceTypeNodes = (await resourcesNode.getChildren()) as ResourceTypeNode[] + const nodesWithMore = resourceTypeNodes.filter((n) => n.contextValue === 'resourceTypeWithMore') + + if (nodesWithMore.length === 0) { + void window.showInformationMessage('No resource types have more resources to load') + return + } + + const selected = await window.showQuickPick( + nodesWithMore.map((n) => ({ label: n.typeName, node: n })), + { placeHolder: 'Select resource type to load more' } + ) + + if (!selected) { + return + } + + node = selected.node + } + + await node.loadMoreResources() + explorer.refresh(node) + }) +} + +export function loadMoreStacksCommand(explorer: CloudFormationExplorer) { + return commands.registerCommand(commandKey('api.loadMoreStacks'), async (node?: StacksNode) => { + if (!node) { + const children = await explorer.getChildren() + node = children.find((child) => child instanceof StacksNode) as StacksNode | undefined + if (!node) { + return + } + } + + if (node.contextValue !== 'stackSectionWithMore') { + void window.showInformationMessage('No more stacks to load') + return + } + + const stacksNode = node + await window.withProgress( + { + location: ProgressLocation.Notification, + title: 'Loading More Stacks', + }, + async () => { + await stacksNode.loadMoreStacks() + explorer.refresh(stacksNode) + } + ) + }) +} + +export function searchResourceCommand(explorer: CloudFormationExplorer, resourcesManager: ResourcesManager) { + return commands.registerCommand(commandKey('api.searchResource'), async (node: ResourceTypeNode) => { + const identifier = await window.showInputBox({ + prompt: `Enter ${node.label} identifier to search`, + placeHolder: 'Resource identifier', + }) + + if (!identifier) { + return + } + + const result = await resourcesManager.searchResource(node.label as string, identifier) + + if (result.found) { + void window.showInformationMessage(`Resource found: ${identifier}`) + explorer.refresh(node) + } else { + void window.showErrorMessage(`Resource not found: ${identifier}`) + } + }) +} + +export function refreshChangeSetsCommand(explorer: CloudFormationExplorer) { + return commands.registerCommand(commandKey('stacks.refreshChangeSets'), async (node: StackChangeSetsNode) => { + explorer.refresh(node) + }) +} + +export function loadMoreChangeSetsCommand(explorer: CloudFormationExplorer) { + return commands.registerCommand(commandKey('api.loadMoreChangeSets'), async (node: StackChangeSetsNode) => { + await node.loadMoreChangeSets() + explorer.refresh(node) + }) +} + +export function viewStackCommand( + coordinator: StackViewCoordinator, + overviewProvider: StackOverviewWebviewProvider, + outputsProvider: StackOutputsWebviewProvider, + resourcesProvider: StackResourcesWebviewProvider +) { + return commands.registerCommand(commandKey('stack.view'), async (node?: StackNode) => { + let stackName: string | undefined + + if (node?.stack.StackName) { + stackName = node.stack.StackName + } else { + stackName = await getStackName() + if (!stackName) { + return + } + } + + await coordinator.setStack(stackName) + + await overviewProvider.showStackOverview(stackName) + + const stackStatus = coordinator.currentStackStatus + + await resourcesProvider.updateData(stackName) + + if (stackStatus && !isStackInTransientState(stackStatus)) { + await outputsProvider.showOutputs(stackName) + } + + await commands.executeCommand(commandKey('stack.overview.focus')) + }) +} + +export function createProjectCommand(uiInterface: CfnInitUiInterface) { + return commands.registerCommand(commandKey('init.initializeProject'), async () => { + await uiInterface.promptForCreate() + }) +} + +export function addEnvironmentCommand( + uiInterface: CfnInitUiInterface, + cfnInit: CfnInitCliCaller, + environmentManager: CfnEnvironmentManager +) { + return commands.registerCommand(commandKey('init.addEnvironment'), async () => { + if (await environmentManager.promptInitializeIfNeeded('Environment Addition')) { + return + } + + try { + const environment = await uiInterface.collectEnvironmentConfig() + if (!environment) { + return + } + + const result = await cfnInit.addEnvironments([environment]) + + if (result.success) { + void window.showInformationMessage(`Environment '${environment.name}' added successfully`) + } else { + showErrorMessage(`Failed to add environment: ${result.error}`) + } + } catch (error) { + showErrorMessage(`Error adding environment: ${error}`) + } + }) +} + +export function removeEnvironmentCommand(cfnInit: CfnInitCliCaller, environmentManager: CfnEnvironmentManager) { + return commands.registerCommand(commandKey('init.removeEnvironment'), async () => { + if (await environmentManager.promptInitializeIfNeeded('Environment Deletion')) { + return + } + + try { + // TODO: Show quickpick of environments instead of inputting it + const envName = await getEnvironmentName() + if (!envName) { + return + } + + const confirm = await window.showWarningMessage(`Remove environment '${envName}'?`, 'Remove', 'Cancel') + if (confirm !== 'Remove') { + return + } + + const result = await cfnInit.removeEnvironment(envName) + if (result.success) { + void window.showInformationMessage(`Environment '${envName}' removed successfully`) + } else { + showErrorMessage(`Failed to remove environment: ${result.error}`) + } + } catch (error) { + showErrorMessage(`Error removing environment: ${error}`) + } + }) +} + +export function addRelatedResourcesCommand(relatedResourcesManager: RelatedResourcesManager) { + return commands.registerCommand(commandKey('api.addRelatedResources'), async (node?: ResourceTypeNode) => { + const selectedResourceType = node?.typeName + await relatedResourcesManager.addRelatedResources(selectedResourceType) + }) +} diff --git a/packages/core/src/awsService/cloudformation/commands/environmentCommands.ts b/packages/core/src/awsService/cloudformation/commands/environmentCommands.ts new file mode 100644 index 00000000000..a778a3b05cb --- /dev/null +++ b/packages/core/src/awsService/cloudformation/commands/environmentCommands.ts @@ -0,0 +1,14 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { CloudFormationExplorer } from '../explorer/explorer' +import { commandKey } from '../utils' + +export function selectEnvironmentCommand(explorer: CloudFormationExplorer): vscode.Disposable { + return vscode.commands.registerCommand(commandKey('environment.select'), async () => { + await explorer.environmentManager.selectEnvironment() + }) +} diff --git a/packages/core/src/awsService/cloudformation/commands/openStackTemplate.ts b/packages/core/src/awsService/cloudformation/commands/openStackTemplate.ts new file mode 100644 index 00000000000..b54248235ba --- /dev/null +++ b/packages/core/src/awsService/cloudformation/commands/openStackTemplate.ts @@ -0,0 +1,138 @@ +/*! +import { getLogger } from '../../../shared/logger' + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { commands, window, workspace, ViewColumn, Position, Range, Selection, ProgressLocation } from 'vscode' +import { LanguageClient } from 'vscode-languageclient/node' +import { RequestType } from 'vscode-languageserver-protocol' +import { commandKey, formatMessage } from '../utils' +import { getLogger } from '../../../shared/logger/logger' + +interface GetStackTemplateParams { + stackName: string + primaryIdentifier?: string +} + +interface GetStackTemplateResponse { + templateBody: string + lineNumber?: number +} + +const GetStackTemplateRequest = new RequestType( + 'aws/cfn/stack/template' +) + +function isValidStackName(stackName: string): boolean { + // CloudFormation stack names: 1-128 chars, alphanumeric and hyphens, start with letter + const stackNameRegex = /^[a-zA-Z][a-zA-Z0-9-]{0,127}$/ + return stackNameRegex.test(stackName) +} + +export function openStackTemplateCommand(client: LanguageClient) { + return commands.registerCommand( + commandKey('api.openStackTemplate'), + async (stackName: string, primaryIdentifier?: string) => { + if (!stackName) { + void window.showErrorMessage(formatMessage('No stack name provided')) + return + } + + if (!isValidStackName(stackName)) { + void window.showErrorMessage(formatMessage('Invalid stack name format')) + return + } + + await window + .withProgress( + { + location: ProgressLocation.Notification, + title: `Opening template for stack: ${stackName}`, + cancellable: false, + }, + async () => { + try { + const response = await client.sendRequest(GetStackTemplateRequest, { + stackName, + primaryIdentifier, + }) + + if (!response?.templateBody) { + void window.showWarningMessage( + formatMessage(`No template found for stack: ${stackName}`) + ) + return + } + + const doc = await workspace.openTextDocument({ + content: response.templateBody, + language: response.templateBody.trim().startsWith('{') ? 'json' : 'yaml', + }) + + const editor = await window.showTextDocument(doc, ViewColumn.Active) + + if (response.lineNumber !== undefined) { + const line = doc.lineAt(response.lineNumber) + const position = new Position(response.lineNumber, line.text.length) + editor.selection = new Selection(position, position) + editor.revealRange(new Range(position, position)) + } + + return response + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + + // Log technical details for debugging + getLogger().error('Failed to get stack template: %O', { + stackName, + primaryIdentifier, + error: errorMessage, + }) + + // Show user-friendly error message + let userMessage = 'Failed to open stack template' + if (errorMessage.includes('does not exist')) { + userMessage = `Stack "${stackName}" not found` + } else if (errorMessage.includes('Access Denied') || errorMessage.includes('Forbidden')) { + userMessage = `Access denied to stack "${stackName}"` + } else if (errorMessage.includes('Resource with PhysicalResourceId')) { + userMessage = 'Resource not found in stack' + } + + void window.showErrorMessage(formatMessage(userMessage)) + } + } + ) + .then(async (response) => { + if (!response) { + return + } + + const action = await window.showInformationMessage( + 'Template opened. Would you like to save it locally?', + 'Save As...', + 'No Thanks' + ) + + if (action === 'Save As...') { + const extension = response.templateBody.trim().startsWith('{') ? 'json' : 'yaml' + const saveUri = await window.showSaveDialog({ + defaultUri: workspace.workspaceFolders?.[0]?.uri.with({ + path: `${workspace.workspaceFolders[0].uri.path}/${stackName}-template.${extension}`, + }), + filters: { + 'CloudFormation Templates': [extension], + 'All Files': ['*'], + }, + }) + + if (saveUri) { + await workspace.fs.writeFile(saveUri, Buffer.from(response.templateBody, 'utf8')) + void window.showInformationMessage(`Template saved to ${saveUri.fsPath}`) + } + } + }) + } + ) +} diff --git a/packages/core/src/awsService/cloudformation/commands/regionCommands.ts b/packages/core/src/awsService/cloudformation/commands/regionCommands.ts new file mode 100644 index 00000000000..0869b40437e --- /dev/null +++ b/packages/core/src/awsService/cloudformation/commands/regionCommands.ts @@ -0,0 +1,14 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { CloudFormationExplorer } from '../explorer/explorer' +import { commandKey } from '../utils' + +export function selectRegionCommand(explorer: CloudFormationExplorer): vscode.Disposable { + return vscode.commands.registerCommand(commandKey('selectRegion'), async () => { + await explorer.selectRegion() + }) +} diff --git a/packages/core/src/awsService/cloudformation/documents/documentManager.ts b/packages/core/src/awsService/cloudformation/documents/documentManager.ts new file mode 100644 index 00000000000..5232d6c00cb --- /dev/null +++ b/packages/core/src/awsService/cloudformation/documents/documentManager.ts @@ -0,0 +1,44 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NotificationType } from 'vscode-languageserver-protocol' +import { LanguageClient } from 'vscode-languageclient/node' + +export type DocumentMetadata = { + uri: string + fileName: string + ext: string + type: string + cfnType: string + languageId: string + version: number + lineCount: number +} + +const DocumentsMetadataNotification = new NotificationType('aws/documents/metadata') + +type DocumentsChangeListener = (documents: DocumentMetadata[]) => void + +export class DocumentManager { + private documents: DocumentMetadata[] = [] + private readonly listeners: DocumentsChangeListener[] = [] + + constructor(private readonly client: LanguageClient) { + this.client.onNotification(DocumentsMetadataNotification, (documents: DocumentMetadata[]) => { + this.documents = documents + for (const listener of this.listeners) { + listener(this.documents) + } + }) + } + + addListener(listener: DocumentsChangeListener) { + this.listeners.push(listener) + } + + get() { + return [...this.documents] + } +} diff --git a/packages/core/src/awsService/cloudformation/documents/documentPreview.ts b/packages/core/src/awsService/cloudformation/documents/documentPreview.ts new file mode 100644 index 00000000000..208ba7867cd --- /dev/null +++ b/packages/core/src/awsService/cloudformation/documents/documentPreview.ts @@ -0,0 +1,42 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NotificationType } from 'vscode-languageserver-protocol' +import { LanguageClient } from 'vscode-languageclient/node' +import { ViewColumn, window, workspace } from 'vscode' + +type DocumentPreviewType = { + content: string + language: string + viewColumn?: number + preserveFocus?: boolean +} + +const DocumentPreviewNotification = new NotificationType('aws/document/preview') + +export class DocumentPreview { + constructor(private readonly client: LanguageClient) { + this.client.onNotification(DocumentPreviewNotification, (preview: DocumentPreviewType) => { + if (preview) { + void docPreview(preview) + } + }) + } +} + +export async function docPreview(preview: DocumentPreviewType) { + const { content, language, viewColumn = ViewColumn.Beside, preserveFocus = true } = preview + + await window.showTextDocument( + await workspace.openTextDocument({ + content, + language, + }), + { + viewColumn, + preserveFocus, + } + ) +} diff --git a/packages/core/src/awsService/cloudformation/explorer/contextValue.ts b/packages/core/src/awsService/cloudformation/explorer/contextValue.ts new file mode 100644 index 00000000000..0122fa2ffc2 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/explorer/contextValue.ts @@ -0,0 +1,11 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export const ResourceSectionContextValue = 'resourceSection' +export const ResourceTypeContextValue = 'resourceType' +export const ResourceTypeWithMoreContextValue = 'resourceTypeWithMore' +export const LoadMoreResourcesContextValue = 'loadMoreResources' +export const ResourceContextValue = 'resource' +export const RegionSelectorContextValue = 'regionSelector' diff --git a/packages/core/src/awsService/cloudformation/explorer/explorer.ts b/packages/core/src/awsService/cloudformation/explorer/explorer.ts new file mode 100644 index 00000000000..1afa1256b72 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/explorer/explorer.ts @@ -0,0 +1,107 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { RegionProvider } from '../../../shared/regions/regionProvider' +import { AWSTreeNodeBase } from '../../../shared/treeview/nodes/awsTreeNodeBase' +import { PlaceholderNode } from '../../../shared/treeview/nodes/placeholderNode' +import { RefreshableAwsTreeProvider } from '../../../shared/treeview/awsTreeProvider' +import { CloudFormationRegionManager } from './regionManager' +import { StacksNode } from './nodes/stacksNode' +import { ResourcesNode } from './nodes/resourcesNode' +import { RegionSelectorNode } from './nodes/regionSelectorNode' +import { AwsCredentialsService } from '../auth/credentials' +import { getLogger } from '../../../shared/logger/logger' +import { getIcon } from '../../../shared/icons' +import globals from '../../../shared/extensionGlobals' + +import { StacksManager } from '../stacks/stacksManager' +import { ResourcesManager } from '../resources/resourcesManager' + +import { DocumentManager } from '../documents/documentManager' +import { ChangeSetsManager } from '../stacks/changeSetsManager' +import { CfnEnvironmentManager } from '../cfn-init/cfnEnvironmentManager' +import { CfnEnvironmentsNode } from './nodes/cfnEnvironmentsNode' +import { telemetry } from '../../../shared/telemetry/telemetry' +import { cloudFormationUiClickMetric } from '../utils' + +export class CloudFormationExplorer implements vscode.TreeDataProvider, RefreshableAwsTreeProvider { + public viewProviderId: string = 'aws.cloudformation' + public readonly onDidChangeTreeData: vscode.Event + private readonly _onDidChangeTreeData: vscode.EventEmitter + public readonly regionManager: CloudFormationRegionManager + public readonly environmentManager: CfnEnvironmentManager + private credentialsService: AwsCredentialsService | undefined + + public constructor( + private readonly stacksManager: StacksManager, + private readonly resourcesManager: ResourcesManager, + private readonly changeSetsManager: ChangeSetsManager, + documentManager: DocumentManager, + regionProvider: RegionProvider, + environmentManager: CfnEnvironmentManager + ) { + this._onDidChangeTreeData = new vscode.EventEmitter() + this.onDidChangeTreeData = this._onDidChangeTreeData.event + this.regionManager = new CloudFormationRegionManager(regionProvider) + this.environmentManager = environmentManager + } + + public setCredentialsService(credentialsService: AwsCredentialsService): void { + this.credentialsService = credentialsService + } + + public async selectRegion(): Promise { + telemetry.ui_click.emit({ elementId: cloudFormationUiClickMetric }) + const changed = await this.regionManager.showRegionSelector() + if (changed) { + this.refresh() + if (this.credentialsService) { + await this.credentialsService.updateRegion() + } + } + } + + public getTreeItem(element: AWSTreeNodeBase): vscode.TreeItem { + return element + } + + public async getChildren(element?: AWSTreeNodeBase): Promise { + if (!element) { + return this.getRootChildren() + } + telemetry.ui_click.emit({ elementId: cloudFormationUiClickMetric }) + return element.getChildren() + } + + private getRootChildren(): AWSTreeNodeBase[] { + try { + // Show sign-in message when not authenticated + if (!globals.awsContext.getCredentialProfileName()) { + const signInNode = new PlaceholderNode(this as any, 'Sign in to get started') + signInNode.iconPath = getIcon('vscode-account') + signInNode.command = { + command: 'aws.toolkit.login', + title: 'Sign in', + } + return [signInNode] + } + + return [ + new RegionSelectorNode(this.regionManager), + new CfnEnvironmentsNode(this.environmentManager), + new StacksNode(this.stacksManager, this.changeSetsManager), + new ResourcesNode(this.resourcesManager), + ] + } catch (error) { + getLogger().error('CloudFormation explorer error: %O', error) + return [] + } + } + + public refresh(node?: AWSTreeNodeBase): void { + this._onDidChangeTreeData.fire(node) + } +} diff --git a/packages/core/src/awsService/cloudformation/explorer/nodes/cfnEnvironmentsNode.ts b/packages/core/src/awsService/cloudformation/explorer/nodes/cfnEnvironmentsNode.ts new file mode 100644 index 00000000000..4e287158c6e --- /dev/null +++ b/packages/core/src/awsService/cloudformation/explorer/nodes/cfnEnvironmentsNode.ts @@ -0,0 +1,31 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TreeItemCollapsibleState, ThemeIcon } from 'vscode' +import { AWSTreeNodeBase } from '../../../../shared/treeview/nodes/awsTreeNodeBase' +import { CfnEnvironmentManager } from '../../cfn-init/cfnEnvironmentManager' +import { commandKey } from '../../utils' + +export class CfnEnvironmentsNode extends AWSTreeNodeBase { + public constructor(readonly environmentManager: CfnEnvironmentManager) { + const selectedEnv = environmentManager.getSelectedEnvironmentName() + const label = selectedEnv ? `Environment: ${selectedEnv}` : 'Environment: not selected' + + super(label, TreeItemCollapsibleState.None) + this.contextValue = 'environmentsSection' + this.iconPath = new ThemeIcon('settings-gear') + this.tooltip = selectedEnv + ? `Current environment: ${selectedEnv}. Click to select a different environment.` + : 'No environment selected. Click to select an environment.' + this.command = { + command: commandKey('environment.select'), + title: 'Select Environment', + } + } + + public override async getChildren(): Promise { + return [] + } +} diff --git a/packages/core/src/awsService/cloudformation/explorer/nodes/regionSelectorNode.ts b/packages/core/src/awsService/cloudformation/explorer/nodes/regionSelectorNode.ts new file mode 100644 index 00000000000..9766a6a3c4f --- /dev/null +++ b/packages/core/src/awsService/cloudformation/explorer/nodes/regionSelectorNode.ts @@ -0,0 +1,24 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TreeItemCollapsibleState, ThemeIcon } from 'vscode' +import { AWSTreeNodeBase } from '../../../../shared/treeview/nodes/awsTreeNodeBase' +import { CloudFormationRegionManager } from '../regionManager' +import { RegionSelectorContextValue } from '../contextValue' +import { commandKey } from '../../utils' + +export class RegionSelectorNode extends AWSTreeNodeBase { + public constructor(regionManager: CloudFormationRegionManager) { + const currentRegion = regionManager.getSelectedRegion() + super(currentRegion, TreeItemCollapsibleState.None) + this.contextValue = RegionSelectorContextValue + this.iconPath = new ThemeIcon('globe') + this.tooltip = `Current region: ${currentRegion}. Click to select a different region.` + this.command = { + command: commandKey('selectRegion'), + title: 'Select Region', + } + } +} diff --git a/packages/core/src/awsService/cloudformation/explorer/nodes/resourceNode.ts b/packages/core/src/awsService/cloudformation/explorer/nodes/resourceNode.ts new file mode 100644 index 00000000000..d92c52aa3f1 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/explorer/nodes/resourceNode.ts @@ -0,0 +1,21 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TreeItemCollapsibleState } from 'vscode' +import { AWSTreeNodeBase } from '../../../../shared/treeview/nodes/awsTreeNodeBase' + +export class ResourceNode extends AWSTreeNodeBase { + public constructor( + public readonly resourceIdentifier: string, + public readonly resourceType: string + ) { + super(resourceIdentifier, TreeItemCollapsibleState.None) + this.contextValue = 'resource' + } + + public override async getChildren(): Promise { + return [] + } +} diff --git a/packages/core/src/awsService/cloudformation/explorer/nodes/resourceTypeNode.ts b/packages/core/src/awsService/cloudformation/explorer/nodes/resourceTypeNode.ts new file mode 100644 index 00000000000..597f453c2bd --- /dev/null +++ b/packages/core/src/awsService/cloudformation/explorer/nodes/resourceTypeNode.ts @@ -0,0 +1,92 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TreeItemCollapsibleState, ThemeIcon } from 'vscode' +import { AWSTreeNodeBase } from '../../../../shared/treeview/nodes/awsTreeNodeBase' +import { ResourceList } from '../../cfn/resourceRequestTypes' +import { ResourceNode } from './resourceNode' +import { commandKey } from '../../utils' +import { ResourcesManager } from '../../resources/resourcesManager' +import { + LoadMoreResourcesContextValue, + ResourceTypeContextValue, + ResourceTypeWithMoreContextValue, +} from '../contextValue' + +class LoadMoreResourcesNode extends AWSTreeNodeBase { + public constructor(private readonly parent: ResourceTypeNode) { + super('[Load More...]', TreeItemCollapsibleState.None) + this.contextValue = LoadMoreResourcesContextValue + this.command = { + title: 'Load More', + command: commandKey('api.loadMoreResources'), + arguments: [this.parent], + } + } +} + +class NoResourcesNode extends AWSTreeNodeBase { + public constructor() { + super('No resources found', TreeItemCollapsibleState.None) + this.contextValue = 'noResources' + this.iconPath = new ThemeIcon('info') + } +} + +export class ResourceTypeNode extends AWSTreeNodeBase { + private loaded = false + + public constructor( + public readonly typeName: string, + private readonly resourcesManager: ResourcesManager, + private resourceList?: ResourceList + ) { + super(typeName, TreeItemCollapsibleState.Collapsed) + this.loaded = resourceList !== undefined + this.updateNode() + } + + private updateNode(): void { + if (!this.resourceList) { + this.description = undefined + this.contextValue = ResourceTypeContextValue + return + } + const count = this.resourceList.resourceIdentifiers.length + const hasMore = this.resourceList.nextToken !== undefined + this.description = hasMore ? `(${count}+)` : `(${count})` + this.contextValue = hasMore ? ResourceTypeWithMoreContextValue : ResourceTypeContextValue + } + + public override async getChildren(): Promise { + if (!this.loaded) { + await this.resourcesManager.loadResourceType(this.typeName) + this.resourceList = this.resourcesManager.get().find((r) => r.typeName === this.typeName) + this.loaded = true + this.updateNode() + } + + if (!this.resourceList || this.resourceList.resourceIdentifiers.length === 0) { + return [new NoResourcesNode()] + } + + const nodes = this.resourceList.resourceIdentifiers.map( + (identifier) => new ResourceNode(identifier, this.typeName) + ) + + return this.resourceList.nextToken ? [...nodes, new LoadMoreResourcesNode(this)] : nodes + } + + public async loadMoreResources(): Promise { + if (!this.resourceList?.nextToken) { + return + } + + await this.resourcesManager.loadMoreResources(this.typeName, this.resourceList.nextToken) + + this.resourceList = this.resourcesManager.get().find((r) => r.typeName === this.typeName) + this.updateNode() + } +} diff --git a/packages/core/src/awsService/cloudformation/explorer/nodes/resourcesNode.ts b/packages/core/src/awsService/cloudformation/explorer/nodes/resourcesNode.ts new file mode 100644 index 00000000000..69292f12bb3 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/explorer/nodes/resourcesNode.ts @@ -0,0 +1,26 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TreeItemCollapsibleState } from 'vscode' +import { AWSTreeNodeBase } from '../../../../shared/treeview/nodes/awsTreeNodeBase' +import { ResourcesManager } from '../../resources/resourcesManager' +import { ResourceTypeNode } from './resourceTypeNode' + +export class ResourcesNode extends AWSTreeNodeBase { + public constructor(private readonly resourcesManager: ResourcesManager) { + super('Resources', TreeItemCollapsibleState.Collapsed) + this.contextValue = 'resourceSection' + } + + public override async getChildren(): Promise { + const selectedTypes = this.resourcesManager.getSelectedResourceTypes() + const loadedResources = this.resourcesManager.get() + + return selectedTypes.map((typeName) => { + const resourceList = loadedResources.find((r) => r.typeName === typeName) + return new ResourceTypeNode(typeName, this.resourcesManager, resourceList) + }) + } +} diff --git a/packages/core/src/awsService/cloudformation/explorer/nodes/stackChangeSetsNode.ts b/packages/core/src/awsService/cloudformation/explorer/nodes/stackChangeSetsNode.ts new file mode 100644 index 00000000000..343bf97c880 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/explorer/nodes/stackChangeSetsNode.ts @@ -0,0 +1,109 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TreeItemCollapsibleState, ThemeIcon, ThemeColor } from 'vscode' +import { AWSTreeNodeBase } from '../../../../shared/treeview/nodes/awsTreeNodeBase' +import { ChangeSetsManager } from '../../stacks/changeSetsManager' +import { ChangeSetInfo } from '../../stacks/actions/stackActionRequestType' +import { commandKey } from '../../utils' + +class LoadMoreChangeSetsNode extends AWSTreeNodeBase { + public constructor(private readonly parent: StackChangeSetsNode) { + super('[Load More...]', TreeItemCollapsibleState.None) + this.contextValue = 'loadMoreChangeSets' + this.command = { + title: 'Load More', + command: commandKey('api.loadMoreChangeSets'), + arguments: [this.parent], + } + } +} + +class NoChangeSetsNode extends AWSTreeNodeBase { + public constructor() { + super('No change sets found', TreeItemCollapsibleState.None) + this.contextValue = 'noChangeSets' + this.iconPath = new ThemeIcon('info') + } +} + +export class StackChangeSetsNode extends AWSTreeNodeBase { + public constructor( + private readonly stackName: string, + private readonly changeSetsManager: ChangeSetsManager + ) { + super('Change Sets', TreeItemCollapsibleState.Collapsed) + this.contextValue = 'stackChangeSets' + this.iconPath = new ThemeIcon('diff') + this.updateNode() + } + + private updateNode(): void { + const count = this.changeSetsManager.get(this.stackName).length + const hasMore = this.changeSetsManager.hasMore(this.stackName) + this.description = hasMore ? `(${count}+)` : `(${count})` + this.contextValue = hasMore ? 'stackChangeSetsWithMore' : 'stackChangeSets' + } + + public override async getChildren(): Promise { + const changeSets = await this.changeSetsManager.getChangeSets(this.stackName) + this.updateNode() + + if (changeSets.length === 0) { + return [new NoChangeSetsNode()] + } + + const nodes = changeSets.map((changeSet) => new ChangeSetNode(changeSet, this.stackName)) + return this.changeSetsManager.hasMore(this.stackName) ? [...nodes, new LoadMoreChangeSetsNode(this)] : nodes + } + + public async loadMoreChangeSets(): Promise { + await this.changeSetsManager.loadMoreChangeSets(this.stackName) + this.updateNode() + } +} + +export class ChangeSetNode extends AWSTreeNodeBase { + public readonly stackName: string + public readonly changeSetName: string + + public constructor( + public readonly changeSet: ChangeSetInfo, + stackName: string + ) { + super(changeSet.changeSetName, TreeItemCollapsibleState.None) + this.stackName = stackName + this.changeSetName = changeSet.changeSetName + this.contextValue = 'changeSet' + this.tooltip = `${changeSet.changeSetName} [${changeSet.status}]` + this.iconPath = this.getIconForStatus(changeSet.status) + this.stackName = stackName + this.changeSetName = changeSet.changeSetName + } + + private getIconForStatus(status: string): ThemeIcon { + switch (status) { + case 'CREATE_PENDING': + case 'DELETE_PENDING': + return new ThemeIcon('clock') + case 'CREATE_IN_PROGRESS': + case 'DELETE_IN_PROGRESS': + return new ThemeIcon('sync~spin', new ThemeColor('charts.yellow')) + case 'CREATE_COMPLETE': + return new ThemeIcon('check', new ThemeColor('charts.green')) + case 'DELETE_COMPLETE': + return new ThemeIcon('trash') + case 'DELETE_FAILED': + case 'FAILED': + return new ThemeIcon('error', new ThemeColor('charts.red')) + default: + return new ThemeIcon('git-commit') + } + } + + public override async getChildren(): Promise { + return [] + } +} diff --git a/packages/core/src/awsService/cloudformation/explorer/nodes/stackNode.ts b/packages/core/src/awsService/cloudformation/explorer/nodes/stackNode.ts new file mode 100644 index 00000000000..531f864b3db --- /dev/null +++ b/packages/core/src/awsService/cloudformation/explorer/nodes/stackNode.ts @@ -0,0 +1,46 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TreeItemCollapsibleState, ThemeIcon, ThemeColor } from 'vscode' +import { AWSTreeNodeBase } from '../../../../shared/treeview/nodes/awsTreeNodeBase' +import { StackSummary } from '@aws-sdk/client-cloudformation' +import { StackChangeSetsNode } from './stackChangeSetsNode' +import { ChangeSetsManager } from '../../stacks/changeSetsManager' + +export class StackNode extends AWSTreeNodeBase { + public constructor( + public readonly stack: StackSummary, + private readonly changeSetsManager: ChangeSetsManager + ) { + super(stack.StackName ?? 'Unknown Stack', TreeItemCollapsibleState.Collapsed) + this.contextValue = 'stack' + this.tooltip = `${stack.StackName} [${stack.StackStatus}]` + this.iconPath = this.getStackIcon(stack.StackStatus) + } + + private getStackIcon(status?: string): ThemeIcon { + if (!status) { + return new ThemeIcon('layers') + } + + if (status.includes('COMPLETE') && !status.includes('ROLLBACK')) { + return new ThemeIcon('check', new ThemeColor('charts.green')) + } else if (status.includes('FAILED') || status.includes('ROLLBACK')) { + return new ThemeIcon('error', new ThemeColor('charts.red')) + } else if (status.includes('PROGRESS')) { + return new ThemeIcon('sync~spin', new ThemeColor('charts.yellow')) + } else { + return new ThemeIcon('layers') + } + } + + public override async getChildren(): Promise { + const stackName = this.stack.StackName ?? '' + + await this.changeSetsManager.getChangeSets(stackName) + + return [new StackChangeSetsNode(stackName, this.changeSetsManager)] + } +} diff --git a/packages/core/src/awsService/cloudformation/explorer/nodes/stacksNode.ts b/packages/core/src/awsService/cloudformation/explorer/nodes/stacksNode.ts new file mode 100644 index 00000000000..1b01e5456b1 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/explorer/nodes/stacksNode.ts @@ -0,0 +1,53 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TreeItemCollapsibleState } from 'vscode' +import { AWSTreeNodeBase } from '../../../../shared/treeview/nodes/awsTreeNodeBase' +import { commandKey } from '../../utils' +import { StacksManager } from '../../stacks/stacksManager' +import { StackSummary } from '@aws-sdk/client-cloudformation' +import { ChangeSetsManager } from '../../stacks/changeSetsManager' +import { StackNode } from './stackNode' + +class LoadMoreStacksNode extends AWSTreeNodeBase { + public constructor(private readonly parent: StacksNode) { + super('[Load More...]', TreeItemCollapsibleState.None) + this.contextValue = 'loadMoreStacks' + this.command = { + title: 'Load More', + command: commandKey('api.loadMoreStacks'), + arguments: [this.parent], + } + } +} + +export class StacksNode extends AWSTreeNodeBase { + public constructor( + private readonly stacksManager: StacksManager, + private readonly changeSetsManager: ChangeSetsManager + ) { + super('Stacks', TreeItemCollapsibleState.Collapsed) + this.updateNode() + } + + public override async getChildren(): Promise { + this.updateNode() + const stacks = this.stacksManager.get() + const nodes = stacks.map((stack: StackSummary) => new StackNode(stack, this.changeSetsManager)) + return this.stacksManager.hasMore() ? [...nodes, new LoadMoreStacksNode(this)] : nodes + } + + private updateNode(): void { + const count = this.stacksManager.get().length + const hasMore = this.stacksManager.hasMore() + this.description = hasMore ? `(${count}+)` : `(${count})` + this.contextValue = hasMore ? 'stackSectionWithMore' : 'stackSection' + } + + public async loadMoreStacks(): Promise { + await this.stacksManager.loadMoreStacks() + this.updateNode() + } +} diff --git a/packages/core/src/awsService/cloudformation/explorer/regionManager.ts b/packages/core/src/awsService/cloudformation/explorer/regionManager.ts new file mode 100644 index 00000000000..6dfdaf9291b --- /dev/null +++ b/packages/core/src/awsService/cloudformation/explorer/regionManager.ts @@ -0,0 +1,70 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import * as nls from 'vscode-nls' +import { RegionProvider } from '../../../shared/regions/regionProvider' +import globals from '../../../shared/extensionGlobals' + +const localize = nls.loadMessageBundle() + +export class CloudFormationRegionManager { + private static readonly storageKey = 'aws.cloudformation.region' + + constructor(private readonly regionProvider: RegionProvider) {} + + public getSelectedRegion(): string { + const cfnRegion = globals.globalState.tryGet(CloudFormationRegionManager.storageKey, String) + + // If no CloudFormation region selected, use credential default region, then AWS explorer region as fallback + if (!cfnRegion) { + const credentialDefaultRegion = globals.awsContext.getCredentialDefaultRegion() + if (credentialDefaultRegion) { + return credentialDefaultRegion + } + + const awsExplorerRegions = globals.globalState.tryGet('region', Object, []) + return awsExplorerRegions.length > 0 ? awsExplorerRegions[0] : 'us-east-1' + } + + return cfnRegion + } + + public async updateSelectedRegion(region: string): Promise { + await globals.globalState.update(CloudFormationRegionManager.storageKey, region) + } + + public async showRegionSelector(): Promise { + const currentRegion = this.getSelectedRegion() + const allRegions = this.regionProvider.getRegions() + + const items: vscode.QuickPickItem[] = allRegions.map((r) => ({ + label: r.name, + detail: r.id, + })) + + const placeholder = localize( + 'cloudformation.showHideRegionPlaceholder', + 'Select region for CloudFormation panel' + ) + + const result = await vscode.window.showQuickPick(items, { + placeHolder: placeholder, + canPickMany: false, + matchOnDetail: true, + }) + + if (!result || !result.detail) { + return false + } + + if (result.detail !== currentRegion) { + await this.updateSelectedRegion(result.detail) + return true + } + + return false + } +} diff --git a/packages/core/src/awsService/cloudformation/extension.ts b/packages/core/src/awsService/cloudformation/extension.ts new file mode 100644 index 00000000000..d1d05d9d60a --- /dev/null +++ b/packages/core/src/awsService/cloudformation/extension.ts @@ -0,0 +1,335 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ExtensionContext, window, languages, commands } from 'vscode' +import { + LanguageClient, + LanguageClientOptions, + ServerOptions, + TransportKind, + ErrorHandlerResult, + CloseHandlerResult, +} from 'vscode-languageclient/node' +import { CloseAction, ErrorAction, Message } from 'vscode-languageclient/node' +import { formatMessage, toString } from './utils' +import globals from '../../shared/extensionGlobals' +import { getServiceEnvVarConfig } from '../../shared/vscode/env' +import { DevSettings } from '../../shared/settings' +import { + deployTemplateCommand, + validateDeploymentCommand, + rerunLastValidationCommand, + importResourceStateCommand, + cloneResourceStateCommand, + addResourceTypesCommand, + removeResourceTypeCommand, + refreshAllResourcesCommand, + refreshResourceListCommand, + copyResourceIdentifierCommand, + focusDiffCommand, + getStackManagementInfoCommand, + extractToParameterPositionCursorCommand, + loadMoreResourcesCommand, + loadMoreStacksCommand, + searchResourceCommand, + executeChangeSetCommand, + addRelatedResourcesCommand, + refreshChangeSetsCommand, + loadMoreChangeSetsCommand, + viewStackCommand, + createProjectCommand, + addEnvironmentCommand, + removeEnvironmentCommand, + deleteChangeSetCommand, + viewChangeSetCommand, + deployTemplateFromStacksMenuCommand, +} from './commands/cfnCommands' +import { openStackTemplateCommand } from './commands/openStackTemplate' +import { selectRegionCommand } from './commands/regionCommands' +import { AwsCredentialsService, encryptionKey } from './auth/credentials' +import { ExtensionId, ExtensionName, Version, CloudFormationTelemetrySettings } from './extensionConfig' +import { commandKey } from './utils' +import { CloudFormationExplorer } from './explorer/explorer' +import { promptTelemetryOptIn } from './telemetryOptIn' + +import { refreshCommand, StacksManager } from './stacks/stacksManager' +import { StackOverviewWebviewProvider } from './ui/stackOverviewWebviewProvider' +import { StackEventsWebviewProvider } from './ui/stackEventsWebviewProvider' +import { StackOutputsWebviewProvider } from './ui/stackOutputsWebviewProvider' +import { DiffWebviewProvider } from './ui/diffWebviewProvider' +import { StackResourcesWebviewProvider } from './ui/stackResourcesWebviewProvider' +import { StackViewCoordinator } from './ui/stackViewCoordinator' +import { DocumentManager } from './documents/documentManager' + +import { ResourcesManager } from './resources/resourcesManager' +import { ResourceSelector } from './ui/resourceSelector' +import { RelatedResourcesManager } from './relatedResources/relatedResourcesManager' +import { RelatedResourceSelector } from './ui/relatedResourceSelector' + +import { StackActionCodeLensProvider } from './codelens/stackActionCodeLensProvider' +import { getClientId } from '../../shared/telemetry/util' +import { SettingsLspServerProvider } from './lsp-server/settingsLspServerProvider' +import { DevLspServerProvider } from './lsp-server/devLspServerProvider' +import { RemoteLspServerProvider } from './lsp-server/remoteLspServerProvider' +import { LspServerProvider } from './lsp-server/lspServerProvider' +import { getLogger } from '../../shared/logger/logger' +import { ChangeSetsManager } from './stacks/changeSetsManager' +import { CfnEnvironmentManager } from './cfn-init/cfnEnvironmentManager' +import { CfnEnvironmentSelector } from './ui/cfnEnvironmentSelector' +import { selectEnvironmentCommand } from './commands/environmentCommands' +import { CfnInitUiInterface } from './cfn-init/cfnInitUiInterface' +import { CfnInitCliCaller } from './cfn-init/cfnInitCliCaller' +import { CfnEnvironmentFileSelector } from './ui/cfnEnvironmentFileSelector' + +let client: LanguageClient + +export async function activate(context: ExtensionContext) { + context.subscriptions.push( + commands.registerCommand(commandKey('server.restartServer'), async () => { + try { + await deactivate() + await activate(context) + } catch (error) { + void window.showErrorMessage( + formatMessage(`Failed to restart CloudFormation extension: ${toString(error)}`) + ) + } + }) + ) + + const cfnTelemetrySettings = new CloudFormationTelemetrySettings() + const telemetryEnabled = await promptTelemetryOptIn(context, cfnTelemetrySettings) + + const cfnLspConfig = { + ...DevSettings.instance.getServiceConfig('cloudformationLsp', {}), + ...getServiceEnvVarConfig('cloudformationLsp', ['path', 'cloudformationEndpoint']), + } + + const serverProvider = new LspServerProvider([ + new DevLspServerProvider(context), + new SettingsLspServerProvider(cfnLspConfig), + new RemoteLspServerProvider(), + ]) + const serverFile = await serverProvider.serverExecutable() + getLogger().info(`Found CloudFormation LSP executable: ${serverFile}`) + const serverRootDir = await serverProvider.serverRootDir() + + const envOptions = { + NODE_OPTIONS: '--enable-source-maps', + } + + const serverOptions: ServerOptions = { + run: { + module: serverFile, + transport: TransportKind.ipc, + options: { + env: envOptions, + }, + }, + debug: { + module: serverFile, + transport: TransportKind.ipc, + options: { + execArgv: ['--no-lazy'], + env: envOptions, + }, + }, + } + + const clientOptions: LanguageClientOptions = { + documentSelector: [ + { scheme: 'file', language: 'plaintext' }, + { scheme: 'file', language: 'cloudformation' }, + { scheme: 'file', language: 'template' }, + { scheme: 'file', language: 'json' }, + { scheme: 'file', language: 'yaml' }, + { scheme: 'file', pattern: '**/*.txt' }, + { scheme: 'file', pattern: '**/*.template' }, + { scheme: 'file', pattern: '**/*.cfn' }, + { scheme: 'file', pattern: '**/*.json' }, + { scheme: 'file', pattern: '**/*.yaml' }, + ], + initializationOptions: { + handledSchemaProtocols: ['file'], + aws: { + clientInfo: { + extension: { + name: ExtensionId, + version: Version, + }, + clientId: getClientId(globals.globalState, telemetryEnabled), + }, + telemetryEnabled: telemetryEnabled, + ...(cfnLspConfig.cloudformationEndpoint && { + cloudformation: { + endpoint: cfnLspConfig.cloudformationEndpoint, + }, + }), + encryption: { + key: encryptionKey.toString('base64'), + mode: 'JWT', + }, + }, + }, + errorHandler: { + error: (error: Error, message: Message | undefined, count: number | undefined): ErrorHandlerResult => { + void window.showErrorMessage(formatMessage(`Error count = ${count}): ${toString(message)}`)) + return { action: ErrorAction.Continue } + }, + closed: (): CloseHandlerResult => { + void window.showWarningMessage(formatMessage(`Server connection closed`)) + return { action: CloseAction.DoNotRestart } + }, + }, + } + + client = new LanguageClient(ExtensionId, ExtensionName, serverOptions, clientOptions) + + const stacksManager = new StacksManager(client) + + client + .start() + .then(() => { + const documentManager = new DocumentManager(client) + + const resourceSelector = new ResourceSelector(client) + const resourcesManager = new ResourcesManager(client, resourceSelector) + const relatedResourceSelector = new RelatedResourceSelector(client) + const relatedResourcesManager = new RelatedResourcesManager( + client, + relatedResourceSelector, + resourceSelector, + resourcesManager.importResourceStates.bind(resourcesManager) + ) + const changeSetManager = new ChangeSetsManager(client) + const environmentSelector = new CfnEnvironmentSelector() + const environmentFileSelector = new CfnEnvironmentFileSelector() + const environmentManager = new CfnEnvironmentManager(client, environmentSelector, environmentFileSelector) + + const cfnInitCliCaller = new CfnInitCliCaller(serverRootDir) + const cfnInitUiInterface = new CfnInitUiInterface(cfnInitCliCaller) + + const cfnExplorer = new CloudFormationExplorer( + stacksManager, + resourcesManager, + changeSetManager, + documentManager, + globals.regionProvider, + environmentManager + ) + + // Add listener to refresh explorer when resources change + resourcesManager.addListener(() => { + cfnExplorer.refresh() + }) + + stacksManager.addListener(() => { + cfnExplorer.refresh() + }) + + documentManager.addListener(() => { + cfnExplorer.refresh() + }) + + environmentManager.addListener(() => { + cfnExplorer.refresh() + }) + + const credentialsService = new AwsCredentialsService( + stacksManager, + resourcesManager, + cfnExplorer.regionManager + ) + cfnExplorer.setCredentialsService(credentialsService) + + const stackViewCoordinator = new StackViewCoordinator() + + // Register callback to update stack status in cache and refresh explorer + stackViewCoordinator.setStackStatusUpdateCallback((stackName, stackStatus) => { + stacksManager.updateStackStatus(stackName, stackStatus) + cfnExplorer.refresh() + }) + + const diffProvider = new DiffWebviewProvider(stackViewCoordinator) + const resourcesProvider = new StackResourcesWebviewProvider(client, stackViewCoordinator) + const overviewProvider = new StackOverviewWebviewProvider(client, stackViewCoordinator) + const eventsProvider = new StackEventsWebviewProvider(client, stackViewCoordinator) + const outputsProvider = new StackOutputsWebviewProvider(client, stackViewCoordinator) + + const documentSelector = [ + { scheme: 'file', language: 'cloudformation' }, + { scheme: 'file', language: 'yaml' }, + { scheme: 'file', language: 'json' }, + ] + + const codeLensProvider = languages.registerCodeLensProvider( + documentSelector, + new StackActionCodeLensProvider(client) + ) + + context.subscriptions.push( + { dispose: () => client?.stop() }, + codeLensProvider, + stacksManager, + window.createTreeView('aws.cloudformation', { + treeDataProvider: cfnExplorer, + showCollapseAll: true, + canSelectMany: true, + }), + loadMoreResourcesCommand(cfnExplorer), + loadMoreStacksCommand(cfnExplorer), + searchResourceCommand(cfnExplorer, resourcesManager), + refreshChangeSetsCommand(cfnExplorer), + loadMoreChangeSetsCommand(cfnExplorer), + viewStackCommand(stackViewCoordinator, overviewProvider, outputsProvider, resourcesProvider), + addResourceTypesCommand(resourcesManager), + removeResourceTypeCommand(resourcesManager), + refreshAllResourcesCommand(resourcesManager), + refreshResourceListCommand(resourcesManager, cfnExplorer), + copyResourceIdentifierCommand(), + importResourceStateCommand(resourcesManager), + cloneResourceStateCommand(resourcesManager), + getStackManagementInfoCommand(resourcesManager), + window.registerWebviewViewProvider(commandKey('stack.overview'), overviewProvider), + window.registerWebviewViewProvider(commandKey('diff'), diffProvider), + window.registerWebviewViewProvider(commandKey('stack.events'), eventsProvider), + window.registerWebviewViewProvider(commandKey('stack.resources'), resourcesProvider), + window.registerWebviewViewProvider(commandKey('stack.outputs'), outputsProvider), + focusDiffCommand(), + validateDeploymentCommand(client, diffProvider, documentManager, environmentManager), + deployTemplateCommand(client, diffProvider, documentManager, environmentManager), + deployTemplateFromStacksMenuCommand(), + executeChangeSetCommand(client, stackViewCoordinator), + deleteChangeSetCommand(client), + viewChangeSetCommand(client, diffProvider), + refreshCommand(stacksManager), + openStackTemplateCommand(client), + selectRegionCommand(cfnExplorer), + selectEnvironmentCommand(cfnExplorer), + rerunLastValidationCommand(), + extractToParameterPositionCursorCommand(client), + createProjectCommand(cfnInitUiInterface), + addEnvironmentCommand(cfnInitUiInterface, cfnInitCliCaller, environmentManager), + removeEnvironmentCommand(cfnInitCliCaller, environmentManager), + addRelatedResourcesCommand(relatedResourcesManager), + credentialsService, + serverProvider + ) + + return credentialsService.initialize(client) + }) + .catch((err: any) => { + // Language client already shows error popup for startup failures + getLogger().error(`CloudFormation language server failed to start: ${toString(err)}`) + }) +} + +export function deactivate(): Thenable | undefined { + if (!client) { + return undefined + } + + return client.stop() +} diff --git a/packages/core/src/awsService/cloudformation/extensionConfig.ts b/packages/core/src/awsService/cloudformation/extensionConfig.ts new file mode 100644 index 00000000000..c3577d9c19b --- /dev/null +++ b/packages/core/src/awsService/cloudformation/extensionConfig.ts @@ -0,0 +1,15 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fromExtensionManifest } from '../../shared/settings' + +export const ExtensionId = 'amazonwebservices.cloudformation' +export const ExtensionName = 'AWS CloudFormation' +export const Version = '1.0.0' +export const ExtensionConfigKey = 'aws.cloudformation' + +export class CloudFormationTelemetrySettings extends fromExtensionManifest(`${ExtensionConfigKey}.telemetry`, { + enabled: Boolean, +}) {} diff --git a/packages/core/src/awsService/cloudformation/lsp-server/devLspServerProvider.ts b/packages/core/src/awsService/cloudformation/lsp-server/devLspServerProvider.ts new file mode 100644 index 00000000000..b3020ee0b71 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/lsp-server/devLspServerProvider.ts @@ -0,0 +1,67 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { dirname, join } from 'path' +import { ExtensionContext } from 'vscode' +import { LspServerProviderI } from './lspServerProvider' +import { CfnLspServerFile } from './lspServerConfig' +import { existsSync, readdirSync } from 'fs' // eslint-disable-line no-restricted-imports +import { isDebugInstance } from '../../../shared/vscode/env' +import { getLogger } from '../../../shared/logger/logger' + +export class DevLspServerProvider implements LspServerProviderI { + private readonly devServerLocation?: string + + constructor(context: ExtensionContext) { + this.devServerLocation = findServerInDevelopment(context.extensionPath) + } + + canProvide(): boolean { + return isDebugInstance() && this.devServerLocation !== undefined + } + + async serverExecutable(): Promise { + return Promise.resolve(this.devServerLocation!) + } + + async serverRootDir(): Promise { + return Promise.resolve(dirname(this.devServerLocation!)) + } +} + +function findServerInDevelopment(path: string): string | undefined { + const parentDir = dirname(dirname(dirname(path))) + const possibleLocations = [] + + // Get all directories in parent directory + const siblingDirs = readdirSync(parentDir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name) + + // Check each sibling directory for bundle/development structure + for (const siblingDir of siblingDirs) { + const serverPath = join(parentDir, siblingDir, 'bundle', 'development', CfnLspServerFile) + if (existsSync(serverPath)) { + possibleLocations.push(serverPath) + } + } + + const validLocations = possibleLocations.filter((path) => { + return existsSync(path) + }) + + if (validLocations.length < 1) { + return undefined + } + + if (validLocations.length === 1) { + getLogger().debug(`Found CloudFormation LSP dev server ${possibleLocations[0]}`) + return possibleLocations[0] + } + + throw Error( + `Found ${validLocations.length} locations with server executable file: ${JSON.stringify(possibleLocations)}` + ) +} diff --git a/packages/core/src/awsService/cloudformation/lsp-server/githubManifestAdapter.ts b/packages/core/src/awsService/cloudformation/lsp-server/githubManifestAdapter.ts new file mode 100644 index 00000000000..95461d500ba --- /dev/null +++ b/packages/core/src/awsService/cloudformation/lsp-server/githubManifestAdapter.ts @@ -0,0 +1,142 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Manifest, LspVersion, Target } from '../../../shared/lsp/types' +import { CfnLspName, CfnLspServerEnvType } from './lspServerConfig' +import { addWindows, dedupeAndGetLatestVersions } from './utils' + +export class GitHubManifestAdapter { + constructor( + private readonly repoOwner: string, + private readonly repoName: string, + private readonly environment: CfnLspServerEnvType + ) {} + + async getManifest(): Promise { + const releases = await this.fetchGitHubReleases() + const filteredReleases = this.filterByEnvironment(releases) + + filteredReleases.sort((a, b) => { + return b.tag_name.localeCompare(a.tag_name) + }) + + return { + manifestSchemaVersion: '1.0', + artifactId: CfnLspName, + artifactDescription: 'GitHub CloudFormation Language Server', + isManifestDeprecated: false, + versions: dedupeAndGetLatestVersions(filteredReleases.map((release) => this.convertRelease(release))), + } + } + + private filterByEnvironment(releases: GitHubRelease[]): GitHubRelease[] { + return releases.filter((release) => { + const tag = release.tag_name + if (this.environment === 'alpha') { + return release.prerelease && tag.endsWith('-alpha') + } else if (this.environment === 'beta') { + return release.prerelease && tag.endsWith('-beta') + } else { + return !release.prerelease + } + }) + } + + private async fetchGitHubReleases(): Promise { + const response = await fetch(`https://api.github.com/repos/${this.repoOwner}/${this.repoName}/releases`) + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status}`) + } + return response.json() + } + + private convertRelease(release: GitHubRelease): LspVersion { + return { + serverVersion: release.tag_name, + isDelisted: false, + targets: addWindows(this.extractTargets(release.assets)), + } + } + + private extractTargets(assets: GitHubAsset[]): Target[] { + return assets.map((asset) => { + const { arch, platform } = this.extractPlatformArch(asset.name) + + return { + platform, + arch, + contents: [ + { + filename: asset.name, + url: asset.browser_download_url, + hashes: [], + bytes: asset.size, + }, + ], + } + }) + } + + private extractPlatformArch(filename: string): { + arch: string + platform: string + } { + const lower = filename.toLowerCase().replaceAll('.zip', '') + const splits = lower.split('-') + + const last = splits[splits.length - 1] + + // Check if filename includes node version (e.g., node22) + if (last.startsWith('node')) { + const nodeVersion = process.version.match(/^v(\d+)/)?.[1] + const filenameNodeVersion = last.replace('node', '') + + // Only match if node versions align + if (nodeVersion !== filenameNodeVersion) { + return { arch: '', platform: '' } // Skip this asset + } + + return { arch: splits[splits.length - 2], platform: splits[splits.length - 3] } + } + + return { arch: last, platform: splits[splits.length - 2] } + } +} + +/* eslint-disable @typescript-eslint/naming-convention */ +export interface GitHubAsset { + url: string + browser_download_url: string + id: number + node_id: string + name: string + label: string | null + state: string + content_type: string + size: number + download_count: number + created_at: string + updated_at: string +} + +export interface GitHubRelease { + url: string + html_url: string + assets_url: string + upload_url: string + tarball_url: string | null + zipball_url: string | null + id: number + node_id: string + tag_name: string + target_commitish: string + name: string | null + body: string | null + draft: boolean + prerelease: boolean + created_at: string // ISO 8601 date string + published_at: string | null // ISO 8601 date string + assets: GitHubAsset[] +} diff --git a/packages/core/src/awsService/cloudformation/lsp-server/lspInstaller.ts b/packages/core/src/awsService/cloudformation/lsp-server/lspInstaller.ts new file mode 100644 index 00000000000..237ef48ef1a --- /dev/null +++ b/packages/core/src/awsService/cloudformation/lsp-server/lspInstaller.ts @@ -0,0 +1,99 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BaseLspInstaller } from '../../../shared/lsp/baseLspInstaller' +import { GitHubManifestAdapter } from './githubManifestAdapter' +import { fs } from '../../../shared/fs/fs' +import { CfnLspName, CfnLspServerEnvType, CfnLspServerFile } from './lspServerConfig' +import { isAutomation, isBeta, isDebugInstance } from '../../../shared/vscode/env' +import { dirname, join } from 'path' +import { getLogger } from '../../../shared/logger/logger' +import { ResourcePaths } from '../../../shared/lsp/types' +import { FileType } from 'vscode' +import * as nodeFs from 'fs' // eslint-disable-line no-restricted-imports + +function determineEnvironment(): CfnLspServerEnvType { + if (isDebugInstance()) { + return 'alpha' + } else if (isBeta() || isAutomation()) { + return 'beta' + } + return 'prod' +} + +export class CfnLspInstaller extends BaseLspInstaller { + private log = getLogger() + + constructor() { + super( + { + manifestUrl: 'github', + supportedVersions: '0.*.*', + id: CfnLspName, + suppressPromptPrefix: 'cfnLsp', + }, + 'awsCfnLsp', + { + resolve: async () => { + const environment = determineEnvironment() + this.log.info(`Resolving CloudFormation LSP from GitHub releases (${environment})`) + const githubAdapter = new GitHubManifestAdapter( + 'aws-cloudformation', + 'cloudformation-languageserver', + environment + ) + return await githubAdapter.getManifest() + }, + } as any + ) + } + + protected async postInstall(assetDirectory: string): Promise { + const resourcePaths = this.resourcePaths(assetDirectory) + const rootDir = dirname(resourcePaths.lsp) + await this.makeLspExecutable(rootDir) + await fs.chmod(join(rootDir, 'bin', process.platform === 'win32' ? 'cfn-init.exe' : 'cfn-init'), 0o755) + } + + private async makeLspExecutable(directory: string): Promise { + const extensions = ['.cjs', '.gyp', '.js', '.mjs', '.node', '.wasm', '.json', '.zip', '.map'] + const entries = await fs.readdir(directory) + + for (const [name, type] of entries) { + const fullPath = join(directory, name) + if (type === FileType.Directory) { + await this.makeLspExecutable(fullPath) + } else if (extensions.some((ext) => name.endsWith(ext))) { + try { + await fs.chmod(fullPath, 0o755) + } catch (error) { + this.log.error(`Failed to make ${name} executable`, error) + } + } + } + } + + protected resourcePaths(assetDirectory?: string): ResourcePaths { + if (!assetDirectory) { + return { + lsp: this.config.path ?? CfnLspServerFile, + node: process.execPath, + } + } + + // Find the single extracted directory + const entries = nodeFs.readdirSync(assetDirectory, { withFileTypes: true }) + const folders = entries.filter((entry) => entry.isDirectory()) + + if (folders.length !== 1) { + throw new Error(`1 or more CloudFormation LSP folders found ${folders}`) + } + + return { + lsp: join(assetDirectory, folders[0].name, CfnLspServerFile), + node: process.execPath, + } + } +} diff --git a/packages/core/src/awsService/cloudformation/lsp-server/lspServerConfig.ts b/packages/core/src/awsService/cloudformation/lsp-server/lspServerConfig.ts new file mode 100644 index 00000000000..afc8cfd7ede --- /dev/null +++ b/packages/core/src/awsService/cloudformation/lsp-server/lspServerConfig.ts @@ -0,0 +1,17 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export const CfnLspName = 'cloudformation-languageserver' +export const CfnLspServerFile = 'cfn-lsp-server-standalone.js' +export const CfnLspServerStorageName = '.aws-cfn-storage' +export const RequiredFiles = [ + 'node_modules', + 'cfn-lsp-server-standalone.js', + 'package.json', + 'pyodide-worker.js', + 'assets', +] + +export type CfnLspServerEnvType = 'alpha' | 'beta' | 'prod' diff --git a/packages/core/src/awsService/cloudformation/lsp-server/lspServerProvider.ts b/packages/core/src/awsService/cloudformation/lsp-server/lspServerProvider.ts new file mode 100644 index 00000000000..58d3ea6fa8c --- /dev/null +++ b/packages/core/src/awsService/cloudformation/lsp-server/lspServerProvider.ts @@ -0,0 +1,66 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Disposable } from 'vscode' +import { getLogger } from '../../../shared/logger/logger' + +export interface LspServerResolverI { + serverExecutable(): Promise + serverRootDir(): Promise +} + +export interface LspServerProviderI extends LspServerResolverI { + canProvide(): boolean +} + +export class LspServerProvider implements LspServerResolverI, Disposable { + private readonly matchedProviders: LspServerProviderI[] + private _serverExecutable?: string + private _serverRootDir?: string + + constructor(providers: LspServerProviderI[]) { + const matches = providers.filter((provider) => provider.canProvide()) + + if (matches.length < 1) { + throw new Error(`Matched with 0 CloudFormation LSP providers`) + } + + this.matchedProviders = matches + getLogger().info( + `Found CloudFormation LSP provider: ${this.matchedProviders.map((provider) => provider.constructor.name)}` + ) + } + + async serverExecutable(): Promise { + await this.evaluateProviders() + return this._serverExecutable! + } + + async serverRootDir(): Promise { + await this.evaluateProviders() + return this._serverRootDir! + } + + private async evaluateProviders() { + if (this._serverExecutable && this._serverRootDir) { + return + } + + for (const provider of this.matchedProviders) { + try { + const executable = await provider.serverExecutable() + const dir = await provider.serverRootDir() + + this._serverExecutable = executable + this._serverRootDir = dir + return + } catch (err) { + getLogger().error(`Failed to resolve CloudFormation LSP provider ${provider.constructor.name}`, err) + } + } + } + + dispose() {} +} diff --git a/packages/core/src/awsService/cloudformation/lsp-server/remoteLspServerProvider.ts b/packages/core/src/awsService/cloudformation/lsp-server/remoteLspServerProvider.ts new file mode 100644 index 00000000000..a1ca7bbb8b8 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/lsp-server/remoteLspServerProvider.ts @@ -0,0 +1,31 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { dirname } from 'path' +import { LspServerProviderI } from './lspServerProvider' +import { CfnLspInstaller } from './lspInstaller' + +export class RemoteLspServerProvider implements LspServerProviderI { + private installer = new CfnLspInstaller() + private serverPath?: string + + canProvide(): boolean { + return true + } + + async serverExecutable(): Promise { + if (this.serverPath) { + return this.serverPath + } + + const result = await this.installer.resolve() + this.serverPath = result.resourcePaths.lsp + return this.serverPath + } + + async serverRootDir(): Promise { + return dirname(await this.serverExecutable()) + } +} diff --git a/packages/core/src/awsService/cloudformation/lsp-server/settingsLspServerProvider.ts b/packages/core/src/awsService/cloudformation/lsp-server/settingsLspServerProvider.ts new file mode 100644 index 00000000000..b807a24720f --- /dev/null +++ b/packages/core/src/awsService/cloudformation/lsp-server/settingsLspServerProvider.ts @@ -0,0 +1,29 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { dirname, join } from 'path' +import { LspServerProviderI } from './lspServerProvider' +import { CfnLspServerFile } from './lspServerConfig' + +export class SettingsLspServerProvider implements LspServerProviderI { + private readonly path?: string + + constructor(config?: { path?: string }) { + this.path = config?.path + } + + canProvide(): boolean { + return this.path !== undefined + } + + async serverExecutable(): Promise { + const serverFile = join(this.path!, CfnLspServerFile) + return Promise.resolve(serverFile) + } + + async serverRootDir(): Promise { + return Promise.resolve(dirname(await this.serverExecutable())) + } +} diff --git a/packages/core/src/awsService/cloudformation/lsp-server/utils.ts b/packages/core/src/awsService/cloudformation/lsp-server/utils.ts new file mode 100644 index 00000000000..585135d01a8 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/lsp-server/utils.ts @@ -0,0 +1,86 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { LspVersion, Target } from '../../../shared/lsp/types' + +export function addWindows(targets: Target[]) { + const win32Target = targets.find((target) => { + return target.platform === 'win32' + }) + + const hasDirectWindows = targets.find((target) => { + return target.platform === 'windows' + }) + + if (hasDirectWindows || !win32Target) { + return targets + } + + return [ + ...targets, + { + ...win32Target, + platform: 'windows', + }, + ] +} + +export function dedupeAndGetLatestVersions(versions: LspVersion[]): LspVersion[] { + const grouped: Record = {} + + // Group by normalized version + for (const version of versions) { + const normalizedV = getMajorMinorPatchVersion(version.serverVersion) + if (!grouped[normalizedV]) { + grouped[normalizedV] = [] + } + grouped[normalizedV].push(version) + } + + const groupedAndSorted: Record = Object.fromEntries( + Object.entries(grouped).sort(([v1], [v2]) => { + return compareVersionsDesc(v1, v2) + }) + ) + + // Sort each group by version descending and pick the first (latest) + return Object.values(groupedAndSorted).map((group) => { + group.sort((a, b) => compareVersionsDesc(a.serverVersion, b.serverVersion)) + const latest = group[0] + latest.serverVersion = `${latest.serverVersion.replace('v', '')}` + + return latest // take the highest version + }) +} + +function compareVersionsDesc(v1: string, v2: string) { + const a = convertVersionToNumbers(v1) + const b = convertVersionToNumbers(v2) + + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const partA = a[i] || 0 + const partB = b[i] || 0 + + if (partA > partB) { + return -1 + } + if (partA < partB) { + return 1 + } + } + return 0 +} + +function removeWordsFromVersion(version: string): string { + return version.replaceAll('-beta', '').replaceAll('-alpha', '').replaceAll('-prod', '').replaceAll('v', '') +} + +function convertVersionToNumbers(version: string): number[] { + return removeWordsFromVersion(version).replaceAll('-', '.').split('.').map(Number) +} + +function getMajorMinorPatchVersion(version: string): string { + return removeWordsFromVersion(version).split('-')[0] +} diff --git a/packages/core/src/awsService/cloudformation/lspTypes.ts b/packages/core/src/awsService/cloudformation/lspTypes.ts new file mode 100644 index 00000000000..602587203cb --- /dev/null +++ b/packages/core/src/awsService/cloudformation/lspTypes.ts @@ -0,0 +1,8 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export type Identifiable = { + id: string +} diff --git a/packages/core/src/awsService/cloudformation/relatedResources/relatedResourcesApi.ts b/packages/core/src/awsService/cloudformation/relatedResources/relatedResourcesApi.ts new file mode 100644 index 00000000000..95592e0a9c2 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/relatedResources/relatedResourcesApi.ts @@ -0,0 +1,33 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { LanguageClient } from 'vscode-languageclient/node' +import { + GetAuthoredResourceTypesRequest, + GetRelatedResourceTypesParams, + GetRelatedResourceTypesRequest, + InsertRelatedResourcesParams, + InsertRelatedResourcesRequest, + RelatedResourcesCodeAction, + TemplateUri, +} from './relatedResourcesProtocol' + +export async function getAuthoredResourceTypes(client: LanguageClient, templateUri: TemplateUri): Promise { + return client.sendRequest(GetAuthoredResourceTypesRequest, templateUri) +} + +export async function getRelatedResourceTypes( + client: LanguageClient, + params: GetRelatedResourceTypesParams +): Promise { + return client.sendRequest(GetRelatedResourceTypesRequest, params) +} + +export async function insertRelatedResources( + client: LanguageClient, + params: InsertRelatedResourcesParams +): Promise { + return client.sendRequest(InsertRelatedResourcesRequest, params) +} diff --git a/packages/core/src/awsService/cloudformation/relatedResources/relatedResourcesManager.ts b/packages/core/src/awsService/cloudformation/relatedResources/relatedResourcesManager.ts new file mode 100644 index 00000000000..538142955f9 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/relatedResources/relatedResourcesManager.ts @@ -0,0 +1,123 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Position, Range, TextEdit, TextEditorRevealType, Uri, window, workspace, WorkspaceEdit } from 'vscode' +import { LanguageClient } from 'vscode-languageclient/node' +import { RelatedResourceSelector } from '../ui/relatedResourceSelector' +import { ResourceSelector } from '../ui/resourceSelector' +import { insertRelatedResources } from './relatedResourcesApi' +import { RelatedResourcesCodeAction } from './relatedResourcesProtocol' +import { showErrorMessage } from '../ui/message' +import { ResourceNode } from '../explorer/nodes/resourceNode' + +export class RelatedResourcesManager { + constructor( + private client: LanguageClient, + private selector: RelatedResourceSelector, + private resourceSelector: ResourceSelector, + private importResourceStates: (resourceNodes: ResourceNode[], parentResourceType?: string) => Promise + ) {} + + async addRelatedResources(preSelectedResourceType?: string): Promise { + const activeEditor = window.activeTextEditor + if (!activeEditor) { + void window.showErrorMessage('No template file opened') + return + } + + try { + const templateUri = activeEditor.document.uri.toString() + + const selectedParentResourceType = + preSelectedResourceType || (await this.selector.selectAuthoredResourceType(templateUri)) + if (!selectedParentResourceType) { + return + } + + const selectedRelatedTypes = await this.selector.selectRelatedResourceTypes(selectedParentResourceType) + if (!selectedRelatedTypes || selectedRelatedTypes.length === 0) { + return + } + + const action = await this.selector.promptCreateOrImport() + if (!action) { + return + } + + if (action === 'create') { + await this.createRelatedResources(templateUri, selectedParentResourceType, selectedRelatedTypes) + } else { + await this.importRelatedResources(selectedRelatedTypes, selectedParentResourceType) + } + } catch (error) { + showErrorMessage( + `Error adding related resources: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + private async createRelatedResources( + templateUri: string, + parentResourceType: string, + relatedResourceTypes: string[] + ): Promise { + const result = await insertRelatedResources(this.client, { + templateUri, + relatedResourceTypes, + parentResourceType, + }) + + await this.applyCodeAction(result) + + const activeEditor = window.activeTextEditor + if (activeEditor && result.data?.scrollToPosition) { + const position = new Position(result.data.scrollToPosition.line, result.data.scrollToPosition.character) + const revealRange = new Range( + new Position(Math.max(0, position.line - 2), 0), + new Position(position.line + 8, 0) + ) + activeEditor.revealRange(revealRange, TextEditorRevealType.InCenter) + } + + void window.showInformationMessage(`Added ${relatedResourceTypes.length} related resources`) + } + + private async applyCodeAction(codeAction: RelatedResourcesCodeAction): Promise { + if (codeAction.edit?.changes) { + const workspaceEdit = new WorkspaceEdit() + + for (const [uri, textEdits] of Object.entries(codeAction.edit.changes)) { + const docUri = Uri.parse(uri) + const docEdits = textEdits.map((edit) => { + const range = new Range( + new Position(edit.range.start.line, edit.range.start.character), + new Position(edit.range.end.line, edit.range.end.character) + ) + return new TextEdit(range, edit.newText) + }) + workspaceEdit.set(docUri, docEdits) + } + + await workspace.applyEdit(workspaceEdit) + } + } + + private async importRelatedResources( + relatedResourceTypes: string[], + selectedParentResourceType: string + ): Promise { + const selections = await this.resourceSelector.selectResources(true, relatedResourceTypes) + if (selections.length === 0) { + return + } + + const resourceNodes = selections.map((selection) => ({ + resourceType: selection.resourceType, + resourceIdentifier: selection.resourceIdentifier, + })) as ResourceNode[] + + await this.importResourceStates(resourceNodes, selectedParentResourceType) + } +} diff --git a/packages/core/src/awsService/cloudformation/relatedResources/relatedResourcesProtocol.ts b/packages/core/src/awsService/cloudformation/relatedResources/relatedResourcesProtocol.ts new file mode 100644 index 00000000000..d5ded1a5bff --- /dev/null +++ b/packages/core/src/awsService/cloudformation/relatedResources/relatedResourcesProtocol.ts @@ -0,0 +1,39 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { RequestType, CodeAction, Position } from 'vscode-languageserver-protocol' + +export type TemplateUri = string + +export type GetRelatedResourceTypesParams = { + parentResourceType: string +} + +export type InsertRelatedResourcesParams = { + templateUri: string + relatedResourceTypes: string[] + parentResourceType: string +} + +export interface RelatedResourcesCodeAction extends CodeAction { + data?: { + scrollToPosition?: Position + firstLogicalId?: string + } +} + +export const GetAuthoredResourceTypesRequest = new RequestType( + 'aws/cfn/template/resources/authored' +) + +export const GetRelatedResourceTypesRequest = new RequestType( + 'aws/cfn/template/resources/related' +) + +export const InsertRelatedResourcesRequest = new RequestType< + InsertRelatedResourcesParams, + RelatedResourcesCodeAction, + void +>('aws/cfn/template/resources/insert') diff --git a/packages/core/src/awsService/cloudformation/resources/resourcesManager.ts b/packages/core/src/awsService/cloudformation/resources/resourcesManager.ts new file mode 100644 index 00000000000..0efd0c250e3 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/resources/resourcesManager.ts @@ -0,0 +1,484 @@ +/*! +import { getLogger } from '../../../shared/logger' + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ResourceSelectionResult, ResourceSelector } from '../ui/resourceSelector' +import { ResourceNode } from '../explorer/nodes/resourceNode' +import { LanguageClient } from 'vscode-languageclient/node' +import { + ListResourcesRequest, + RefreshResourcesRequest, + ResourceList, + ResourceSelection, + ResourceStackManagementResult, + ResourceStateParams, + ResourceStatePurpose, + ResourceStateRequest, + ResourceStateResult, + StackMgmtInfoRequest, + SearchResourceRequest, + SearchResourceResult, +} from '../cfn/resourceRequestTypes' + +import { showErrorMessage } from '../ui/message' +import { ProgressLocation, SnippetString, window, env, Position, Range } from 'vscode' +import { getLogger } from '../../../shared/logger/logger' +import globals from '../../../shared/extensionGlobals' +import { setContext } from '../../../shared/vscode/setContext' + +type ResourcesChangeListener = (resources: ResourceList[]) => void + +export class ResourcesManager { + private resources: Map = new Map() + private readonly listeners: ResourcesChangeListener[] = [] + private static readonly resourceTypesKey = 'aws.cloudformation.selectedResourceTypes' + + private readonly CopyStackName = 'Copy Stack Name' + private readonly CopyStackArn = 'Copy Stack Arn' + + constructor( + private readonly client: LanguageClient, + private readonly resourceSelector: ResourceSelector + ) {} + + private get selectedResourceTypes(): string[] { + return globals.globalState.tryGet(ResourcesManager.resourceTypesKey, Object, []) + } + + private async setSelectedResourceTypes(types: string[]): Promise { + await globals.globalState.update(ResourcesManager.resourceTypesKey, types) + } + + getSelectedResourceTypes(): string[] { + return this.selectedResourceTypes + } + + async removeResourceType(typeToRemove: string): Promise { + await globals.globalState.update( + ResourcesManager.resourceTypesKey, + this.selectedResourceTypes.filter((type) => type !== typeToRemove) + ) + this.notifyAllListeners() + } + + get(): ResourceList[] { + return Array.from(this.resources.values()) + } + + addListener(listener: ResourcesChangeListener) { + this.listeners.push(listener) + } + + async loadResources(): Promise { + try { + if (this.selectedResourceTypes.length === 0) { + this.resources.clear() + return + } + + this.resources.clear() + + const response = await this.client.sendRequest(ListResourcesRequest, { + resources: this.selectedResourceTypes.map((resourceType) => ({ resourceType })), + }) + + for (const resource of response.resources) { + this.resources.set(resource.typeName, resource) + } + } catch (error) { + getLogger().error(`Failed to load resources: ${error}`) + this.resources.clear() + } finally { + this.notifyAllListeners() + } + } + + async loadResourceType(resourceType: string): Promise { + try { + const response = await this.client.sendRequest(ListResourcesRequest, { + resources: [{ resourceType }], + }) + + if (response.resources.length > 0) { + this.resources.set(resourceType, response.resources[0]) + this.notifyAllListeners() + } + } catch (error) { + getLogger().error(`Failed to load resource type ${resourceType}: ${error}`) + } + } + + async loadMoreResources(resourceType: string, nextToken: string): Promise { + await setContext('aws.cloudformation.loadingResources', true) + try { + const response = await this.client.sendRequest(ListResourcesRequest, { + resources: [{ resourceType, nextToken }], + }) + + if (response.resources.length > 0) { + this.resources.set(resourceType, response.resources[0]) + } + + this.notifyAllListeners() + } catch (error) { + getLogger().error(`Failed to load more resources: ${error}`) + void window.showErrorMessage( + `Failed to load more resources: ${error instanceof Error ? error.message : String(error)}` + ) + } finally { + await setContext('aws.cloudformation.loadingResources', false) + } + } + + refreshAllResources(): void { + void window.withProgress( + { + location: ProgressLocation.Notification, + title: 'Refreshing All Resources List', + }, + async () => { + await setContext('aws.cloudformation.refreshingAllResources', true) + try { + if (this.selectedResourceTypes.length === 0) { + return + } + + const response = await this.client.sendRequest(RefreshResourcesRequest, { + resources: this.selectedResourceTypes.map((resourceType) => ({ resourceType })), + }) + this.resources.clear() + for (const resource of response.resources) { + this.resources.set(resource.typeName, resource) + } + } catch (error) { + getLogger().error(`Failed to refresh all resources: ${error}`) + } finally { + await setContext('aws.cloudformation.refreshingAllResources', false) + this.notifyAllListeners() + } + } + ) + } + + refreshResourceList(resourceType: string): void { + void window.withProgress( + { + location: ProgressLocation.Notification, + title: `Refreshing ${resourceType} Resources List`, + }, + async () => { + await setContext('aws.cloudformation.refreshingResourceList', true) + try { + const response = await this.client.sendRequest(RefreshResourcesRequest, { + resources: [{ resourceType }], + }) + + const updatedResource = response.resources.find( + (r: { typeName: string }) => r.typeName === resourceType + ) + if (updatedResource) { + this.resources.set(resourceType, updatedResource) + } + } catch (error) { + getLogger().error(`Failed to refresh resource: ${error}`) + } finally { + await setContext('aws.cloudformation.refreshingResourceList', false) + this.notifyAllListeners() + } + } + ) + } + + async searchResource(resourceType: string, identifier: string): Promise { + try { + const response = await this.client.sendRequest(SearchResourceRequest, { + resourceType, + identifier, + }) + + if (response.found && response.resource) { + this.resources.set(resourceType, response.resource) + this.notifyAllListeners() + } + + return response + } catch (error) { + getLogger().error(`Failed to search resource: ${error}`) + return { found: false } + } + } + + async selectResourceTypes(): Promise { + const selectedTypes = await this.resourceSelector.selectResourceTypes(this.selectedResourceTypes) + if (selectedTypes !== undefined) { + await this.setSelectedResourceTypes(selectedTypes) + + // Remove resources that are no longer selected + const selectedSet = new Set(selectedTypes) + for (const typeName of this.resources.keys()) { + if (!selectedSet.has(typeName)) { + this.resources.delete(typeName) + } + } + + this.notifyAllListeners() + } + } + + private async executeResourceStateOperation( + resourceNodes: ResourceNode[] | undefined, + purpose: ResourceStatePurpose, + parentResourceType?: string + ): Promise { + const editor = window.activeTextEditor + if (!editor) { + showErrorMessage('No active editor') + return + } + + const contextKey = + purpose === ResourceStatePurpose.Import + ? 'aws.cloudformation.importingResource' + : 'aws.cloudformation.cloningResource' + await setContext(contextKey, true) + + try { + const resourceSelectionsArray = await this.getResourceSelectionArray(resourceNodes) + if (resourceSelectionsArray.length === 0) { + return + } + + const params: ResourceStateParams = { + textDocument: { uri: editor.document.uri.toString() }, + resourceSelections: resourceSelectionsArray, + purpose, + parentResourceType, + } + + const title = + purpose === ResourceStatePurpose.Import ? 'Importing Resource State' : 'Cloning Resource State' + await window.withProgress( + { + location: ProgressLocation.Notification, + title, + cancellable: false, + }, + async () => { + const result = (await this.client.sendRequest( + ResourceStateRequest.method, + params + )) as ResourceStateResult + if (result.warning) { + void window.showWarningMessage(result.warning) + } + await this.applyCompletionSnippet(result) + const [successCount, failureCount] = this.getSuccessAndFailureCount(result) + this.renderResultMessage(successCount, failureCount, purpose) + } + ) + } catch (error) { + const action = purpose === ResourceStatePurpose.Import ? 'importing' : 'cloning' + showErrorMessage( + `Error ${action} resource state: ${error instanceof Error ? error.message : String(error)}` + ) + } finally { + await setContext(contextKey, false) + } + } + + async importResourceStates(resourceNodes?: ResourceNode[], parentResourceType?: string): Promise { + await this.executeResourceStateOperation(resourceNodes, ResourceStatePurpose.Import, parentResourceType) + } + + private getResourcesToImportInput(selections: ResourceSelectionResult[]): ResourceSelection[] { + // Group selections by resource type + const resourceSelections = new Map() + for (const selection of selections) { + const identifiers = resourceSelections.get(selection.resourceType) ?? [] + identifiers.push(selection.resourceIdentifier) + resourceSelections.set(selection.resourceType, identifiers) + } + + // Convert to ResourceSelection[] format expected by server + return Array.from(resourceSelections.entries()).map(([resourceType, resourceIdentifiers]) => ({ + resourceType, + resourceIdentifiers, + })) + } + + private async applyCompletionSnippet(result: ResourceStateResult): Promise { + const { completionItem } = result + + if (!completionItem?.textEdit) { + getLogger().warn('No completionItem or textEdit in result') + return + } + + const editor = window.activeTextEditor + if (!editor) { + getLogger().warn('No active editor for snippet insertion') + return + } + + try { + const textEdit = completionItem.textEdit + if (!textEdit || !('range' in textEdit)) { + getLogger().warn('No valid textEdit range found') + return + } + + const targetLine = textEdit.range.start.line + await this.ensureLineExists(editor, targetLine) + + const range = new Range( + new Position(textEdit.range.start.line, textEdit.range.start.character), + new Position(textEdit.range.end.line, textEdit.range.end.character) + ) + + getLogger().info( + `Inserting snippet at server-provided position: line ${range.start.line}, char ${range.start.character}` + ) + await editor.insertSnippet(new SnippetString(textEdit.newText), range) + getLogger().info('Snippet insertion successful') + } catch (error) { + getLogger().error(`Failed to insert snippet: ${error instanceof Error ? error.message : String(error)}`) + showErrorMessage(`Failed to insert resource: ${error instanceof Error ? error.message : String(error)}`) + } + } + + private async ensureLineExists(editor: any, targetLine: number): Promise { + const document = editor.document + if (targetLine >= document.lineCount) { + const linesToAdd = targetLine - document.lineCount + 1 + const lastLine = document.lineAt(document.lineCount - 1) + const endPosition = lastLine.range.end + + await editor.edit((editBuilder: any) => { + editBuilder.insert(endPosition, '\n'.repeat(linesToAdd)) + }) + } + } + + private getSuccessAndFailureCount(result: ResourceStateResult): [number, number] { + const successCount = Object.values(result.successfulImports ?? {}).reduce( + (sum: number, ids: string[]) => sum + ids.length, + 0 + ) as number + const failureCount = Object.values(result.failedImports ?? {}).reduce( + (sum: number, ids: string[]) => sum + ids.length, + 0 + ) as number + return [successCount, failureCount] + } + + async cloneResourceStates(resourceNodes?: ResourceNode[]): Promise { + await this.executeResourceStateOperation(resourceNodes, ResourceStatePurpose.Clone) + } + + private async getResourceSelectionArray(resourceNodes?: ResourceNode[]): Promise { + let selections: ResourceSelectionResult[] + + if (resourceNodes?.length) { + selections = resourceNodes.map((node) => ({ + resourceType: node.resourceType, + resourceIdentifier: node.resourceIdentifier, + })) + } else { + selections = await this.resourceSelector.selectResources() + } + + if (selections.length === 0) { + return [] + } + + return this.getResourcesToImportInput(selections) + } + + private renderResultMessage(successCount: number, failureCount: number, purpose: ResourceStatePurpose) { + const action = purpose === ResourceStatePurpose.Import ? 'imported' : 'cloned' + + if (successCount > 0 && failureCount === 0) { + void window.showInformationMessage(`Successfully ${action} ${successCount} resource(s)`) + } else if (successCount > 0 && failureCount > 0) { + void window.showWarningMessage( + `${action.charAt(0).toUpperCase() + action.slice(1)} ${successCount} resource(s), ${failureCount} failed` + ) + } else if (failureCount > 0) { + showErrorMessage(`Failed to ${action.replace('ed', '')} ${failureCount} resource(s)`) + } else { + void window.showInformationMessage(`No resources were ${action}`) + } + } + + private getResourcesArray(): ResourceList[] { + return Array.from(this.resources.values()) + } + + private notifyAllListeners(): void { + for (const listener of this.listeners) { + listener(this.getResourcesArray()) + } + } + + reload() { + this.resources.clear() + this.notifyAllListeners() + } + + async getStackManagementInfo(resourceNode?: ResourceNode): Promise { + let resourceIdentifier: string | undefined + + if (resourceNode?.resourceIdentifier) { + resourceIdentifier = resourceNode.resourceIdentifier + } else { + const selection = await this.resourceSelector.selectSingleResource() + if (!selection) { + return + } + resourceIdentifier = selection.resourceIdentifier + } + + await setContext('aws.cloudformation.gettingStackMgmtInfo', true) + try { + const result = (await window.withProgress( + { + location: ProgressLocation.SourceControl, + title: 'Getting Stack Management Info', + cancellable: false, + }, + async () => { + return await this.client.sendRequest(StackMgmtInfoRequest.method, resourceIdentifier) + } + )) as ResourceStackManagementResult + + await setContext('aws.cloudformation.gettingStackMgmtInfo', false) + + if (result.managedByStack === true && result.stackName && result.stackId) { + const action = await window.showInformationMessage( + `${result.physicalResourceId} is managed by stack: ${result.stackName}`, + this.CopyStackName, + this.CopyStackArn + ) + + if (action === this.CopyStackName) { + await env.clipboard.writeText(result.stackName) + window.setStatusBarMessage('Stack name copied to clipboard', 3000) + } else if (action === this.CopyStackArn) { + await env.clipboard.writeText(result.stackId) + window.setStatusBarMessage('Stack ARN copied to clipboard', 3000) + } + } else if (result.managedByStack === false) { + void window.showInformationMessage(`${result.physicalResourceId} is not managed by any stack`) + } else { + showErrorMessage(`Failed to determine stack management status: ${result.error ?? 'Unknown error'}`) + } + } catch (error) { + showErrorMessage( + `Error getting stack management info: ${error instanceof Error ? error.message : String(error)}` + ) + await setContext('aws.cloudformation.gettingStackMgmtInfo', false) + } + } +} diff --git a/packages/core/src/awsService/cloudformation/stacks/actions/changeSetDeletionWorkflow.ts b/packages/core/src/awsService/cloudformation/stacks/actions/changeSetDeletionWorkflow.ts new file mode 100644 index 00000000000..43e23a64eea --- /dev/null +++ b/packages/core/src/awsService/cloudformation/stacks/actions/changeSetDeletionWorkflow.ts @@ -0,0 +1,92 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { v4 as uuidv4 } from 'uuid' +import { StackActionPhase, StackActionState } from './stackActionRequestType' +import { LanguageClient } from 'vscode-languageclient/node' +import { + showErrorMessage, + showChangeSetDeletionStarted, + showChangeSetDeletionSuccess, + showChangeSetDeletionFailure, +} from '../../ui/message' +import { deleteChangeSet, describeChangeSetDeletionStatus, getChangeSetDeletionStatus } from './stackActionApi' +import { createChangeSetDeletionParams } from './stackActionUtil' +import { getLogger } from '../../../../shared/logger/logger' +import { extractErrorMessage } from '../../utils' + +export class ChangeSetDeletion { + private readonly id: string + private readonly stackName: string + private readonly changeSetName: string + private readonly client: LanguageClient + private status: StackActionPhase | undefined + + constructor(stackName: string, changeSetName: string, client: LanguageClient) { + this.id = uuidv4() + this.stackName = stackName + this.changeSetName = changeSetName + this.client = client + } + + async delete() { + await deleteChangeSet(this.client, createChangeSetDeletionParams(this.id, this.stackName, this.changeSetName)) + showChangeSetDeletionStarted(this.changeSetName, this.stackName) + this.pollForProgress() + } + + private pollForProgress() { + const interval = setInterval(() => { + getChangeSetDeletionStatus(this.client, { id: this.id }) + .then(async (deletionResult) => { + if (deletionResult.phase === this.status) { + return + } + + this.status = deletionResult.phase + + switch (deletionResult.phase) { + case StackActionPhase.DELETION_IN_PROGRESS: + break + case StackActionPhase.DELETION_COMPLETE: + if (deletionResult.state === StackActionState.SUCCESSFUL) { + showChangeSetDeletionSuccess(this.changeSetName, this.stackName) + } else { + const describeDeplomentStatusResult = await describeChangeSetDeletionStatus( + this.client, + { + id: this.id, + } + ) + showChangeSetDeletionFailure( + this.changeSetName, + this.stackName, + describeDeplomentStatusResult.FailureReason ?? 'No failure reason provided' + ) + } + clearInterval(interval) + break + case StackActionPhase.DELETION_FAILED: { + const describeDeplomentStatusResult = await describeChangeSetDeletionStatus(this.client, { + id: this.id, + }) + showChangeSetDeletionFailure( + this.changeSetName, + this.stackName, + describeDeplomentStatusResult.FailureReason ?? 'No failure reason provided' + ) + clearInterval(interval) + break + } + } + }) + .catch(async (error) => { + getLogger().error(`Error polling for deletion status: ${error}`) + showErrorMessage(`Error polling for deletion status: ${extractErrorMessage(error)}`) + clearInterval(interval) + }) + }, 1000) + } +} diff --git a/packages/core/src/awsService/cloudformation/stacks/actions/deploymentWorkflow.ts b/packages/core/src/awsService/cloudformation/stacks/actions/deploymentWorkflow.ts new file mode 100644 index 00000000000..549179a35ee --- /dev/null +++ b/packages/core/src/awsService/cloudformation/stacks/actions/deploymentWorkflow.ts @@ -0,0 +1,94 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { v4 as uuidv4 } from 'uuid' +import { StackActionPhase, StackActionState } from './stackActionRequestType' +import { LanguageClient } from 'vscode-languageclient/node' +import { showDeploymentStarted, showDeploymentSuccess, showDeploymentFailure, showErrorMessage } from '../../ui/message' +import { createDeploymentStatusBar, updateWorkflowStatus } from '../../ui/statusBar' +import { StatusBarItem, commands } from 'vscode' +import { deploy, describeDeploymentStatus, getDeploymentStatus } from './stackActionApi' +import { createDeploymentParams } from './stackActionUtil' +import { getLogger } from '../../../../shared/logger/logger' +import { extractErrorMessage, commandKey } from '../../utils' +import { StackViewCoordinator } from '../../ui/stackViewCoordinator' + +export class Deployment { + private readonly id: string + private status: StackActionPhase | undefined + private statusBarItem?: StatusBarItem + + constructor( + private readonly stackName: string, + private readonly changeSetName: string, + private readonly client: LanguageClient, + private readonly coordinator: StackViewCoordinator + ) { + this.id = uuidv4() + } + + async deploy() { + await deploy(this.client, createDeploymentParams(this.id, this.stackName, this.changeSetName)) + showDeploymentStarted(this.stackName) + + await this.coordinator.setStack(this.stackName) + await commands.executeCommand(commandKey('stack.events.focus')) + + this.statusBarItem = createDeploymentStatusBar() + this.pollForProgress() + } + + private pollForProgress() { + const interval = setInterval(() => { + getDeploymentStatus(this.client, { id: this.id }) + .then(async (deploymentResult) => { + if (deploymentResult.phase === this.status) { + return + } + + this.status = deploymentResult.phase + if (this.statusBarItem) { + updateWorkflowStatus(this.statusBarItem, deploymentResult.phase) + } + + switch (deploymentResult.phase) { + case StackActionPhase.DEPLOYMENT_IN_PROGRESS: + break + case StackActionPhase.DEPLOYMENT_COMPLETE: + if (deploymentResult.state === StackActionState.SUCCESSFUL) { + showDeploymentSuccess(this.stackName) + } else { + const describeDeplomentStatusResult = await describeDeploymentStatus(this.client, { + id: this.id, + }) + showDeploymentFailure( + this.stackName, + describeDeplomentStatusResult.FailureReason ?? 'UNKNOWN' + ) + } + clearInterval(interval) + break + case StackActionPhase.DEPLOYMENT_FAILED: + case StackActionPhase.VALIDATION_FAILED: { + const describeDeplomentStatusResult = await describeDeploymentStatus(this.client, { + id: this.id, + }) + showDeploymentFailure( + this.stackName, + describeDeplomentStatusResult.FailureReason ?? 'UNKNOWN' + ) + clearInterval(interval) + break + } + } + }) + .catch(async (error) => { + getLogger().error(`Error polling for deployment status: ${error}`) + showErrorMessage(`Error polling for deployment status: ${extractErrorMessage(error)}`) + clearInterval(interval) + }) + }, 1000) + } +} diff --git a/packages/core/src/awsService/cloudformation/stacks/actions/stackActionApi.ts b/packages/core/src/awsService/cloudformation/stacks/actions/stackActionApi.ts new file mode 100644 index 00000000000..0ca569de95a --- /dev/null +++ b/packages/core/src/awsService/cloudformation/stacks/actions/stackActionApi.ts @@ -0,0 +1,127 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { LanguageClient } from 'vscode-languageclient/node' +import { + TemplateUri, + GetParametersResult, + GetCapabilitiesResult, + CreateStackActionResult, + GetStackActionStatusResult, + TemplateResource, + CreateValidationParams, + CreateDeploymentParams, + DescribeValidationStatusResult, + DescribeDeploymentStatusResult, + DeleteChangeSetParams, + DescribeDeletionStatusResult, + DescribeChangeSetParams, + DescribeChangeSetResult, + GetTemplateArtifactsResult, +} from './stackActionRequestType' +import { + GetParametersRequest, + GetCapabilitiesRequest, + CreateValidationRequest, + CreateDeploymentRequest, + GetValidationStatusRequest, + GetDeploymentStatusRequest, + GetTemplateResourcesRequest, + GetTemplateArtifactsRequest, + DescribeValidationStatusRequest, + DescribeDeploymentStatusRequest, + DeleteChangeSetRequest, + GetChangeSetDeletionStatusRequest, + DescribeChangeSetDeletionStatusRequest, + DescribeChangeSetRequest, +} from './stackActionProtocol' +import { Identifiable } from '../../lspTypes' + +export async function validate( + client: LanguageClient, + params: CreateValidationParams +): Promise { + return await client.sendRequest(CreateValidationRequest, params) +} + +export async function deploy(client: LanguageClient, params: CreateDeploymentParams): Promise { + return await client.sendRequest(CreateDeploymentRequest, params) +} + +export async function deleteChangeSet( + client: LanguageClient, + params: DeleteChangeSetParams +): Promise { + return await client.sendRequest(DeleteChangeSetRequest, params) +} + +export async function getValidationStatus( + client: LanguageClient, + params: Identifiable +): Promise { + return await client.sendRequest(GetValidationStatusRequest, params) +} + +export async function getDeploymentStatus( + client: LanguageClient, + params: Identifiable +): Promise { + return await client.sendRequest(GetDeploymentStatusRequest, params) +} + +export async function describeValidationStatus( + client: LanguageClient, + params: Identifiable +): Promise { + return await client.sendRequest(DescribeValidationStatusRequest, params) +} + +export async function describeDeploymentStatus( + client: LanguageClient, + params: Identifiable +): Promise { + return await client.sendRequest(DescribeDeploymentStatusRequest, params) +} + +export async function getChangeSetDeletionStatus( + client: LanguageClient, + params: Identifiable +): Promise { + return await client.sendRequest(GetChangeSetDeletionStatusRequest, params) +} + +export async function describeChangeSetDeletionStatus( + client: LanguageClient, + params: Identifiable +): Promise { + return await client.sendRequest(DescribeChangeSetDeletionStatusRequest, params) +} + +export async function getParameters(client: LanguageClient, params: TemplateUri): Promise { + return await client.sendRequest(GetParametersRequest, params) +} + +export async function getCapabilities(client: LanguageClient, params: TemplateUri): Promise { + return await client.sendRequest(GetCapabilitiesRequest, params) +} + +export async function getTemplateResources(client: LanguageClient, params: TemplateUri): Promise { + const result = await client.sendRequest(GetTemplateResourcesRequest, params) + return result.resources +} + +export async function getTemplateArtifacts( + client: LanguageClient, + params: TemplateUri +): Promise { + return await client.sendRequest(GetTemplateArtifactsRequest, params) +} + +export async function describeChangeSet( + client: LanguageClient, + params: DescribeChangeSetParams +): Promise { + return await client.sendRequest(DescribeChangeSetRequest, params) +} diff --git a/packages/core/src/awsService/cloudformation/stacks/actions/stackActionInputValidation.ts b/packages/core/src/awsService/cloudformation/stacks/actions/stackActionInputValidation.ts new file mode 100644 index 00000000000..56f38c30c00 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/stacks/actions/stackActionInputValidation.ts @@ -0,0 +1,96 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fs } from '../../../../shared/fs/fs' +import { TemplateParameter } from './stackActionRequestType' + +export function validateTemplatePath(value: string): string | undefined { + if (!value) { + return 'Template path is required' + } + + const filePath = value.startsWith('file://') ? value.slice(7) : value + if (!fs.exists(filePath)) { + return 'Template file does not exist' + } + + const validExtensions = ['.yaml', '.json', '.yml', '.txt', '.cfn', '.template'] + if (!validExtensions.some((ext) => filePath.endsWith(ext))) { + return 'Invalid template file extension' + } + + return undefined +} + +export function validateStackName(value: string): string | undefined { + if (!value) { + return 'Stack name is required' + } + + if (value.length > 128) { + return 'Stack name must be 128 characters or less' + } + + if (!/^[a-zA-Z][-a-zA-Z0-9]*$/.test(value)) { + return 'Stack name must start with a letter and contain only alphanumeric characters and hyphens' + } + + return undefined +} + +export function validateChangeSetName(value: string): string | undefined { + if (!value) { + return 'Change Set name is required' + } + + if (value.length > 128) { + return 'Change Set name must be 128 characters or less' + } + + if (!/^[a-zA-Z][-a-zA-Z0-9]*$/.test(value)) { + return 'Change Set name must start with a letter and contain only alphanumeric characters and hyphens' + } + + return undefined +} + +export function validateParameterValue(input: string, param: TemplateParameter): string | undefined { + if (!input && !param.Default) { + return `Parameter ${param.name} is required` + } + + const actualValue = input ?? param.Default?.toString() ?? '' + + if (param.AllowedValues && !param.AllowedValues.includes(actualValue)) { + return `Value must be one of: ${param.AllowedValues.join(', ')}` + } + + if (param.AllowedPattern && !new RegExp(param.AllowedPattern).test(actualValue)) { + return `Value must match pattern: ${param.AllowedPattern}` + } + + if (param.MinLength && actualValue.length < param.MinLength) { + return `Value must be at least ${param.MinLength} characters` + } + + if (param.MaxLength && actualValue.length > param.MaxLength) { + return `Value must be at most ${param.MaxLength} characters` + } + + if (param.Type === 'Number') { + const numValue = Number(actualValue) + if (isNaN(numValue)) { + return 'Value must be a number' + } + if (param.MinValue && numValue < param.MinValue) { + return `Value must be at least ${param.MinValue}` + } + if (param.MaxValue && numValue > param.MaxValue) { + return `Value must be at most ${param.MaxValue}` + } + } + + return undefined +} diff --git a/packages/core/src/awsService/cloudformation/stacks/actions/stackActionProtocol.ts b/packages/core/src/awsService/cloudformation/stacks/actions/stackActionProtocol.ts new file mode 100644 index 00000000000..e01a0423f89 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/stacks/actions/stackActionProtocol.ts @@ -0,0 +1,105 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { RequestType } from 'vscode-languageserver-protocol' +import { Identifiable } from '../../lspTypes' +import { + TemplateUri, + GetParametersResult, + CreateStackActionResult, + GetStackActionStatusResult, + GetCapabilitiesResult, + GetTemplateResourcesResult, + GetTemplateArtifactsResult, + ListChangeSetsParams, + ListChangeSetsResult, + CreateValidationParams, + CreateDeploymentParams, + DescribeValidationStatusResult, + DescribeDeploymentStatusResult, + DeleteChangeSetParams, + DescribeDeletionStatusResult, + GetStackEventsParams, + GetStackEventsResult, + ClearStackEventsParams, + DescribeChangeSetParams, + DescribeChangeSetResult, + GetStackResourcesParams, + ListStackResourcesResult, + DescribeStackParams, + DescribeStackResult, +} from './stackActionRequestType' + +export const CreateValidationRequest = new RequestType( + 'aws/cfn/stack/validation/create' +) + +export const CreateDeploymentRequest = new RequestType( + 'aws/cfn/stack/deployment/create' +) + +export const GetValidationStatusRequest = new RequestType( + 'aws/cfn/stack/validation/status' +) + +export const GetDeploymentStatusRequest = new RequestType( + 'aws/cfn/stack/deployment/status' +) + +export const DescribeValidationStatusRequest = new RequestType( + 'aws/cfn/stack/validation/status/describe' +) + +export const DescribeDeploymentStatusRequest = new RequestType( + 'aws/cfn/stack/deployment/status/describe' +) + +export const DeleteChangeSetRequest = new RequestType( + 'aws/cfn/stack/changeSet/delete' +) + +export const GetChangeSetDeletionStatusRequest = new RequestType( + 'aws/cfn/stack/changeSet/deletion/status' +) + +export const DescribeChangeSetDeletionStatusRequest = new RequestType( + 'aws/cfn/stack/changeSet/deletion/status/describe' +) + +export const GetParametersRequest = new RequestType('aws/cfn/stack/parameters') + +export const GetCapabilitiesRequest = new RequestType( + 'aws/cfn/stack/capabilities' +) + +export const GetTemplateResourcesRequest = new RequestType( + 'aws/cfn/stack/import/resources' +) + +export const GetTemplateArtifactsRequest = new RequestType( + 'aws/cfn/stack/template/artifacts' +) + +export const ListChangeSetsRequest = new RequestType( + 'aws/cfn/stack/changeSet/list' +) + +export const GetStackEventsRequest = new RequestType( + 'aws/cfn/stack/events' +) + +export const ClearStackEventsRequest = new RequestType('aws/cfn/stack/events/clear') + +export const DescribeStackRequest = new RequestType( + 'aws/cfn/stack/describe' +) + +export const DescribeChangeSetRequest = new RequestType( + 'aws/cfn/stack/changeSet/describe' +) + +export const GetStackResourcesRequest = new RequestType( + 'aws/cfn/stack/resources' +) diff --git a/packages/core/src/awsService/cloudformation/stacks/actions/stackActionRequestType.ts b/packages/core/src/awsService/cloudformation/stacks/actions/stackActionRequestType.ts new file mode 100644 index 00000000000..bfbbcc9ee4e --- /dev/null +++ b/packages/core/src/awsService/cloudformation/stacks/actions/stackActionRequestType.ts @@ -0,0 +1,295 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Parameter, + Capability, + ResourceChangeDetail, + ResourceStatus, + DetailedStatus, + ResourceTargetDefinition, + StackEvent, + OnStackFailure, + Tag, + Stack, +} from '@aws-sdk/client-cloudformation' +import { Identifiable } from '../../lspTypes' + +export type ResourceToImport = { + ResourceType: string + LogicalResourceId: string + ResourceIdentifier: Record +} + +export enum DeploymentMode { + REVERT_DRIFT = 'REVERT_DRIFT', +} + +export type ChangeSetOptionalFlags = { + onStackFailure?: OnStackFailure + includeNestedStacks?: boolean + tags?: Tag[] + importExistingResources?: boolean + deploymentMode?: DeploymentMode +} + +export type CreateValidationParams = Identifiable & { + uri: string + stackName: string + parameters?: Parameter[] + capabilities?: Capability[] + resourcesToImport?: ResourceToImport[] + keepChangeSet?: boolean + onStackFailure?: OnStackFailure + includeNestedStacks?: boolean + tags?: Tag[] + importExistingResources?: boolean + deploymentMode?: DeploymentMode + s3Bucket?: string + s3Key?: string +} + +export type ChangeSetReference = { + changeSetName: string + stackName: string +} + +export type CreateDeploymentParams = Identifiable & ChangeSetReference + +export type DeleteChangeSetParams = Identifiable & ChangeSetReference + +export type CreateStackActionResult = Identifiable & ChangeSetReference + +export type ValidationResult = { + level: 'FAIL' | 'WARN' | 'INFO' + type: string + validationName: string + status: 'COMPLETE' | 'FAILED' | 'SKIPPED' + details: string + propertyPath?: string + remediationAction?: string + detailedStatus?: string +} + +export type StackChange = { + type?: string + resourceChange?: { + action?: string + logicalResourceId?: string + physicalResourceId?: string + resourceType?: string + replacement?: string + scope?: string[] + beforeContext?: string + afterContext?: string + resourceDriftStatus?: string + details?: ResourceChangeDetailV2[] + } + validationResults?: ValidationResult[] +} + +export type ResourceTargetDefinitionV2 = ResourceTargetDefinition & { + Drift?: { + PreviousValue: string + ActualValue?: string + } + LiveResourceDrift?: { + PreviousValue: string + ActualValue?: string + } +} + +export type ResourceChangeDetailV2 = Omit & { + Target?: ResourceTargetDefinitionV2 +} + +export enum StackActionPhase { + VALIDATION_STARTED = 'VALIDATION_STARTED', + VALIDATION_IN_PROGRESS = 'VALIDATION_IN_PROGRESS', + VALIDATION_COMPLETE = 'VALIDATION_COMPLETE', + VALIDATION_FAILED = 'VALIDATION_FAILED', + DEPLOYMENT_STARTED = 'DEPLOYMENT_STARTED', + DEPLOYMENT_IN_PROGRESS = 'DEPLOYMENT_IN_PROGRESS', + DEPLOYMENT_COMPLETE = 'DEPLOYMENT_COMPLETE', + DEPLOYMENT_FAILED = 'DEPLOYMENT_FAILED', + DELETION_STARTED = 'DELETION_STARTED', + DELETION_IN_PROGRESS = 'DELETION_IN_PROGRESS', + DELETION_COMPLETE = 'DELETION_COMPLETE', + DELETION_FAILED = 'DELETION_FAILED', +} + +export enum StackActionState { + IN_PROGRESS = 'IN_PROGRESS', + SUCCESSFUL = 'SUCCESSFUL', + FAILED = 'FAILED', +} + +export type GetStackActionStatusResult = Identifiable & { + phase: StackActionPhase + state: StackActionState + changes?: StackChange[] +} + +export type ValidationDetail = { + ValidationName: string + LogicalId?: string + ResourcePropertyPath?: string + Severity: 'INFO' | 'ERROR' + Message: string +} + +export type DeploymentEvent = { + LogicalResourceId?: string + ResourceType?: string + ResourceStatus?: ResourceStatus + ResourceStatusReason?: string + DetailedStatus?: DetailedStatus +} + +export type Failable = { + FailureReason?: string +} + +export type DescribeValidationStatusResult = GetStackActionStatusResult & + Failable & { + ValidationDetails?: ValidationDetail[] + } + +export type DescribeDeploymentStatusResult = GetStackActionStatusResult & + Failable & { + DeploymentEvents?: DeploymentEvent[] + } + +export type DescribeDeletionStatusResult = GetStackActionStatusResult & Failable + +export type GetParametersResult = { + parameters: TemplateParameter[] +} + +export type GetCapabilitiesResult = { + capabilities: Capability[] +} + +export type TemplateResource = { + logicalId: string + type: string + primaryIdentifierKeys?: string[] + primaryIdentifier?: Record +} + +export type GetTemplateResourcesResult = { + resources: TemplateResource[] +} + +export type Artifact = { + resourceType: string + filePath: string +} + +export type GetTemplateArtifactsResult = { + artifacts: Artifact[] +} + +export enum OptionalFlagMode { + Skip = 'Skip Optional Flags', + Input = 'Input Optional Flags', + DevFriendly = 'Use Developer Friendly Flag Selections', +} + +export type TemplateParameter = { + name: string + Type?: string + Default?: string | number | boolean + Description?: string + AllowedValues?: (string | number | boolean)[] + AllowedPattern?: string + MinLength?: number + MaxLength?: number + MinValue?: number + MaxValue?: number +} + +export type TemplateUri = string + +export type ChangeSetInfo = { + changeSetName: string + status: string + creationTime?: string + description?: string +} + +export type ListChangeSetsParams = { + stackName: string + nextToken?: string +} + +export type ListChangeSetsResult = { + changeSets: ChangeSetInfo[] + nextToken?: string +} + +export type DescribeChangeSetParams = ChangeSetReference + +export type DescribeChangeSetResult = ChangeSetInfo & { + stackName: string + changes?: StackChange[] +} + +export type StackInfo = { + StackName: string + StackId?: string + StackStatus?: string + StackStatusReason?: string + TemplateDescription?: string + CreationTime?: string + LastUpdatedTime?: string + RootId?: string + ParentId?: string + DisableRollback?: boolean + EnableTerminationProtection?: boolean + TimeoutInMinutes?: number +} + +export type GetStackEventsParams = { + stackName: string + nextToken?: string + refresh?: boolean +} + +export type GetStackEventsResult = { + events: StackEvent[] + nextToken?: string + gapDetected?: boolean +} + +export type ClearStackEventsParams = { + stackName: string +} + +export type DescribeStackParams = { + stackName: string +} + +export type DescribeStackResult = { + stack?: Stack +} + +export interface StackResourceSummary { + LogicalResourceId: string + PhysicalResourceId?: string + ResourceType: string + ResourceStatus: string + Timestamp?: string +} + +export type ListStackResourcesResult = { + resources: StackResourceSummary[] + nextToken?: string +} + +export interface GetStackResourcesParams { + stackName: string + nextToken?: string +} diff --git a/packages/core/src/awsService/cloudformation/stacks/actions/stackActionUtil.ts b/packages/core/src/awsService/cloudformation/stacks/actions/stackActionUtil.ts new file mode 100644 index 00000000000..9a88bb71aa7 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/stacks/actions/stackActionUtil.ts @@ -0,0 +1,55 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ChangeSetOptionalFlags, + CreateDeploymentParams, + CreateValidationParams, + DeleteChangeSetParams, + ResourceToImport, +} from './stackActionRequestType' +import { Capability, Parameter } from '@aws-sdk/client-cloudformation' + +export function createValidationParams( + id: string, + uri: string, + stackName: string, + parameters?: Parameter[], + capabilities?: Capability[], + resourcesToImport?: ResourceToImport[], + keepChangeSet?: boolean, + optionalFlags?: ChangeSetOptionalFlags, + s3Bucket?: string, + s3Key?: string +): CreateValidationParams { + return { + id, + uri, + stackName, + parameters, + capabilities, + resourcesToImport, + keepChangeSet, + onStackFailure: optionalFlags?.onStackFailure, + includeNestedStacks: optionalFlags?.includeNestedStacks, + tags: optionalFlags?.tags, + importExistingResources: optionalFlags?.importExistingResources, + deploymentMode: optionalFlags?.deploymentMode, + s3Bucket, + s3Key, + } +} + +export function createDeploymentParams(id: string, stackName: string, changeSetName: string): CreateDeploymentParams { + return { id, stackName, changeSetName } +} + +export function createChangeSetDeletionParams( + id: string, + stackName: string, + changeSetName: string +): DeleteChangeSetParams { + return { id, stackName, changeSetName } +} diff --git a/packages/core/src/awsService/cloudformation/stacks/actions/validationWorkflow.ts b/packages/core/src/awsService/cloudformation/stacks/actions/validationWorkflow.ts new file mode 100644 index 00000000000..d7cffc82b0e --- /dev/null +++ b/packages/core/src/awsService/cloudformation/stacks/actions/validationWorkflow.ts @@ -0,0 +1,188 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { v4 as uuidv4 } from 'uuid' +import { Parameter, Capability } from '@aws-sdk/client-cloudformation' +import { + StackActionPhase, + StackChange, + StackActionState, + ResourceToImport, + ChangeSetOptionalFlags, +} from './stackActionRequestType' +import { LanguageClient } from 'vscode-languageclient/node' +import { showErrorMessage, showValidationStarted, showValidationSuccess, showValidationFailure } from '../../ui/message' +import { describeValidationStatus, getValidationStatus, validate } from './stackActionApi' +import { createDeploymentStatusBar, updateWorkflowStatus } from '../../ui/statusBar' +import { StatusBarItem, commands } from 'vscode' +import { DiffWebviewProvider } from '../../ui/diffWebviewProvider' +import { createValidationParams } from './stackActionUtil' +import { extractErrorMessage } from '../../utils' +import { getLogger } from '../../../../shared/logger/logger' + +// TODO move this to server side, we should let server handle last validation +let lastValidation: Validation | undefined = undefined + +export function getLastValidation(): Validation | undefined { + return lastValidation +} + +export function setLastValidation(validation: Validation | undefined): void { + lastValidation = validation +} + +export class Validation { + private id: string + public readonly uri: string + public readonly stackName: string + public readonly parameters?: Parameter[] + private capabilities?: Capability[] + private resourcesToImport?: ResourceToImport[] + private client: LanguageClient + private diffProvider: DiffWebviewProvider + private status: StackActionPhase | undefined + private changes: StackChange[] | undefined + private statusBarItem: StatusBarItem | undefined + private shouldEnableDeployment: boolean + private changeSetName?: string + private optionalFlags?: ChangeSetOptionalFlags + private s3Bucket?: string + private s3Key?: string + + constructor( + uri: string, + stackName: string, + client: LanguageClient, + diffProvider: DiffWebviewProvider, + parameters?: Parameter[], + capabilities?: Capability[], + resourcesToImport?: ResourceToImport[], + shouldEnableDeployment: boolean = false, + optionalFlags?: ChangeSetOptionalFlags, + s3Bucket?: string, + s3Key?: string + ) { + this.id = uuidv4() + this.uri = uri + this.stackName = stackName + this.client = client + this.diffProvider = diffProvider + this.parameters = parameters + this.capabilities = capabilities + this.resourcesToImport = resourcesToImport + this.shouldEnableDeployment = shouldEnableDeployment + this.optionalFlags = optionalFlags + this.s3Bucket = s3Bucket + this.s3Key = s3Key + } + + async validate() { + try { + showValidationStarted(this.stackName) + this.statusBarItem = createDeploymentStatusBar() + // Capture the result to get changeSetName + const result = await validate( + this.client, + createValidationParams( + this.id, + this.uri, + this.stackName, + this.parameters, + this.capabilities, + this.resourcesToImport, + this.shouldEnableDeployment, + this.optionalFlags, + this.s3Bucket, + this.s3Key + ) + ) + + // Store changeSetName from validation result + this.changeSetName = result.changeSetName + + this.pollForProgress() + } catch (error) { + showErrorMessage(`Error validating template: ${error instanceof Error ? error.message : String(error)}`) + } + } + + getChanges(): StackChange[] | undefined { + return this.changes + } + + private pollForProgress() { + const interval = setInterval(() => { + getValidationStatus(this.client, { id: this.id }) + .then(async (validationResult) => { + if (validationResult.phase === this.status) { + return + } + + this.status = validationResult.phase + this.changes = validationResult.changes + + if (this.statusBarItem) { + updateWorkflowStatus(this.statusBarItem, validationResult.phase) + } + + switch (validationResult.phase) { + case StackActionPhase.VALIDATION_IN_PROGRESS: + // Status bar updated above + break + case StackActionPhase.VALIDATION_COMPLETE: + if (validationResult.state === StackActionState.SUCCESSFUL) { + showValidationSuccess(this.stackName) + + this.showDiffView() + } else { + const describeValidationStatusResult = await describeValidationStatus(this.client, { + id: this.id, + }) + showValidationFailure( + this.stackName, + describeValidationStatusResult.FailureReason ?? 'UNKNOWN' + ) + } + clearInterval(interval) + break + case StackActionPhase.VALIDATION_FAILED: { + const describeValidationStatusResult = await describeValidationStatus(this.client, { + id: this.id, + }) + showValidationFailure( + this.stackName, + describeValidationStatusResult.FailureReason ?? 'UNKNOWN' + ) + clearInterval(interval) + break + } + } + }) + .catch((error) => { + getLogger().error(`Error polling for deployment status: ${error}`) + showErrorMessage(`Error polling for validation status: ${extractErrorMessage(error)}`) + clearInterval(interval) + }) + }, 1000) + } + + private showDiffView() { + void this.diffProvider.updateData(this.stackName, this.changes, this.changeSetName, this.shouldEnableDeployment) + void commands.executeCommand('aws.cloudformation.diff.focus') + } + + // Test-specific accessors - protected to limit access + protected getDiffProvider(): DiffWebviewProvider { + return this.diffProvider + } + + protected setChanges(changes: StackChange[]): void { + this.changes = changes + } + + protected showDiffViewForTest(): void { + this.showDiffView() + } +} diff --git a/packages/core/src/awsService/cloudformation/stacks/changeSetsManager.ts b/packages/core/src/awsService/cloudformation/stacks/changeSetsManager.ts new file mode 100644 index 00000000000..c9929b735ca --- /dev/null +++ b/packages/core/src/awsService/cloudformation/stacks/changeSetsManager.ts @@ -0,0 +1,66 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { LanguageClient } from 'vscode-languageclient/node' +import { ListChangeSetsRequest } from './actions/stackActionProtocol' +import { ChangeSetInfo } from './actions/stackActionRequestType' + +type StackChangeSets = { + changeSets: ChangeSetInfo[] + nextToken?: string +} + +export class ChangeSetsManager { + private stackChangeSets = new Map() + + constructor(private readonly client: LanguageClient) {} + + async getChangeSets(stackName: string): Promise { + try { + const response = await this.client.sendRequest(ListChangeSetsRequest, { + stackName, + }) + + this.stackChangeSets.set(stackName, { + changeSets: response.changeSets, + nextToken: response.nextToken, + }) + + return response.changeSets + } catch (error) { + this.stackChangeSets.set(stackName, { changeSets: [] }) + return [] + } + } + + async loadMoreChangeSets(stackName: string): Promise { + const current = this.stackChangeSets.get(stackName) + if (!current?.nextToken) { + return + } + + try { + const response = await this.client.sendRequest(ListChangeSetsRequest, { + stackName, + nextToken: current.nextToken, + }) + + this.stackChangeSets.set(stackName, { + changeSets: [...current.changeSets, ...response.changeSets], + nextToken: response.nextToken, + }) + } catch (error) { + // Keep existing data on error + } + } + + get(stackName: string): ChangeSetInfo[] { + return this.stackChangeSets.get(stackName)?.changeSets ?? [] + } + + hasMore(stackName: string): boolean { + return this.stackChangeSets.get(stackName)?.nextToken !== undefined + } +} diff --git a/packages/core/src/awsService/cloudformation/stacks/stacksManager.ts b/packages/core/src/awsService/cloudformation/stacks/stacksManager.ts new file mode 100644 index 00000000000..bd0a47b1ff8 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/stacks/stacksManager.ts @@ -0,0 +1,133 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { commands, Disposable, window } from 'vscode' +import { StackStatus, StackSummary } from '@aws-sdk/client-cloudformation' +import { RequestType } from 'vscode-languageserver-protocol' +import { LanguageClient } from 'vscode-languageclient/node' +import { commandKey } from '../utils' +import { setContext } from '../../../shared/vscode/setContext' + +type ListStacksParams = { + statusToInclude?: StackStatus[] + statusToExclude?: StackStatus[] + loadMore?: boolean +} + +type ListStacksResult = { + stacks: StackSummary[] + nextToken?: string +} + +const ListStacksRequest = new RequestType('aws/cfn/stacks') +const PollIntervalMs = 1000 + +type StacksChangeListener = (stacks: StackSummary[]) => void + +export class StacksManager implements Disposable { + private stacks: StackSummary[] = [] + private nextToken?: string + private readonly listeners: StacksChangeListener[] = [] + private poller?: NodeJS.Timeout + + constructor(private readonly client: LanguageClient) {} + + addListener(listener: StacksChangeListener) { + this.listeners.push(listener) + } + + get() { + return [...this.stacks] + } + + hasMore(): boolean { + return this.nextToken !== undefined + } + + reload() { + void this.loadStacks() + } + + updateStackStatus(stackName: string, stackStatus: string) { + const stack = this.stacks.find((s) => s.StackName === stackName) + if (stack) { + stack.StackStatus = stackStatus as any + this.notifyListeners() + } + } + + async loadMoreStacks() { + if (!this.nextToken) { + return + } + + await setContext('aws.cloudformation.loadingStacks', true) + try { + const response = await this.client.sendRequest(ListStacksRequest, { + statusToExclude: ['DELETE_COMPLETE'], + loadMore: true, + }) + this.stacks = response.stacks + this.nextToken = response.nextToken + } catch (error) { + void window.showErrorMessage( + `Failed to load more stacks: ${error instanceof Error ? error.message : String(error)}` + ) + } finally { + await setContext('aws.cloudformation.loadingStacks', false) + this.notifyListeners() + } + } + + startPolling() { + this.poller ??= setInterval(() => { + this.reload() + }, PollIntervalMs) + } + + stopPolling() { + if (this.poller) { + clearInterval(this.poller) + this.poller = undefined + } + } + + dispose() { + this.stopPolling() + } + + private async loadStacks() { + await setContext('aws.cloudformation.refreshingStacks', true) + try { + const response = await this.client.sendRequest(ListStacksRequest, { + statusToExclude: ['DELETE_COMPLETE'], + loadMore: false, + }) + this.stacks = response.stacks + this.nextToken = response.nextToken + } catch (error) { + this.stacks = [] + this.nextToken = undefined + } finally { + await setContext('aws.cloudformation.refreshingStacks', false) + this.notifyListeners() + if (this.stacks.length === 0) { + this.stopPolling() + } + } + } + + private notifyListeners() { + for (const listener of this.listeners) { + listener(this.stacks) + } + } +} + +export function refreshCommand(manager: StacksManager) { + return commands.registerCommand(commandKey('stacks.refresh'), () => { + manager.reload() + }) +} diff --git a/packages/core/src/awsService/cloudformation/telemetryOptIn.ts b/packages/core/src/awsService/cloudformation/telemetryOptIn.ts new file mode 100644 index 00000000000..8cd47866244 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/telemetryOptIn.ts @@ -0,0 +1,65 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ExtensionContext, env, Uri, window } from 'vscode' +import { CloudFormationTelemetrySettings } from './extensionConfig' +import { commandKey } from './utils' +import { isAutomation } from '../../shared/vscode/env' + +/* eslint-disable aws-toolkits/no-banned-usages */ +export async function promptTelemetryOptIn( + context: ExtensionContext, + cfnTelemetrySettings: CloudFormationTelemetrySettings +): Promise { + const telemetryEnabled = cfnTelemetrySettings.get('enabled', false) + if (isAutomation()) { + return telemetryEnabled + } + + const hasResponded = context.globalState.get(commandKey('telemetry.hasResponded'), false) + const lastPromptDate = context.globalState.get(commandKey('telemetry.lastPromptDate'), 0) + const now = Date.now() + const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000 + + // If user has permanently responded, use their choice + if (hasResponded) { + return telemetryEnabled + } + + // Check if we should show reminder (30 days since last prompt) + const shouldPrompt = lastPromptDate === 0 || now - lastPromptDate >= thirtyDaysMs + if (!shouldPrompt) { + return telemetryEnabled + } + + const message = + 'Help us improve the AWS CloudFormation Language Server by sharing anonymous telemetry data with AWS. You can change this preference at any time in aws.cloudformation Settings.' + + const allow = 'Yes, Allow' + const later = 'Not Now' + const never = 'Never' + const learnMore = 'Learn More' + const response = await window.showInformationMessage(message, allow, later, never, learnMore) + + if (response === learnMore) { + await env.openExternal( + Uri.parse('https://github.com/aws-cloudformation/cloudformation-languageserver/tree/main/src/telemetry') + ) + return promptTelemetryOptIn(context, cfnTelemetrySettings) + } + + if (response === allow) { + await cfnTelemetrySettings.update('enabled', true) + await context.globalState.update(commandKey('telemetry.hasResponded'), true) + } else if (response === never) { + await cfnTelemetrySettings.update('enabled', false) + await context.globalState.update(commandKey('telemetry.hasResponded'), true) + } else if (response === later) { + await cfnTelemetrySettings.update('enabled', false) + await context.globalState.update(commandKey('telemetry.lastPromptDate'), now) + } + + return cfnTelemetrySettings.get('enabled', false) +} diff --git a/packages/core/src/awsService/cloudformation/ui/cfnEnvironmentFileSelector.ts b/packages/core/src/awsService/cloudformation/ui/cfnEnvironmentFileSelector.ts new file mode 100644 index 00000000000..270718392e8 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/ui/cfnEnvironmentFileSelector.ts @@ -0,0 +1,51 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { window } from 'vscode' +import { CfnEnvironmentFileSelectorItem } from '../cfn-init/cfnProjectTypes' + +export class CfnEnvironmentFileSelector { + public async selectEnvironmentFile( + files: CfnEnvironmentFileSelectorItem[], + requiredParameterCount: number + ): Promise { + // Sort files: matching template path first, then by compatible parameter count (descending) + const sortedFiles = files.sort((a, b) => { + // First sort by hasMatchingTemplatePath (true first) + if (a.hasMatchingTemplatePath !== b.hasMatchingTemplatePath) { + return a.hasMatchingTemplatePath ? -1 : 1 + } + + // Then sort by compatible parameter count (higher first) + const aCount = a.compatibleParameters?.length ?? 0 + const bCount = b.compatibleParameters?.length ?? 0 + return bCount - aCount + }) + + const items = [ + { + label: '$(close) Enter parameters manually', + detail: 'Skip parameter file selection', + parameters: undefined, + }, + ...sortedFiles.map((file) => { + const compatibleCount = file.compatibleParameters?.length ?? 0 + const countText = `${compatibleCount}/${requiredParameterCount} parameters match` + + return { + label: file.hasMatchingTemplatePath ? `$(star-full) ${file.fileName}` : file.fileName, + detail: file.hasMatchingTemplatePath ? `Matching template path • ${countText}` : countText, + parameters: file, + } + }), + ] + + const selected = await window.showQuickPick(items, { + placeHolder: 'Select an environment file or enter manually', + }) + + return selected?.parameters + } +} diff --git a/packages/core/src/awsService/cloudformation/ui/cfnEnvironmentSelector.ts b/packages/core/src/awsService/cloudformation/ui/cfnEnvironmentSelector.ts new file mode 100644 index 00000000000..98a88f0a44c --- /dev/null +++ b/packages/core/src/awsService/cloudformation/ui/cfnEnvironmentSelector.ts @@ -0,0 +1,36 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { commands, window } from 'vscode' +import { CfnEnvironmentConfig, CfnEnvironmentLookup } from '../cfn-init/cfnProjectTypes' +import { commandKey } from '../utils' + +export class CfnEnvironmentSelector { + public async selectEnvironment(environmentLookup: CfnEnvironmentLookup): Promise { + if (Object.keys(environmentLookup).length === 0) { + const choice = await window.showWarningMessage('No environments found in CFN Project', 'Add environment') + + if (choice === 'Add environment') { + void commands.executeCommand(commandKey('init.addEnvironment')) + } + + return + } + + const items = [ + { label: 'None', description: 'No environment selected' }, + ...Object.values(environmentLookup).map((env: CfnEnvironmentConfig) => ({ + label: env.name, + description: `AWS Profile: ${env.profile}`, + })), + ] + + const selected = await window.showQuickPick(items, { + placeHolder: 'Select an environment', + }) + + return selected?.label === 'None' ? undefined : selected?.label + } +} diff --git a/packages/core/src/awsService/cloudformation/ui/diffViewHelper.ts b/packages/core/src/awsService/cloudformation/ui/diffViewHelper.ts new file mode 100644 index 00000000000..97948465046 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/ui/diffViewHelper.ts @@ -0,0 +1,258 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Uri, commands, workspace, Range, Position, window, ThemeColor } from 'vscode' +import { StackChange } from '../stacks/actions/stackActionRequestType' +import * as path from 'path' +import { fs } from '../../../shared/fs/fs' +import * as os from 'os' + +export class DiffViewHelper { + static async openDiff(stackName: string, changes: StackChange[], resourceId?: string) { + const tmpDir = os.tmpdir() + const beforePath = path.join(tmpDir, `${stackName}-before.json`) + const afterPath = path.join(tmpDir, `${stackName}-after.json`) + + const beforeData: Record = {} + const afterData: Record = {} + + for (const change of changes) { + const rc = change.resourceChange + if (!rc?.logicalResourceId) { + continue + } + + const id = rc.logicalResourceId + + if (rc.action !== 'Add' || rc.resourceDriftStatus === 'DELETED') { + if (rc.beforeContext) { + try { + beforeData[id] = JSON.parse(rc.beforeContext) as Record + } catch { + beforeData[id] = {} + } + } else { + beforeData[id] = {} + } + } + + if (rc.action !== 'Remove') { + if (rc.afterContext) { + try { + afterData[id] = JSON.parse(rc.afterContext) as Record + } catch { + afterData[id] = {} + } + } else { + afterData[id] = {} + } + } + + if (!rc.beforeContext && !rc.afterContext) { + if (rc.details) { + for (const detail of rc.details) { + const target = detail.Target + if (target?.Name) { + if (rc.action !== 'Add') { + ;(beforeData[id] as Record)[target.Name] = + target.BeforeValue ?? '' + } + if (rc.action !== 'Remove') { + ;(afterData[id] as Record)[target.Name] = + target.AfterValue ?? '' + } + } + } + } + } + } + + await fs.writeFile(beforePath, JSON.stringify(beforeData, undefined, 2)) + await fs.writeFile(afterPath, JSON.stringify(afterData, undefined, 2)) + + const beforeUri = Uri.file(beforePath) + const afterUri = Uri.file(afterPath) + + await commands.executeCommand('vscode.diff', beforeUri, afterUri, `${stackName}: Before ↔ After`) + + this.addDriftDecorations(beforeUri, changes) + + if (resourceId) { + // Find the line with the resource ID in the after doc. + // In a deleted resource case this will just be the top + const editor = await workspace.openTextDocument(afterUri) + const text = editor.getText() + const lines = text.split('\n') + const lineIndex = lines.findIndex((line) => line.includes(`"${resourceId}"`)) + + if (lineIndex !== -1) { + await commands.executeCommand('vscode.diff', beforeUri, afterUri, `${stackName}: Before ↔ After`, { + selection: new Range(new Position(lineIndex, 0), new Position(lineIndex + 1, 0)), + }) + } + } + } + + private static propertyExistsInContext(context: string, path: string): boolean { + try { + const data = JSON.parse(context) + const pathParts = path.split('/').filter(Boolean) + let current: any = data + + for (const part of pathParts) { + if (/^\d+$/.test(part)) { + const index = parseInt(part, 10) + if (Array.isArray(current) && current[index] !== undefined) { + current = current[index] + } else { + return false + } + } else if (current && typeof current === 'object' && part in current) { + current = current[part] + } else { + return false + } + } + return true + } catch { + return false + } + } + + private static findPropertyLineIndex(lines: string[], startLineIndex: number, path: string): number { + const pathParts = path.split('/').filter(Boolean) + let currentLineIndex = startLineIndex + + for (const part of pathParts) { + // Skip numeric array indices - they don't appear as keys in JSON + if (/^\d+$/.test(part)) { + continue + } + + const foundIndex = lines.findIndex((line, idx) => idx > currentLineIndex && line.includes(`"${part}"`)) + if (foundIndex < 0) { + return -1 + } + currentLineIndex = foundIndex + } + + return currentLineIndex + } + + private static createDeletedResourceHoverMessage(logicalResourceId: string): string { + return [ + '### ⚠️ Resource Drift Detected', + '', + `**Resource:** \`${logicalResourceId}\``, + '', + '**Status:** Resource Deleted', + '', + '*This resource was deleted sometime after the previous deployment (out-of-band).*', + ].join('\n') + } + + private static createPropertyDriftHoverMessage( + logicalResourceId: string, + path: string, + previousValue: string, + actualValue: string + ): string { + return [ + '### ⚠️ Resource Drift Detected', + '', + `**Resource:** \`${logicalResourceId}\``, + '', + `**Property:** \`${path}\``, + '', + '| Source | Value |', + '|--------|-------|', + `| 📄 Template | \`${previousValue}\` |`, + `| ☁️ Live AWS | \`${actualValue}\` |`, + '', + '*The live resource has drifted from the previously deployed template.*', + ].join('\n') + } + + private static addDriftDecorations(beforeUri: Uri, changes: StackChange[]) { + const driftDecorationType = window.createTextEditorDecorationType({ + after: { + contentText: ' ⚠️ Drifted', + color: new ThemeColor('editorWarning.foreground'), + fontWeight: 'bold', + }, + backgroundColor: new ThemeColor('editorWarning.background'), + cursor: 'pointer', + }) + + setTimeout(() => { + const editors = window.visibleTextEditors.filter( + (editor) => editor.document.uri.toString() === beforeUri.toString() + ) + + for (const editor of editors) { + const decorations: any[] = [] + const lines = editor.document.getText().split('\n') + + for (const change of changes) { + const rc = change.resourceChange + if (!rc?.logicalResourceId) { + continue + } + + const resourceLineIndex = lines.findIndex((line) => line.includes(`"${rc.logicalResourceId}"`)) + if (resourceLineIndex < 0) { + continue + } + + // Handle DELETED drift status + if (rc.resourceDriftStatus === 'DELETED') { + const line = lines[resourceLineIndex] + const endCol = line.trimEnd().length + const range = new Range(resourceLineIndex, endCol, resourceLineIndex, endCol) + const hoverMessage = this.createDeletedResourceHoverMessage(rc.logicalResourceId) + + decorations.push({ range, hoverMessage }) + continue + } + + if (!rc.details) { + continue + } + + for (const detail of rc.details) { + const target = detail.Target + const drift = target?.Drift || target?.LiveResourceDrift + if (drift && target?.Path && drift.ActualValue !== undefined) { + // Check if property exists in afterContext + if (rc.afterContext && !this.propertyExistsInContext(rc.afterContext, target.Path)) { + continue + } + + const currentLineIndex = this.findPropertyLineIndex(lines, resourceLineIndex, target.Path) + if (currentLineIndex <= resourceLineIndex) { + continue + } + + const line = lines[currentLineIndex] + const endCol = line.trimEnd().length + // sets hover range to just the decoration + const range = new Range(currentLineIndex, endCol, currentLineIndex, endCol) + const hoverMessage = this.createPropertyDriftHoverMessage( + rc.logicalResourceId, + target.Path, + drift.PreviousValue, + drift.ActualValue + ) + + decorations.push({ range, hoverMessage }) + } + } + } + + editor.setDecorations(driftDecorationType, decorations) + } + }, 100) + } +} diff --git a/packages/core/src/awsService/cloudformation/ui/diffWebviewProvider.ts b/packages/core/src/awsService/cloudformation/ui/diffWebviewProvider.ts new file mode 100644 index 00000000000..15aa8f3dbcd --- /dev/null +++ b/packages/core/src/awsService/cloudformation/ui/diffWebviewProvider.ts @@ -0,0 +1,406 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WebviewView, WebviewViewProvider, commands, Disposable } from 'vscode' +import { StackChange } from '../stacks/actions/stackActionRequestType' +import { DiffViewHelper } from './diffViewHelper' +import { commandKey } from '../utils' +import { StackViewCoordinator } from './stackViewCoordinator' + +const webviewCommandOpenDiff = 'openDiff' + +export class DiffWebviewProvider implements WebviewViewProvider, Disposable { + private _view?: WebviewView + private stackName = '' + private changes: StackChange[] = [] + private changeSetName?: string + private enableDeployments: boolean = false + private currentPage: number = 0 + private pageSize: number = 50 + private totalPages: number = 0 + private readonly disposables: Disposable[] = [] + + constructor(private readonly coordinator: StackViewCoordinator) { + this.disposables.push( + coordinator.onDidChangeStack((state) => { + if (!state.isChangeSetMode) { + this.stackName = '' + this.changes = [] + this.changeSetName = undefined + if (this._view) { + this._view.webview.html = this.getHtmlContent() + } + } + }) + ) + } + + async updateData( + stackName: string, + changes: StackChange[] = [], + changeSetName?: string, + enableDeployments = false + ) { + this.stackName = stackName + this.changes = changes + this.changeSetName = changeSetName + this.enableDeployments = enableDeployments + this.currentPage = 0 + this.totalPages = Math.ceil(changes.length / this.pageSize) + await this.coordinator.setChangeSetMode(stackName, true) + if (this._view) { + this._view.webview.html = this.getHtmlContent() + } + } + + resolveWebviewView(webviewView: WebviewView) { + this._view = webviewView + webviewView.webview.options = { enableScripts: true } + webviewView.webview.html = this.getHtmlContent() + + webviewView.webview.onDidReceiveMessage((message: { command: string; resourceId?: string }) => { + if (message.command === webviewCommandOpenDiff) { + void DiffViewHelper.openDiff(this.stackName, this.changes, message.resourceId) + } else if (message.command === 'confirmDeploy') { + if (this.changeSetName) { + void commands.executeCommand(commandKey('api.executeChangeSet'), this.stackName, this.changeSetName) + this.changeSetName = undefined + this.enableDeployments = false + this._view!.webview.html = this.getHtmlContent() + } + } else if (message.command === 'deleteChangeSet') { + void commands.executeCommand(commandKey('stacks.deleteChangeSet'), { + stackName: this.stackName, + changeSetName: this.changeSetName, + }) + this.changeSetName = undefined + this.enableDeployments = false + this._view!.webview.html = this.getHtmlContent() + } else if (message.command === 'nextPage') { + if (this.currentPage < this.totalPages - 1) { + this.currentPage++ + this._view!.webview.html = this.getHtmlContent() + } + } else if (message.command === 'prevPage') { + if (this.currentPage > 0) { + this.currentPage-- + this._view!.webview.html = this.getHtmlContent() + } + } + }) + } + + private getHtmlContent(): string { + const changes = this.changes + + const startIndex = this.currentPage * this.pageSize + const endIndex = startIndex + this.pageSize + const displayedChanges = changes.slice(startIndex, endIndex) + const hasNext = this.currentPage < this.totalPages - 1 + const hasPrev = this.currentPage > 0 + + if (!changes || changes.length === 0) { + return ` + + + + + + +

No changes detected for stack: ${this.stackName}

+ + + ` + } + + // Check if any resource has drift + // TODO: adapt if we do real backend pagination + const hasDrift = changes.some( + (change) => + change.resourceChange?.resourceDriftStatus || + change.resourceChange?.details?.some( + (detail) => detail.Target?.Drift || detail.Target?.LiveResourceDrift + ) + ) + + let tableHtml = ` + + + + + + + + ${ + hasDrift + ? ` + ` + : '' + } + ` + + for (const [changeIndex, change] of displayedChanges.entries()) { + const rc = change.resourceChange + if (!rc) { + continue + } + + const borderColor = + rc.action === 'Add' + ? 'var(--vscode-gitDecoration-addedResourceForeground)' + : rc.action === 'Remove' + ? 'var(--vscode-gitDecoration-deletedResourceForeground)' + : rc.action === 'Modify' + ? 'var(--vscode-gitDecoration-modifiedResourceForeground)' + : 'transparent' + + const hasDetails = rc.details && rc.details.length > 0 + const expandIcon = hasDetails ? '▶' : '' + + const driftStatus = rc.resourceDriftStatus + const hasDriftDetails = rc.details?.some( + (detail) => detail.Target?.Drift || detail.Target?.LiveResourceDrift + ) + let driftDisplay = '' + if (driftStatus === 'DELETED') { + driftDisplay = '⚠️ Deleted' + } else if (hasDriftDetails) { + driftDisplay = '⚠️ Modified' + } else if (driftStatus && driftStatus !== 'IN_SYNC') { + driftDisplay = `⚠️ ${driftStatus}` + } + + tableHtml += ` + + + + + + ${ + hasDrift + ? ` + ` + : '' + } + ` + + if (hasDetails) { + tableHtml += ` + ` + } + } + + tableHtml += `
ActionLogicalResourceIdPhysicalResourceIdResourceTypeReplacementDrift Status
+ ${expandIcon} + ${rc.action ?? 'Unknown'}${rc.logicalResourceId ?? 'Unknown'}${rc.physicalResourceId ?? ' '}${rc.resourceType ?? 'Unknown'}${rc.replacement ?? 'N/A'}${driftDisplay || '-'}
` + + const paginationControls = + this.totalPages > 1 + ? ` +
+ Page ${this.currentPage + 1} of ${this.totalPages} + + +
+ ` + : '' + + const viewDiffButton = ` +
+ +
+ ` + + const deploymentButtons = + this.changeSetName && this.enableDeployments + ? ` +
+ + +
+ ` + : '' + + return ` + + + + + + + ${paginationControls} +
+ ${viewDiffButton}${deploymentButtons} + ${tableHtml} +
+ + + + ` + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose() + } + } +} diff --git a/packages/core/src/awsService/cloudformation/ui/htmlPreview.ts b/packages/core/src/awsService/cloudformation/ui/htmlPreview.ts new file mode 100644 index 00000000000..500596d0ca6 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/ui/htmlPreview.ts @@ -0,0 +1,20 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ViewColumn } from 'vscode' +import { docPreview } from '../documents/documentPreview' + +export async function htmlPreview(content: unknown, title: string) { + if (typeof content !== 'string') { + return + } + + await docPreview({ + content: `# ${title}\n${content}`, + language: 'markdown', + viewColumn: ViewColumn.Beside, + preserveFocus: true, + }) +} diff --git a/packages/core/src/awsService/cloudformation/ui/inputBox.ts b/packages/core/src/awsService/cloudformation/ui/inputBox.ts new file mode 100644 index 00000000000..a91b72b6be8 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/ui/inputBox.ts @@ -0,0 +1,587 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { window, workspace, Uri, commands } from 'vscode' +import { + validateStackName, + validateParameterValue, + validateChangeSetName, +} from '../stacks/actions/stackActionInputValidation' +import { Parameter, Capability, Tag, OnStackFailure } from '@aws-sdk/client-cloudformation' +import { + TemplateParameter, + ResourceToImport, + TemplateResource, + OptionalFlagMode, + DeploymentMode, +} from '../stacks/actions/stackActionRequestType' +import { DocumentManager } from '../documents/documentManager' +import path from 'path' +import fs from '../../../shared/fs/fs' + +export async function getTemplatePath(documentManager: DocumentManager): Promise { + const validTemplates = documentManager + .get() + .filter((doc) => doc.cfnType === 'template') + .map((doc) => { + const uri = doc.uri + + return { + label: doc.fileName, + description: workspace.asRelativePath(Uri.parse(uri)), + uri: uri, + } + }) + .sort((a, b) => a.label.localeCompare(b.label)) + + const options = [ + ...validTemplates, + { + label: '$(file) Browse for template file...', + description: 'Select a CloudFormation template file', + uri: 'browse', + }, + ] + + const selected = await window.showQuickPick(options, { + placeHolder: 'Select CloudFormation template', + ignoreFocusOut: true, + }) + + if (!selected) { + return undefined + } + + if (selected.uri === 'browse') { + const fileUri = await window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + filters: { + 'CloudFormation Templates': ['yaml', 'yml', 'json', 'template', 'cfn', 'txt', ''], + }, + title: 'Select CloudFormation Template', + }) + + return fileUri?.[0]?.fsPath + } + + return selected.uri +} + +export async function getStackName(prefill?: string): Promise { + return await window.showInputBox({ + prompt: 'Enter the CloudFormation stack name', + value: prefill, + validateInput: validateStackName, + ignoreFocusOut: true, + }) +} + +export async function getChangeSetName(prefill?: string): Promise { + return await window.showInputBox({ + prompt: 'Enter the CloudFormation change set name', + value: prefill, + validateInput: validateChangeSetName, + ignoreFocusOut: true, + }) +} + +export async function getParameterValues( + templateParameters: TemplateParameter[], + prefillParameters?: Parameter[] +): Promise { + const parameters: Parameter[] = [] + + for (const param of templateParameters) { + const prefillCandidate = prefillParameters?.find((p) => p.ParameterKey === param.name)?.ParameterValue + + // If we are using a previous parameter value, we must ensure that it is compatible with possibly modified template + const prefillValue = + prefillCandidate && !validateParameterValue(prefillCandidate, param) ? prefillCandidate : undefined + + const value = await getParameterValue(param, prefillValue) + if (value) { + parameters.push(value) + } + } + + return parameters +} + +async function getParameterValue(parameter: TemplateParameter, prefill?: string): Promise { + const prompt = `Enter value for parameter "${parameter.name}"${parameter.Description ? ` - ${parameter.Description}` : ''}` + const placeHolder = parameter.Default ? `Default: ${parameter.Default}` : (parameter.Type ?? 'String') + const allowedInfo = parameter.AllowedValues ? ` (Allowed: ${parameter.AllowedValues.join(', ')})` : '' + + const value = await window.showInputBox({ + prompt: prompt + allowedInfo, + placeHolder, + value: prefill ?? parameter.Default?.toString(), + validateInput: (input: string) => validateParameterValue(input, parameter), + ignoreFocusOut: true, + }) + + if (value === undefined) { + return undefined + } + + return { ParameterKey: parameter.name, ParameterValue: value } +} + +export async function confirmCapabilities(capabilities: Capability[]): Promise { + // Confirm if user wants to use detected capabilities + const useDetected = await window.showQuickPick(['Yes', 'No, modify capabilities'], { + placeHolder: `Use capabilities: ${capabilities.join(', ') || '(none)'}?`, + canPickMany: false, + }) + + if (!useDetected) { + return undefined // User cancelled + } + + if (useDetected === 'Yes') { + return capabilities + } + + // Allow user to modify capabilities + const allCapabilities: Capability[] = [ + Capability.CAPABILITY_IAM, + Capability.CAPABILITY_NAMED_IAM, + Capability.CAPABILITY_AUTO_EXPAND, + ] + + const selected = await window.showQuickPick( + allCapabilities.map((cap) => ({ label: cap, picked: capabilities.includes(cap) })), + { + placeHolder: 'Select capabilities to use', + canPickMany: true, + } + ) + + return selected ? selected.map((item) => item.label) : undefined +} + +export async function shouldImportResources(): Promise { + const choice = await window.showQuickPick(['Deploy new/updated resources', 'Import existing resources'], { + placeHolder: 'Select deployment mode', + ignoreFocusOut: true, + }) + + return choice === 'Import existing resources' +} + +export async function chooseOptionalFlagSuggestion(): Promise { + const choice = await window.showQuickPick( + [OptionalFlagMode.Skip, OptionalFlagMode.Input, OptionalFlagMode.DevFriendly], + { + placeHolder: 'Enter optional change set flags?', + ignoreFocusOut: true, + } + ) + + return choice +} + +export async function getTags(previousTags?: Tag[]): Promise { + const prefill = previousTags + ?.filter((tag) => tag.Key && tag.Value) + .map((tag) => `${tag.Key}=${tag.Value}`) + .join(',') + + const input = await window.showInputBox({ + prompt: 'Enter CloudFormation tags (key=value pairs, comma-separated). Enter empty for no tags', + placeHolder: 'key1=value1,key2=value2,key3=value3', + value: prefill, + validateInput: (value) => { + if (!value) { + return undefined + } + const isValid = /^[^=,]+=[^=,]+(,[^=,]+=[^=,]+)*$/.test(value.trim()) + return isValid ? undefined : 'Format: key1=value1,key2=value2' + }, + ignoreFocusOut: true, + }) + + if (!input) { + return undefined + } + + return input.split(',').map((pair) => { + const [key, value] = pair.split('=').map((s) => s.trim()) + return { Key: key, Value: value } + }) +} + +export async function getIncludeNestedStacks(): Promise { + return ( + await window.showQuickPick( + [ + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ], + { placeHolder: 'Include nested stacks?', ignoreFocusOut: true } + ) + )?.value +} + +export async function getImportExistingResources(): Promise { + return ( + await window.showQuickPick( + [ + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ], + { placeHolder: 'Import existing resources?', ignoreFocusOut: true } + ) + )?.value +} + +export async function getOnStackFailure(stackExists?: boolean): Promise { + const options: Array<{ label: string; description: string; value: OnStackFailure }> = [ + { label: 'Do Nothing', description: 'Leave stack in failed state', value: OnStackFailure.DO_NOTHING }, + { label: 'Rollback', description: 'Rollback to previous state', value: OnStackFailure.ROLLBACK }, + ] + + if (!stackExists) { + // only a valid option for CREATE + options.unshift({ label: 'Delete', description: 'Delete the stack on failure', value: OnStackFailure.DELETE }) + } + + return (await window.showQuickPick(options, { placeHolder: 'What to do on stack failure?', ignoreFocusOut: true })) + ?.value +} + +export async function getDeploymentMode(): Promise { + return ( + await window.showQuickPick( + [ + { + label: 'Revert Drift', + description: 'Revert drift during deployment', + value: DeploymentMode.REVERT_DRIFT, + }, + { label: 'Standard', description: 'No special handling during deployment', value: undefined }, + ], + { placeHolder: 'Select deployment mode', ignoreFocusOut: true } + ) + )?.value +} + +export async function getResourcesToImport( + templateResources: TemplateResource[] +): Promise { + const resourcesToImport: ResourceToImport[] = [] + + const selectedResources = await window.showQuickPick( + templateResources.map((r) => ({ + label: r.logicalId, + description: r.type, + picked: false, + resource: r, + })), + { + placeHolder: 'Select resources to import', + canPickMany: true, + ignoreFocusOut: true, + } + ) + + if (!selectedResources || selectedResources.length === 0) { + return undefined + } + + for (const selected of selectedResources) { + const resourceIdentifier = await getResourceIdentifier( + selected.resource.logicalId, + selected.resource.type, + selected.resource.primaryIdentifierKeys, + selected.resource.primaryIdentifier + ) + + if (!resourceIdentifier) { + return undefined + } + + resourcesToImport.push({ + ResourceType: selected.resource.type, + LogicalResourceId: selected.resource.logicalId, + ResourceIdentifier: resourceIdentifier, + }) + } + + return resourcesToImport +} + +async function getResourceIdentifier( + logicalId: string, + resourceType: string, + primaryIdentifierKeys?: string[], + primaryIdentifier?: Record +): Promise | undefined> { + if (!primaryIdentifierKeys || primaryIdentifierKeys.length === 0) { + void window.showErrorMessage(`No primary identifier keys found for ${resourceType}`) + return undefined + } + + if (primaryIdentifier && Object.keys(primaryIdentifier).length > 0) { + const id = Object.values(primaryIdentifier).join('|') + + const usePrimary = await window.showQuickPick([id, 'Enter manually'], { + placeHolder: `Select primary identifier for ${logicalId}`, + ignoreFocusOut: true, + }) + if (!usePrimary) { + return undefined + } + if (usePrimary === id) { + return primaryIdentifier + } + } + + const identifiers: Record = {} + + for (const key of primaryIdentifierKeys) { + const value = await window.showInputBox({ + prompt: `Enter ${key} for ${logicalId} (${resourceType})`, + placeHolder: `Physical ${key} of existing resource`, + ignoreFocusOut: true, + }) + + if (!value) { + return undefined + } + + identifiers[key] = value + } + + return identifiers +} + +export async function getProjectName(prefillValue: string | undefined) { + return await window.showInputBox({ + prompt: 'Enter project name', + value: prefillValue, + validateInput: (v) => { + if (!v.trim()) { + return 'Required' + } + if (!/^[a-zA-Z0-9_-]{1,64}$/.test(v.trim())) { + return 'Must be 1-64 characters, alphanumeric with hyphens and underscores only' + } + return undefined + }, + }) +} + +export async function getProjectPath(prefillValue: string) { + while (true) { + const input = await window.showInputBox({ + prompt: 'Enter project path (optional)', + value: prefillValue, + placeHolder: 'Press Enter for current directory', + ignoreFocusOut: true, + }) + + if (input === undefined) { + return undefined + } // User cancelled + if (!input.trim()) { + return input + } // Empty is valid (optional field) + + // Validate after input + try { + const resolvedPath = path.resolve(input.trim()) + const parentDir = path.dirname(resolvedPath) + + const parentPathExists = await fs.existsDir(parentDir) + if (!parentPathExists) { + void window.showErrorMessage('Parent directory does not exist. Please try again.') + continue // Ask again + } + + return input + } catch (error) { + void window.showErrorMessage('Invalid path format. Please try again.') + continue // Ask again + } + } +} + +export async function getEnvironmentName() { + return await window.showInputBox({ + prompt: 'Environment name', + validateInput: (v) => { + if (!v.trim()) { + return 'Required' + } + if (!/^[a-zA-Z0-9_-]{1,32}$/.test(v.trim())) { + return 'Must be 1-32 characters, alphanumeric with hyphens and underscores only' + } + return undefined + }, + }) +} + +export async function shouldSaveFlagsToFile(): Promise { + const config = workspace.getConfiguration('aws.cloudformation') + const currentSetting = config.get('environment.saveOptions', 'alwaysAsk') + + if (currentSetting === 'alwaysSave') { + return true + } + if (currentSetting === 'neverSave') { + return false + } + + const choice = await window.showQuickPick( + [ + { + label: 'Save Options to file', + description: 'Save the deployment options to a file in your environment', + value: 'save', + }, + { + label: 'Do not save options to file', + description: 'Do not save options to environment file', + value: 'skip', + }, + { + label: 'Configure in Settings', + description: + 'Open CloudFormation Environment settings (settings will not affect this current deployment)', + value: 'configure', + }, + ], + { + placeHolder: 'Choose deployment options configuration for CloudFormation template', + ignoreFocusOut: true, + } + ) + + if (!choice) { + return false + } + + if (choice.value === 'configure') { + await commands.executeCommand('workbench.action.openSettings', 'aws.cloudformation.environment.saveOptions') + return undefined // Exit command, let user configure first + } + + return choice.value === 'save' +} + +export async function getFilePath(environmentDir: string) { + while (true) { + const input = await window.showInputBox({ + prompt: 'Enter File Name to save options to (must be .json, .yaml, or .yml)', + ignoreFocusOut: true, + validateInput: (v) => { + if (!v.trim()) { + return 'Required' + } + if (!/^[a-zA-Z0-9_-]{1,32}\.(json|yaml|yml)$/.test(v.trim())) { + return 'Must be 1-32 characters (alphanumeric with hyphens and underscores) and end with .json, .yaml, or .yml' + } + return undefined + }, + }) + + if (input === undefined) { + return undefined + } // User cancelled + + // Validate after input + try { + const resolvedPath = path.resolve(path.join(environmentDir, input.trim())) + + const parentPathExists = await fs.existsFile(resolvedPath) + if (parentPathExists) { + void window.showErrorMessage('File already exists. Please try again.') + continue // Ask again + } + + return resolvedPath + } catch (error) { + void window.showErrorMessage('Environment directory was not found') + return + } + } +} + +export async function shouldUploadToS3(): Promise { + const config = workspace.getConfiguration('aws.cloudformation') + const currentSetting = config.get('s3', 'alwaysAsk') + + if (currentSetting === 'alwaysUpload') { + return true + } + if (currentSetting === 'neverUpload') { + return false + } + + const choice = await window.showQuickPick( + [ + { + label: 'Upload to S3', + description: 'Upload template to S3', + value: 'upload', + }, + { + label: 'Do not upload to S3', + description: 'Do not upload template to S3', + value: 'skip', + }, + { + label: 'Configure in Settings', + description: 'Open CloudFormation S3 settings', + value: 'configure', + }, + ], + { + placeHolder: 'Choose S3 upload option for CloudFormation template', + } + ) + + if (!choice) { + return false + } + + if (choice.value === 'configure') { + await commands.executeCommand('workbench.action.openSettings', 'aws.cloudformation.s3') + return undefined // Exit command, let user configure first + } + + return choice.value === 'upload' +} + +export async function getS3Bucket(prompt?: string): Promise { + return await window.showInputBox({ + prompt: prompt || 'Enter S3 bucket name', + validateInput: (value) => { + if (!value.trim()) { + return 'Bucket name is required' + } + if (!/^[a-z0-9.-]{3,63}$/.test(value)) { + return 'Invalid bucket name format' + } + return undefined + }, + }) +} + +export async function getS3Key(prefill?: string): Promise { + return await window.showInputBox({ + prompt: 'Enter S3 object key', + value: prefill, + validateInput: (value) => { + if (!value.trim()) { + return 'Object key is required' + } + return undefined + }, + }) +} diff --git a/packages/core/src/awsService/cloudformation/ui/message.ts b/packages/core/src/awsService/cloudformation/ui/message.ts new file mode 100644 index 00000000000..132c000c944 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/ui/message.ts @@ -0,0 +1,84 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { window } from 'vscode' +import { LanguageClient } from 'vscode-languageclient/node' +import { getDeploymentStatus } from '../stacks/actions/stackActionApi' +import { StackActionPhase, StackActionState } from '../stacks/actions/stackActionRequestType' + +export async function showDeploymentCompletion( + client: LanguageClient, + deploymentId: string, + stackName: string +): Promise { + try { + const pollResult = await getDeploymentStatus(client, { id: deploymentId }) + + if ( + pollResult.phase === StackActionPhase.DEPLOYMENT_COMPLETE && + pollResult.state === StackActionState.SUCCESSFUL + ) { + void window.showInformationMessage(`Deployment completed successfully for stack: ${stackName}`) + } else if ( + pollResult.phase === StackActionPhase.DEPLOYMENT_FAILED || + pollResult.phase === StackActionPhase.VALIDATION_FAILED || + pollResult.state === StackActionState.FAILED + ) { + void window.showErrorMessage(`Deployment failed for stack: ${stackName}`) + } else { + void window.showWarningMessage(`Deployment status unknown for stack: ${stackName}`) + } + } catch (error) { + void window.showErrorMessage(`Error checking deployment status for stack: ${stackName}`) + } +} + +export function showDeploymentSuccess(stackName: string) { + void window.showInformationMessage(`Deployment completed successfully for stack: ${stackName}`) +} + +export function showChangeSetDeletionSuccess(changeSetName: string, stackName: string) { + void window.showInformationMessage( + `Deletion completed successfully for change set: ${changeSetName}, in stack: ${stackName}` + ) +} + +export function showDeploymentFailure(stackName: string, failureReason: string) { + void window.showErrorMessage(`Deployment failed for stack: ${stackName} with reason: ${failureReason}`) +} + +export function showChangeSetDeletionFailure(changeSetName: string, stackName: string, failureReason: string) { + void window.showErrorMessage( + `Change Set Deletion failed for change set: ${changeSetName}, in stack: ${stackName} with reason: ${failureReason}` + ) +} + +export function showValidationComplete(stackName: string) { + void window.showInformationMessage(`Validation completed for stack: ${stackName}. Starting deployment...`) +} + +export function showValidationStarted(stackName: string) { + void window.showInformationMessage(`Validation started for stack: ${stackName}`) +} + +export function showValidationSuccess(stackName: string) { + void window.showInformationMessage(`Validation completed successfully for stack: ${stackName}`) +} + +export function showValidationFailure(stackName: string, failureReason: string) { + void window.showErrorMessage(`Validation failed for stack: ${stackName} with reason: ${failureReason}`) +} + +export function showDeploymentStarted(stackName: string) { + void window.showInformationMessage(`Deployment started for stack: ${stackName}`) +} + +export function showChangeSetDeletionStarted(changeSetName: string, stackName: string) { + void window.showInformationMessage(`Deletion started for change set: ${changeSetName}, in stack: ${stackName}`) +} + +export function showErrorMessage(message: string) { + void window.showErrorMessage(message) +} diff --git a/packages/core/src/awsService/cloudformation/ui/relatedResourceSelector.ts b/packages/core/src/awsService/cloudformation/ui/relatedResourceSelector.ts new file mode 100644 index 00000000000..63be2d7c3fb --- /dev/null +++ b/packages/core/src/awsService/cloudformation/ui/relatedResourceSelector.ts @@ -0,0 +1,52 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { window } from 'vscode' +import { LanguageClient } from 'vscode-languageclient/node' +import { getAuthoredResourceTypes, getRelatedResourceTypes } from '../relatedResources/relatedResourcesApi' + +export class RelatedResourceSelector { + constructor(private client: LanguageClient) {} + + async selectAuthoredResourceType(templateUri: string): Promise { + const resourceTypes = await getAuthoredResourceTypes(this.client, templateUri) + if (resourceTypes.length === 0) { + void window.showInformationMessage('No resources found in the current template') + return undefined + } + + return window.showQuickPick(resourceTypes, { + placeHolder: 'Select an existing resource type from your template', + canPickMany: false, + }) + } + + async promptCreateOrImport(): Promise<'create' | 'import' | undefined> { + const action = await window.showQuickPick(['Create new', 'Import existing'], { + placeHolder: 'How would you like to add related resource types?', + canPickMany: false, + }) + + if (!action) { + return undefined + } + + return action === 'Create new' ? 'create' : 'import' + } + + async selectRelatedResourceTypes(selectedResourceType: string): Promise { + const relatedTypes = await getRelatedResourceTypes(this.client, { parentResourceType: selectedResourceType }) + + if (relatedTypes.length === 0) { + void window.showInformationMessage(`No related resources found for ${selectedResourceType}`) + return undefined + } + + return window.showQuickPick(relatedTypes, { + placeHolder: 'Select related resource types', + canPickMany: true, + }) + } +} diff --git a/packages/core/src/awsService/cloudformation/ui/resourceSelector.ts b/packages/core/src/awsService/cloudformation/ui/resourceSelector.ts new file mode 100644 index 00000000000..9ac2491a29b --- /dev/null +++ b/packages/core/src/awsService/cloudformation/ui/resourceSelector.ts @@ -0,0 +1,129 @@ +/*! +import { getLogger } from '../../../shared/logger' + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { window } from 'vscode' +import { LanguageClient } from 'vscode-languageclient/node' +import { ResourceTypesRequest, ListResourcesRequest, ResourceList } from '../cfn/resourceRequestTypes' +import { getLogger } from '../../../shared/logger/logger' + +export interface ResourceSelectionResult { + resourceType: string + resourceIdentifier: string +} + +export class ResourceSelector { + constructor(private client: LanguageClient) {} + + async selectResourceTypes(selectedTypes: string[] = [], multiSelect = true): Promise { + try { + const response = await this.client.sendRequest(ResourceTypesRequest, {}) + const availableTypes = response.resourceTypes + + if (availableTypes.length === 0) { + void window.showWarningMessage('No resource types available') + return undefined + } + + const quickPickItems = availableTypes.map((type: string) => ({ + label: type, + picked: selectedTypes.includes(type), + })) + + const result = await window.showQuickPick(quickPickItems, { + canPickMany: multiSelect, + placeHolder: 'Select resource types', + title: 'Select Resource Types', + }) + + if (!result) { + return undefined + } + + if (Array.isArray(result)) { + return result.map((item: { label: string }) => item.label) + } + return [(result as { label: string }).label] + } catch (error) { + getLogger().error(`Failed to get resource types: ${error}`) + void window.showErrorMessage('Failed to get available resource types') + return undefined + } + } + + async selectResources(multiSelect = true, preSelectedTypes?: string[]): Promise { + try { + let selectedTypes: string[] + + if (preSelectedTypes && preSelectedTypes.length > 0) { + selectedTypes = preSelectedTypes + } else { + const types = await this.selectResourceTypes([], multiSelect) + if (!types || types.length === 0) { + return [] + } + selectedTypes = types + } + + const allSelections: ResourceSelectionResult[] = [] + + for (const resourceType of selectedTypes) { + const resourceIdentifiers = await this.getResourceIdentifiers(resourceType) + if (resourceIdentifiers.length === 0) { + void window.showWarningMessage(`No resources found for type: ${resourceType}`) + continue + } + + const result = await window.showQuickPick(resourceIdentifiers, { + canPickMany: multiSelect, + placeHolder: `Select ${resourceType} identifiers`, + title: `Select from all ${resourceType} Resources`, + }) + + if (!result) { + continue + } + + const identifiers = Array.isArray(result) ? result : [result] + for (const identifier of identifiers) { + allSelections.push({ resourceType, resourceIdentifier: identifier }) + } + } + + return allSelections + } catch (error) { + void window.showErrorMessage('Failed to select resources') + return [] + } + } + + async selectSingleResource(): Promise { + const result = await this.selectResources(false) + return result[0] + } + + private async getResourceIdentifiers(resourceType: string, cachedResources?: ResourceList[]): Promise { + // First try to use cached resources from CfnPanel + if (cachedResources) { + const cachedResource = cachedResources.find((r) => r.typeName === resourceType) + if (cachedResource) { + return cachedResource.resourceIdentifiers + } + } + + // If not cached, fetch from server + try { + const resourcesResponse = await this.client.sendRequest(ListResourcesRequest, { + resources: [{ resourceType }], + }) + + const resources = resourcesResponse.resources.find((r: { typeName: string }) => r.typeName === resourceType) + return resources?.resourceIdentifiers ?? [] + } catch (error) { + getLogger().error(`Failed to get resources for type ${resourceType}:`, error) + return [] + } + } +} diff --git a/packages/core/src/awsService/cloudformation/ui/sectionUI.ts b/packages/core/src/awsService/cloudformation/ui/sectionUI.ts new file mode 100644 index 00000000000..5471ab9e14b --- /dev/null +++ b/packages/core/src/awsService/cloudformation/ui/sectionUI.ts @@ -0,0 +1,13 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EventEmitter, TreeItem } from 'vscode' + +export interface SectionUI { + base: TreeItem + children(element?: T): (T | null | undefined)[] + registerTreeChangedEvent(event: EventEmitter): void + onChange: () => void +} diff --git a/packages/core/src/awsService/cloudformation/ui/stackEventsWebviewProvider.ts b/packages/core/src/awsService/cloudformation/ui/stackEventsWebviewProvider.ts new file mode 100644 index 00000000000..7b88a15556d --- /dev/null +++ b/packages/core/src/awsService/cloudformation/ui/stackEventsWebviewProvider.ts @@ -0,0 +1,373 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WebviewView, WebviewViewProvider, Disposable } from 'vscode' +import { StackEvent } from '@aws-sdk/client-cloudformation' +import { LanguageClient } from 'vscode-languageclient/node' +import { extractErrorMessage, getStackStatusClass, isStackInTransientState } from '../utils' +import { GetStackEventsRequest, ClearStackEventsRequest } from '../stacks/actions/stackActionProtocol' +import { StackViewCoordinator } from './stackViewCoordinator' + +const EventsPerPage = 50 +const RefreshIntervalMs = 5000 + +export class StackEventsWebviewProvider implements WebviewViewProvider, Disposable { + private view?: WebviewView + private stackName?: string + private allEvents: StackEvent[] = [] + private currentPage = 0 + private nextToken?: string + private refreshTimer?: NodeJS.Timeout + private readonly disposables: Disposable[] = [] + private readonly coordinatorSubscription: Disposable + + constructor( + private readonly client: LanguageClient, + coordinator: StackViewCoordinator + ) { + this.coordinatorSubscription = coordinator.onDidChangeStack(async (state) => { + try { + if (state.stackName && !state.isChangeSetMode) { + this.stopAutoRefresh() + await this.showStackEvents(state.stackName) + } else if (!state.stackName || state.isChangeSetMode) { + this.stopAutoRefresh() + this.stackName = undefined + this.allEvents = [] + this.render() + } + + if (state.stackStatus && !isStackInTransientState(state.stackStatus)) { + this.stopAutoRefresh() + } + } catch (error) { + // Silently handle errors to prevent breaking the coordinator + } + }) + } + + async showStackEvents(stackName: string): Promise { + this.stackName = stackName + this.allEvents = [] + this.currentPage = 0 + this.nextToken = undefined + await this.loadEvents() + this.render() + this.startAutoRefresh() + } + + resolveWebviewView(webviewView: WebviewView): void { + this.view = webviewView + webviewView.webview.options = { enableScripts: true } + webviewView.onDidDispose(() => { + this.stopAutoRefresh() + }) + webviewView.onDidChangeVisibility(() => { + if (webviewView.visible) { + this.startAutoRefresh() + } else { + this.stopAutoRefresh() + } + }) + + webviewView.webview.onDidReceiveMessage(async (message: { command: string }) => { + if (message.command === 'nextPage') { + await this.nextPage() + } else if (message.command === 'prevPage') { + await this.prevPage() + } + }) + + this.render() + } + + dispose(): void { + this.stopAutoRefresh() + if (this.stackName) { + void this.client.sendRequest(ClearStackEventsRequest, { stackName: this.stackName }) + } + for (const d of this.disposables) { + d.dispose() + } + this.coordinatorSubscription.dispose() + } + + private async loadEvents(): Promise { + if (!this.stackName) { + return + } + + try { + const result = await this.client.sendRequest(GetStackEventsRequest, { + stackName: this.stackName, + nextToken: this.nextToken, + }) + + this.allEvents.push(...result.events) + this.nextToken = result.nextToken + } catch (error) { + this.renderError(`Failed to load events: ${extractErrorMessage(error)}`) + } + } + + private async refresh(): Promise { + if (!this.stackName) { + return + } + + try { + const result = await this.client.sendRequest(GetStackEventsRequest, { + stackName: this.stackName, + refresh: true, + }) + + if (result.gapDetected) { + this.allEvents = [] + this.currentPage = 0 + this.nextToken = undefined + await this.loadEvents() + this.render('Event history reloaded due to high activity') + } else if (result.events.length > 0) { + this.allEvents.unshift(...result.events) + this.currentPage = 0 + this.render() + } + } catch (error) { + this.renderError(`Failed to refresh events: ${extractErrorMessage(error)}`) + } + } + + private async nextPage(): Promise { + const totalPages = Math.ceil(this.allEvents.length / EventsPerPage) + const nextPageIndex = this.currentPage + 1 + + if (nextPageIndex < totalPages) { + this.currentPage = nextPageIndex + this.render() + } else if (this.nextToken) { + await this.loadEvents() + this.currentPage = nextPageIndex + this.render() + } + } + + private async prevPage(): Promise { + if (this.currentPage > 0) { + this.currentPage-- + this.render() + } else { + await this.refresh() + } + } + + private startAutoRefresh(): void { + this.stopAutoRefresh() + this.refreshTimer = setInterval(() => void this.refresh(), RefreshIntervalMs) + } + + private stopAutoRefresh(): void { + if (this.refreshTimer) { + clearInterval(this.refreshTimer) + this.refreshTimer = undefined + } + } + + private renderError(message: string): void { + if (!this.view || this.view.visible === false) { + return + } + this.view.webview.html = ` + + + + + + +

Error

+

${message}

+ +` + } + + private render(notification?: string): void { + if (!this.view || this.view.visible === false) { + return + } + + const start = this.currentPage * EventsPerPage + const end = start + EventsPerPage + const pageEvents = this.allEvents.slice(start, end) + const totalPages = Math.ceil(this.allEvents.length / EventsPerPage) + const hasMore = this.nextToken !== undefined + + this.view.webview.html = this.getHtml( + pageEvents, + this.currentPage + 1, + totalPages, + hasMore, + this.allEvents.length, + notification + ) + } + + private getHtml( + events: StackEvent[], + currentPage: number, + totalPages: number, + hasMore: boolean, + totalEvents: number, + notification?: string + ): string { + return ` + + + + + + +
+
+
+ ${this.stackName ?? ''} + (${totalEvents} events) +
+ +
+
+ ${notification ? `
${notification}
` : ''} +
+ + + + + + + + + + + + ${events + .map( + (e) => ` + + + + + + + + ` + ) + .join('')} + +
TimestampResourceTypeStatusReason
${e.Timestamp ? new Date(e.Timestamp).toLocaleString() : '-'}${e.LogicalResourceId ?? '-'}${e.ResourceType ?? '-'}${e.ResourceStatus ?? '-'}${e.ResourceStatusReason ?? '-'}
+
+ + +` + } +} diff --git a/packages/core/src/awsService/cloudformation/ui/stackOutputsWebviewProvider.ts b/packages/core/src/awsService/cloudformation/ui/stackOutputsWebviewProvider.ts new file mode 100644 index 00000000000..4eb07218913 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/ui/stackOutputsWebviewProvider.ts @@ -0,0 +1,217 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WebviewView, WebviewViewProvider, Disposable } from 'vscode' +import { Output } from '@aws-sdk/client-cloudformation' +import { LanguageClient } from 'vscode-languageclient/node' +import { extractErrorMessage } from '../utils' +import { DescribeStackRequest } from '../stacks/actions/stackActionProtocol' +import { StackViewCoordinator } from './stackViewCoordinator' + +export class StackOutputsWebviewProvider implements WebviewViewProvider, Disposable { + private view?: WebviewView + private stackName?: string + private outputs: Output[] = [] + private readonly disposables: Disposable[] = [] + + constructor( + private readonly client: LanguageClient, + private readonly coordinator: StackViewCoordinator + ) { + this.disposables.push( + coordinator.onDidChangeStack(async (state) => { + if (state.stackName && !state.isChangeSetMode) { + this.stackName = state.stackName + this.outputs = [] + this.render() + await this.showOutputs(state.stackName) + } else if (!state.stackName || state.isChangeSetMode) { + this.stackName = undefined + this.outputs = [] + this.render() + } + }) + ) + } + + async resolveWebviewView(webviewView: WebviewView): Promise { + this.view = webviewView + webviewView.webview.options = { enableScripts: true } + + if (this.stackName) { + await this.loadOutputs() + } else { + this.render() + } + } + + async showOutputs(stackName: string): Promise { + this.stackName = stackName + this.outputs = [] + + if (this.view) { + await this.loadOutputs() + } + } + + private async loadOutputs(): Promise { + if (!this.stackName) { + return + } + + try { + const result = await this.client.sendRequest(DescribeStackRequest, { + stackName: this.stackName, + }) + + this.outputs = result.stack?.Outputs ?? [] + // Only update coordinator if status changed + if (result.stack?.StackStatus && this.coordinator.currentStackStatus !== result.stack.StackStatus) { + await this.coordinator.setStack(this.stackName, result.stack.StackStatus) + } + this.render() + } catch (error) { + this.renderError(`Failed to load outputs: ${extractErrorMessage(error)}`) + } + } + + private renderError(message: string): void { + if (!this.view || !this.view.visible) { + return + } + this.view.webview.html = ` + + + + + + +

Error

+

${message}

+ +` + } + + private render(): void { + if (!this.view || this.view.visible === false) { + return + } + + this.view.webview.html = this.getHtml(this.outputs) + } + + private getHtml(outputs: Output[]): string { + const outputRows = + outputs.length > 0 + ? outputs + .map( + (output) => ` + + ${output.OutputKey ?? ''} + ${output.OutputValue ?? ''} + ${output.Description ?? ''} + ${output.ExportName ?? ''} + + ` + ) + .join('') + : 'No outputs found' + + return ` + + + + + + +
+
+ ${this.stackName ?? ''} + (${outputs.length} outputs) +
+
+
+ + + + + + + + + + + ${outputRows} + +
KeyValueDescriptionExport Name
+
+ +` + } + + dispose(): void { + this.view = undefined + for (const d of this.disposables) { + d.dispose() + } + } +} diff --git a/packages/core/src/awsService/cloudformation/ui/stackOverviewWebviewProvider.ts b/packages/core/src/awsService/cloudformation/ui/stackOverviewWebviewProvider.ts new file mode 100644 index 00000000000..8b4de2f8977 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/ui/stackOverviewWebviewProvider.ts @@ -0,0 +1,271 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WebviewView, WebviewViewProvider, Disposable } from 'vscode' +import { LanguageClient } from 'vscode-languageclient/node' +import { Stack } from '@aws-sdk/client-cloudformation' +import { StackViewCoordinator } from './stackViewCoordinator' +import { DescribeStackRequest } from '../stacks/actions/stackActionProtocol' +import { extractErrorMessage, getStackStatusClass, isStackInTransientState } from '../utils' + +export class StackOverviewWebviewProvider implements WebviewViewProvider, Disposable { + private view?: WebviewView + private stack?: Stack + private readonly disposables: Disposable[] = [] + private refreshTimer?: NodeJS.Timeout + private currentStackName?: string + + constructor( + private readonly client: LanguageClient, + private readonly coordinator: StackViewCoordinator + ) { + this.disposables.push( + coordinator.onDidChangeStack(async (state) => { + if (state.stackName && !state.isChangeSetMode) { + this.stopAutoRefresh() + this.currentStackName = state.stackName + this.stack = undefined + this.render() + await this.loadStack(state.stackName) + this.startAutoRefresh() + } else { + this.stopAutoRefresh() + this.currentStackName = undefined + this.stack = undefined + this.render() + } + + // Stop auto-refresh if stack is in terminal state + if (state.stackStatus && !isStackInTransientState(state.stackStatus)) { + this.stopAutoRefresh() + } + }) + ) + } + + private startAutoRefresh(): void { + this.stopAutoRefresh() + if (this.currentStackName) { + this.refreshTimer = setInterval(() => { + if (this.currentStackName) { + void this.loadStack(this.currentStackName) + } + }, 5000) + } + } + + private stopAutoRefresh(): void { + if (this.refreshTimer) { + clearInterval(this.refreshTimer) + this.refreshTimer = undefined + } + } + + private async loadStack(stackName: string): Promise { + try { + const result = await this.client.sendRequest(DescribeStackRequest, { stackName }) + if (result.stack) { + this.stack = result.stack + // Only update coordinator if status changed + if (this.coordinator.currentStackStatus !== result.stack.StackStatus) { + await this.coordinator.setStack(stackName, result.stack.StackStatus) + } + this.render() + } + } catch (error) { + this.stack = undefined + this.renderError(`Failed to load stack: ${extractErrorMessage(error)}`) + } + } + + resolveWebviewView(webviewView: WebviewView): void { + this.view = webviewView + webviewView.webview.options = { enableScripts: true } + + webviewView.onDidChangeVisibility(() => { + if (webviewView.visible && this.currentStackName) { + this.startAutoRefresh() + } else { + this.stopAutoRefresh() + } + }) + + webviewView.onDidDispose(() => { + this.stopAutoRefresh() + }) + + this.render() + } + + async showStackOverview(stackName: string): Promise { + if (this.view) { + await this.loadStack(stackName) + } + } + + private render(): void { + if (!this.view || !this.view.visible) { + return + } + + if (!this.stack) { + this.view.webview.html = this.getEmptyContent() + return + } + + this.view.webview.html = this.getWebviewContent(this.stack) + } + + private renderError(message: string): void { + if (!this.view || !this.view.visible) { + return + } + this.view.webview.html = ` + + + + + + +

Error

+

${message}

+ +` + } + + private getEmptyContent(): string { + return ` + + + + + + +

Select a stack to view details

+ +` + } + + private getWebviewContent(stack: Stack): string { + return ` + + + + + + +
+
Stack Name
+
${stack.StackName ?? 'N/A'}
+
+
+
Status
+
+ ${stack.StackStatus ?? 'UNKNOWN'} +
+
+ ${ + stack.StackId + ? ` +
+
Stack ID
+
${stack.StackId}
+
` + : '' + } + ${ + stack.Description + ? ` +
+
Description
+
${stack.Description}
+
` + : '' + } + ${ + stack.CreationTime + ? ` +
+
Created
+
${new Date(stack.CreationTime).toLocaleString()}
+
` + : '' + } + ${ + stack.LastUpdatedTime + ? ` +
+
Last Updated
+
${new Date(stack.LastUpdatedTime).toLocaleString()}
+
` + : '' + } + ${ + stack.StackStatusReason + ? ` +
+
Status Reason
+
${stack.StackStatusReason}
+
` + : '' + } + +` + } + + dispose(): void { + this.stopAutoRefresh() + for (const d of this.disposables) { + d.dispose() + } + } +} diff --git a/packages/core/src/awsService/cloudformation/ui/stackResourcesWebviewProvider.ts b/packages/core/src/awsService/cloudformation/ui/stackResourcesWebviewProvider.ts new file mode 100644 index 00000000000..b444acd45e7 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/ui/stackResourcesWebviewProvider.ts @@ -0,0 +1,407 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WebviewView, WebviewViewProvider, Disposable } from 'vscode' +import { LanguageClient } from 'vscode-languageclient/node' +import { showErrorMessage } from './message' +import { GetStackResourcesRequest } from '../stacks/actions/stackActionProtocol' +import { StackResourceSummary, GetStackResourcesParams } from '../stacks/actions/stackActionRequestType' +import { StackViewCoordinator } from './stackViewCoordinator' + +const ResourcesPerPage = 50 + +export class StackResourcesWebviewProvider implements WebviewViewProvider, Disposable { + private _view?: WebviewView + private stackName = '' + private allResources: StackResourceSummary[] = [] + private currentPage = 0 + private nextToken?: string + private updateInterval?: NodeJS.Timeout + private readonly disposables: Disposable[] = [] + + constructor( + private client: LanguageClient, + private readonly coordinator: StackViewCoordinator + ) { + this.disposables.push( + coordinator.onDidChangeStack(async (state) => { + if (state.stackName && !state.isChangeSetMode) { + this.stopAutoRefresh() + this.stackName = state.stackName + this.allResources = [] + this.currentPage = 0 + this.nextToken = undefined + if (this._view && this._view.visible) { + this._view.webview.html = this.getHtmlContent() + } + await this.updateData(state.stackName) + } else if (!state.stackName || state.isChangeSetMode) { + this.stopAutoRefresh() + this.stackName = '' + this.allResources = [] + if (this._view && this._view.visible) { + this._view.webview.html = this.getHtmlContent() + } + } + + // Stop auto-refresh if stack is in terminal state + if (state.stackStatus && !this.isStackInTransientState(state.stackStatus)) { + this.stopAutoUpdate() + } + }) + ) + } + + private isStackInTransientState(status: string): boolean { + return status.includes('_IN_PROGRESS') || status.includes('_CLEANUP_IN_PROGRESS') + } + + async updateData(stackName: string) { + this.stackName = stackName + this.allResources = [] + this.currentPage = 0 + this.nextToken = undefined + await this.loadResources() + + if (this._view && this._view.visible) { + this._view.webview.html = this.getHtmlContent() + } + } + + resolveWebviewView(webviewView: WebviewView) { + this._view = webviewView + this.setupWebview(webviewView) + this.setupMessageHandling(webviewView) + this.setupLifecycleHandlers(webviewView) + } + + private setupWebview(webviewView: WebviewView) { + webviewView.webview.options = { enableScripts: true } + webviewView.webview.html = this.getHtmlContent() + } + + private setupMessageHandling(webviewView: WebviewView) { + webviewView.webview.onDidReceiveMessage(async (message: { command: string }) => { + if (message.command === 'nextPage') { + await this.loadNextPage() + } else if (message.command === 'prevPage') { + await this.loadPrevPage() + } + }) + } + + private setupLifecycleHandlers(webviewView: WebviewView) { + webviewView.onDidChangeVisibility(() => { + if (webviewView.visible) { + this.startAutoUpdate() + } else { + this.stopAutoUpdate() + } + }) + + if (webviewView.visible) { + this.startAutoUpdate() + } + + webviewView.onDidDispose(() => { + this.stopAutoUpdate() + }) + } + + private async loadResources(): Promise { + if (!this.client || !this.stackName) { + return + } + + try { + const params: GetStackResourcesParams = { stackName: this.stackName } + if (this.nextToken) { + params.nextToken = this.nextToken + } + const result = await this.client.sendRequest(GetStackResourcesRequest, params) + this.allResources.push(...result.resources) + this.nextToken = result.nextToken + } catch (error) { + showErrorMessage(`Failed to fetch stack resources: ${error}`) + } + } + + private async loadNextPage(): Promise { + const totalPages = Math.ceil(this.allResources.length / ResourcesPerPage) + const nextPageIndex = this.currentPage + 1 + + // Don't proceed if we're already at the last page and have no more data + if (nextPageIndex >= totalPages && !this.nextToken) { + return + } + + if (nextPageIndex < totalPages) { + this.currentPage = nextPageIndex + this.render() + } else if (this.nextToken) { + await this.loadResources() + if (this.allResources.length > nextPageIndex * ResourcesPerPage) { + this.currentPage = nextPageIndex + this.render() + } + } + } + + private async loadPrevPage(): Promise { + // Don't proceed if we're already at the first page + if (this.currentPage <= 0) { + return + } + + this.currentPage-- + this.render() + } + + private render(): void { + if (this._view && this._view.visible !== false) { + this._view.webview.html = this.getHtmlContent() + } + } + + private startAutoUpdate() { + if (!this.updateInterval && this.stackName) { + this.updateInterval = setInterval(async () => { + if (this._view && !this.coordinator.currentStackStatus?.includes('_IN_PROGRESS')) { + this.stopAutoUpdate() + return + } + + if (this._view) { + // Reset to page 1 with fresh data + this.allResources = [] + this.currentPage = 0 + this.nextToken = undefined + await this.loadResources() + + if (this._view && this._view.visible !== false) { + this._view.webview.html = this.getHtmlContent() + } + } + }, 5000) + } + } + + private stopAutoUpdate() { + if (this.updateInterval) { + clearInterval(this.updateInterval) + this.updateInterval = undefined + } + } + + private stopAutoRefresh() { + this.stopAutoUpdate() + } + + private getHtmlContent(): string { + const start = this.currentPage * ResourcesPerPage + const end = start + ResourcesPerPage + const pageResources = this.allResources.slice(start, end) + const totalPages = Math.ceil(this.allResources.length / ResourcesPerPage) + const hasMore = this.nextToken !== undefined + + if (!pageResources || pageResources.length === 0) { + return ` + + + + + + +
+
+ ${this.stackName} + (0 resources) +
+
+
+

No resources found

+
+ +` + } + + const resourceRows = pageResources + .map( + (resource) => ` + + ${resource.LogicalResourceId} + ${resource.PhysicalResourceId || ''} + ${resource.ResourceType} + ${resource.ResourceStatus} + + ` + ) + .join('') + + return ` + + + + + + +
+
+
+ ${this.stackName} + (${this.allResources.length}${hasMore ? '+' : ''} resources) +
+ +
+
+
+ + + + + + + + + + + ${resourceRows} + +
Logical IDPhysical IDTypeStatus
+
+ + +` + } + + dispose(): void { + this.stopAutoRefresh() + for (const d of this.disposables) { + d.dispose() + } + } +} diff --git a/packages/core/src/awsService/cloudformation/ui/stackViewCoordinator.ts b/packages/core/src/awsService/cloudformation/ui/stackViewCoordinator.ts new file mode 100644 index 00000000000..debd0f55897 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/ui/stackViewCoordinator.ts @@ -0,0 +1,85 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EventEmitter } from 'vscode' +import { setContext } from '../../../shared/vscode/setContext' + +export interface StackViewState { + stackName?: string + isChangeSetMode: boolean + stackStatus?: string +} + +export class StackViewCoordinator { + private readonly _onDidChangeStack = new EventEmitter() + readonly onDidChangeStack = this._onDidChangeStack.event + + private _currentStackName?: string + private _isChangeSetMode = false + private _currentStackStatus?: string + private _stackStatusUpdateCallback?: (stackName: string, stackStatus: string) => void + + get currentStackName(): string | undefined { + return this._currentStackName + } + + get isChangeSetMode(): boolean { + return this._isChangeSetMode + } + + get currentStackStatus(): string | undefined { + return this._currentStackStatus + } + + setStackStatusUpdateCallback(callback: (stackName: string, stackStatus: string) => void): void { + this._stackStatusUpdateCallback = callback + } + + async setStack(stackName: string, stackStatus?: string): Promise { + const statusChanged = stackStatus && this._currentStackStatus !== stackStatus + + this._currentStackName = stackName + this._currentStackStatus = stackStatus + this._isChangeSetMode = false + await this.updateContexts() + this._onDidChangeStack.fire(this.getState()) + + if (statusChanged && stackStatus && this._stackStatusUpdateCallback) { + this._stackStatusUpdateCallback(stackName, stackStatus) + } + } + + async setChangeSetMode(stackName: string, enabled: boolean): Promise { + this._currentStackName = stackName + this._isChangeSetMode = enabled + await this.updateContexts() + this._onDidChangeStack.fire(this.getState()) + } + + async clearStack(): Promise { + this._currentStackName = undefined + this._currentStackStatus = undefined + this._isChangeSetMode = false + await this.updateContexts() + this._onDidChangeStack.fire(this.getState()) + } + + private async updateContexts(): Promise { + await setContext('aws.cloudformation.stackSelected', !!this._currentStackName) + await setContext('aws.cloudformation.changeSetMode', this._isChangeSetMode) + } + + private getState(): StackViewState { + return { + stackName: this._currentStackName, + isChangeSetMode: this._isChangeSetMode, + stackStatus: this._currentStackStatus, + } + } + + dispose(): void { + this._onDidChangeStack.dispose() + } +} diff --git a/packages/core/src/awsService/cloudformation/ui/statusBar.ts b/packages/core/src/awsService/cloudformation/ui/statusBar.ts new file mode 100644 index 00000000000..002123ab346 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/ui/statusBar.ts @@ -0,0 +1,60 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { window, StatusBarAlignment, StatusBarItem, ThemeColor } from 'vscode' +import { StackActionPhase } from '../stacks/actions/stackActionRequestType' + +let globalStatusBarItem: StatusBarItem | undefined + +function getStatusProperties(status: StackActionPhase): { text: string; color: ThemeColor | undefined } { + let color: ThemeColor | undefined = undefined + let text: string + + switch (status) { + case StackActionPhase.DEPLOYMENT_STARTED: + text = '$(sync~spin) Validation Starting...' + break + case StackActionPhase.VALIDATION_IN_PROGRESS: + text = '$(sync~spin) Validating Template...' + break + case StackActionPhase.VALIDATION_COMPLETE: + text = '$(check) Validation Complete' + break + case StackActionPhase.VALIDATION_FAILED: + text = '$(error) Validation Failed' + color = new ThemeColor('statusBarItem.errorBackground') + break + case StackActionPhase.DEPLOYMENT_IN_PROGRESS: + text = '$(sync~spin) Deploying Stack...' + break + case StackActionPhase.DEPLOYMENT_COMPLETE: + text = '$(check) Deployment Complete' + break + case StackActionPhase.DEPLOYMENT_FAILED: + text = '$(error) Deployment Failed' + color = new ThemeColor('statusBarItem.errorBackground') + break + default: + text = '$(sync~spin) Processing...' + } + + return { text, color } +} + +export function createDeploymentStatusBar(): StatusBarItem { + globalStatusBarItem ??= window.createStatusBarItem(StatusBarAlignment.Left, 100) + + globalStatusBarItem.text = '$(sync~spin) Validation Starting...' + globalStatusBarItem.show() + + return globalStatusBarItem +} + +export function updateWorkflowStatus(statusBarItem: StatusBarItem, status: StackActionPhase): void { + const properties = getStatusProperties(status) + + statusBarItem.text = properties.text + statusBarItem.backgroundColor = properties.color +} diff --git a/packages/core/src/awsService/cloudformation/utils.ts b/packages/core/src/awsService/cloudformation/utils.ts new file mode 100644 index 00000000000..214f1f4b6e8 --- /dev/null +++ b/packages/core/src/awsService/cloudformation/utils.ts @@ -0,0 +1,153 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ExtensionConfigKey, ExtensionId } from './extensionConfig' +import { Position } from 'vscode' + +export function toString(value: unknown): string { + if (value === undefined || !['object', 'function'].includes(typeof value)) { + return String(value) + } + + return JSON.stringify(value) +} + +export function formatMessage(message: string): string { + return `${ExtensionId}: ${message}` +} + +export function commandKey(key: string): string { + return `${ExtensionConfigKey}.${key}` +} + +export const cloudFormationUiClickMetric = 'cloudformation_nodeExpansion' + +export function getStackStatusClass(status?: string): string { + if (!status) { + return '' + } + // Terminal success states + if (status.includes('COMPLETE') && !status.includes('ROLLBACK')) { + return 'status-complete' + } + // Terminal failed states + if (status.includes('FAILED') || status.includes('ROLLBACK')) { + return 'status-failed' + } + // Transient states (in progress) + if (status.includes('PROGRESS')) { + return 'status-progress' + } + return '' +} + +export function isStackInTransientState(status: string): boolean { + return status.includes('_IN_PROGRESS') || status.includes('_CLEANUP_IN_PROGRESS') +} + +export function extractErrorMessage(error: unknown) { + if (error instanceof Error) { + const prefix = error.name === 'Error' ? '' : `${error.name}: ` + return `${prefix}${error.message}` + } + + return toString(error) +} + +/** + * Finds the position of the parameter description value where the cursor should be placed. + * Returns the position between the quotes of the Description property. + */ +export function findParameterDescriptionPosition( + text: string, + parameterName: string, + documentType: string +): Position | undefined { + const lines = text.split('\n') + + if (documentType === 'JSON') { + return findJsonParameterDescriptionPosition(lines, parameterName) + } else { + return findYamlParameterDescriptionPosition(lines, parameterName) + } +} + +/** + * Finds the description position in JSON format. + * Looks for: "ParameterName": { ... "Description": "HERE" ... } + */ +function findParameterDescription( + lines: string[], + parameterPattern: RegExp, + descriptionMatcher: (line: string) => { match: RegExpMatchArray; character: number } | undefined, + endMatcher: (line: string) => boolean +): Position | undefined { + let inParameter = false + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + if (!inParameter && parameterPattern.test(line)) { + inParameter = true + continue + } + + if (inParameter) { + const result = descriptionMatcher(line) + if (result) { + return new Position(i, result.character) + } + + if (endMatcher(line)) { + break + } + } + } + + return undefined +} + +function findJsonParameterDescriptionPosition(lines: string[], parameterName: string): Position | undefined { + const parameterPattern = new RegExp(`^\\s*"${escapeRegex(parameterName)}"\\s*:\\s*\\{`) + + return findParameterDescription( + lines, + parameterPattern, + (line) => { + const match = line.match(/^(\s*)"Description"\s*:\s*"([^"]*)"/) + return match + ? { match, character: match[1].length + '"Description": "'.length + match[2].length } + : undefined + }, + (line) => !!line.match(/^\s*\}/) + ) +} + +/** + * Finds the description position in YAML format. + * Looks for: ParameterName: ... Description: "HERE" ... + */ +function findYamlParameterDescriptionPosition(lines: string[], parameterName: string): Position | undefined { + const parameterPattern = new RegExp(`^\\s*${escapeRegex(parameterName)}\\s*:`) + + return findParameterDescription( + lines, + parameterPattern, + (line) => { + const match = line.match(/^(\s*)Description\s*:\s*(['"]?)([^'"]*)\2/) + return match + ? { match, character: match[1].length + 'Description: '.length + match[2].length + match[3].length } + : undefined + }, + (line) => !!line.match(/^\s*\w+\s*:/) && !line.match(/^\s*(Type|Default|Description|AllowedValues)\s*:/) + ) +} + +/** + * Escapes special regex characters in a string. + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/packages/core/src/extensionNode.ts b/packages/core/src/extensionNode.ts index a8a7855913e..da2d52f0ae6 100644 --- a/packages/core/src/extensionNode.ts +++ b/packages/core/src/extensionNode.ts @@ -8,11 +8,13 @@ import * as nls from 'vscode-nls' import * as codecatalyst from './codecatalyst/activation' import { activate as activateAppBuilder } from './awsService/appBuilder/activation' +import { activate as activateCloudFormation } from './awsService/cloudformation/extension' import { activate as activateAwsExplorer } from './awsexplorer/activation' import { activate as activateCloudWatchLogs } from './awsService/cloudWatchLogs/activation' import { activate as activateSchemas } from './eventSchemas/activation' import { activate as activateLambda } from './lambda/activation' import { activate as activateCloudFormationTemplateRegistry } from './shared/cloudformation/activation' + import { AwsContextCommands } from './shared/awsContextCommands' import { getIdeProperties, @@ -152,6 +154,8 @@ export async function activate(context: vscode.ExtensionContext) { await activateCloudFormationTemplateRegistry(context) + await activateCloudFormation(context) + await activateAwsExplorer({ context: extContext, regionProvider: globals.regionProvider, diff --git a/packages/core/src/lambda/explorer/cloudFormationNodes.ts b/packages/core/src/lambda/explorer/cloudFormationNodes.ts index 4ef298a391e..daf49a06a47 100644 --- a/packages/core/src/lambda/explorer/cloudFormationNodes.ts +++ b/packages/core/src/lambda/explorer/cloudFormationNodes.ts @@ -15,6 +15,7 @@ import { DefaultLambdaClient } from '../../shared/clients/lambdaClient' import { AWSResourceNode } from '../../shared/treeview/nodes/awsResourceNode' import { AWSTreeNodeBase } from '../../shared/treeview/nodes/awsTreeNodeBase' import { PlaceholderNode } from '../../shared/treeview/nodes/placeholderNode' +import { AWSCommandTreeNode } from '../../shared/treeview/nodes/awsCommandTreeNode' import { makeChildrenNodes } from '../../shared/treeview/utils' import { intersection, toArrayAsync, toMap, toMapAsync, updateInPlace } from '../../shared/utilities/collectionUtils' import { listCloudFormationStacks, listLambdaFunctions } from '../utils' @@ -40,11 +41,38 @@ export class CloudFormationNode extends AWSTreeNodeBase { getChildNodes: async () => { await this.updateChildren() - return [...this.stackNodes.values()] + const panelNode = new AWSCommandTreeNode( + this, + '✨ Try the new CloudFormation panel', + 'aws.cloudformation.focus', + undefined, + 'Open the enhanced CloudFormation panel with improved features' + ) + panelNode.iconPath = getIcon('vscode-star-full') + + return [panelNode, ...this.stackNodes.values()] + }, + getNoChildrenPlaceholderNode: async () => { + const panelNode = new AWSCommandTreeNode( + this, + '✨ Try the new CloudFormation panel', + 'aws.cloudformation.focus', + undefined, + 'Open the enhanced CloudFormation panel with improved features' + ) + panelNode.iconPath = getIcon('vscode-star-full') + return panelNode + }, + sort: (nodeA, nodeB) => { + // Keep the panel node at the top + if (nodeA instanceof AWSCommandTreeNode) { + return -1 + } + if (nodeB instanceof AWSCommandTreeNode) { + return 1 + } + return nodeA.stackName.localeCompare(nodeB.stackName) }, - getNoChildrenPlaceholderNode: async () => - new PlaceholderNode(this, localize('AWS.explorerNode.cloudformation.noStacks', '[No Stacks found]')), - sort: (nodeA, nodeB) => nodeA.stackName.localeCompare(nodeB.stackName), }) } diff --git a/packages/core/src/shared/extensions.ts b/packages/core/src/shared/extensions.ts index d9b242e96a6..cd14a9f5996 100644 --- a/packages/core/src/shared/extensions.ts +++ b/packages/core/src/shared/extensions.ts @@ -47,3 +47,5 @@ export interface ExtContext { * Version of the .vsix produced by package.ts with the --debug option. */ export const extensionAlphaVersion = '99.0.0-SNAPSHOT' + +export const cloudformation = 'cloudformation' diff --git a/packages/core/src/shared/globalState.ts b/packages/core/src/shared/globalState.ts index edde0611e0b..dc4f7878b0c 100644 --- a/packages/core/src/shared/globalState.ts +++ b/packages/core/src/shared/globalState.ts @@ -85,6 +85,8 @@ export type globalKey = | 'aws.sagemaker.selectedDomainUsers' // Name of the connection if it's not to the AWS cloud. Current supported value only 'localstack' | 'aws.toolkit.externalConnection' + | 'aws.cloudformation.region' + | 'aws.cloudformation.selectedResourceTypes' /** * Extension-local (not visible to other vscode extensions) shared state which persists after IDE diff --git a/packages/core/src/shared/logger/logger.ts b/packages/core/src/shared/logger/logger.ts index 95c4c7af769..cacbe260ffe 100644 --- a/packages/core/src/shared/logger/logger.ts +++ b/packages/core/src/shared/logger/logger.ts @@ -15,6 +15,7 @@ export type LogTopic = | 'amazonqWorkspaceLsp' | 'amazonqLsp' | 'amazonqLsp.lspClient' + | 'awsCfnLsp' | 'chat' | 'stepfunctions' | 'unknown' diff --git a/packages/core/src/shared/lsp/baseLspInstaller.ts b/packages/core/src/shared/lsp/baseLspInstaller.ts index 7acf58ad788..8ba85ebe1e1 100644 --- a/packages/core/src/shared/lsp/baseLspInstaller.ts +++ b/packages/core/src/shared/lsp/baseLspInstaller.ts @@ -26,7 +26,8 @@ export abstract class BaseLspInstaller + loggerName: Extract, + private manifestResolver?: ManifestResolver ) { this.logger = getLogger(loggerName) } @@ -45,7 +46,9 @@ export abstract class BaseLspInstaller fetchResult.res && fetchResult.res.ok && fetchResult.res.body) .flatMap(async (fetchResult) => { const arrBuffer = await fetchResult.res!.arrayBuffer() const data = Buffer.from(arrBuffer) + // Skip hash verification if no hash is provided + if (!fetchResult.hash) { + return [{ filename: fetchResult.filename, data }] + } + const hash = createHash('sha384', data) if (hash === fetchResult.hash) { return [{ filename: fetchResult.filename, data }] diff --git a/packages/core/src/shared/sam/activation.ts b/packages/core/src/shared/sam/activation.ts index 855dde39a29..c3fd1376710 100644 --- a/packages/core/src/shared/sam/activation.ts +++ b/packages/core/src/shared/sam/activation.ts @@ -18,8 +18,8 @@ import * as pyLensProvider from '../codelens/pythonCodeLensProvider' import * as goLensProvider from '../codelens/goCodeLensProvider' import { SamTemplateCodeLensProvider } from '../codelens/samTemplateCodeLensProvider' import * as jsLensProvider from '../codelens/typescriptCodeLensProvider' -import { ExtContext, VSCODE_EXTENSION_ID } from '../extensions' -import { getIdeProperties, getIdeType } from '../extensionUtilities' +import { ExtContext } from '../extensions' +import { getIdeProperties } from '../extensionUtilities' import { getLogger } from '../logger/logger' import { PerfLog } from '../logger/perfLogger' import { NoopWatcher } from '../fs/watchedFiles' @@ -28,12 +28,10 @@ import { CodelensRootRegistry } from '../fs/codelensRootRegistry' import { AWS_SAM_DEBUG_TYPE } from './debugger/awsSamDebugConfiguration' import { SamDebugConfigProvider } from './debugger/awsSamDebugger' import { addSamDebugConfiguration } from './debugger/commands/addSamDebugConfiguration' -import { ToolkitPromptSettings } from '../settings' import { shared } from '../utilities/functionUtils' import { SamCliSettings } from './cli/samCliSettings' import { Commands } from '../vscode/commands2' import { runSync } from './sync' -import { showExtensionPage } from '../utilities/vsCodeUtils' import { runDeploy } from './deploy' import { telemetry } from '../telemetry/telemetry' @@ -48,7 +46,6 @@ const supportedLanguages: { */ export async function activate(ctx: ExtContext): Promise { let didActivateCodeLensProviders = false - await createYamlExtensionPrompt() const config = SamCliSettings.instance // Do this "on-demand" because it is slow. @@ -285,152 +282,3 @@ async function activateCodefileOverlays( perflog.done() return disposables } - -/** - * Creates a prompt (via toast) to guide users to installing the Red Hat YAML extension. - * This is necessary for displaying codelenses on templaye YAML files. - * Will show once per extension activation at most (all prompting triggers are disposed of on first trigger) - * Will not show if the YAML extension is installed or if a user has permanently dismissed the message. - */ -async function createYamlExtensionPrompt(): Promise { - const settings = ToolkitPromptSettings.instance - - /** - * Prompt the user to install the YAML plugin when AWSTemplateFormatVersion becomes available as a top level key - * in the document - * @param event An vscode text document change event - * @returns nothing - */ - async function promptOnAWSTemplateFormatVersion( - event: vscode.TextDocumentChangeEvent, - yamlPromptDisposables: vscode.Disposable[] - ): Promise { - for (const change of event.contentChanges) { - const changedLine = event.document.lineAt(change.range.start.line) - if (changedLine.text.includes('AWSTemplateFormatVersion')) { - await promptInstallYamlPlugin(yamlPromptDisposables) - return - } - } - return - } - - // Show this only in VSCode since other VSCode-like IDEs (e.g. Theia) may - // not have a marketplace or contain the YAML plugin. - if ( - settings.isPromptEnabled('yamlExtPrompt') && - getIdeType() === 'vscode' && - !vscode.extensions.getExtension(VSCODE_EXTENSION_ID.yaml) - ) { - // Disposed immediately after showing one, so the user isn't prompted - // more than once per session. - const yamlPromptDisposables: vscode.Disposable[] = [] - - // user opens a template file - vscode.workspace.onDidOpenTextDocument( - async (doc: vscode.TextDocument) => { - void promptInstallYamlPluginFromFilename(doc.fileName, yamlPromptDisposables) - }, - undefined, - yamlPromptDisposables - ) - - // user swaps to an already-open template file that didn't have focus - vscode.window.onDidChangeActiveTextEditor( - async (editor: vscode.TextEditor | undefined) => { - await promptInstallYamlPluginFromEditor(editor, yamlPromptDisposables) - }, - undefined, - yamlPromptDisposables - ) - - const promptNotifications = new Map>() - vscode.workspace.onDidChangeTextDocument( - (event: vscode.TextDocumentChangeEvent) => { - const uri = event.document.uri.toString() - if ( - event.document.languageId === 'yaml' && - !vscode.extensions.getExtension(VSCODE_EXTENSION_ID.yaml) && - !promptNotifications.has(uri) - ) { - promptNotifications.set( - uri, - promptOnAWSTemplateFormatVersion(event, yamlPromptDisposables).finally(() => - promptNotifications.delete(uri) - ) - ) - } - }, - undefined, - yamlPromptDisposables - ) - - vscode.workspace.onDidCloseTextDocument((event: vscode.TextDocument) => { - promptNotifications.delete(event.uri.toString()) - }) - - // user already has an open template with focus - // prescreen if a template.yaml is current open so we only call once - const openTemplateYamls = vscode.window.visibleTextEditors.filter((editor) => { - const fileName = editor.document.fileName - return fileName.endsWith('template.yaml') || fileName.endsWith('template.yml') - }) - - if (openTemplateYamls.length > 0) { - void promptInstallYamlPluginFromEditor(openTemplateYamls[0], yamlPromptDisposables) - } - } -} - -async function promptInstallYamlPluginFromEditor( - editor: vscode.TextEditor | undefined, - disposables: vscode.Disposable[] -): Promise { - if (editor) { - void promptInstallYamlPluginFromFilename(editor.document.fileName, disposables) - } -} - -/** - * Prompt user to install YAML plugin for template.yaml and template.yml files - * @param fileName File name to check against - * @param disposables List of disposables to dispose of when the filename is a template YAML file - */ -async function promptInstallYamlPluginFromFilename(fileName: string, disposables: vscode.Disposable[]): Promise { - if (fileName.endsWith('template.yaml') || fileName.endsWith('template.yml')) { - void promptInstallYamlPlugin(disposables) - } -} - -/** - * Show the install YAML extension prompt and dispose other listeners - * @param disposables - */ -async function promptInstallYamlPlugin(disposables: vscode.Disposable[]) { - // immediately dispose other triggers so it doesn't flash again - for (const prompt of disposables) { - prompt.dispose() - } - const settings = ToolkitPromptSettings.instance - - const installBtn = localize('AWS.missingExtension.install', 'Install...') - const permanentlySuppress = localize('AWS.message.info.yaml.suppressPrompt', "Don't show again") - - const response = await vscode.window.showInformationMessage( - localize( - 'AWS.message.info.yaml.prompt', - 'Install YAML extension for more {0} features in CloudFormation templates', - getIdeProperties().company - ), - installBtn, - permanentlySuppress - ) - - switch (response) { - case installBtn: - await showExtensionPage(VSCODE_EXTENSION_ID.yaml) - break - case permanentlySuppress: - await settings.disablePrompt('yamlExtPrompt') - } -} diff --git a/packages/core/src/shared/schemas.ts b/packages/core/src/shared/schemas.ts index 1506908a7c8..8e737d9ce67 100644 --- a/packages/core/src/shared/schemas.ts +++ b/packages/core/src/shared/schemas.ts @@ -149,37 +149,8 @@ export async function getDefaultSchemas(): Promise { const devfileSchemaUri = GlobalStorage.devfileSchemaUri() const devfileSchemaVersion = await getPropertyFromJsonUrl(devfileManifestUrl, 'tag_name') - // Sam schema is a superset of Cfn schema, so we can use it for both - const samAndCfnSchemaDestinationUri = GlobalStorage.samAndCfnSchemaDestinationUri() - const schemas: Schemas = {} - try { - await updateSchemaFromRemoteETag({ - destination: samAndCfnSchemaDestinationUri, - eTag: undefined, - url: samAndCfnSchemaUrl, - cacheKey: 'samAndCfnSchemaVersion', - title: schemaPrefix + 'cloudformation.schema.json', - }) - schemas['cfn'] = samAndCfnSchemaDestinationUri - } catch (e) { - getLogger().verbose('Could not download sam/cfn schema: %s', (e as Error).message) - } - - try { - await updateSchemaFromRemoteETag({ - destination: samAndCfnSchemaDestinationUri, - eTag: undefined, - url: samAndCfnSchemaUrl, - cacheKey: 'samAndCfnSchemaVersion', - title: schemaPrefix + 'sam.schema.json', - }) - schemas['sam'] = samAndCfnSchemaDestinationUri - } catch (e) { - getLogger().verbose('Could not download sam/cfn schema: %s', (e as Error).message) - } - try { await updateSchemaFromRemote({ destination: devfileSchemaUri, diff --git a/packages/core/src/shared/settings-toolkit.gen.ts b/packages/core/src/shared/settings-toolkit.gen.ts index 55bc77f9828..6547b6b1db4 100644 --- a/packages/core/src/shared/settings-toolkit.gen.ts +++ b/packages/core/src/shared/settings-toolkit.gen.ts @@ -42,6 +42,7 @@ export const toolkitSettings = { }, "aws.experiments": { "jsonResourceModification": {}, + "cloudFormationService": {}, "amazonqLSP": {}, "amazonqLSPInline": {}, "amazonqChatLSP": {}, @@ -53,7 +54,20 @@ export const toolkitSettings = { "aws.accessAnalyzer.policyChecks.checkNoNewAccessFilePath": {}, "aws.accessAnalyzer.policyChecks.checkAccessNotGrantedFilePath": {}, "aws.accessAnalyzer.policyChecks.cloudFormationParameterFilePath": {}, - "aws.sagemaker.studio.spaces.enableIdentityFiltering": {} + "aws.sagemaker.studio.spaces.enableIdentityFiltering": {}, + "aws.cloudformation.telemetry.enabled": {}, + "aws.cloudformation.hover.enabled": {}, + "aws.cloudformation.completion.enabled": {}, + "aws.cloudformation.diagnostics.cfnLint.enabled": {}, + "aws.cloudformation.diagnostics.cfnLint.lintOnChange": {}, + "aws.cloudformation.diagnostics.cfnLint.delayMs": {}, + "aws.cloudformation.diagnostics.cfnLint.path": {}, + "aws.cloudformation.diagnostics.cfnGuard.enabled": {}, + "aws.cloudformation.diagnostics.cfnGuard.validateOnChange": {}, + "aws.cloudformation.diagnostics.cfnGuard.enabledRulePacks": {}, + "aws.cloudformation.diagnostics.cfnGuard.rulesFile": {}, + "aws.cloudformation.s3": {}, + "aws.cloudformation.environment.saveOptions": {} } export default toolkitSettings diff --git a/packages/core/src/shared/settings.ts b/packages/core/src/shared/settings.ts index 4e3e99f8207..abdddf636a3 100644 --- a/packages/core/src/shared/settings.ts +++ b/packages/core/src/shared/settings.ts @@ -778,6 +778,7 @@ const devSettings = { codewhispererService: Record(String, String), amazonqLsp: Record(String, String), amazonqWorkspaceLsp: Record(String, String), + cloudformationLsp: Record(String, String), ssoCacheDirectory: String, autofillStartUrl: String, webAuth: Boolean, @@ -792,6 +793,7 @@ interface ServiceTypeMap { amazonqLsp: object // type is provided inside of amazon q amazonqWorkspaceLsp: object // type is provided inside of amazon q codewhispererService: CodeWhispererConfig + cloudformationLsp: object // type is provided inside of cloudformation lsp } /** diff --git a/packages/core/src/shared/vscode/setContext.ts b/packages/core/src/shared/vscode/setContext.ts index 3d45d93e14a..fb56be98f3e 100644 --- a/packages/core/src/shared/vscode/setContext.ts +++ b/packages/core/src/shared/vscode/setContext.ts @@ -28,10 +28,23 @@ export type contextKey = | 'aws.toolkit.amazonq.dismissed' | 'aws.toolkit.amazonqInstall.dismissed' | 'aws.stepFunctions.isWorkflowStudioFocused' + | 'aws.cloudformation.stacks.diffVisible' + | 'aws.cloudformation.stackSelected' + | 'aws.cloudformation.changeSetMode' + | 'aws.cloudformation.loadingStacks' + | 'aws.cloudformation.loadingResources' + | 'aws.cloudformation.importingResource' + | 'aws.cloudformation.cloningResource' + | 'aws.cloudformation.gettingStackMgmtInfo' + | 'aws.cloudformation.refreshingResourceList' + | 'aws.cloudformation.refreshingAllResources' + | 'aws.cloudformation.refreshingStacks' + | 'aws.cloudformation.stacks.detailVisible' | 'aws.toolkit.notifications.show' | 'aws.amazonq.editSuggestionActive' | 'aws.smus.connected' | 'aws.smus.inSmusSpaceEnvironment' + | 'aws.cloudFormation.serviceEnabled' // Deprecated/legacy names. New keys should start with "aws.". | 'codewhisperer.activeLine' | 'gumby.isPlanAvailable' diff --git a/packages/core/src/test/awsService/cloudformation/auth/credentials.test.ts b/packages/core/src/test/awsService/cloudformation/auth/credentials.test.ts new file mode 100644 index 00000000000..b9a2bf6997d --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/auth/credentials.test.ts @@ -0,0 +1,93 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import * as jose from 'jose' +import { AwsCredentialsService, encryptionKey } from '../../../../awsService/cloudformation/auth/credentials' + +describe('AwsCredentialsService', function () { + let sandbox: sinon.SinonSandbox + let mockStacksManager: any + let mockResourcesManager: any + let mockRegionManager: any + let mockClient: any + let credentialsService: AwsCredentialsService + + beforeEach(function () { + sandbox = sinon.createSandbox() + mockStacksManager = { reload: sandbox.stub(), hasMore: sandbox.stub().returns(false) } + mockResourcesManager = { reload: sandbox.stub() } + mockClient = { sendRequest: sandbox.stub() } + + const mockRegionManager = { getSelectedRegion: () => 'us-east-1' } as any + + credentialsService = new AwsCredentialsService(mockStacksManager, mockResourcesManager, mockRegionManager) + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('constructor', function () { + it('should initialize credentials service', function () { + credentialsService = new AwsCredentialsService(mockStacksManager, mockResourcesManager, mockRegionManager) + assert(credentialsService !== undefined) + }) + }) + + describe('createEncryptedCredentialsRequest', function () { + beforeEach(function () { + credentialsService = new AwsCredentialsService(mockStacksManager, mockResourcesManager, mockRegionManager) + }) + + it('should create encrypted request with correct structure', async function () { + const mockCredentials = { + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + sessionToken: 'test-token', + expiration: new Date(), + } + + const result = await (credentialsService as any).createEncryptedCredentialsRequest(mockCredentials) + + assert.strictEqual(typeof result.data, 'string') + assert.strictEqual(result.encrypted, true) + }) + + it('should encrypt credentials that can be decrypted', async function () { + const mockCredentials = { + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + sessionToken: 'test-token', + expiration: new Date(), + } + + const encryptedRequest = await (credentialsService as any).createEncryptedCredentialsRequest( + mockCredentials + ) + + // Verify we can decrypt it back + const decrypted = await jose.compactDecrypt(encryptedRequest.data, encryptionKey) + const decryptedData = JSON.parse(new TextDecoder().decode(decrypted.plaintext)) + + // Compare with expected serialized format (Date becomes string in JSON) + const expectedCredentials = { + ...mockCredentials, + expiration: mockCredentials.expiration.toISOString(), + } + assert.deepStrictEqual(decryptedData.data, expectedCredentials) + }) + }) + + describe('initialize', function () { + it('should accept language client', async function () { + credentialsService = new AwsCredentialsService(mockStacksManager, mockResourcesManager, mockRegionManager) + await credentialsService.initialize(mockClient) + // Test passes if no error thrown + assert(true) + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/cfn-init/cfnEnvironmentManager.test.ts b/packages/core/src/test/awsService/cloudformation/cfn-init/cfnEnvironmentManager.test.ts new file mode 100644 index 00000000000..8c851a64978 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/cfn-init/cfnEnvironmentManager.test.ts @@ -0,0 +1,435 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { strict as assert } from 'assert' +import * as sinon from 'sinon' +import { CfnEnvironmentManager } from '../../../../awsService/cloudformation/cfn-init/cfnEnvironmentManager' +import { Auth } from '../../../../auth/auth' +import { globals } from '../../../../shared' +import { workspace, commands } from 'vscode' +import fs from '../../../../shared/fs/fs' +import { CfnEnvironmentSelector } from '../../../../awsService/cloudformation/ui/cfnEnvironmentSelector' +import { CfnEnvironmentFileSelector } from '../../../../awsService/cloudformation/ui/cfnEnvironmentFileSelector' +import { OnStackFailure } from '@aws-sdk/client-cloudformation' +import * as environmentApi from '../../../../awsService/cloudformation/cfn-init/cfnEnvironmentApi' +import { getTestWindow } from '../../../shared/vscode/window' + +describe('CfnEnvironmentManager', () => { + let environmentManager: CfnEnvironmentManager + let mockAuth: sinon.SinonStubbedInstance + let mockWorkspaceState: any + let mockEnvironmentSelector: sinon.SinonStubbedInstance + let mockEnvironmentFileSelector: sinon.SinonStubbedInstance + let fsStub: sinon.SinonStub + let workspaceStub: sinon.SinonStub + let parseEnvironmentFilesStub: sinon.SinonStub + let mockClient: any + + let existsDirStub: sinon.SinonStub + let existsFileStub: sinon.SinonStub + + beforeEach(() => { + mockAuth = { + getConnection: sinon.stub(), + useConnection: sinon.stub(), + activeConnection: { + id: 'profile:test-profile', + type: 'iam', + label: 'test-profile', + state: 'valid', + } as any, + } as any + + sinon.stub(Auth, 'instance').get(() => mockAuth) + + mockWorkspaceState = { + get: sinon.stub(), + update: sinon.stub(), + } + sinon.stub(globals, 'context').value({ workspaceState: mockWorkspaceState }) + + mockEnvironmentSelector = { + selectEnvironment: sinon.stub(), + } as any + + mockEnvironmentFileSelector = { + selectEnvironmentFile: sinon.stub(), + } as any + + fsStub = sinon.stub(fs, 'readFileText') + // Mock project as initialized by default + existsDirStub = sinon.stub(fs, 'existsDir').resolves(true) + existsFileStub = sinon.stub(fs, 'existsFile').resolves(true) + + workspaceStub = sinon.stub(workspace, 'workspaceFolders').value([{ uri: { fsPath: '/test/workspace' } }]) + parseEnvironmentFilesStub = sinon.stub(environmentApi, 'parseCfnEnvironmentFiles') + mockClient = {} + + environmentManager = new CfnEnvironmentManager(mockClient, mockEnvironmentSelector, mockEnvironmentFileSelector) + }) + + afterEach(() => { + sinon.restore() + }) + + describe('getSelectedEnvironmentName', () => { + it('should return selected environment from workspace state', () => { + mockWorkspaceState.get.returns('test-env') + + const result = environmentManager.getSelectedEnvironmentName() + + assert.strictEqual(result, 'test-env') + assert(mockWorkspaceState.get.calledWith('aws.cloudformation.selectedEnvironment')) + }) + }) + + describe('promptInitializeIfNeeded', () => { + it('should return false when project is already initialized', async () => { + // Project is initialized by default in beforeEach + const result = await environmentManager.promptInitializeIfNeeded('Test Operation') + + assert.strictEqual(result, false) + const messages = getTestWindow().shownMessages + assert.strictEqual(messages.length, 0) + }) + + it('should show warning and execute command when user clicks Initialize Project', async () => { + existsDirStub.resolves(false) + existsFileStub.resolves(false) + + getTestWindow().onDidShowMessage((message) => { + if (message.message === 'You must initialize your CFN Project to perform Test Operation') { + message.selectItem('Initialize Project') + } + }) + + const executeCommandStub = sinon.stub(commands, 'executeCommand') + + const result = await environmentManager.promptInitializeIfNeeded('Test Operation') + + assert.strictEqual(result, true) + const messages = getTestWindow().shownMessages + assert(messages.some((m) => m.message === 'You must initialize your CFN Project to perform Test Operation')) + assert(executeCommandStub.calledWith('aws.cloudformation.init.initializeProject')) + }) + }) + + describe('selectEnvironment', () => { + it('should show warning when project is not initialized', async () => { + // Override default - mock project as not initialized + existsDirStub.resolves(false) + existsFileStub.resolves(false) + + // Set up message handler to simulate user clicking "Initialize Project" + getTestWindow().onDidShowMessage((message) => { + if (message.message === 'You must initialize your CFN Project to perform Environment Selection') { + // Simulate user clicking the "Initialize Project" button + message.selectItem('Initialize Project') + } + }) + + const executeCommandStub = sinon.stub(commands, 'executeCommand') + + await environmentManager.selectEnvironment() + + const messages = getTestWindow().shownMessages + assert( + messages.some( + (m) => m.message === 'You must initialize your CFN Project to perform Environment Selection' + ) + ) + assert(executeCommandStub.calledWith('aws.cloudformation.init.initializeProject')) + assert(mockEnvironmentSelector.selectEnvironment.notCalled) + }) + + it('should select environment successfully', async () => { + const mockEnvironmentLookup = { 'test-env': { name: 'test-env', profile: 'test-profile' } } + fsStub.resolves(JSON.stringify({ environments: mockEnvironmentLookup })) + mockEnvironmentSelector.selectEnvironment.resolves('test-env') + + const mockConnection = { + id: 'profile:test-profile', + type: 'iam', + label: 'test-profile', + state: 'valid', + } as any + mockAuth.getConnection.resolves(mockConnection) + + const listener = sinon.stub() + environmentManager.addListener(listener) + + await environmentManager.selectEnvironment() + + assert(mockEnvironmentSelector.selectEnvironment.calledWith(mockEnvironmentLookup)) + assert(mockWorkspaceState.update.calledWith('aws.cloudformation.selectedEnvironment', 'test-env')) + assert(mockAuth.getConnection.calledWith({ id: 'profile:test-profile' })) + assert(mockAuth.useConnection.calledWith(mockConnection)) + assert(listener.called) + }) + + it('should handle fetch error gracefully', async () => { + fsStub.rejects(new Error('File not found')) + + await environmentManager.selectEnvironment() + + assert(mockEnvironmentSelector.selectEnvironment.notCalled) + }) + + it('should handle no environment selected', async () => { + const mockEnvironmentLookup = { 'test-env': { name: 'test-env', profile: 'test-profile' } } + fsStub.resolves(JSON.stringify({ environments: mockEnvironmentLookup })) + mockEnvironmentSelector.selectEnvironment.resolves(undefined) + + await environmentManager.selectEnvironment() + + assert(mockWorkspaceState.update.notCalled) + assert(mockAuth.getConnection.notCalled) + }) + + it('should handle missing connection gracefully', async () => { + const mockEnvironmentLookup = { 'test-env': { name: 'test-env', profile: 'missing-profile' } } + fsStub.resolves(JSON.stringify({ environments: mockEnvironmentLookup })) + mockEnvironmentSelector.selectEnvironment.resolves('test-env') + mockAuth.getConnection.resolves(undefined) + + await environmentManager.selectEnvironment() + + assert(mockWorkspaceState.update.calledWith('aws.cloudformation.selectedEnvironment', 'test-env')) + assert(mockAuth.useConnection.notCalled) + }) + }) + + describe('fetchAvailableEnvironments', () => { + it('should fetch environments successfully', async () => { + const mockEnvironmentLookup = { env1: { name: 'env1', profile: 'profile1' } } + fsStub.resolves(JSON.stringify({ environments: mockEnvironmentLookup })) + + const result = await environmentManager.fetchAvailableEnvironments() + + assert.deepStrictEqual(result, mockEnvironmentLookup) + }) + + it('should throw error when workspace not found', async () => { + workspaceStub.value(undefined) + + await assert.rejects(environmentManager.fetchAvailableEnvironments(), /No workspace folder found/) + }) + + it('should throw error when file read fails', async () => { + fsStub.rejects(new Error('File not found')) + + await assert.rejects(environmentManager.fetchAvailableEnvironments(), /File not found/) + }) + }) + + describe('selectEnvironmentFile', () => { + let readdirStub: sinon.SinonStub + + beforeEach(() => { + readdirStub = sinon.stub(fs, 'readdir') + }) + + it('should return undefined when no environment selected', async () => { + mockWorkspaceState.get.returns(undefined) + + const result = await environmentManager.selectEnvironmentFile('template.yaml', [{ name: 'Param1' }]) + + assert.strictEqual(result, undefined) + }) + + it('should collect all environment files and pass to selector', async () => { + mockWorkspaceState.get.returns('test-env') + + // Mock multiple files + readdirStub.resolves([ + ['params1.json', 1], + ['params2.yaml', 1], + ['params3.yml', 1], + ]) + + // Mock file contents + fsStub.onCall(0).resolves( + JSON.stringify({ + parameters: { Param1: 'value1' }, + tags: { Tag1: 'value1' }, + 'on-stack-failure': OnStackFailure.DO_NOTHING, + 'import-existing-resources': true, + 'include-nested-stacks': false, + }) + ) + fsStub.onCall(1).resolves('template-file-path: template.yaml\nparameters:\n Param2: value2') + fsStub.onCall(2).resolves('template-file-path: wrong-file.yaml\nparameters:\n Param3: value3') + + // Mock parseEnvironmentFiles response + parseEnvironmentFilesStub.resolves([ + { + fileName: 'params1.json', + deploymentConfig: { + parameters: { Param1: 'value1' }, + tags: { Tag1: 'value1' }, + onStackFailure: OnStackFailure.DO_NOTHING, + importExistingResources: true, + includeNestedStacks: false, + }, + }, + { + fileName: 'params2.yaml', + deploymentConfig: { + templateFilePath: 'template.yaml', + parameters: { Param2: 'value2' }, + }, + }, + { + fileName: 'params3.yml', + deploymentConfig: { + templateFilePath: 'wrong-file.yaml', + parameters: { Param3: 'value3' }, + }, + }, + ]) + + // Mock workspace.asRelativePath to return matching path for template.yaml + sinon.stub(workspace, 'asRelativePath').returns('template.yaml') + + const mockSelectorItem = { + fileName: 'selected.json', + hasMatchingTemplatePath: true, + compatibleParameters: [{ ParameterKey: 'Param1', ParameterValue: 'value1' }], + } + mockEnvironmentFileSelector.selectEnvironmentFile.resolves(mockSelectorItem) + + const result = await environmentManager.selectEnvironmentFile('template.yaml', [ + { name: 'Param1' }, + { name: 'Param2' }, + { name: 'Param3' }, + ]) + + const [selectorItems, paramCount] = mockEnvironmentFileSelector.selectEnvironmentFile.getCall(0).args + + // Assert call arguments + assert(mockEnvironmentFileSelector.selectEnvironmentFile.calledOnce) + assert.strictEqual(selectorItems.length, 3) + assert.strictEqual(paramCount, 3) + + // Check params1.json + assert.strictEqual(selectorItems[0].fileName, 'params1.json') + assert.strictEqual(selectorItems[0].hasMatchingTemplatePath, false) + assert.deepStrictEqual(selectorItems[0].compatibleParameters, [ + { ParameterKey: 'Param1', ParameterValue: 'value1' }, + ]) + assert.deepStrictEqual(selectorItems[0].optionalFlags?.tags, [{ Key: 'Tag1', Value: 'value1' }]) + assert.deepStrictEqual(selectorItems[0].optionalFlags?.includeNestedStacks, false), + assert.deepStrictEqual(selectorItems[0].optionalFlags?.importExistingResources, true), + assert.deepStrictEqual(selectorItems[0].optionalFlags?.onStackFailure, OnStackFailure.DO_NOTHING), + // Check params2.yaml + assert.strictEqual(selectorItems[1].fileName, 'params2.yaml') + assert.strictEqual(selectorItems[1].hasMatchingTemplatePath, true) + assert.deepStrictEqual(selectorItems[1].compatibleParameters, [ + { ParameterKey: 'Param2', ParameterValue: 'value2' }, + ]) + + // Check params3.yml + assert.strictEqual(selectorItems[2].fileName, 'params3.yml') + assert.strictEqual(selectorItems[2].hasMatchingTemplatePath, false) + assert.deepStrictEqual(selectorItems[2].compatibleParameters, [ + { ParameterKey: 'Param3', ParameterValue: 'value3' }, + ]) + assert.strictEqual(result, mockSelectorItem) + }) + + it('should only use files returned from parser', async () => { + mockWorkspaceState.get.returns('test-env') + readdirStub.resolves([ + ['valid1.json', 1], + ['malformed1.json', 1], + ['valid2.yaml', 1], + ['malformed2.yaml', 1], + ['malformed3.yml', 1], + ]) + + // Mock file contents for all 5 files + fsStub.onCall(0).resolves(JSON.stringify({ parameters: { Param1: 'value1' } })) + fsStub.onCall(1).resolves('invalid json') + fsStub.onCall(2).resolves('parameters:\n Param2: value2') + fsStub.onCall(3).resolves('invalid: yaml: content') + fsStub.onCall(4).resolves('null') + + // Parser only returns 2 valid files out of 5 + parseEnvironmentFilesStub.resolves([ + { + fileName: 'valid1.json', + deploymentConfig: { + parameters: { Param1: 'value1' }, + }, + }, + { + fileName: 'valid2.yaml', + deploymentConfig: { + parameters: { Param2: 'value2' }, + }, + }, + ]) + + const mockSelectorItem = { fileName: 'selected.json' } + mockEnvironmentFileSelector.selectEnvironmentFile.resolves(mockSelectorItem) + + await environmentManager.selectEnvironmentFile('template.yaml', [{ name: 'Param1' }, { name: 'Param2' }]) + + // Verify parseEnvironmentFiles was called with all files + assert( + parseEnvironmentFilesStub.calledOnceWith(mockClient, { + documents: [ + { fileName: 'valid1.json', type: 'JSON', content: '{"parameters":{"Param1":"value1"}}' }, + { fileName: 'malformed1.json', type: 'JSON', content: 'invalid json' }, + { fileName: 'valid2.yaml', type: 'YAML', content: 'parameters:\n Param2: value2' }, + { fileName: 'malformed2.yaml', type: 'YAML', content: 'invalid: yaml: content' }, + { fileName: 'malformed3.yml', type: 'YAML', content: 'null' }, + ], + }) + ) + + const [selectorItems, paramCount] = mockEnvironmentFileSelector.selectEnvironmentFile.getCall(0).args + + assert(mockEnvironmentFileSelector.selectEnvironmentFile.calledOnce) + assert.strictEqual(selectorItems.length, 2) + assert.strictEqual(paramCount, 2) + + // Check valid1.json + assert.strictEqual(selectorItems[0].fileName, 'valid1.json') + assert.strictEqual(selectorItems[0].hasMatchingTemplatePath, false) + assert.deepStrictEqual(selectorItems[0].compatibleParameters, [ + { ParameterKey: 'Param1', ParameterValue: 'value1' }, + ]) + + // Check valid2.yaml + assert.strictEqual(selectorItems[1].fileName, 'valid2.yaml') + assert.strictEqual(selectorItems[1].hasMatchingTemplatePath, false) + assert.deepStrictEqual(selectorItems[1].compatibleParameters, [ + { ParameterKey: 'Param2', ParameterValue: 'value2' }, + ]) + }) + + it('should return undefined when parameter file selector returns undefined', async () => { + mockWorkspaceState.get.returns('test-env') + readdirStub.resolves([['params.json', 1]]) + fsStub.resolves(JSON.stringify({ parameters: { Param1: 'value1' } })) + + parseEnvironmentFilesStub.resolves([ + { + fileName: 'params.json', + deploymentConfig: { + parameters: { Param1: 'value1' }, + }, + }, + ]) + + mockEnvironmentFileSelector.selectEnvironmentFile.resolves(undefined) + + const result = await environmentManager.selectEnvironmentFile('template.yaml', [{ name: 'Param1' }]) + + assert.strictEqual(result, undefined) + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/commands/cfnCommands.test.ts b/packages/core/src/test/awsService/cloudformation/commands/cfnCommands.test.ts new file mode 100644 index 00000000000..24eb5d95549 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/commands/cfnCommands.test.ts @@ -0,0 +1,370 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import * as vscode from 'vscode' +import { OnStackFailure, Parameter } from '@aws-sdk/client-cloudformation' +import { + rerunLastValidationCommand, + extractToParameterPositionCursorCommand, + promptForOptionalFlags, + promptToSaveToFile, + addResourceTypesCommand, + removeResourceTypeCommand, +} from '../../../../awsService/cloudformation/commands/cfnCommands' +import { OptionalFlagMode } from '../../../../awsService/cloudformation/stacks/actions/stackActionRequestType' +import * as inputBox from '../../../../awsService/cloudformation/ui/inputBox' +import { fs } from '../../../../shared/fs/fs' +import { ResourceTypeNode } from '../../../../awsService/cloudformation/explorer/nodes/resourceTypeNode' + +describe('CfnCommands', function () { + let sandbox: sinon.SinonSandbox + let registerCommandStub: sinon.SinonStub + + beforeEach(function () { + sandbox = sinon.createSandbox() + registerCommandStub = sandbox.stub(vscode.commands, 'registerCommand').returns({ + dispose: () => {}, + } as vscode.Disposable) + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('rerunLastValidationCommand', function () { + it('should register rerun last validation command', function () { + const result = rerunLastValidationCommand() + assert.ok(result) + assert.ok(registerCommandStub.calledOnce) + assert.strictEqual(registerCommandStub.firstCall.args[0], 'aws.cloudformation.api.rerunLastValidation') + }) + }) + + describe('extractToParameterPositionCursorCommand', function () { + it('should register extract to parameter command', function () { + const mockClient = {} as any + const result = extractToParameterPositionCursorCommand(mockClient) + assert.ok(result) + assert.ok(registerCommandStub.calledOnce) + assert.strictEqual( + registerCommandStub.firstCall.args[0], + 'aws.cloudformation.extractToParameter.positionCursor' + ) + }) + }) + + describe('promptForOptionalFlags', function () { + let chooseOptionalFlagModeStub: sinon.SinonStub + let getOnStackFailureStub: sinon.SinonStub + let getIncludeNestedStacksStub: sinon.SinonStub + let getTagsStub: sinon.SinonStub + let getImportExistingResourcesStub: sinon.SinonStub + let getDeploymentModeStub: sinon.SinonStub + + beforeEach(function () { + chooseOptionalFlagModeStub = sandbox.stub(inputBox, 'chooseOptionalFlagSuggestion') + getOnStackFailureStub = sandbox.stub(inputBox, 'getOnStackFailure') + getIncludeNestedStacksStub = sandbox.stub(inputBox, 'getIncludeNestedStacks') + getTagsStub = sandbox.stub(inputBox, 'getTags') + getImportExistingResourcesStub = sandbox.stub(inputBox, 'getImportExistingResources') + getDeploymentModeStub = sandbox.stub(inputBox, 'getDeploymentMode') + }) + + it('should return skip mode with existing file flags', async function () { + chooseOptionalFlagModeStub.resolves(OptionalFlagMode.Skip) + + const fileFlags = { + onStackFailure: OnStackFailure.DO_NOTHING, + includeNestedStacks: true, + tags: [{ Key: 'test', Value: 'value' }], + importExistingResources: false, + } + + const result = await promptForOptionalFlags(fileFlags) + + assert.deepStrictEqual(result, { + onStackFailure: OnStackFailure.DO_NOTHING, + includeNestedStacks: true, + tags: [{ Key: 'test', Value: 'value' }], + importExistingResources: false, + shouldSaveOptions: false, + }) + }) + + it('should use dev friendly defaults', async function () { + chooseOptionalFlagModeStub.resolves(OptionalFlagMode.DevFriendly) + getTagsStub.resolves(undefined) + + const result = await promptForOptionalFlags() + + assert.deepStrictEqual(result, { + onStackFailure: OnStackFailure.DO_NOTHING, + includeNestedStacks: true, + tags: undefined, + importExistingResources: true, + deploymentMode: undefined, + }) + }) + + it('should set shouldSaveOptions to true when input mode collects new values', async function () { + chooseOptionalFlagModeStub.resolves(OptionalFlagMode.Input) + getOnStackFailureStub.resolves(OnStackFailure.DELETE) + getIncludeNestedStacksStub.resolves(true) + getTagsStub.resolves([{ Key: 'Environment', Value: 'prod' }]) + getImportExistingResourcesStub.resolves(false) + + const result = await promptForOptionalFlags() + + assert.deepStrictEqual(result, { + onStackFailure: OnStackFailure.DELETE, + includeNestedStacks: true, + tags: [{ Key: 'Environment', Value: 'prod' }], + importExistingResources: false, + deploymentMode: undefined, + shouldSaveOptions: true, + }) + }) + + it('should prompt for deployment mode on stack update when conditions are met', async function () { + chooseOptionalFlagModeStub.resolves(OptionalFlagMode.Input) + getOnStackFailureStub.resolves(OnStackFailure.ROLLBACK) + getIncludeNestedStacksStub.resolves(false) + getTagsStub.resolves(undefined) + getImportExistingResourcesStub.resolves(false) + getDeploymentModeStub.resolves('INCREMENTAL') + + const stackDetails = { StackName: 'test-stack' } + const result = await promptForOptionalFlags(undefined, stackDetails as any) + + assert.ok(getDeploymentModeStub.calledOnce) + assert.deepStrictEqual(result, { + onStackFailure: OnStackFailure.ROLLBACK, + includeNestedStacks: false, + tags: undefined, + importExistingResources: false, + deploymentMode: 'INCREMENTAL', + shouldSaveOptions: true, + }) + }) + + it('should not prompt for deployment mode on stack create', async function () { + chooseOptionalFlagModeStub.resolves(OptionalFlagMode.Input) + getOnStackFailureStub.resolves(OnStackFailure.ROLLBACK) + getIncludeNestedStacksStub.resolves(false) + getTagsStub.resolves(undefined) + getImportExistingResourcesStub.resolves(false) + + const result = await promptForOptionalFlags() + + assert.ok(getDeploymentModeStub.notCalled) + assert.strictEqual(result?.deploymentMode, undefined) + }) + + it('should not prompt for deployment mode when importExistingResources is true', async function () { + chooseOptionalFlagModeStub.resolves(OptionalFlagMode.Input) + getOnStackFailureStub.resolves(OnStackFailure.ROLLBACK) + getIncludeNestedStacksStub.resolves(false) + getTagsStub.resolves(undefined) + getImportExistingResourcesStub.resolves(true) + + const stackDetails = { StackName: 'test-stack' } + const result = await promptForOptionalFlags(undefined, stackDetails as any) + + assert.ok(getDeploymentModeStub.notCalled) + assert.strictEqual(result?.deploymentMode, undefined) + }) + + it('should include deploymentMode from fileFlags in skip mode', async function () { + chooseOptionalFlagModeStub.resolves(OptionalFlagMode.Skip) + + const fileFlags = { + onStackFailure: OnStackFailure.DO_NOTHING, + includeNestedStacks: true, + tags: undefined, + importExistingResources: false, + deploymentMode: 'COMPLETE_REPLACEMENT' as any, + } + + const result = await promptForOptionalFlags(fileFlags) + + assert.deepStrictEqual(result, { + onStackFailure: OnStackFailure.DO_NOTHING, + includeNestedStacks: true, + tags: undefined, + importExistingResources: false, + deploymentMode: 'COMPLETE_REPLACEMENT', + shouldSaveOptions: false, + }) + }) + + it('should default to REVERT_DRIFT in skip mode when conditions are met', async function () { + chooseOptionalFlagModeStub.resolves(OptionalFlagMode.Skip) + + const fileFlags = { + onStackFailure: OnStackFailure.ROLLBACK, + includeNestedStacks: false, + tags: undefined, + importExistingResources: false, + } + + const stackDetails = { StackName: 'test-stack' } + const result = await promptForOptionalFlags(fileFlags, stackDetails as any) + + assert.deepStrictEqual(result, { + onStackFailure: OnStackFailure.ROLLBACK, + includeNestedStacks: false, + tags: undefined, + importExistingResources: false, + deploymentMode: 'REVERT_DRIFT', + shouldSaveOptions: false, + }) + }) + + it('should not default to REVERT_DRIFT in skip mode when stack does not exist', async function () { + chooseOptionalFlagModeStub.resolves(OptionalFlagMode.Skip) + + const fileFlags = { + onStackFailure: OnStackFailure.ROLLBACK, + includeNestedStacks: false, + tags: undefined, + importExistingResources: false, + } + + const result = await promptForOptionalFlags(fileFlags) + + assert.deepStrictEqual(result, { + onStackFailure: OnStackFailure.ROLLBACK, + includeNestedStacks: false, + tags: undefined, + importExistingResources: false, + deploymentMode: undefined, + shouldSaveOptions: false, + }) + }) + + it('should not default to REVERT_DRIFT in skip mode when includeNestedStacks is true', async function () { + chooseOptionalFlagModeStub.resolves(OptionalFlagMode.Skip) + + const fileFlags = { + onStackFailure: OnStackFailure.ROLLBACK, + includeNestedStacks: true, + tags: undefined, + importExistingResources: false, + } + + const stackDetails = { StackName: 'test-stack' } + const result = await promptForOptionalFlags(fileFlags, stackDetails as any) + + assert.deepStrictEqual(result, { + onStackFailure: OnStackFailure.ROLLBACK, + includeNestedStacks: true, + tags: undefined, + importExistingResources: false, + deploymentMode: undefined, + shouldSaveOptions: false, + }) + }) + }) + + describe('promptToSaveToFile', function () { + let shouldSaveFlagsToFileStub: sinon.SinonStub + let getFilePathStub: sinon.SinonStub + let workspaceConfigStub: sinon.SinonStub + let workspaceAsRelativePathStub: sinon.SinonStub + let fsWriteFileStub: sinon.SinonStub + + beforeEach(function () { + shouldSaveFlagsToFileStub = sandbox.stub(inputBox, 'shouldSaveFlagsToFile') + getFilePathStub = sandbox.stub(inputBox, 'getFilePath') + workspaceConfigStub = sandbox.stub(vscode.workspace, 'getConfiguration') + workspaceAsRelativePathStub = sandbox.stub(vscode.workspace, 'asRelativePath') + fsWriteFileStub = sandbox.stub(fs, 'writeFile') + }) + + it('should return early when user chooses not to save', async function () { + shouldSaveFlagsToFileStub.resolves(false) + + await promptToSaveToFile('/test/env', undefined, undefined) + + assert(getFilePathStub.notCalled) + assert(fsWriteFileStub.notCalled) + }) + + it('should save JSON file with correct format', async function () { + shouldSaveFlagsToFileStub.resolves(true) + getFilePathStub.resolves('/test/env/config.json') + workspaceAsRelativePathStub.returns('config.json') + + const mockConfig = { + get: sandbox.stub(), + } + mockConfig.get.withArgs('tabSize', 2).returns(2) + mockConfig.get.withArgs('insertSpaces', true).returns(true) + workspaceConfigStub.returns(mockConfig) + + const parameters: Parameter[] = [ + { ParameterKey: 'Environment', ParameterValue: 'test' }, + { ParameterKey: 'InstanceType', ParameterValue: 't3.micro' }, + ] + + const optionalFlags = { + onStackFailure: OnStackFailure.ROLLBACK, + includeNestedStacks: false, + tags: [{ Key: 'Project', Value: 'MyApp' }], + importExistingResources: true, + } + + await promptToSaveToFile('/test/env', optionalFlags, parameters) + + assert(fsWriteFileStub.calledOnce) + const [filePath, content] = fsWriteFileStub.getCall(0).args + assert.strictEqual(filePath, '/test/env/config.json') + + const parsed = JSON.parse(content) + assert.deepStrictEqual(parsed['parameters'], { + Environment: 'test', + InstanceType: 't3.micro', + }) + assert.deepStrictEqual(parsed['tags'], { Project: 'MyApp' }) + assert.strictEqual(parsed['on-stack-failure'], OnStackFailure.ROLLBACK) + assert.strictEqual(parsed['include-nested-stacks'], false) + assert.strictEqual(parsed['import-existing-resources'], true) + }) + }) + + describe('addResourceTypesCommand', function () { + it('should register add resource types command', function () { + const mockResourcesManager = { selectResourceTypes: sinon.stub() } as any + const result = addResourceTypesCommand(mockResourcesManager) + assert.ok(result) + assert.ok(registerCommandStub.calledOnce) + assert.strictEqual(registerCommandStub.firstCall.args[0], 'aws.cloudformation.api.addResourceTypes') + }) + }) + + describe('removeResourceTypeCommand', function () { + it('should register remove resource type command', function () { + const mockResourcesManager = { removeResourceType: sinon.stub() } as any + const result = removeResourceTypeCommand(mockResourcesManager) + assert.ok(result) + assert.ok(registerCommandStub.calledOnce) + assert.strictEqual(registerCommandStub.firstCall.args[0], 'aws.cloudformation.removeResourceType') + }) + + it('should call removeResourceType with node typeName', async function () { + const mockResourcesManager = { removeResourceType: sinon.stub().resolves() } as any + removeResourceTypeCommand(mockResourcesManager) + + const commandHandler = registerCommandStub.firstCall.args[1] + const mockNode = { typeName: 'AWS::S3::Bucket' } as ResourceTypeNode + + await commandHandler(mockNode) + + assert.ok(mockResourcesManager.removeResourceType.calledOnceWith('AWS::S3::Bucket')) + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/commands/cursorPositioning.test.ts b/packages/core/src/test/awsService/cloudformation/commands/cursorPositioning.test.ts new file mode 100644 index 00000000000..4ad80eb216f --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/commands/cursorPositioning.test.ts @@ -0,0 +1,26 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' + +describe('CursorPositioning', function () { + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('cursor positioning', function () { + it('should position cursor correctly', function () { + // Basic test structure - implementation depends on actual CursorPositioning module + assert.ok(true, 'CursorPositioning test placeholder') + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/explorer/nodes/resourceNode.test.ts b/packages/core/src/test/awsService/cloudformation/explorer/nodes/resourceNode.test.ts new file mode 100644 index 00000000000..03c2a765d7d --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/explorer/nodes/resourceNode.test.ts @@ -0,0 +1,33 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { TreeItemCollapsibleState } from 'vscode' +import { ResourceNode } from '../../../../../awsService/cloudformation/explorer/nodes/resourceNode' + +describe('ResourceNode', function () { + let resourceNode: ResourceNode + + beforeEach(function () { + resourceNode = new ResourceNode('my-bucket-123', 'AWS::S3::Bucket') + }) + + describe('constructor', function () { + it('should set correct properties', function () { + assert.strictEqual(resourceNode.label, 'my-bucket-123') + assert.strictEqual(resourceNode.resourceIdentifier, 'my-bucket-123') + assert.strictEqual(resourceNode.resourceType, 'AWS::S3::Bucket') + assert.strictEqual(resourceNode.contextValue, 'resource') + assert.strictEqual(resourceNode.collapsibleState, TreeItemCollapsibleState.None) + }) + }) + + describe('getChildren', function () { + it('should return empty array', async function () { + const children = await resourceNode.getChildren() + assert.strictEqual(children.length, 0) + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/explorer/nodes/resourceTypeNode.test.ts b/packages/core/src/test/awsService/cloudformation/explorer/nodes/resourceTypeNode.test.ts new file mode 100644 index 00000000000..ea92a92a486 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/explorer/nodes/resourceTypeNode.test.ts @@ -0,0 +1,111 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { TreeItemCollapsibleState } from 'vscode' +import { ResourceTypeNode } from '../../../../../awsService/cloudformation/explorer/nodes/resourceTypeNode' +import { ResourceList } from '../../../../../awsService/cloudformation/cfn/resourceRequestTypes' +import { ResourcesManager } from '../../../../../awsService/cloudformation/resources/resourcesManager' + +describe('ResourceTypeNode', function () { + let mockResourceList: ResourceList + let resourceTypeNode: ResourceTypeNode + let mockResourcesManager: ResourcesManager + + beforeEach(function () { + mockResourceList = { + typeName: 'AWS::S3::Bucket', + resourceIdentifiers: ['bucket-1', 'bucket-2', 'bucket-3'], + } + + mockResourcesManager = {} as ResourcesManager + + resourceTypeNode = new ResourceTypeNode('AWS::S3::Bucket', mockResourcesManager, mockResourceList) + }) + + describe('constructor', function () { + it('should set correct properties when resourceList is provided', function () { + assert.strictEqual(resourceTypeNode.label, 'AWS::S3::Bucket') + assert.strictEqual(resourceTypeNode.description, '(3)') + assert.strictEqual(resourceTypeNode.contextValue, 'resourceType') + assert.strictEqual(resourceTypeNode.collapsibleState, TreeItemCollapsibleState.Collapsed) + }) + + it('should set correct properties when resourceList is undefined', function () { + const lazyNode = new ResourceTypeNode('AWS::Lambda::Function', mockResourcesManager) + assert.strictEqual(lazyNode.label, 'AWS::Lambda::Function') + assert.strictEqual(lazyNode.description, undefined) + assert.strictEqual(lazyNode.contextValue, 'resourceType') + }) + }) + + describe('getChildren', function () { + it('should return resource nodes for each identifier', async function () { + const children = await resourceTypeNode.getChildren() + assert.strictEqual(children.length, 3) + + const labels = children.map((child) => child.label) + assert(labels.includes('bucket-1')) + assert(labels.includes('bucket-2')) + assert(labels.includes('bucket-3')) + }) + + it('should lazy load resources when not provided', async function () { + const lazyResourceList: ResourceList = { + typeName: 'AWS::DynamoDB::Table', + resourceIdentifiers: ['table-1'], + } + + mockResourcesManager.loadResourceType = async () => {} + mockResourcesManager.get = () => [lazyResourceList] + + const lazyNode = new ResourceTypeNode('AWS::DynamoDB::Table', mockResourcesManager) + const children = await lazyNode.getChildren() + + assert.strictEqual(children.length, 1) + assert.strictEqual(children[0].label, 'table-1') + }) + }) + + describe('empty resource list', function () { + it('should handle empty resource identifiers', async function () { + const emptyResourceList: ResourceList = { + typeName: 'AWS::Lambda::Function', + resourceIdentifiers: [], + } + + const emptyNode = new ResourceTypeNode('AWS::Lambda::Function', mockResourcesManager, emptyResourceList) + assert.strictEqual(emptyNode.description, '(0)') + + const children = await emptyNode.getChildren() + assert.strictEqual(children.length, 1) + assert.strictEqual(children[0].label, 'No resources found') + }) + }) + + describe('pagination', function () { + it('should show load more node when nextToken exists', async function () { + const paginatedList: ResourceList = { + typeName: 'AWS::EC2::Instance', + resourceIdentifiers: ['i-1', 'i-2'], + nextToken: 'token123', + } + + const paginatedNode = new ResourceTypeNode('AWS::EC2::Instance', mockResourcesManager, paginatedList) + assert.strictEqual(paginatedNode.description, '(2+)') + assert.strictEqual(paginatedNode.contextValue, 'resourceTypeWithMore') + + const children = await paginatedNode.getChildren() + assert.strictEqual(children.length, 3) + assert.strictEqual(children[2].label, '[Load More...]') + }) + + it('should not show load more node when no nextToken', async function () { + const children = await resourceTypeNode.getChildren() + assert.strictEqual(children.length, 3) + assert(!children.some((child) => child.label === '[Load More...]')) + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/explorer/nodes/resourcesNode.test.ts b/packages/core/src/test/awsService/cloudformation/explorer/nodes/resourcesNode.test.ts new file mode 100644 index 00000000000..5116a02cca7 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/explorer/nodes/resourcesNode.test.ts @@ -0,0 +1,63 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { TreeItemCollapsibleState } from 'vscode' +import { ResourcesNode } from '../../../../../awsService/cloudformation/explorer/nodes/resourcesNode' +import { ResourcesManager } from '../../../../../awsService/cloudformation/resources/resourcesManager' +import { ResourceList } from '../../../../../awsService/cloudformation/cfn/resourceRequestTypes' + +describe('ResourcesNode', function () { + let resourcesNode: ResourcesNode + let mockResourcesManager: ResourcesManager + + beforeEach(function () { + mockResourcesManager = {} as ResourcesManager + resourcesNode = new ResourcesNode(mockResourcesManager) + }) + + describe('constructor', function () { + it('should set correct properties', function () { + assert.strictEqual(resourcesNode.label, 'Resources') + assert.strictEqual(resourcesNode.contextValue, 'resourceSection') + assert.strictEqual(resourcesNode.collapsibleState, TreeItemCollapsibleState.Collapsed) + }) + }) + + describe('getChildren', function () { + it('should return ResourceTypeNode for each selected type', async function () { + mockResourcesManager.getSelectedResourceTypes = () => ['AWS::S3::Bucket', 'AWS::Lambda::Function'] + mockResourcesManager.get = () => [] + + const children = await resourcesNode.getChildren() + assert.strictEqual(children.length, 2) + assert.strictEqual(children[0].label, 'AWS::S3::Bucket') + assert.strictEqual(children[1].label, 'AWS::Lambda::Function') + }) + + it('should pass loaded resourceList when available', async function () { + const loadedResource: ResourceList = { + typeName: 'AWS::S3::Bucket', + resourceIdentifiers: ['bucket-1', 'bucket-2'], + } + + mockResourcesManager.getSelectedResourceTypes = () => ['AWS::S3::Bucket'] + mockResourcesManager.get = () => [loadedResource] + + const children = await resourcesNode.getChildren() + assert.strictEqual(children.length, 1) + assert.strictEqual(children[0].label, 'AWS::S3::Bucket') + assert.strictEqual(children[0].description, '(2)') + }) + + it('should return empty array when no types selected', async function () { + mockResourcesManager.getSelectedResourceTypes = () => [] + mockResourcesManager.get = () => [] + + const children = await resourcesNode.getChildren() + assert.strictEqual(children.length, 0) + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/explorer/regionManager.test.ts b/packages/core/src/test/awsService/cloudformation/explorer/regionManager.test.ts new file mode 100644 index 00000000000..73428afddb9 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/explorer/regionManager.test.ts @@ -0,0 +1,46 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import { CloudFormationRegionManager } from '../../../../awsService/cloudformation/explorer/regionManager' +import { RegionProvider } from '../../../../shared/regions/regionProvider' + +describe('CloudFormationRegionManager', function () { + let sandbox: sinon.SinonSandbox + let mockRegionProvider: RegionProvider + let regionManager: CloudFormationRegionManager + + beforeEach(function () { + sandbox = sinon.createSandbox() + mockRegionProvider = { + getRegions: () => [ + { id: 'us-east-1', name: 'US East (N. Virginia)' }, + { id: 'us-west-2', name: 'US West (Oregon)' }, + ], + } as any + regionManager = new CloudFormationRegionManager(mockRegionProvider) + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('getSelectedRegion', function () { + it('should return a region string', function () { + const region = regionManager.getSelectedRegion() + assert(typeof region === 'string') + }) + }) + + describe('updateSelectedRegion', function () { + it('should accept a region string', async function () { + const testRegion = 'us-east-1' + await regionManager.updateSelectedRegion(testRegion) + // Test passes if no error thrown + assert(true) + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/grammar.test.ts b/packages/core/src/test/awsService/cloudformation/grammar.test.ts new file mode 100644 index 00000000000..54906112866 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/grammar.test.ts @@ -0,0 +1,76 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { fs } from '../../../shared/fs/fs' +import * as path from 'path' + +describe('CloudFormation Grammar', function () { + let grammar: any + + before(async function () { + // Load grammar from toolkit syntaxes directory + const grammarPath = path.join(__dirname, '../../../../../../toolkit/syntaxes/cloudformation.tmLanguage.json') + const content = await fs.readFileText(grammarPath) + grammar = JSON.parse(content) + }) + + describe('Grammar Structure', function () { + it('should have correct basic structure', function () { + assert.strictEqual(grammar.name, 'CloudFormation') + assert.strictEqual(grammar.scopeName, 'source.cloudformation') + assert.ok(grammar.fileTypes.includes('template')) + assert.ok(grammar.fileTypes.includes('cfn')) + }) + + it('should include dual-format detection patterns', function () { + assert.strictEqual(grammar.patterns.length, 2) + + // JSON detection pattern + assert.strictEqual(grammar.patterns[0].begin, '^\\s*\\{') + assert.strictEqual(grammar.patterns[0].name, 'meta.cloudformation.json') + assert.strictEqual(grammar.patterns[0].patterns[0].include, 'source.json') + + // YAML detection pattern + assert.strictEqual(grammar.patterns[1].begin, '^(?!\\s*\\{)') + assert.strictEqual(grammar.patterns[1].name, 'meta.cloudformation.yaml') + }) + + it('should have repository with required patterns', function () { + const requiredPatterns = ['cfn-top-level-keys', 'cfn-logical-ids', 'cfn-functions'] + + for (const pattern of requiredPatterns) { + assert.ok(grammar.repository[pattern], `Pattern ${pattern} should be defined`) + } + }) + }) + + describe('CloudFormation-Specific Patterns', function () { + it('should match top-level CloudFormation sections', function () { + const pattern = grammar.repository['cfn-top-level-keys'].patterns[0] + assert.ok(pattern) + }) + + it('should have logical ID patterns for all major sections', function () { + const logicalIds = grammar.repository['cfn-logical-ids'] + assert.ok(logicalIds) + assert.ok(logicalIds.patterns) + + // Check that we have patterns for Resources, Parameters, Conditions, Outputs, and Mappings + const sectionNames: (string | undefined)[] = logicalIds.patterns.map((pattern: any) => { + const match = (pattern.begin as string).match(/\^\(([^)]+)\)/) + return match ? match[1] : undefined + }) + + const expectedSections = ['Resources', 'Parameters', 'Conditions', 'Outputs', 'Mappings'] + for (const section of expectedSections) { + assert.ok( + sectionNames.some((name) => name && name.includes(section)), + `Should have pattern for ${section} section` + ) + } + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/lsp-server/utils.test.ts b/packages/core/src/test/awsService/cloudformation/lsp-server/utils.test.ts new file mode 100644 index 00000000000..354bcc897e1 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/lsp-server/utils.test.ts @@ -0,0 +1,43 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { dedupeAndGetLatestVersions } from '../../../../awsService/cloudformation/lsp-server/utils' +import { LspVersion } from '../../../../shared/lsp/types' + +describe('dedupeAndGetLatestVersions', () => { + for (const prefix of ['v', '']) { + it(`handles versions with timestamp: ${prefix}`, () => { + const result = dedupeAndGetLatestVersions( + generateLspVersion(['0.0.1-2020', '0.0.2-2024', '0.0.3-2026', '0.0.2-2025', '0.0.3-2030'], prefix) + ) + + assert.strictEqual(result.length, 3) + assert.strictEqual(result[0].serverVersion, '0.0.3-2030') + assert.strictEqual(result[1].serverVersion, '0.0.2-2025') + assert.strictEqual(result[2].serverVersion, '0.0.1-2020') + }) + + it('handles versions with timestamp and environment', () => { + const result = dedupeAndGetLatestVersions( + generateLspVersion( + ['0.0.1-2020-alpha', '0.0.2-2024-beta', '0.0.3-2026-alpha', '0.0.2-2025-prod', '0.0.3-2030-beta'], + prefix + ) + ) + + assert.strictEqual(result.length, 3) + assert.strictEqual(result[0].serverVersion, '0.0.3-2030-beta') + assert.strictEqual(result[1].serverVersion, '0.0.2-2025-prod') + assert.strictEqual(result[2].serverVersion, '0.0.1-2020-alpha') + }) + } + + function generateLspVersion(versions: string[], prefix: string = ''): LspVersion[] { + return versions.map((version) => { + return { serverVersion: `${prefix}${version}`, targets: [], isDelisted: false } + }) + } +}) diff --git a/packages/core/src/test/awsService/cloudformation/relatedResources/relatedResourcesApi.test.ts b/packages/core/src/test/awsService/cloudformation/relatedResources/relatedResourcesApi.test.ts new file mode 100644 index 00000000000..31b4b3b926e --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/relatedResources/relatedResourcesApi.test.ts @@ -0,0 +1,137 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import { + getAuthoredResourceTypes, + getRelatedResourceTypes, + insertRelatedResources, +} from '../../../../awsService/cloudformation/relatedResources/relatedResourcesApi' +import { + GetAuthoredResourceTypesRequest, + GetRelatedResourceTypesRequest, + InsertRelatedResourcesRequest, +} from '../../../../awsService/cloudformation/relatedResources/relatedResourcesProtocol' + +describe('RelatedResourcesApi', function () { + let sandbox: sinon.SinonSandbox + let mockClient: any + + beforeEach(function () { + sandbox = sinon.createSandbox() + mockClient = { + sendRequest: sandbox.stub(), + } + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('getAuthoredResourceTypes', function () { + it('should send request with template URI and return resource types', async function () { + const templateUri = 'file:///test/template.yaml' + const expectedTypes = ['AWS::S3::Bucket', 'AWS::Lambda::Function'] + + mockClient.sendRequest.resolves(expectedTypes) + + const result = await getAuthoredResourceTypes(mockClient, templateUri) + + assert.deepStrictEqual(result, expectedTypes) + assert.ok(mockClient.sendRequest.calledOnce) + assert.ok(mockClient.sendRequest.calledWith(GetAuthoredResourceTypesRequest, templateUri)) + }) + + it('should return empty array when no resources found', async function () { + const templateUri = 'file:///test/empty.yaml' + + mockClient.sendRequest.resolves([]) + + const result = await getAuthoredResourceTypes(mockClient, templateUri) + + assert.deepStrictEqual(result, []) + }) + }) + + describe('getRelatedResourceTypes', function () { + it('should send request with resource type and return related types', async function () { + const params = { parentResourceType: 'AWS::S3::Bucket' } + const expectedTypes = ['AWS::Lambda::Function', 'AWS::IAM::Role'] + + mockClient.sendRequest.resolves(expectedTypes) + + const result = await getRelatedResourceTypes(mockClient, params) + + assert.deepStrictEqual(result, expectedTypes) + assert.ok(mockClient.sendRequest.calledOnce) + assert.ok(mockClient.sendRequest.calledWith(GetRelatedResourceTypesRequest, params)) + }) + + it('should return empty array when no related types found', async function () { + const params = { parentResourceType: 'AWS::Custom::Resource' } + + mockClient.sendRequest.resolves([]) + + const result = await getRelatedResourceTypes(mockClient, params) + + assert.deepStrictEqual(result, []) + }) + }) + + describe('insertRelatedResources', function () { + it('should send request and return code action', async function () { + const params = { + templateUri: 'file:///test/template.yaml', + relatedResourceTypes: ['AWS::Lambda::Function'], + parentResourceType: 'AWS::S3::Bucket', + } + const expectedAction = { + title: 'Insert 1 related resources', + kind: 'refactor', + edit: { + changes: { + 'file:///test/template.yaml': [], + }, + }, + data: { + scrollToPosition: { line: 5, character: 0 }, + firstLogicalId: 'LambdaFunctionRelatedToS3Bucket', + }, + } + + mockClient.sendRequest.resolves(expectedAction) + + const result = await insertRelatedResources(mockClient, params) + + assert.deepStrictEqual(result, expectedAction) + assert.ok(mockClient.sendRequest.calledOnce) + assert.ok(mockClient.sendRequest.calledWith(InsertRelatedResourcesRequest, params)) + }) + + it('should handle multiple resource types', async function () { + const params = { + templateUri: 'file:///test/template.yaml', + relatedResourceTypes: ['AWS::Lambda::Function', 'AWS::IAM::Role'], + parentResourceType: 'AWS::S3::Bucket', + } + const expectedAction = { + title: 'Insert 2 related resources', + kind: 'refactor', + edit: { + changes: { + 'file:///test/template.yaml': [], + }, + }, + } + + mockClient.sendRequest.resolves(expectedAction) + + const result = await insertRelatedResources(mockClient, params) + + assert.strictEqual(result.title, 'Insert 2 related resources') + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/resources/resourcesManager.test.ts b/packages/core/src/test/awsService/cloudformation/resources/resourcesManager.test.ts new file mode 100644 index 00000000000..32aacc98e38 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/resources/resourcesManager.test.ts @@ -0,0 +1,279 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import { ResourcesManager } from '../../../../awsService/cloudformation/resources/resourcesManager' +import { ResourceSelector } from '../../../../awsService/cloudformation/ui/resourceSelector' +import { ResourceStateResult } from '../../../../awsService/cloudformation/cfn/resourceRequestTypes' +import { Range, SnippetString, TextEditor, window } from 'vscode' +import { getLogger } from '../../../../shared/logger' +import globals from '../../../../shared/extensionGlobals' + +describe('ResourcesManager - applyCompletionSnippet', () => { + let sandbox: sinon.SinonSandbox + let mockClient: any + let mockResourceSelector: ResourceSelector + let resourcesManager: ResourcesManager + let mockEditor: Partial + let windowStub: sinon.SinonStub + const baseResourceStateResult = { + successfulImports: new Map(), + failedImports: new Map(), + } + + const createResult = (overrides?: Partial): ResourceStateResult => ({ + ...baseResourceStateResult, + ...overrides, + }) + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockClient = { + sendRequest: sandbox.stub(), + } + mockResourceSelector = {} as ResourceSelector + + mockEditor = { + insertSnippet: sandbox.stub().resolves(true), + edit: sandbox.stub().resolves(true), + document: { + lineCount: 100, + lineAt: sandbox.stub().returns({ range: { end: { line: 99, character: 0 } } }), + } as any, + } + + windowStub = sandbox.stub(window, 'activeTextEditor').get(() => mockEditor) + + sandbox.stub(getLogger(), 'warn') + sandbox.stub(getLogger(), 'info') + sandbox.stub(getLogger(), 'error') + + resourcesManager = new ResourcesManager(mockClient, mockResourceSelector) + }) + + afterEach(() => { + sandbox.restore() + }) + + it('should insert snippet at server-provided position', async () => { + const result = createResult({ + completionItem: { + label: 'Import Resource', + textEdit: { + range: { + start: { line: 5, character: 10 }, + end: { line: 5, character: 10 }, + }, + newText: ' "MyBucket": {\n "Type": "AWS::S3::Bucket"\n }', + }, + }, + }) + + await (resourcesManager as any).applyCompletionSnippet(result) + + assert.ok((mockEditor.insertSnippet as sinon.SinonStub).calledOnce) + const [snippetArg, rangeArg] = (mockEditor.insertSnippet as sinon.SinonStub).firstCall.args + + assert.ok(snippetArg instanceof SnippetString) + assert.strictEqual(snippetArg.value, result.completionItem!.textEdit!.newText) + + assert.ok(rangeArg instanceof Range) + assert.strictEqual(rangeArg.start.line, 5) + assert.strictEqual(rangeArg.start.character, 10) + assert.strictEqual(rangeArg.end.line, 5) + assert.strictEqual(rangeArg.end.character, 10) + }) + + it('should handle snippet with tabstops', async () => { + const result = createResult({ + completionItem: { + label: 'Clone Resource', + textEdit: { + range: { + start: { line: 10, character: 0 }, + end: { line: 10, character: 0 }, + }, + newText: '"BucketName": "${1:enter new identifier for MyBucket}"', + }, + }, + }) + + await (resourcesManager as any).applyCompletionSnippet(result) + + assert.ok((mockEditor.insertSnippet as sinon.SinonStub).calledOnce) + const [snippetArg] = (mockEditor.insertSnippet as sinon.SinonStub).firstCall.args + assert.strictEqual(snippetArg.value, result.completionItem!.textEdit!.newText) + }) + + it('should not insert when completionItem is missing', async () => { + const result = createResult() + + await (resourcesManager as any).applyCompletionSnippet(result) + + assert.ok((mockEditor.insertSnippet as sinon.SinonStub).notCalled) + }) + + it('should not insert when textEdit is missing', async () => { + const result = createResult({ + completionItem: { + label: 'Test', + }, + }) + + await (resourcesManager as any).applyCompletionSnippet(result) + + assert.ok((mockEditor.insertSnippet as sinon.SinonStub).notCalled) + }) + + it('should not insert when no active editor', async () => { + windowStub.get(() => undefined) + + const result = createResult({ + completionItem: { + label: 'Test', + textEdit: { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: 'test', + }, + }, + }) + + await (resourcesManager as any).applyCompletionSnippet(result) + + assert.ok((mockEditor.insertSnippet as sinon.SinonStub).notCalled) + }) + + it('should handle different range positions', async () => { + const result = createResult({ + completionItem: { + label: 'Test', + textEdit: { + range: { + start: { line: 100, character: 50 }, + end: { line: 105, character: 20 }, + }, + newText: 'replacement text', + }, + }, + }) + + await (resourcesManager as any).applyCompletionSnippet(result) + + const [, rangeArg] = (mockEditor.insertSnippet as sinon.SinonStub).firstCall.args + assert.strictEqual(rangeArg.start.line, 100) + assert.strictEqual(rangeArg.start.character, 50) + assert.strictEqual(rangeArg.end.line, 105) + assert.strictEqual(rangeArg.end.character, 20) + }) + + it('should handle multi-line snippet text', async () => { + const multiLineText = `"MyResource": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "\${1:enter new identifier}" + } +}` + + const result = createResult({ + completionItem: { + label: 'Test', + textEdit: { + range: { + start: { line: 20, character: 4 }, + end: { line: 20, character: 4 }, + }, + newText: multiLineText, + }, + }, + }) + + await (resourcesManager as any).applyCompletionSnippet(result) + + assert.ok((mockEditor.insertSnippet as sinon.SinonStub).calledOnce) + const [snippetArg] = (mockEditor.insertSnippet as sinon.SinonStub).firstCall.args + assert.strictEqual(snippetArg.value, multiLineText) + }) + + it('should add newlines when target line does not exist', async () => { + const mockDocument = { + lineCount: 10, + lineAt: sinon.stub().returns({ range: { end: { line: 9, character: 20 } } }), + } + ;(mockEditor as any).document = mockDocument + ;(mockEditor as any).edit = sinon.stub().resolves(true) + + const result = createResult({ + completionItem: { + label: 'Test', + textEdit: { + range: { + start: { line: 15, character: 0 }, + end: { line: 15, character: 0 }, + }, + newText: 'test content', + }, + }, + }) + + await (resourcesManager as any).applyCompletionSnippet(result) + + // Should call edit to add newlines + assert.ok(((mockEditor as any).edit as sinon.SinonStub).calledOnce) + + // Should still call insertSnippet + assert.ok((mockEditor.insertSnippet as sinon.SinonStub).calledOnce) + }) +}) + +describe('ResourcesManager - removeResourceType', () => { + let sandbox: sinon.SinonSandbox + let mockClient: any + let mockResourceSelector: ResourceSelector + let resourcesManager: ResourcesManager + let globalStateStub: sinon.SinonStub + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockClient = { sendRequest: sandbox.stub() } + mockResourceSelector = {} as ResourceSelector + globalStateStub = sandbox.stub(globals.globalState, 'update').resolves() + sandbox.stub(globals.globalState, 'tryGet').returns(['AWS::S3::Bucket', 'AWS::Lambda::Function']) + resourcesManager = new ResourcesManager(mockClient, mockResourceSelector) + }) + + afterEach(() => { + sandbox.restore() + }) + + it('should remove resource type from selected types', async () => { + await resourcesManager.removeResourceType('AWS::S3::Bucket') + + assert.ok(globalStateStub.calledOnce) + const [key, updatedTypes] = globalStateStub.firstCall.args + assert.strictEqual(key, 'aws.cloudformation.selectedResourceTypes') + assert.deepStrictEqual(updatedTypes, ['AWS::Lambda::Function']) + }) + + it('should notify listeners after removing resource type', async () => { + const listener = sandbox.stub() + resourcesManager.addListener(listener) + + await resourcesManager.removeResourceType('AWS::Lambda::Function') + + assert.ok(listener.calledOnce) + }) + + it('should handle removing non-existent resource type', async () => { + await resourcesManager.removeResourceType('AWS::DynamoDB::Table') + + assert.ok(globalStateStub.calledOnce) + const [, updatedTypes] = globalStateStub.firstCall.args + assert.deepStrictEqual(updatedTypes, ['AWS::S3::Bucket', 'AWS::Lambda::Function']) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/resources/sample-template.yaml b/packages/core/src/test/awsService/cloudformation/resources/sample-template.yaml new file mode 100644 index 00000000000..e354cc7a3a9 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/resources/sample-template.yaml @@ -0,0 +1,54 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: 'Sample CloudFormation template for testing' + +Parameters: + InstanceType: + Type: String + Default: t2.micro + Description: EC2 instance type + +Resources: + MyInstance: + Type: AWS::EC2::Instance + Properties: + ImageId: ami-12345678 + InstanceType: !Ref InstanceType + SecurityGroups: + - !Ref MySecurityGroup + UserData: + Fn::Base64: !Sub | + #!/bin/bash + echo "Hello World" + + MySecurityGroup: + Type: 'AWS::EC2::SecurityGroup' + Properties: + GroupDescription: Security group for testing + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 80 + ToPort: 80 + CidrIp: 0.0.0.0/0 + + MyBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Sub '${AWS::StackName}-test-bucket' + VersioningConfiguration: + Status: Enabled + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + +Outputs: + InstanceId: + Description: Instance ID + Value: !Ref MyInstance + Export: + Name: !Sub '${AWS::StackName}-InstanceId' + + BucketName: + Description: S3 Bucket Name + Value: !Ref MyBucket diff --git a/packages/core/src/test/awsService/cloudformation/resources/template-with-json.yaml b/packages/core/src/test/awsService/cloudformation/resources/template-with-json.yaml new file mode 100644 index 00000000000..92975737ad0 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/resources/template-with-json.yaml @@ -0,0 +1,77 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: 'CloudFormation template with embedded JSON' + +Resources: + MyRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: | + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + } + Policies: + - PolicyName: MyPolicy + PolicyDocument: > + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": "*" + } + ] + } + + MyBucket: + Type: AWS::S3::Bucket + Properties: + NotificationConfiguration: + CloudWatchConfigurations: + - Event: s3:ObjectCreated:* + CloudWatchConfiguration: + LogGroupName: !Ref MyLogGroup + FilterPattern: | + { + "eventSource": ["aws:s3"], + "eventName": { + "prefix": "ObjectCreated" + } + } + + MyFunction: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + import json + def lambda_handler(event, context): + response = { + "statusCode": 200, + "body": json.dumps({ + "message": "Hello from Lambda!", + "event": event + }) + } + return response + Environment: + Variables: + CONFIG: '{"debug": true, "timeout": 30}' + + MyLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: /aws/lambda/my-function diff --git a/packages/core/src/test/awsService/cloudformation/stacks/actions/deployment.test.ts b/packages/core/src/test/awsService/cloudformation/stacks/actions/deployment.test.ts new file mode 100644 index 00000000000..0c42dda8896 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/stacks/actions/deployment.test.ts @@ -0,0 +1,26 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' + +describe('Deployment', function () { + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('deployment process', function () { + it('should handle deployment correctly', function () { + // Basic test structure - implementation depends on actual Deployment module + assert.ok(true, 'Deployment test placeholder') + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/stacks/actions/validation.test.ts b/packages/core/src/test/awsService/cloudformation/stacks/actions/validation.test.ts new file mode 100644 index 00000000000..ab20d6c8129 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/stacks/actions/validation.test.ts @@ -0,0 +1,36 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import { + getLastValidation, + setLastValidation, +} from '../../../../../awsService/cloudformation/stacks/actions/validationWorkflow' + +describe('Validation', function () { + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('last validation tracking', function () { + it('should get and set last validation', function () { + assert.strictEqual(getLastValidation(), undefined) + + const validation: any = { templateUri: 'test.yaml', stackName: 'test-stack' } + setLastValidation(validation) + assert.strictEqual(getLastValidation(), validation) + + setLastValidation(undefined) + assert.strictEqual(getLastValidation(), undefined) + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/stacks/actions/validationEnhanced.test.ts b/packages/core/src/test/awsService/cloudformation/stacks/actions/validationEnhanced.test.ts new file mode 100644 index 00000000000..b059ffa7603 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/stacks/actions/validationEnhanced.test.ts @@ -0,0 +1,26 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' + +describe('ValidationEnhanced', function () { + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('enhanced validation', function () { + it('should perform enhanced validation correctly', function () { + // Basic test structure - implementation depends on actual ValidationEnhanced module + assert.ok(true, 'ValidationEnhanced test placeholder') + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/stacks/stacksManager.test.ts b/packages/core/src/test/awsService/cloudformation/stacks/stacksManager.test.ts new file mode 100644 index 00000000000..50fea4913b8 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/stacks/stacksManager.test.ts @@ -0,0 +1,77 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import { StacksManager } from '../../../../awsService/cloudformation/stacks/stacksManager' + +describe('StacksManager', () => { + let sandbox: sinon.SinonSandbox + let manager: StacksManager + let mockClient: any + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockClient = { + sendRequest: sandbox.stub().resolves({ + stacks: [ + { StackName: 'stack-1', StackStatus: 'CREATE_COMPLETE' }, + { StackName: 'stack-2', StackStatus: 'UPDATE_IN_PROGRESS' }, + ], + nextToken: undefined, + }), + } + manager = new StacksManager(mockClient) + }) + + afterEach(() => { + manager.dispose() + sandbox.restore() + }) + + describe('updateStackStatus', () => { + beforeEach(async () => { + await new Promise((resolve) => { + manager.addListener(() => resolve()) + manager.reload() + }) + }) + + it('should update status of existing stack', () => { + manager.updateStackStatus('stack-1', 'UPDATE_COMPLETE') + + const stacks = manager.get() + const updatedStack = stacks.find((s) => s.StackName === 'stack-1') + assert.strictEqual(updatedStack?.StackStatus, 'UPDATE_COMPLETE') + }) + + it('should not affect other stacks', () => { + manager.updateStackStatus('stack-1', 'UPDATE_COMPLETE') + + const stacks = manager.get() + const otherStack = stacks.find((s) => s.StackName === 'stack-2') + assert.strictEqual(otherStack?.StackStatus, 'UPDATE_IN_PROGRESS') + }) + + it('should notify listeners when status updated', () => { + let listenerCalled = false + manager.addListener(() => { + listenerCalled = true + }) + + manager.updateStackStatus('stack-1', 'UPDATE_COMPLETE') + + assert.strictEqual(listenerCalled, true) + }) + + it('should do nothing if stack not found', () => { + const stacksBefore = manager.get() + manager.updateStackStatus('non-existent-stack', 'CREATE_COMPLETE') + const stacksAfter = manager.get() + + assert.deepStrictEqual(stacksBefore, stacksAfter) + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/ui/diffViewHelper.test.ts b/packages/core/src/test/awsService/cloudformation/ui/diffViewHelper.test.ts new file mode 100644 index 00000000000..0fa0da96df3 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/ui/diffViewHelper.test.ts @@ -0,0 +1,578 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import * as vscode from 'vscode' +import * as path from 'path' +import * as os from 'os' +import { DiffViewHelper } from '../../../../awsService/cloudformation/ui/diffViewHelper' +import { StackChange } from '../../../../awsService/cloudformation/stacks/actions/stackActionRequestType' +import { fs } from '../../../../shared/fs/fs' + +describe('DiffViewHelper', function () { + let sandbox: sinon.SinonSandbox + let writeFileStub: sinon.SinonStub + let executeCommandStub: sinon.SinonStub + let openTextDocumentStub: sinon.SinonStub + + beforeEach(function () { + sandbox = sinon.createSandbox() + writeFileStub = sandbox.stub(fs, 'writeFile') + executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand') + openTextDocumentStub = sandbox.stub(vscode.workspace, 'openTextDocument') + }) + + afterEach(function () { + sandbox.restore() + }) + + async function testDiffGeneration(stackName: string, changes: StackChange[]) { + await DiffViewHelper.openDiff(stackName, changes) + + const tmpDir = os.tmpdir() + const beforePath = path.join(tmpDir, `${stackName}-before.json`) + const afterPath = path.join(tmpDir, `${stackName}-after.json`) + + return { beforePath, afterPath } + } + + function assertFileCallsAndParseData() { + assert.ok(writeFileStub.calledTwice) + const beforeCall = writeFileStub.getCall(0) + const afterCall = writeFileStub.getCall(1) + + const beforeData = JSON.parse(beforeCall.args[1]) + const afterData = JSON.parse(afterCall.args[1]) + + return { beforeData, afterData } + } + + describe('openDiff', function () { + it('should create diff files and open diff view for Add action', async function () { + const stackName = 'test-stack' + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Add', + logicalResourceId: 'TestResource', + afterContext: '{"Type": "AWS::S3::Bucket", "Properties": {"BucketName": "new-bucket"}}', + }, + }, + ] + + const { beforePath, afterPath } = await testDiffGeneration(stackName, changes) + + assert.ok(writeFileStub.calledTwice) + assert.ok(writeFileStub.calledWith(beforePath, '{}')) + assert.ok(writeFileStub.calledWith(afterPath, sinon.match.string)) + assert.ok( + executeCommandStub.calledWith( + 'vscode.diff', + sinon.match.any, + sinon.match.any, + `${stackName}: Before ↔ After` + ) + ) + }) + + it('should create diff files and open diff view for Remove action', async function () { + const stackName = 'test-stack' + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Remove', + logicalResourceId: 'TestResource', + beforeContext: '{"Type": "AWS::S3::Bucket", "Properties": {"BucketName": "old-bucket"}}', + }, + }, + ] + + const { beforePath, afterPath } = await testDiffGeneration(stackName, changes) + + assert.ok(writeFileStub.calledTwice) + assert.ok(writeFileStub.calledWith(beforePath, sinon.match.string)) + assert.ok(writeFileStub.calledWith(afterPath, '{}')) + assert.ok( + executeCommandStub.calledWith( + 'vscode.diff', + sinon.match.any, + sinon.match.any, + `${stackName}: Before ↔ After` + ) + ) + }) + + it('should create diff files for Modify action with beforeContext and afterContext', async function () { + const stackName = 'test-stack' + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Modify', + logicalResourceId: 'TestResource', + beforeContext: '{"Type": "AWS::S3::Bucket", "Properties": {"BucketName": "old-bucket"}}', + afterContext: '{"Type": "AWS::S3::Bucket", "Properties": {"BucketName": "new-bucket"}}', + }, + }, + ] + + await testDiffGeneration(stackName, changes) + + const { beforeData, afterData } = assertFileCallsAndParseData() + + assert.ok(beforeData.TestResource) + assert.ok(afterData.TestResource) + }) + + it('should handle Modify action with details when no context provided', async function () { + const stackName = 'test-stack' + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Modify', + logicalResourceId: 'ModifiedResource', + details: [ + { + Target: { + Name: 'BucketName', + BeforeValue: 'old-bucket', + AfterValue: 'new-bucket', + }, + }, + ], + }, + }, + ] + + await testDiffGeneration(stackName, changes) + + const { beforeData, afterData } = assertFileCallsAndParseData() + + assert.strictEqual(beforeData.ModifiedResource.BucketName, 'old-bucket') + assert.strictEqual(afterData.ModifiedResource.BucketName, 'new-bucket') + }) + + it('should handle invalid JSON in context gracefully', async function () { + const stackName = 'test-stack' + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Modify', + logicalResourceId: 'InvalidResource', + beforeContext: 'invalid-json', + afterContext: 'also-invalid-json', + }, + }, + ] + + await DiffViewHelper.openDiff(stackName, changes) + + const { beforeData, afterData } = assertFileCallsAndParseData() + + assert.deepStrictEqual(beforeData.InvalidResource, {}) + assert.deepStrictEqual(afterData.InvalidResource, {}) + }) + + it('should skip changes without logicalResourceId', async function () { + const stackName = 'test-stack' + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Add', + // Missing logicalResourceId + }, + }, + ] + + await DiffViewHelper.openDiff(stackName, changes) + + assert.ok(writeFileStub.calledTwice) + assert.ok(writeFileStub.calledWith(sinon.match.any, '{}')) + }) + + it('should open diff with selection when resourceId is provided', async function () { + const stackName = 'test-stack' + const resourceId = 'TargetResource' + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Add', + logicalResourceId: resourceId, + afterContext: '{"Type": "AWS::S3::Bucket"}', + }, + }, + ] + + const mockDocument = { + getText: () => `{\n "${resourceId}": {\n "Type": "AWS::S3::Bucket"\n }\n}`, + } + openTextDocumentStub.resolves(mockDocument) + + await DiffViewHelper.openDiff(stackName, changes, resourceId) + + assert.ok(executeCommandStub.calledTwice) + const secondCall = executeCommandStub.getCall(1) + assert.ok(secondCall.args[4]?.selection) + }) + + it('should handle resourceId not found in document', async function () { + const stackName = 'test-stack' + const resourceId = 'NonExistentResource' + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Add', + logicalResourceId: 'DifferentResource', + afterContext: '{"Type": "AWS::S3::Bucket"}', + }, + }, + ] + + const mockDocument = { + getText: () => '{\n "DifferentResource": {\n "Type": "AWS::S3::Bucket"\n }\n}', + } + openTextDocumentStub.resolves(mockDocument) + + await DiffViewHelper.openDiff(stackName, changes, resourceId) + + // Should only call diff once (without selection) + assert.ok(executeCommandStub.calledOnce) + }) + + it('should handle details with missing BeforeValue/AfterValue', async function () { + const stackName = 'test-stack' + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Modify', + logicalResourceId: 'ModifiedResource', + details: [ + { + Target: { + Name: 'Property1', + // Missing BeforeValue and AfterValue + }, + }, + ], + }, + }, + ] + + await testDiffGeneration(stackName, changes) + + const { beforeData, afterData } = assertFileCallsAndParseData() + + assert.strictEqual(beforeData.ModifiedResource.Property1, '') + assert.strictEqual(afterData.ModifiedResource.Property1, '') + }) + + it('should handle empty changes array', async function () { + const stackName = 'test-stack' + const changes: StackChange[] = [] + + await DiffViewHelper.openDiff(stackName, changes) + + assert.ok(writeFileStub.calledTwice) + assert.ok(writeFileStub.calledWith(sinon.match.any, '{}')) + assert.ok(executeCommandStub.calledOnce) + }) + }) + + describe('drift decorations', function () { + let createTextEditorDecorationTypeStub: sinon.SinonStub + let setDecorationsStub: sinon.SinonStub + let clock: sinon.SinonFakeTimers + + beforeEach(function () { + createTextEditorDecorationTypeStub = sandbox.stub(vscode.window, 'createTextEditorDecorationType') + setDecorationsStub = sandbox.stub() + clock = sandbox.useFakeTimers() + }) + + function setupMockEditor(stackName: string, documentText: string) { + const tmpDir = os.tmpdir() + const beforePath = path.join(tmpDir, `${stackName}-before.json`) + const beforeUri = vscode.Uri.file(beforePath).toString() + + const mockEditor = { + document: { + uri: { toString: () => beforeUri }, + getText: () => documentText, + }, + setDecorations: setDecorationsStub, + } + + sandbox.stub(vscode.window, 'visibleTextEditors').get(() => [mockEditor]) + } + + async function runDriftTest(stackName: string, changes: StackChange[]) { + await DiffViewHelper.openDiff(stackName, changes) + clock.tick(500) + } + + function assertDecorationCount(expectedCount: number) { + assert.ok(setDecorationsStub.called) + const decorations = setDecorationsStub.getCall(0).args[1] + assert.strictEqual(decorations.length, expectedCount) + return decorations + } + + function createDriftChange( + logicalResourceId: string, + beforeContext: string, + afterContext: string, + details: any[] + ): StackChange { + return { + resourceChange: { + action: 'Modify', + logicalResourceId, + beforeContext, + afterContext, + details, + }, + } + } + + function createDetailTarget( + name: string, + path: string, + beforeValue: string, + afterValue: string, + drift?: { PreviousValue: string; ActualValue: string } + ) { + return { + Target: { + Name: name, + Path: path, + BeforeValue: beforeValue, + AfterValue: afterValue, + ...(drift && { LiveResourceDrift: drift }), + }, + } + } + + it('should add drift decoration when LiveResourceDrift is present', async function () { + const stackName = 'test-stack' + const changes: StackChange[] = [ + createDriftChange( + 'MyQueue', + '{"Properties":{"DelaySeconds":"5"}}', + '{"Properties":{"DelaySeconds":"1"}}', + [ + createDetailTarget('DelaySeconds', '/Properties/DelaySeconds', '5', '1', { + PreviousValue: '1', + ActualValue: '5', + }), + ] + ), + ] + + setupMockEditor( + stackName, + '{\n "MyQueue": {\n "Properties": {\n "DelaySeconds": "5"\n }\n }\n}' + ) + await runDriftTest(stackName, changes) + + assert.ok(createTextEditorDecorationTypeStub.called) + const decorations = assertDecorationCount(1) + assert.ok(decorations[0].hoverMessage.includes('Resource Drift Detected')) + assert.ok(decorations[0].hoverMessage.includes('MyQueue')) + }) + + it('should not add decoration when LiveResourceDrift is not present', async function () { + const stackName = 'test-stack' + const changes: StackChange[] = [ + createDriftChange( + 'MyQueue', + '{"Properties":{"DelaySeconds":"5"}}', + '{"Properties":{"DelaySeconds":"1"}}', + [createDetailTarget('DelaySeconds', '/Properties/DelaySeconds', '5', '1')] + ), + ] + + setupMockEditor( + stackName, + '{\n "MyQueue": {\n "Properties": {\n "DelaySeconds": "5"\n }\n }\n}' + ) + await runDriftTest(stackName, changes) + + assertDecorationCount(0) + }) + + it('should handle nested property paths correctly', async function () { + const stackName = 'test-stack' + const changes: StackChange[] = [ + createDriftChange( + 'MyResource', + '{"Properties":{"Config":{"Setting":"old"}}}', + '{"Properties":{"Config":{"Setting":"new"}}}', + [ + createDetailTarget('Setting', '/Properties/Config/Setting', 'old', 'new', { + PreviousValue: 'new', + ActualValue: 'old', + }), + ] + ), + ] + + setupMockEditor( + stackName, + '{\n "MyResource": {\n "Properties": {\n "Config": {\n "Setting": "old"\n }\n }\n }\n}' + ) + await runDriftTest(stackName, changes) + + const decorations = assertDecorationCount(1) + assert.ok(decorations[0].hoverMessage.includes('/Properties/Config/Setting')) + }) + + it('should handle multiple drift decorations for different properties', async function () { + const stackName = 'test-stack' + const changes: StackChange[] = [ + createDriftChange( + 'MyQueue', + '{"Properties":{"DelaySeconds":"5","MessageRetentionPeriod":"100"}}', + '{"Properties":{"DelaySeconds":"1","MessageRetentionPeriod":"200"}}', + [ + createDetailTarget('DelaySeconds', '/Properties/DelaySeconds', '5', '1', { + PreviousValue: '1', + ActualValue: '5', + }), + createDetailTarget( + 'MessageRetentionPeriod', + '/Properties/MessageRetentionPeriod', + '100', + '200', + { PreviousValue: '100', ActualValue: '150' } + ), + ] + ), + ] + + setupMockEditor( + stackName, + '{\n "MyQueue": {\n "Properties": {\n "DelaySeconds": "5",\n "MessageRetentionPeriod": "100"\n }\n }\n}' + ) + await runDriftTest(stackName, changes) + + assertDecorationCount(2) + }) + + it('should add drift decoration for DELETED resources', async function () { + const stackName = 'test-stack' + const changes: StackChange[] = [ + { + resourceChange: { + logicalResourceId: 'DeletedResource', + resourceDriftStatus: 'DELETED', + }, + }, + ] + + setupMockEditor(stackName, '{\n "DeletedResource": {}\n}') + await runDriftTest(stackName, changes) + + const decorations = assertDecorationCount(1) + assert.ok(decorations[0].hoverMessage.includes('Resource Deleted')) + assert.ok(decorations[0].hoverMessage.includes('deleted sometime after the previous deployment')) + }) + + it('should handle array indices in property paths', async function () { + const stackName = 'test-stack' + const changes: StackChange[] = [ + createDriftChange( + 'MyRole', + '{"Properties":{"Policies":[{"PolicyDocument":"old"}]}}', + '{"Properties":{"Policies":[{"PolicyDocument":"new"}]}}', + [ + createDetailTarget('PolicyDocument', '/Properties/Policies/0/PolicyDocument', 'old', 'new', { + PreviousValue: 'old', + ActualValue: 'drifted', + }), + ] + ), + ] + + setupMockEditor( + stackName, + '{\n "MyRole": {\n "Properties": {\n "Policies": [\n {\n "PolicyDocument": "old"\n }\n ]\n }\n }\n}' + ) + await runDriftTest(stackName, changes) + + const decorations = assertDecorationCount(1) + assert.ok(decorations[0].hoverMessage.includes('/Properties/Policies/0/PolicyDocument')) + }) + + it('should not add decoration when property is not in afterContext', async function () { + const stackName = 'test-stack' + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Modify', + logicalResourceId: 'MyQueue', + beforeContext: '{"Properties":{"DelaySeconds":"5","MessageRetentionPeriod":"100"}}', + afterContext: '{"Properties":{"MessageRetentionPeriod":"200"}}', + details: [ + { + Target: { + Name: 'DelaySeconds', + Path: '/Properties/DelaySeconds', + BeforeValue: '5', + AfterValue: '1', + Drift: { + PreviousValue: '1', + ActualValue: '5', + }, + }, + }, + ], + }, + }, + ] + + setupMockEditor( + stackName, + '{\n "MyQueue": {\n "Properties": {\n "DelaySeconds": "5",\n "MessageRetentionPeriod": "100"\n }\n }\n}' + ) + await runDriftTest(stackName, changes) + + assertDecorationCount(0) + }) + + it('should not add decoration when ActualValue is undefined', async function () { + const stackName = 'test-stack' + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Modify', + logicalResourceId: 'MyQueue', + beforeContext: '{"Properties":{"DelaySeconds":"5"}}', + afterContext: '{"Properties":{"DelaySeconds":"1"}}', + details: [ + { + Target: { + Name: 'DelaySeconds', + Path: '/Properties/DelaySeconds', + AfterValue: '1', + Drift: { + PreviousValue: '1', + }, + }, + }, + ], + }, + }, + ] + + setupMockEditor( + stackName, + '{\n "MyQueue": {\n "Properties": {\n "DelaySeconds": "5"\n }\n }\n}' + ) + await runDriftTest(stackName, changes) + + assertDecorationCount(0) + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/ui/diffWebviewProvider.test.ts b/packages/core/src/test/awsService/cloudformation/ui/diffWebviewProvider.test.ts new file mode 100644 index 00000000000..6166d6ac51f --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/ui/diffWebviewProvider.test.ts @@ -0,0 +1,292 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import { DiffWebviewProvider } from '../../../../awsService/cloudformation/ui/diffWebviewProvider' +import { StackChange } from '../../../../awsService/cloudformation/stacks/actions/stackActionRequestType' + +describe('DiffWebviewProvider', function () { + let sandbox: sinon.SinonSandbox + let provider: DiffWebviewProvider + + beforeEach(function () { + sandbox = sinon.createSandbox() + const mockCoordinator = { + onDidChangeStack: sandbox.stub().returns({ dispose: () => {} }), + setChangeSetMode: sandbox.stub().resolves(), + } as any + provider = new DiffWebviewProvider(mockCoordinator) + }) + + afterEach(function () { + sandbox.restore() + }) + + function createMockWebview() { + return { + webview: { + options: {}, + html: '', + onDidReceiveMessage: sandbox.stub(), + }, + } + } + + function setupProviderWithChanges(stackName: string, changes: StackChange[]) { + void provider.updateData(stackName, changes) + const mockWebview = createMockWebview() + provider.resolveWebviewView(mockWebview as any) + return mockWebview.webview.html + } + + describe('updateData', function () { + it('should update stack name and changes', function () { + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Add', + logicalResourceId: 'TestResource', + resourceType: 'AWS::S3::Bucket', + }, + }, + ] + + const html = setupProviderWithChanges('test-stack', changes) + + // The HTML should contain the resource information (stack name doesn't appear in table HTML) + assert.ok(html.includes('TestResource')) + assert.ok(html.includes('Add')) + assert.ok(html.includes('AWS::S3::Bucket')) + // Verify it's not the "No changes detected" message + assert.ok(!html.includes('No changes detected')) + }) + + it('should handle empty changes array', function () { + const html = setupProviderWithChanges('empty-stack', []) + assert.ok(html.includes('No changes detected')) + assert.ok(html.includes('empty-stack')) + }) + }) + + describe('resolveWebviewView', function () { + it('should configure webview options and set HTML content', function () { + const mockWebview = createMockWebview() + + void provider.updateData('test-stack', []) + provider.resolveWebviewView(mockWebview as any) + + assert.deepStrictEqual(mockWebview.webview.options, { enableScripts: true }) + assert.ok(mockWebview.webview.html.length > 0) + assert.ok(mockWebview.webview.onDidReceiveMessage.calledOnce) + }) + }) + + describe('HTML generation', function () { + it('should generate table with correct columns for changes with details', function () { + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Modify', + logicalResourceId: 'TestBucket', + physicalResourceId: 'test-bucket-123', + resourceType: 'AWS::S3::Bucket', + replacement: 'False', + scope: ['Properties'], + details: [ + { + Target: { + Name: 'BucketName', + RequiresRecreation: 'Never', + BeforeValue: 'old-bucket', + AfterValue: 'new-bucket', + AttributeChangeType: 'Modify', + }, + ChangeSource: 'DirectModification', + CausingEntity: 'user-change', + }, + ], + }, + }, + ] + + const html = setupProviderWithChanges('test-stack', changes) + + // Verify main table headers + assert.ok(html.includes('Action')) + assert.ok(html.includes('LogicalResourceId')) + assert.ok(html.includes('ResourceType')) + assert.ok(html.includes('Replacement')) + + // Verify main row data + assert.ok(html.includes('Modify')) + assert.ok(html.includes('TestBucket')) + assert.ok(html.includes('test-bucket-123')) + assert.ok(html.includes('AWS::S3::Bucket')) + + // Verify detail data (in expandable section) + assert.ok(html.includes('BucketName')) + assert.ok(html.includes('old-bucket')) + assert.ok(html.includes('new-bucket')) + assert.ok(html.includes('DirectModification')) + assert.ok(html.includes('user-change')) + + // Verify expandable structure + assert.ok(html.includes('toggleDetails')) + assert.ok(html.includes('display: none')) + assert.ok(html.includes('▶')) + }) + + it('should handle multiple detail rows with proper expandable structure', function () { + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Modify', + logicalResourceId: 'TestResource', + details: [ + { + Target: { Name: 'Property1' }, + ChangeSource: 'DirectModification', + }, + { + Target: { Name: 'Property2' }, + ChangeSource: 'ParameterReference', + }, + ], + }, + }, + ] + + void provider.updateData('test-stack', changes) + + const mockWebview = { + webview: { + options: {}, + html: '', + onDidReceiveMessage: sandbox.stub(), + }, + } + + provider.resolveWebviewView(mockWebview as any) + const html = mockWebview.webview.html + + // Should have expandable details with both properties + assert.ok(html.includes('Property1')) + assert.ok(html.includes('Property2')) + assert.ok(html.includes('toggleDetails')) + }) + + it('should handle changes without details', function () { + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Add', + logicalResourceId: 'NewResource', + resourceType: 'AWS::Lambda::Function', + }, + }, + ] + + const html = setupProviderWithChanges('test-stack', changes) + + assert.ok(html.includes('Add')) + assert.ok(html.includes('NewResource')) + // Should have empty expand icon cell for resources without details + assert.ok(html.includes('expand-icon-0')) + }) + + it('should apply correct border colors for different actions', function () { + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Add', + logicalResourceId: 'AddedResource', + }, + }, + { + resourceChange: { + action: 'Remove', + logicalResourceId: 'RemovedResource', + }, + }, + { + resourceChange: { + action: 'Modify', + logicalResourceId: 'ModifiedResource', + }, + }, + ] + + const html = setupProviderWithChanges('test-stack', changes) + + assert.ok(html.includes('--vscode-gitDecoration-addedResourceForeground')) + assert.ok(html.includes('--vscode-gitDecoration-deletedResourceForeground')) + assert.ok(html.includes('--vscode-gitDecoration-modifiedResourceForeground')) + }) + + it('should show drift status column when drift is detected', function () { + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Modify', + logicalResourceId: 'DriftedResource', + resourceDriftStatus: 'DELETED', + }, + }, + ] + + const html = setupProviderWithChanges('test-stack', changes) + + assert.ok(html.includes('Drift Status')) + assert.ok(html.includes('⚠️ Deleted')) + }) + + it('should not show drift status column when no drift is detected', function () { + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Modify', + logicalResourceId: 'NormalResource', + }, + }, + ] + + const html = setupProviderWithChanges('test-stack', changes) + + assert.ok(!html.includes('Drift Status')) + }) + + it('should show drift detail columns when property drift is detected', function () { + const changes: StackChange[] = [ + { + resourceChange: { + action: 'Modify', + logicalResourceId: 'DriftedResource', + details: [ + { + Target: { + Name: 'BucketName', + AttributeChangeType: 'Modify', + Drift: { + PreviousValue: 'template-value', + ActualValue: 'live-value', + }, + }, + }, + ], + }, + }, + ] + + const html = setupProviderWithChanges('test-stack', changes) + + assert.ok(html.includes('Drift: Previous')) + assert.ok(html.includes('Drift: Actual')) + assert.ok(html.includes('template-value')) + assert.ok(html.includes('live-value')) + assert.ok(html.includes('⚠️ Modified')) + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/ui/inputBox.test.ts b/packages/core/src/test/awsService/cloudformation/ui/inputBox.test.ts new file mode 100644 index 00000000000..a9085ba73c0 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/ui/inputBox.test.ts @@ -0,0 +1,26 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' + +describe('InputBox', function () { + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('input validation', function () { + it('should validate input correctly', function () { + // Basic test structure - implementation depends on actual InputBox module + assert.ok(true, 'InputBox test placeholder') + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/ui/message.test.ts b/packages/core/src/test/awsService/cloudformation/ui/message.test.ts new file mode 100644 index 00000000000..b1aa2d21a1c --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/ui/message.test.ts @@ -0,0 +1,26 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' + +describe('Message', function () { + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('message display', function () { + it('should display messages correctly', function () { + // Basic test structure - implementation depends on actual Message module + assert.ok(true, 'Message test placeholder') + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/ui/stackEventsWebviewProvider.test.ts b/packages/core/src/test/awsService/cloudformation/ui/stackEventsWebviewProvider.test.ts new file mode 100644 index 00000000000..e21c9ee94ab --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/ui/stackEventsWebviewProvider.test.ts @@ -0,0 +1,91 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import { StackEventsWebviewProvider } from '../../../../awsService/cloudformation/ui/stackEventsWebviewProvider' + +describe('StackEventsWebviewProvider', () => { + let sandbox: sinon.SinonSandbox + let provider: StackEventsWebviewProvider + let mockClient: any + let coordinatorCallback: any + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockClient = { + sendRequest: sandbox.stub().resolves({ + events: [ + { + EventId: 'event-1', + StackName: 'test-stack', + Timestamp: new Date(), + ResourceStatus: 'CREATE_IN_PROGRESS', + }, + ], + nextToken: undefined, + }), + } + const mockCoordinator: any = { + onDidChangeStack: sandbox.stub().callsFake((callback: any) => { + coordinatorCallback = callback + return { dispose: () => {} } + }), + } + provider = new StackEventsWebviewProvider(mockClient, mockCoordinator) + }) + + afterEach(() => { + provider.dispose() + sandbox.restore() + }) + + it('should load stack events', async () => { + await provider.showStackEvents('test-stack') + + assert.strictEqual(mockClient.sendRequest.calledOnce, true) + }) + + it('should stop auto-refresh on terminal state', async () => { + const clock = sandbox.useFakeTimers() + + await provider.showStackEvents('test-stack') + + // Simulate terminal state notification + await coordinatorCallback({ + stackName: 'test-stack', + isChangeSetMode: false, + stackStatus: 'CREATE_COMPLETE', + }) + + clock.tick(10000) + + // Should not continue refreshing after terminal state + const callCount = mockClient.sendRequest.callCount + clock.tick(5000) + assert.strictEqual(mockClient.sendRequest.callCount, callCount) + + clock.restore() + }) + + it('should continue auto-refresh during in-progress state', async () => { + const clock = sandbox.useFakeTimers() + + await provider.showStackEvents('test-stack') + + await coordinatorCallback({ + stackName: 'test-stack', + isChangeSetMode: false, + stackStatus: 'UPDATE_IN_PROGRESS', + }) + + const initialCalls = mockClient.sendRequest.callCount + clock.tick(5000) + + assert.strictEqual(mockClient.sendRequest.callCount > initialCalls, true) + + clock.restore() + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/ui/stackOutputsWebviewProvider.test.ts b/packages/core/src/test/awsService/cloudformation/ui/stackOutputsWebviewProvider.test.ts new file mode 100644 index 00000000000..3c035add8cf --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/ui/stackOutputsWebviewProvider.test.ts @@ -0,0 +1,89 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import { StackOutputsWebviewProvider } from '../../../../awsService/cloudformation/ui/stackOutputsWebviewProvider' + +describe('StackOutputsWebviewProvider', () => { + let sandbox: sinon.SinonSandbox + let provider: StackOutputsWebviewProvider + let mockClient: any + let mockCoordinator: any + + function createMockView() { + return { + webview: { + options: {}, + html: '', + }, + } + } + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockClient = { + sendRequest: sandbox.stub().resolves({ + stack: { + StackName: 'test-stack', + StackStatus: 'CREATE_COMPLETE', + Outputs: [ + { + OutputKey: 'BucketName', + OutputValue: 'my-bucket', + Description: 'S3 bucket name', + }, + ], + }, + }), + } + mockCoordinator = { + onDidChangeStack: sandbox.stub().returns({ dispose: () => {} }), + setStack: sandbox.stub().resolves(), + currentStackStatus: undefined, + } + provider = new StackOutputsWebviewProvider(mockClient, mockCoordinator) + }) + + afterEach(() => { + provider.dispose() + sandbox.restore() + }) + + it('should use DescribeStackRequest to load outputs', async () => { + await provider.resolveWebviewView(createMockView() as any) + await provider.showOutputs('test-stack') + + assert.strictEqual(mockClient.sendRequest.calledOnce, true) + const requestArgs = mockClient.sendRequest.firstCall.args + assert.strictEqual(requestArgs[1].stackName, 'test-stack') + }) + + it('should extract outputs from stack object', async () => { + const mockView = createMockView() + await provider.resolveWebviewView(mockView as any) + + await provider.showOutputs('test-stack') + + assert.strictEqual(mockView.webview.html.includes('BucketName'), true) + assert.strictEqual(mockView.webview.html.includes('my-bucket'), true) + }) + + it('should update coordinator with stack status', async () => { + await provider.resolveWebviewView(createMockView() as any) + await provider.showOutputs('test-stack') + + assert.strictEqual(mockCoordinator.setStack.calledWith('test-stack', 'CREATE_COMPLETE'), true) + }) + + it('should not update coordinator if status unchanged', async () => { + mockCoordinator.currentStackStatus = 'CREATE_COMPLETE' + + await provider.resolveWebviewView(createMockView() as any) + await provider.showOutputs('test-stack') + + assert.strictEqual(mockCoordinator.setStack.called, false) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/ui/stackOverviewWebviewProvider.test.ts b/packages/core/src/test/awsService/cloudformation/ui/stackOverviewWebviewProvider.test.ts new file mode 100644 index 00000000000..b265e9ccb77 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/ui/stackOverviewWebviewProvider.test.ts @@ -0,0 +1,112 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import { StackOverviewWebviewProvider } from '../../../../awsService/cloudformation/ui/stackOverviewWebviewProvider' + +describe('StackOverviewWebviewProvider', () => { + let sandbox: sinon.SinonSandbox + let provider: StackOverviewWebviewProvider + let mockClient: any + let mockCoordinator: any + let coordinatorCallback: any + + function createMockView() { + return { + webview: { + options: {}, + html: '', + }, + onDidChangeVisibility: sandbox.stub(), + onDidDispose: sandbox.stub(), + } + } + + beforeEach(() => { + sandbox = sinon.createSandbox() + mockClient = { + sendRequest: sandbox.stub().resolves({ + stack: { + StackName: 'test-stack', + StackStatus: 'CREATE_COMPLETE', + StackId: 'stack-id-123', + CreationTime: new Date(), + }, + }), + } + mockCoordinator = { + onDidChangeStack: sandbox.stub().callsFake((callback: any) => { + coordinatorCallback = callback + return { dispose: () => {} } + }), + setStack: sandbox.stub().resolves(), + currentStackStatus: undefined, + } + provider = new StackOverviewWebviewProvider(mockClient, mockCoordinator) + }) + + afterEach(() => { + provider.dispose() + sandbox.restore() + }) + + it('should load stack overview', async () => { + provider.resolveWebviewView(createMockView() as any) + await provider.showStackOverview('test-stack') + + assert.strictEqual(mockClient.sendRequest.calledOnce, true) + assert.strictEqual(mockCoordinator.setStack.calledOnce, true) + }) + + it('should update coordinator with stack status', async () => { + provider.resolveWebviewView(createMockView() as any) + await provider.showStackOverview('test-stack') + + assert.strictEqual(mockCoordinator.setStack.calledWith('test-stack', 'CREATE_COMPLETE'), true) + }) + + it('should not update coordinator if status unchanged', async () => { + mockCoordinator.currentStackStatus = 'CREATE_COMPLETE' + + provider.resolveWebviewView(createMockView() as any) + await provider.showStackOverview('test-stack') + + assert.strictEqual(mockCoordinator.setStack.called, false) + }) + + it('should start auto-refresh on stack change', async () => { + const clock = sandbox.useFakeTimers() + + await coordinatorCallback({ + stackName: 'test-stack', + isChangeSetMode: false, + stackStatus: 'CREATE_IN_PROGRESS', + }) + + clock.tick(5000) + + assert.strictEqual(mockClient.sendRequest.callCount >= 2, true) + + clock.restore() + }) + + it('should stop auto-refresh on terminal state', async () => { + const clock = sandbox.useFakeTimers() + + await coordinatorCallback({ + stackName: 'test-stack', + isChangeSetMode: false, + stackStatus: 'CREATE_COMPLETE', + }) + + clock.tick(10000) + + // Should only be called once (initial load), not refreshed + assert.strictEqual(mockClient.sendRequest.callCount, 1) + + clock.restore() + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/ui/stackResourcesWebviewProvider.test.ts b/packages/core/src/test/awsService/cloudformation/ui/stackResourcesWebviewProvider.test.ts new file mode 100644 index 00000000000..0e8c68b58f2 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/ui/stackResourcesWebviewProvider.test.ts @@ -0,0 +1,277 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import { StackResourcesWebviewProvider } from '../../../../awsService/cloudformation/ui/stackResourcesWebviewProvider' + +describe('StackResourcesWebviewProvider', function () { + let sandbox: sinon.SinonSandbox + let provider: StackResourcesWebviewProvider + let mockClient: any + let mockCoordinator: any + + beforeEach(function () { + sandbox = sinon.createSandbox() + mockClient = { + sendRequest: sandbox.stub(), + } + mockCoordinator = { + onDidChangeStack: sandbox.stub().returns({ dispose: () => {} }), + setStack: sandbox.stub().resolves(), + currentStackStatus: undefined, + } as any + provider = new StackResourcesWebviewProvider(mockClient, mockCoordinator) + }) + + afterEach(function () { + sandbox.restore() + }) + + function createMockWebview() { + return { + webview: { + options: {}, + html: '', + onDidReceiveMessage: sandbox.stub(), + }, + onDidChangeVisibility: sandbox.stub(), + onDidDispose: sandbox.stub(), + visible: true, + } + } + + function createMockResources(count: number, startIndex = 0) { + return Array.from({ length: count }, (_, i) => ({ + LogicalResourceId: `Resource${i + startIndex}`, + PhysicalResourceId: `resource-${i + startIndex}-123`, + ResourceType: 'AWS::S3::Bucket', + ResourceStatus: 'CREATE_COMPLETE', + })) + } + + async function setupProviderWithResources(stackName: string, resources: any[], nextToken?: string) { + mockClient.sendRequest.resolves({ resources, nextToken }) + const mockWebview = createMockWebview() + provider.resolveWebviewView(mockWebview as any) + await provider.updateData(stackName) + return mockWebview + } + + describe('updateData', function () { + it('should update stack name and fetch resources', async function () { + const mockResources = createMockResources(1) + mockClient.sendRequest.resolves({ resources: mockResources }) + + const mockWebview = createMockWebview() + provider.resolveWebviewView(mockWebview as any) + await provider.updateData('test-stack') + + assert.ok(mockClient.sendRequest.calledOnce) + const [, params] = mockClient.sendRequest.firstCall.args + assert.strictEqual(params.stackName, 'test-stack') + }) + + it('should handle client request errors gracefully', async function () { + mockClient.sendRequest.rejects(new Error('Network error')) + const mockWebview = createMockWebview() + provider.resolveWebviewView(mockWebview as any) + + // Should not throw + await provider.updateData('test-stack') + }) + }) + + describe('resolveWebviewView', function () { + it('should configure webview options and set HTML content', function () { + const mockWebview = createMockWebview() + provider.resolveWebviewView(mockWebview as any) + + assert.deepStrictEqual(mockWebview.webview.options, { enableScripts: true }) + assert.ok(mockWebview.webview.html.length > 0) + }) + + it('should set up visibility change handlers', function () { + const mockWebview = createMockWebview() + provider.resolveWebviewView(mockWebview as any) + + assert.ok(mockWebview.onDidChangeVisibility.calledOnce) + assert.ok(mockWebview.onDidDispose.calledOnce) + }) + + it('should set up message handlers for pagination', function () { + const mockWebview = createMockWebview() + provider.resolveWebviewView(mockWebview as any) + + assert.ok(mockWebview.webview.onDidReceiveMessage.calledOnce) + }) + }) + + describe('HTML generation', function () { + it('should show no resources message when empty', async function () { + const mockWebview = await setupProviderWithResources('test-stack', []) + assert.ok(mockWebview.webview.html.includes('No resources found')) + }) + + it('should generate table with resources', async function () { + const mockResources = [ + { + LogicalResourceId: 'TestBucket', + PhysicalResourceId: 'test-bucket-123', + ResourceType: 'AWS::S3::Bucket', + ResourceStatus: 'CREATE_COMPLETE', + }, + ] + + const mockWebview = await setupProviderWithResources('test-stack', mockResources) + const html = mockWebview.webview.html + + // Verify table headers and data + assert.ok(html.includes('Logical ID')) + assert.ok(html.includes('Physical ID')) + assert.ok(html.includes('Type')) + assert.ok(html.includes('Status')) + assert.ok(html.includes('TestBucket')) + assert.ok(html.includes('test-bucket-123')) + assert.ok(html.includes('AWS::S3::Bucket')) + assert.ok(html.includes('CREATE_COMPLETE')) + }) + + it('should handle resources without physical ID', async function () { + const mockResources = [ + { + LogicalResourceId: 'TestResource', + ResourceType: 'AWS::CloudFormation::WaitConditionHandle', + ResourceStatus: 'CREATE_COMPLETE', + }, + ] + + const mockWebview = await setupProviderWithResources('test-stack', mockResources) + const html = mockWebview.webview.html + + assert.ok(html.includes('TestResource')) + assert.ok(html.includes('AWS::CloudFormation::WaitConditionHandle')) + assert.ok(html.includes('CREATE_COMPLETE')) + }) + + it('should show pagination controls with buttons disabled when there is only one page', async function () { + const mockWebview = await setupProviderWithResources('test-stack', createMockResources(10)) + const html = mockWebview.webview.html + + // Pagination is always shown, but buttons should be disabled for single page + assert.ok(html.includes('Previous')) + assert.ok(html.includes('Next')) + assert.ok(html.includes('disabled')) + }) + + it('should show pagination controls when there are multiple pages', async function () { + const mockWebview = await setupProviderWithResources('test-stack', createMockResources(60)) + const html = mockWebview.webview.html + + // Should show pagination buttons for multiple pages + assert.ok(html.includes('Previous')) + assert.ok(html.includes('Next')) + }) + + it('should disable Previous button on first page', async function () { + const mockWebview = await setupProviderWithResources('test-stack', createMockResources(60)) + const html = mockWebview.webview.html + + // Previous button should be disabled on first page + assert.ok(html.includes('disabled')) + assert.ok(html.includes('Previous')) + }) + }) + + describe('pagination functionality', function () { + let clock: sinon.SinonFakeTimers + + beforeEach(function () { + clock = sandbox.useFakeTimers() + }) + + afterEach(function () { + clock.restore() + }) + + async function testPaginationMessage(command: string) { + const mockWebview = await setupProviderWithResources('test-stack', createMockResources(60)) + const messageHandler = mockWebview.webview.onDidReceiveMessage.firstCall.args[0] + await messageHandler({ command }) + assert.ok(mockWebview.webview.html.length > 0) + } + + it('should handle nextPage message', async function () { + await testPaginationMessage('nextPage') + }) + + it('should handle prevPage message', async function () { + await testPaginationMessage('prevPage') + }) + + it('should start auto-update when webview becomes visible', async function () { + mockCoordinator.currentStackStatus = 'UPDATE_IN_PROGRESS' + const mockWebview = await setupProviderWithResources('test-stack', []) + const visibilityHandler = mockWebview.onDidChangeVisibility.firstCall.args[0] + mockWebview.visible = true + visibilityHandler() + + const initialCallCount = mockClient.sendRequest.callCount + clock.tick(5000) + + assert.ok(mockClient.sendRequest.callCount >= initialCallCount + 1) + }) + + it('should stop auto-update when webview becomes hidden', async function () { + const mockWebview = await setupProviderWithResources('test-stack', []) + const visibilityHandler = mockWebview.onDidChangeVisibility.firstCall.args[0] + + // Start then stop auto-update + mockWebview.visible = true + visibilityHandler() + mockWebview.visible = false + visibilityHandler() + + const callCountAfterStop = mockClient.sendRequest.callCount + clock.tick(10000) + assert.strictEqual(mockClient.sendRequest.callCount, callCountAfterStop) + }) + }) + + describe('loadResources', function () { + it('should handle nextToken for pagination', async function () { + const firstBatch = createMockResources(50) + const secondBatch = createMockResources(10, 50) + + mockClient.sendRequest + .onFirstCall() + .resolves({ resources: firstBatch, nextToken: 'token123' }) + .onSecondCall() + .resolves({ resources: secondBatch }) + + const mockWebview = createMockWebview() + provider.resolveWebviewView(mockWebview as any) + await provider.updateData('test-stack') + + // Simulate nextPage to load more resources + const messageHandler = mockWebview.webview.onDidReceiveMessage.firstCall.args[0] + await messageHandler({ command: 'nextPage' }) + + assert.strictEqual(mockClient.sendRequest.callCount, 2) + }) + + it('should return early if no client or stack name', async function () { + const mockCoordinator = { + onDidChangeStack: sandbox.stub().returns({ dispose: () => {} }), + } as any + const providerWithoutClient = new StackResourcesWebviewProvider(undefined as any, mockCoordinator) + const mockWebview = createMockWebview() + providerWithoutClient.resolveWebviewView(mockWebview as any) + + // Should not throw + await providerWithoutClient.updateData('') + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/ui/stackViewCoordinator.test.ts b/packages/core/src/test/awsService/cloudformation/ui/stackViewCoordinator.test.ts new file mode 100644 index 00000000000..8f307010d1b --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/ui/stackViewCoordinator.test.ts @@ -0,0 +1,103 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import { StackViewCoordinator } from '../../../../awsService/cloudformation/ui/stackViewCoordinator' + +describe('StackViewCoordinator', () => { + let coordinator: StackViewCoordinator + + beforeEach(() => { + coordinator = new StackViewCoordinator() + }) + + afterEach(() => { + coordinator.dispose() + }) + + it('should initialize with undefined state', () => { + assert.strictEqual(coordinator.currentStackName, undefined) + assert.strictEqual(coordinator.currentStackStatus, undefined) + assert.strictEqual(coordinator.isChangeSetMode, false) + }) + + it('should set stack name and status', async () => { + await coordinator.setStack('test-stack', 'CREATE_COMPLETE') + + assert.strictEqual(coordinator.currentStackName, 'test-stack') + assert.strictEqual(coordinator.currentStackStatus, 'CREATE_COMPLETE') + assert.strictEqual(coordinator.isChangeSetMode, false) + }) + + it('should fire event when stack changes', async () => { + let eventFired = false + let receivedState: any + + coordinator.onDidChangeStack((state) => { + eventFired = true + receivedState = state + }) + + await coordinator.setStack('test-stack', 'CREATE_IN_PROGRESS') + + assert.strictEqual(eventFired, true) + assert.strictEqual(receivedState.stackName, 'test-stack') + assert.strictEqual(receivedState.stackStatus, 'CREATE_IN_PROGRESS') + assert.strictEqual(receivedState.isChangeSetMode, false) + }) + + it('should call status update callback when status changes', async () => { + let callbackCount = 0 + let receivedStackName: string | undefined + let receivedStatus: string | undefined + + coordinator.setStackStatusUpdateCallback((stackName, status) => { + callbackCount++ + receivedStackName = stackName + receivedStatus = status + }) + + await coordinator.setStack('test-stack', 'CREATE_COMPLETE') + + assert.strictEqual(callbackCount, 1) + assert.strictEqual(receivedStackName, 'test-stack') + assert.strictEqual(receivedStatus, 'CREATE_COMPLETE') + + await coordinator.setStack('test-stack', 'UPDATE_IN_PROGRESS') + + assert.strictEqual(callbackCount, 2) + assert.strictEqual(receivedStatus, 'UPDATE_IN_PROGRESS') + }) + + it('should not call callback if status unchanged', async () => { + let callbackCount = 0 + + coordinator.setStackStatusUpdateCallback(() => { + callbackCount++ + }) + + await coordinator.setStack('test-stack', 'CREATE_COMPLETE') + assert.strictEqual(callbackCount, 1) + + await coordinator.setStack('test-stack', 'CREATE_COMPLETE') + assert.strictEqual(callbackCount, 1) + }) + + it('should set change set mode', async () => { + await coordinator.setChangeSetMode('test-stack', true) + + assert.strictEqual(coordinator.currentStackName, 'test-stack') + assert.strictEqual(coordinator.isChangeSetMode, true) + }) + + it('should clear stack', async () => { + await coordinator.setStack('test-stack', 'CREATE_COMPLETE') + await coordinator.clearStack() + + assert.strictEqual(coordinator.currentStackName, undefined) + assert.strictEqual(coordinator.currentStackStatus, undefined) + assert.strictEqual(coordinator.isChangeSetMode, false) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/ui/statusBar.test.ts b/packages/core/src/test/awsService/cloudformation/ui/statusBar.test.ts new file mode 100644 index 00000000000..6d39ace12e1 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/ui/statusBar.test.ts @@ -0,0 +1,33 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' + +describe('StatusBar', function () { + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('createDeploymentStatusBar', function () { + it('should create status bar item', function () { + // Basic test structure - implementation depends on actual StatusBar module + assert.ok(true, 'StatusBar test placeholder') + }) + }) + + describe('updateDeploymentStatus', function () { + it('should update status bar with deployment info', function () { + // Basic test structure - implementation depends on actual StatusBar module + assert.ok(true, 'StatusBar test placeholder') + }) + }) +}) diff --git a/packages/core/src/test/lambda/explorer/cloudFormationNodes.test.ts b/packages/core/src/test/lambda/explorer/cloudFormationNodes.test.ts index ba8d7ccd516..2100b1ae4a5 100644 --- a/packages/core/src/test/lambda/explorer/cloudFormationNodes.test.ts +++ b/packages/core/src/test/lambda/explorer/cloudFormationNodes.test.ts @@ -22,6 +22,7 @@ import { } from '../../utilities/explorerNodeAssertions' import { stub } from '../../utilities/stubber' import { getLabel } from '../../../shared/treeview/utils' +import { AWSCommandTreeNode } from '../../../shared/treeview/nodes/awsCommandTreeNode' const regionCode = 'someregioncode' @@ -168,8 +169,15 @@ describe('CloudFormationNode', function () { const cloudFormationNode = new CloudFormationNode(regionCode, client) const children = await cloudFormationNode.getChildren() - for (const node of children) { - assert.ok(node instanceof CloudFormationStackNode, 'Expected child node to be CloudFormationStackNode') + // First node should be the panel promotion node + assert.ok(children[0] instanceof AWSCommandTreeNode, 'Expected first child to be panel promotion node') + + // Remaining nodes should be CloudFormationStackNode + for (let i = 1; i < children.length; i++) { + assert.ok( + children[i] instanceof CloudFormationStackNode, + 'Expected child node to be CloudFormationStackNode' + ) } }) @@ -178,16 +186,19 @@ describe('CloudFormationNode', function () { const cloudFormationNode = new CloudFormationNode(regionCode, client) const children = await cloudFormationNode.getChildren() - const actualChildOrder = children.map((node) => (node as CloudFormationStackNode).stackName) + // Skip the first node (panel promotion) and check stack sorting + const stackNodes = children.slice(1) as CloudFormationStackNode[] + const actualChildOrder = stackNodes.map((node) => node.stackName) assert.deepStrictEqual(actualChildOrder, ['a', 'b'], 'Unexpected child sort order') }) - it('returns placeholder node if no children are present', async function () { + it('returns panel promotion node if no stacks are present', async function () { const client = createCloudFormationClient() const cloudFormationNode = new CloudFormationNode(regionCode, client) const children = await cloudFormationNode.getChildren() - assertNodeListOnlyHasPlaceholderNode(children) + assert.strictEqual(children.length, 1, 'Expected exactly one child node') + assert.ok(children[0] instanceof AWSCommandTreeNode, 'Expected panel promotion node') }) it('has an error node for a child if an error happens during loading', async function () { diff --git a/packages/toolkit/.changes/next-release/feature-cloudformation-lsp-integration.json b/packages/toolkit/.changes/next-release/feature-cloudformation-lsp-integration.json new file mode 100644 index 00000000000..b996a29cae1 --- /dev/null +++ b/packages/toolkit/.changes/next-release/feature-cloudformation-lsp-integration.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "CloudFormation: Add comprehensive Language Server Protocol integration with stack management, deployment workflows, drift detection, and cfn-init project support" +} diff --git a/packages/toolkit/cloudformation-language-config.json b/packages/toolkit/cloudformation-language-config.json new file mode 100644 index 00000000000..c5d33df3c93 --- /dev/null +++ b/packages/toolkit/cloudformation-language-config.json @@ -0,0 +1,53 @@ +{ + "comments": { + "lineComment": "#", + "blockComment": ["/*", "*/"] + }, + "brackets": [ + ["{", "}"], + ["[", "]"], + ["(", ")"] + ], + "autoClosingPairs": [ + ["{", "}"], + ["[", "]"], + ["(", ")"], + ["\"", "\""], + ["'", "'"], + ["`", "`"] + ], + "surroundingPairs": [ + ["{", "}"], + ["[", "]"], + ["(", ")"], + ["\"", "\""], + ["'", "'"], + ["`", "`"] + ], + "folding": { + "offSide": true, + "markers": { + "start": "^\\s*#\\s*region\\b", + "end": "^\\s*#\\s*endregion\\b" + } + }, + "indentationRules": { + "increaseIndentPattern": "^\\s*.*(:|-) ?(&\\w+)?(\\{[^}\"']*|\\([^)\"']*)?$", + "decreaseIndentPattern": "^\\s+\\}$" + }, + "onEnterRules": [ + { + "beforeText": "^\\s*\\w+:\\s*$", + "action": { + "indent": "indent" + } + }, + { + "beforeText": "^\\s*- \\w+:$", + "action": { + "indent": "indent" + } + } + ], + "wordPattern": "(^.?[^\\s]+)+|([^\\s\n={[][\\w\\-\\./$%&*:\"']+)" +} diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index a7de5f18113..82762fe1403 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -46,6 +46,7 @@ "onLanguage:python", "onLanguage:csharp", "onLanguage:yaml", + "onLanguage:cloudformation", "onFileSystem:s3", "onFileSystem:s3-readonly" ], @@ -240,13 +241,19 @@ "type": "object", "markdownDescription": "%AWS.configuration.description.experiments%", "default": { - "jsonResourceModification": false + "jsonResourceModification": false, + "cloudFormationService": false }, "properties": { "jsonResourceModification": { "type": "boolean", "default": false }, + "cloudFormationService": { + "type": "boolean", + "default": false, + "markdownDescription": "Enable the new CloudFormation language server and service features" + }, "amazonqLSP": { "type": "boolean", "default": true @@ -304,6 +311,148 @@ "type": "boolean", "default": false, "description": "Enable automatic filtration of spaces based on your AWS identity." + }, + "aws.cloudformation.telemetry.enabled": { + "type": "boolean", + "default": false, + "description": "Configure anonymous telemetry collection for AWS CloudFormation Language Server" + }, + "aws.cloudformation.hover.enabled": { + "type": "boolean", + "default": true, + "description": "Enable hover information for CloudFormation resources" + }, + "aws.cloudformation.completion.enabled": { + "type": "boolean", + "default": true, + "description": "Enable auto-completion for CloudFormation templates" + }, + "aws.cloudformation.diagnostics.cfnLint.enabled": { + "type": "boolean", + "default": true, + "description": "Enable or disable CloudFormation linting" + }, + "aws.cloudformation.diagnostics.cfnLint.lintOnChange": { + "type": "boolean", + "default": true, + "description": "Run cfn-lint when document content changes" + }, + "aws.cloudformation.diagnostics.cfnLint.delayMs": { + "type": "number", + "default": 3000, + "minimum": 0, + "description": "Delay in milliseconds before running cfn-lint after changes" + }, + "aws.cloudformation.diagnostics.cfnLint.path": { + "type": "string", + "default": "", + "description": "Path to locally installed cfn-lint executable. If empty, uses bundled version." + }, + "aws.cloudformation.diagnostics.cfnGuard.enabled": { + "type": "boolean", + "default": true, + "description": "Enable or disable CloudFormation Guard validation" + }, + "aws.cloudformation.diagnostics.cfnGuard.validateOnChange": { + "type": "boolean", + "default": true, + "description": "Run cfn-guard when document content changes" + }, + "aws.cloudformation.diagnostics.cfnGuard.enabledRulePacks": { + "type": "array", + "default": [ + "wa-Security-Pillar" + ], + "items": { + "type": "string", + "enum": [ + "ABS-CCIGv2-Material", + "ABS-CCIGv2-Standard", + "acsc-essential-8", + "acsc-ism", + "apra-cpg-234", + "bnm-rmit", + "cis-aws-benchmark-level-1", + "cis-aws-benchmark-level-2", + "cis-critical-security-controls-v8-ig1", + "cis-critical-security-controls-v8-ig2", + "cis-critical-security-controls-v8-ig3", + "cis-top-20", + "cisa-ce", + "cmmc-level-1", + "cmmc-level-2", + "cmmc-level-3", + "cmmc-level-4", + "cmmc-level-5", + "enisa-cybersecurity-guide-for-smes", + "ens-high", + "ens-low", + "ens-medium", + "FDA-21CFR-Part-11", + "FedRAMP-Low", + "FedRAMP-Moderate", + "ffiec", + "hipaa-security", + "K-ISMS", + "mas-notice-655", + "mas-trmg", + "nbc-trmg", + "ncsc-cafv3", + "ncsc", + "nerc", + "nist-1800-25", + "nist-800-171", + "nist-800-172", + "nist-800-181", + "nist-csf", + "nist-privacy-framework", + "NIST800-53Rev4", + "NIST800-53Rev5", + "nzism", + "PCI-DSS-3-2-1", + "rbi-bcsf-ucb", + "rbi-md-itf", + "us-nydfs", + "wa-Reliability-Pillar", + "wa-Security-Pillar" + ] + }, + "description": "Cfn-guard enabled rule packs" + }, + "aws.cloudformation.diagnostics.cfnGuard.rulesFile": { + "type": "string", + "default": "", + "description": "Path to custom cfn-guard rules file. If empty, uses default rule packs." + }, + "aws.cloudformation.s3": { + "type": "string", + "enum": [ + "alwaysAsk", + "alwaysUpload", + "neverUpload" + ], + "enumDescriptions": [ + "Always ask during validation and deploy workflow", + "Always upload to S3 for both validation and deployment", + "Never upload to S3 (only works for template smaller than 51200 bytes)" + ], + "default": "alwaysAsk", + "description": "Configure S3 upload behavior for CloudFormation templates" + }, + "aws.cloudformation.environment.saveOptions": { + "type": "string", + "enum": [ + "alwaysAsk", + "alwaysSave", + "neverSave" + ], + "enumDescriptions": [ + "Always ask during validation and deploy workflow", + "Always save to file for both validation and deployment", + "Never save to file" + ], + "default": "alwaysAsk", + "description": "Configure optional changeset flags for CloudFormation templates" } } }, @@ -722,6 +871,13 @@ } } } + ], + "panel": [ + { + "id": "cfn-diff", + "title": "CloudFormation", + "icon": "$(diff)" + } ] }, "viewsWelcome": [ @@ -737,6 +893,43 @@ } ], "views": { + "cfn-diff": [ + { + "id": "aws.cloudformation.stack.overview", + "name": "Overview", + "type": "webview", + "when": "aws.cloudformation.stackSelected && !aws.cloudformation.changeSetMode", + "icon": "$(info)" + }, + { + "id": "aws.cloudformation.stack.resources", + "name": "Resources", + "type": "webview", + "when": "aws.cloudformation.stackSelected && !aws.cloudformation.changeSetMode", + "icon": "$(symbol-class)" + }, + { + "id": "aws.cloudformation.stack.events", + "name": "Events", + "type": "webview", + "when": "aws.cloudformation.stackSelected && !aws.cloudformation.changeSetMode", + "icon": "$(history)" + }, + { + "id": "aws.cloudformation.stack.outputs", + "name": "Outputs", + "type": "webview", + "when": "aws.cloudformation.stackSelected && !aws.cloudformation.changeSetMode", + "icon": "$(output)" + }, + { + "id": "aws.cloudformation.diff", + "name": "Stack Changes", + "type": "webview", + "when": "aws.cloudformation.changeSetMode", + "icon": "$(diff)" + } + ], "explorer": [ { "id": "aws.appBuilderForFileExplorer", @@ -769,6 +962,11 @@ "name": "%AWS.cdk.explorerTitle%", "when": "!aws.explorer.showAuthView" }, + { + "id": "aws.cloudformation", + "name": "CloudFormation", + "when": "!aws.explorer.showAuthView" + }, { "id": "aws.appBuilder", "name": "%AWS.appBuilder.explorerTitle%", @@ -1270,6 +1468,34 @@ { "command": "aws.smus.refreshProject", "when": "false" + }, + { + "command": "aws.cloudformation.api.copyResourceIdentifier", + "when": "false" + }, + { + "command": "aws.cloudformation.api.searchResource", + "when": "false" + }, + { + "command": "aws.cloudformation.api.loadMoreChangeSets", + "when": "false" + }, + { + "command": "aws.cloudformation.stacks.refreshChangeSets", + "when": "false" + }, + { + "command": "aws.cloudformation.stacks.viewChangeSet", + "when": "false" + }, + { + "command": "aws.cloudformation.stacks.deleteChangeSet", + "when": "false" + }, + { + "command": "aws.cloudformation.api.deployTemplateFromStacksMenu", + "when": "false" } ], "editor/title": [ @@ -1331,6 +1557,13 @@ "group": "1_cutcopypaste@1" } ], + "editor/context": [ + { + "command": "aws.cloudformation.api.rerunLastValidation", + "when": "resourceExtname == .yaml || resourceExtname == .json || resourceExtname == .yml || resourceExtname == .txt || resourceExtname == .cfn || resourceExtname == .template", + "group": "1_cloudformation@1" + } + ], "view/title": [ { "command": "aws.smus.switchProject", @@ -2399,6 +2632,195 @@ "command": "aws.appBuilder.tailLogs", "when": "view =~ /^(aws.appBuilder|aws.appBuilderForFileExplorer)$/ && viewItem =~ /^(awsRegionFunctionNode|awsRegionFunctionNodeDownloadable|awsRegionFunctionNodeDownloadableOnly|awsCloudFormationFunctionNode)$/", "group": "inline@3" + }, + { + "command": "aws.cloudformation.stack.view", + "when": "view == aws.cloudformation && viewItem == stack", + "group": "inline@3" + }, + { + "command": "aws.cloudformation.api.loadMoreStacks", + "when": "view == aws.cloudformation && viewItem == stackSectionWithMore && !aws.cloudformation.loadingStacks", + "group": "inline@4" + }, + { + "command": "aws.cloudformation.stacks.refresh", + "when": "view == aws.cloudformation && (viewItem == stackSection || viewItem == stackSectionWithMore) && !aws.cloudformation.refreshingStacks", + "group": "inline@3" + }, + { + "command": "aws.cloudformation.api.deployTemplateFromStacksMenu", + "when": "view == aws.cloudformation && (viewItem == stackSection || viewItem == stackSectionWithMore) && !aws.cloudformation.refreshingStacks", + "group": "inline@3" + }, + { + "command": "aws.cloudformation.api.deployTemplateFromStacksMenu", + "when": "view == aws.cloudformation && (viewItem == stackSection || viewItem == stackSectionWithMore) && !aws.cloudformation.refreshingStacks", + "group": "1@2" + }, + { + "command": "aws.cloudformation.api.validateDeployment", + "when": "view == aws.cloudformation && (viewItem == stackSection || viewItem == stackSectionWithMore) && !aws.cloudformation.refreshingStacks", + "group": "inline@1" + }, + { + "command": "aws.cloudformation.api.validateDeployment", + "when": "view == aws.cloudformation && (viewItem == stackSection || viewItem == stackSectionWithMore) && !aws.cloudformation.refreshingStacks", + "group": "1@1" + }, + { + "command": "aws.cloudformation.api.loadMoreStacks", + "when": "view == aws.cloudformation && viewItem == stackSectionWithMore && !aws.cloudformation.loadingStacks", + "group": "1@1" + }, + { + "command": "aws.cloudformation.stacks.refresh", + "when": "view == aws.cloudformation && (viewItem == stackSection || viewItem == stackSectionWithMore) && !aws.cloudformation.refreshingStacks", + "group": "1@2" + }, + { + "command": "aws.cloudformation.api.validateDeployment", + "when": "view == aws.cloudformation && (viewItem == stack)", + "group": "inline@1" + }, + { + "command": "aws.cloudformation.api.deployTemplate", + "when": "view == aws.cloudformation && (viewItem == stack)", + "group": "inline@2" + }, + { + "command": "aws.cloudformation.api.validateDeployment", + "when": "view == aws.cloudformation && (viewItem == stack)", + "group": "1@1" + }, + { + "command": "aws.cloudformation.api.deployTemplate", + "when": "view == aws.cloudformation && (viewItem == stack)", + "group": "1@2" + }, + { + "command": "aws.cloudformation.stack.view", + "when": "view == aws.cloudformation && viewItem == stack", + "group": "1@3" + }, + { + "command": "aws.cloudformation.selectRegion", + "when": "view == aws.cloudformation && viewItem == regionSelector", + "group": "inline" + }, + { + "command": "aws.cloudformation.api.addResourceTypes", + "when": "view == aws.cloudformation && viewItem == resourceSection && !aws.cloudformation.refreshingAllResources", + "group": "inline@1" + }, + { + "command": "aws.cloudformation.api.refreshAllResources", + "when": "view == aws.cloudformation && viewItem == resourceSection && !aws.cloudformation.refreshingAllResources", + "group": "inline@2" + }, + { + "command": "aws.cloudformation.api.loadMoreResources", + "when": "view == aws.cloudformation && viewItem == resourceTypeWithMore && !aws.cloudformation.loadingResources", + "group": "inline@3" + }, + { + "command": "aws.cloudformation.api.searchResource", + "when": "view == aws.cloudformation && viewItem == resourceTypeWithMore", + "group": "inline@1" + }, + { + "command": "aws.cloudformation.api.importResourceState", + "when": "view == aws.cloudformation && viewItem == resource && !aws.cloudformation.importingResource", + "group": "inline@1" + }, + { + "command": "aws.cloudformation.api.cloneResourceState", + "when": "view == aws.cloudformation && viewItem == resource && !aws.cloudformation.cloningResource", + "group": "inline@2" + }, + { + "command": "aws.cloudformation.api.getStackManagementInfo", + "when": "view == aws.cloudformation && viewItem == resource && !aws.cloudformation.gettingStackMgmtInfo", + "group": "inline@3" + }, + { + "command": "aws.cloudformation.api.refreshResourceList", + "when": "view == aws.cloudformation && (viewItem == resourceType || viewItem == resourceTypeWithMore) && !aws.cloudformation.refreshingResourceList", + "group": "inline@2" + }, + { + "command": "aws.cloudformation.api.importResourceState", + "when": "view == aws.cloudformation && viewItem == resource && !aws.cloudformation.importingResource", + "group": "1@1" + }, + { + "command": "aws.cloudformation.api.cloneResourceState", + "when": "view == aws.cloudformation && viewItem == resource && !aws.cloudformation.cloningResource", + "group": "1@2" + }, + { + "command": "aws.cloudformation.api.getStackManagementInfo", + "when": "view == aws.cloudformation && viewItem == resource && !listMultiSelection", + "group": "1@3" + }, + { + "command": "aws.cloudformation.api.copyResourceIdentifier", + "when": "view == aws.cloudformation && viewItem == resource && !listMultiSelection", + "group": "1@4" + }, + { + "command": "aws.cloudformation.api.searchResource", + "when": "view == aws.cloudformation && viewItem == resourceTypeWithMore", + "group": "1@1" + }, + { + "command": "aws.cloudformation.api.loadMoreResources", + "when": "view == aws.cloudformation && viewItem == resourceTypeWithMore && !aws.cloudformation.loadingResources", + "group": "1@2" + }, + { + "command": "aws.cloudformation.api.refreshResourceList", + "when": "view == aws.cloudformation && (viewItem == resourceType || viewItem == resourceTypeWithMore) && !aws.cloudformation.refreshingResourceList", + "group": "1@1" + }, + { + "command": "aws.cloudformation.removeResourceType", + "when": "view == aws.cloudformation && (viewItem == resourceType || viewItem == resourceTypeWithMore)" + }, + { + "command": "aws.cloudformation.api.addResourceTypes", + "when": "view == aws.cloudformation && viewItem == resourceSection && !aws.cloudformation.refreshingAllResources", + "group": "1@1" + }, + { + "command": "aws.cloudformation.api.refreshAllResources", + "when": "view == aws.cloudformation && viewItem == resourceSection && !aws.cloudformation.refreshingAllResources", + "group": "1@2" + }, + { + "command": "aws.cloudformation.stacks.viewChangeSet", + "when": "view == aws.cloudformation && viewItem == changeSet", + "group": "inline@1" + }, + { + "command": "aws.cloudformation.stacks.viewChangeSet", + "when": "view == aws.cloudformation && viewItem == changeSet", + "group": "1@1" + }, + { + "command": "aws.cloudformation.stacks.deleteChangeSet", + "when": "view == aws.cloudformation && viewItem == changeSet", + "group": "1@2" + }, + { + "command": "aws.cloudformation.stacks.refreshChangeSets", + "when": "view == aws.cloudformation && (viewItem == stackChangeSets || viewItem == stackChangeSetsWithMore)", + "group": "inline@1" + }, + { + "command": "aws.cloudformation.api.loadMoreChangeSets", + "when": "view == aws.cloudformation && viewItem == stackChangeSetsWithMore", + "group": "inline@2" } ], "aws.toolkit.auth": [ @@ -4428,6 +4850,160 @@ "command": "aws.smus.notebookscheduling.viewjobs", "title": "View Notebook Jobs", "category": "Job" + }, + { + "command": "aws.cloudformation.api.importResourceState", + "title": "Import Resource State", + "icon": "$(diff-added)", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.api.copyResourceIdentifier", + "title": "Copy Resource Identifier", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.api.refreshResourceList", + "title": "Refresh Resource List", + "icon": "$(refresh)", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.api.addResourceTypes", + "title": "Add Resource Types", + "icon": "$(add)", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.removeResourceType", + "title": "Remove Resource Type", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.api.refreshAllResources", + "title": "Refresh All Resources", + "icon": "$(refresh)", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.stacks.refresh", + "title": "Refresh Stacks", + "icon": "$(refresh)", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.api.loadMoreStacks", + "title": "Load More Stacks", + "icon": "$(surround-with)", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.api.loadMoreResources", + "title": "Load More Resources", + "icon": "$(surround-with)", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.api.searchResource", + "title": "Find Resource by Identifier", + "icon": "$(search)", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.api.cloneResourceState", + "title": "Clone Resource State", + "icon": "$(copy)", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.api.getStackManagementInfo", + "title": "Get Stack Management Info", + "icon": "$(info)", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.server.restartServer", + "title": "Restart Server", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.api.validateDeployment", + "title": "Validate Deployment", + "category": "AWS CloudFormation", + "icon": "$(go-to-file)" + }, + { + "command": "aws.cloudformation.api.deployTemplate", + "title": "Deploy Template", + "category": "AWS CloudFormation", + "icon": "$(cloud-upload)" + }, + { + "command": "aws.cloudformation.api.deployTemplateFromStacksMenu", + "title": "Deploy Template", + "category": "AWS CloudFormation", + "icon": "$(plus)" + }, + { + "command": "aws.cloudformation.api.rerunLastValidation", + "title": "Rerun Last Validation", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.selectRegion", + "title": "Select Region", + "icon": "$(gear)", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.stacks.viewChangeSet", + "title": "View Change Set", + "icon": "$(eye)", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.stacks.deleteChangeSet", + "title": "Delete Change Set", + "icon": "$(close)", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.stacks.refreshChangeSets", + "title": "Refresh Change Sets", + "icon": "$(refresh)", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.stack.view", + "title": "View Stack Detail", + "icon": "$(eye)", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.api.loadMoreChangeSets", + "title": "Load More Change Sets", + "icon": "$(surround-with)", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.init.initializeProject", + "title": "CFN Init: Initialize Project", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.init.addEnvironment", + "title": "CFN Init: Add Environment", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.init.removeEnvironment", + "title": "CFN Init: Remove Environment", + "category": "AWS CloudFormation" + }, + { + "command": "aws.cloudformation.api.addRelatedResources", + "title": "Add Related Resources by Type", + "category": "AWS CloudFormation" } ], "jsonValidation": [ @@ -4441,6 +5017,17 @@ } ], "languages": [ + { + "id": "cloudformation", + "extensions": [ + ".template", + ".cfn" + ], + "aliases": [ + "CloudFormation" + ], + "configuration": "./cloudformation-language-config.json" + }, { "id": "asl", "extensions": [ @@ -4502,6 +5089,11 @@ } ], "grammars": [ + { + "language": "cloudformation", + "scopeName": "source.cloudformation", + "path": "./syntaxes/cloudformation.tmLanguage.json" + }, { "language": "asl", "scopeName": "source.asl", diff --git a/packages/toolkit/syntaxes/cloudformation.tmLanguage.json b/packages/toolkit/syntaxes/cloudformation.tmLanguage.json new file mode 100644 index 00000000000..d66db236cc0 --- /dev/null +++ b/packages/toolkit/syntaxes/cloudformation.tmLanguage.json @@ -0,0 +1,868 @@ +{ + "version": "1.0.0", + "name": "CloudFormation", + "scopeName": "source.cloudformation", + "fileTypes": ["cfn", "template"], + "patterns": [ + { + "begin": "^\\s*\\{", + "end": "\\z", + "name": "meta.cloudformation.json", + "patterns": [ + { + "include": "source.json" + } + ] + }, + { + "begin": "^(?!\\s*\\{)", + "end": "\\z", + "name": "meta.cloudformation.yaml", + "patterns": [ + { + "include": "#comment" + }, + { + "include": "#cfn-logical-ids" + }, + { + "include": "#cfn-top-level-keys" + }, + { + "include": "#property" + }, + { + "include": "#directive" + }, + { + "match": "^---", + "name": "entity.other.document.begin.yaml" + }, + { + "match": "^\\.{3}", + "name": "entity.other.document.end.yaml" + }, + { + "include": "#node" + } + ] + } + ], + "repository": { + "cfn-top-level-keys": { + "patterns": [ + { + "match": "^(AWSTemplateFormatVersion|Description|Metadata|Parameters|Mappings|Conditions|Transform|Resources|Outputs)\\s*:", + "captures": { + "1": { + "name": "entity.name.tag.cloudformation.top-level" + } + } + } + ] + }, + "cfn-logical-ids": { + "patterns": [ + { + "begin": "^(Resources)\\s*:", + "beginCaptures": { + "1": { + "name": "entity.name.tag.cloudformation.top-level" + } + }, + "end": "^(?=\\S)", + "patterns": [ + { + "include": "#comment" + }, + { + "begin": "^(\\s+)([A-Za-z][A-Za-z0-9]*)\\s*:", + "beginCaptures": { + "2": { + "name": "entity.name.function.cloudformation.resource-id" + } + }, + "end": "^(?!\\1\\s|\\1$)", + "patterns": [ + { + "include": "#node" + } + ] + } + ] + }, + { + "begin": "^(Parameters)\\s*:", + "beginCaptures": { + "1": { + "name": "entity.name.tag.cloudformation.top-level" + } + }, + "end": "^(?=\\S)", + "patterns": [ + { + "include": "#comment" + }, + { + "begin": "^(\\s+)([A-Za-z][A-Za-z0-9]*)\\s*:", + "beginCaptures": { + "2": { + "name": "entity.name.function.cloudformation.parameter-id" + } + }, + "end": "^(?!\\1\\s|\\1$)", + "patterns": [ + { + "include": "#node" + } + ] + } + ] + }, + { + "begin": "^(Conditions)\\s*:", + "beginCaptures": { + "1": { + "name": "entity.name.tag.cloudformation.top-level" + } + }, + "end": "^(?=\\S)", + "patterns": [ + { + "include": "#comment" + }, + { + "begin": "^(\\s+)([A-Za-z][A-Za-z0-9]*)\\s*:", + "beginCaptures": { + "2": { + "name": "entity.name.function.cloudformation.condition-id" + } + }, + "end": "^(?!\\1\\s|\\1$)", + "patterns": [ + { + "include": "#node" + } + ] + } + ] + }, + { + "begin": "^(Outputs)\\s*:", + "beginCaptures": { + "1": { + "name": "entity.name.tag.cloudformation.top-level" + } + }, + "end": "^(?=\\S)", + "patterns": [ + { + "include": "#comment" + }, + { + "begin": "^(\\s+)([A-Za-z][A-Za-z0-9]*)\\s*:", + "beginCaptures": { + "2": { + "name": "entity.name.function.cloudformation.output-id" + } + }, + "end": "^(?!\\1\\s|\\1$)", + "patterns": [ + { + "include": "#node" + } + ] + } + ] + }, + { + "begin": "^(Mappings)\\s*:", + "beginCaptures": { + "1": { + "name": "entity.name.tag.cloudformation.top-level" + } + }, + "end": "^(?=\\S)", + "patterns": [ + { + "include": "#comment" + }, + { + "begin": "^(\\s+)([A-Za-z][A-Za-z0-9]*)\\s*:", + "beginCaptures": { + "2": { + "name": "entity.name.function.cloudformation.mapping-id" + } + }, + "end": "^(?!\\1\\s|\\1$)", + "patterns": [ + { + "include": "#node" + } + ] + } + ] + } + ] + }, + "cfn-functions": { + "patterns": [ + { + "match": "!(Ref|GetAtt|GetAZs|ImportValue|Join|Split|Select|Sub|Base64|GetParam|Equals|If|Not|And|Or|FindInMap|Condition)\\b", + "name": "keyword.control.cloudformation.function" + }, + { + "match": "Fn::(GetAtt|GetAZs|ImportValue|Join|Split|Select|Sub|Base64|GetParam|Equals|If|Not|And|Or|FindInMap)", + "name": "keyword.control.cloudformation.function" + }, + { + "match": "\\bRef(?=\\s*:)", + "name": "keyword.control.cloudformation.function" + } + ] + }, + "cfn-sub-parameters": { + "patterns": [ + { + "match": "\\$\\{(AWS::(AccountId|NotificationARNs|NoValue|Partition|Region|StackId|StackName|URLSuffix))\\}", + "name": "variable.language.cloudformation.pseudo-parameter" + }, + { + "match": "\\$\\{[^}]+\\}", + "name": "variable.other.cloudformation.sub-parameter" + } + ] + }, + "block-collection": { + "patterns": [ + { + "include": "#block-sequence" + }, + { + "include": "#block-mapping" + } + ] + }, + "block-mapping": { + "patterns": [ + { + "include": "#block-pair" + } + ] + }, + "block-node": { + "patterns": [ + { + "include": "#prototype" + }, + { + "include": "#block-scalar" + }, + { + "include": "#block-collection" + }, + { + "include": "#flow-scalar-plain-out" + }, + { + "include": "#flow-node" + } + ] + }, + "block-pair": { + "patterns": [ + { + "begin": "\\?", + "beginCaptures": { + "1": { + "name": "punctuation.definition.key-value.begin.yaml" + } + }, + "end": "(?=\\?)|^ *(:)|(:)", + "endCaptures": { + "1": { + "name": "punctuation.separator.key-value.mapping.yaml" + }, + "2": { + "name": "invalid.illegal.expected-newline.yaml" + } + }, + "name": "meta.block-mapping.yaml", + "patterns": [ + { + "include": "#block-node" + } + ] + }, + { + "begin": "(?x)\n (?=\n (?x:\n [^\\s[-?:,\\[\\]{}#&*!|>'\"%@`]]\n | [?:-] \\S\n )\n (\n [^\\s:]\n | : \\S\n | \\s+ (?![#\\s])\n )*\n \\s*\n :\n\t\t\t\t\t\t\t(\\s|$)\n )\n ", + "end": "(?x)\n (?=\n \\s* $\n | \\s+ \\#\n | \\s* : (\\s|$)\n )\n ", + "name": "meta.map.key.yaml", + "patterns": [ + { + "include": "#flow-scalar-plain-out-implicit-type" + }, + { + "include": "#cfn-functions" + }, + { + "begin": "(?x)\n [^\\s[-?:,\\[\\]{}#&*!|>'\"%@`]]\n | [?:-] \\S\n ", + "beginCaptures": { + "0": { + "name": "entity.name.tag.yaml" + } + }, + "contentName": "entity.name.tag.yaml", + "end": "(?x)\n (?=\n \\s* $\n | \\s+ \\#\n | \\s* : (\\s|$)\n )\n ", + "name": "string.unquoted.plain.out.yaml" + } + ] + }, + { + "match": ":(?=\\s|$)", + "name": "punctuation.separator.key-value.mapping.yaml" + } + ] + }, + "block-scalar": { + "begin": "(?:(\\|)|(>))([1-9])?([-+])?(.*\\n?)", + "beginCaptures": { + "1": { + "name": "keyword.control.flow.block-scalar.literal.yaml" + }, + "2": { + "name": "keyword.control.flow.block-scalar.folded.yaml" + }, + "3": { + "name": "constant.numeric.indentation-indicator.yaml" + }, + "4": { + "name": "storage.modifier.chomping-indicator.yaml" + }, + "5": { + "patterns": [ + { + "include": "#comment" + }, + { + "match": ".+", + "name": "invalid.illegal.expected-comment-or-newline.yaml" + } + ] + } + }, + "end": "^(?=\\S)|(?!\\G)", + "patterns": [ + { + "begin": "^([ ]+)(?! )", + "end": "^(?!\\1|\\s*$)", + "name": "string.unquoted.block.yaml", + "patterns": [ + { + "include": "#cfn-sub-parameters" + } + ] + } + ] + }, + "block-sequence": { + "match": "(-)(?!\\S)", + "name": "punctuation.definition.block.sequence.item.yaml" + }, + "comment": { + "begin": "(?:(^[ \\t]*)|[ \\t]+)(?=#\\p{Print}*$)", + "beginCaptures": { + "1": { + "name": "punctuation.whitespace.comment.leading.yaml" + } + }, + "end": "(?!\\G)", + "patterns": [ + { + "begin": "#", + "beginCaptures": { + "0": { + "name": "punctuation.definition.comment.yaml" + } + }, + "end": "\\n", + "name": "comment.line.number-sign.yaml" + } + ] + }, + "directive": { + "begin": "^%", + "beginCaptures": { + "0": { + "name": "punctuation.definition.directive.begin.yaml" + } + }, + "end": "(?=$|[ \\t]+($|#))", + "name": "meta.directive.yaml", + "patterns": [ + { + "captures": { + "1": { + "name": "keyword.other.directive.yaml.yaml" + }, + "2": { + "name": "constant.numeric.yaml-version.yaml" + } + }, + "match": "\\G(YAML)[ \\t]+(\\d+\\.\\d+)" + }, + { + "captures": { + "1": { + "name": "keyword.other.directive.tag.yaml" + }, + "2": { + "name": "storage.type.tag-handle.yaml" + }, + "3": { + "name": "support.type.tag-prefix.yaml" + } + }, + "match": "(?x)\n \\G\n (TAG)\n (?:[ \\t]+\n ((?:!(?:[0-9A-Za-z\\-]*!)?))\n (?:[ \\t]+ (\n ! (?x: %[0-9A-Fa-f]{2} | [0-9A-Za-z\\-#;/?:@&=+$,_.!~*'()\\[\\]] )*\n | (?![,!\\[\\]{}]) (?x: %[0-9A-Fa-f]{2} | [0-9A-Za-z\\-#;/?:@&=+$,_.!~*'()\\[\\]] )+\n )\n )?\n )?\n " + }, + { + "captures": { + "1": { + "name": "support.other.directive.reserved.yaml" + }, + "2": { + "name": "string.unquoted.directive-name.yaml" + }, + "3": { + "name": "string.unquoted.directive-parameter.yaml" + } + }, + "match": "(?x) \\G (\\w+) (?:[ \\t]+ (\\w+) (?:[ \\t]+ (\\w+))? )?" + }, + { + "match": "\\S+", + "name": "invalid.illegal.unrecognized.yaml" + } + ] + }, + "flow-alias": { + "captures": { + "1": { + "name": "keyword.control.flow.alias.yaml" + }, + "2": { + "name": "punctuation.definition.alias.yaml" + }, + "3": { + "name": "variable.other.alias.yaml" + }, + "4": { + "name": "invalid.illegal.character.anchor.yaml" + } + }, + "match": "((\\*))([^\\s\\[\\]/{/},]+)([^\\s\\]},]\\S*)?" + }, + "flow-collection": { + "patterns": [ + { + "include": "#flow-sequence" + }, + { + "include": "#flow-mapping" + } + ] + }, + "flow-mapping": { + "begin": "\\{", + "beginCaptures": { + "0": { + "name": "punctuation.definition.mapping.begin.yaml" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.mapping.end.yaml" + } + }, + "name": "meta.flow-mapping.yaml", + "patterns": [ + { + "include": "#prototype" + }, + { + "match": ",", + "name": "punctuation.separator.mapping.yaml" + }, + { + "include": "#flow-pair" + } + ] + }, + "flow-node": { + "patterns": [ + { + "include": "#prototype" + }, + { + "include": "#flow-alias" + }, + { + "include": "#flow-collection" + }, + { + "include": "#flow-scalar" + } + ] + }, + "flow-pair": { + "patterns": [ + { + "match": "\"((?:\\\\.|[^\"])*)\"\\s*(?=:)", + "captures": { + "0": { + "name": "entity.name.tag.yaml" + } + } + }, + { + "match": "'((?:''|[^'])*)'\\s*(?=:)", + "captures": { + "0": { + "name": "entity.name.tag.yaml" + } + } + }, + { + "begin": "\\?", + "beginCaptures": { + "0": { + "name": "punctuation.definition.key-value.begin.yaml" + } + }, + "end": "(?=[},\\]])", + "name": "meta.flow-pair.explicit.yaml", + "patterns": [ + { + "include": "#prototype" + }, + { + "include": "#flow-pair" + }, + { + "include": "#flow-node" + }, + { + "begin": ":(?=\\s|$|[\\[\\]{},])", + "beginCaptures": { + "0": { + "name": "punctuation.separator.key-value.mapping.yaml" + } + }, + "end": "(?=[},\\]])", + "patterns": [ + { + "include": "#flow-value" + } + ] + } + ] + }, + { + "begin": "(?x)\n (?=\n (?:\n [^\\s[-?:,\\[\\]{}#&*!|>'\"%@`]]\n | [?:-] [^\\s[\\[\\]{},]]\n )\n (\n [^\\s:[\\[\\]{},]]\n | : [^\\s[\\[\\]{},]]\n | \\s+ (?![#\\s])\n )*\n \\s*\n :\n\t\t\t\t\t\t\t(\\s|$)\n )\n ", + "end": "(?x)\n (?=\n \\s* $\n | \\s+ \\#\n | \\s* : (\\s|$)\n | \\s* : [\\[\\]{},]\n | \\s* [\\[\\]{},]\n )\n ", + "name": "meta.flow.map.implicit.yaml", + "patterns": [ + { + "include": "#flow-scalar-plain-in-implicit-type" + }, + { + "begin": "(?x)\n [^\\s[-?:,\\[\\]{}#&*!|>'\"%@`]]\n | [?:-] [^\\s[\\[\\]{},]]\n ", + "beginCaptures": { + "0": { + "name": "entity.name.tag.yaml" + } + }, + "contentName": "entity.name.tag.yaml", + "end": "(?x)\n (?=\n \\s* $\n | \\s+ \\#\n | \\s* : (\\s|$)\n | \\s* : [\\[\\]{},]\n | \\s* [\\[\\]{},]\n )\n ", + "name": "string.unquoted.plain.in.yaml" + } + ] + }, + { + "include": "#flow-node" + }, + { + "begin": ":(?=\\s|$|[\\[\\]{},])", + "captures": { + "0": { + "name": "punctuation.separator.key-value.mapping.yaml" + } + }, + "end": "(?=[},\\]])", + "name": "meta.flow-pair.yaml", + "patterns": [ + { + "include": "#flow-value" + } + ] + } + ] + }, + "flow-scalar": { + "patterns": [ + { + "include": "#flow-scalar-double-quoted" + }, + { + "include": "#flow-scalar-single-quoted" + }, + { + "include": "#flow-scalar-plain-in" + } + ] + }, + "flow-scalar-double-quoted": { + "begin": "\"", + "beginCaptures": { + "0": { + "name": "punctuation.definition.string.begin.yaml" + } + }, + "end": "\"", + "endCaptures": { + "0": { + "name": "punctuation.definition.string.end.yaml" + } + }, + "name": "string.quoted.double.yaml", + "patterns": [ + { + "include": "#cfn-sub-parameters" + }, + { + "match": "\\\\([0abtnvfre \"/\\\\N_Lp]|x\\d\\d|u\\d{4}|U\\d{8})", + "name": "constant.character.escape.yaml" + }, + { + "match": "\\\\\\n", + "name": "constant.character.escape.double-quoted.newline.yaml" + } + ] + }, + "flow-scalar-plain-in": { + "patterns": [ + { + "include": "#flow-scalar-plain-in-implicit-type" + }, + { + "begin": "(?x)\n [^\\s[-?:,\\[\\]{}#&*!|>'\"%@`]]\n | [?:-] [^\\s[\\[\\]{},]]\n ", + "end": "(?x)\n (?=\n \\s* $\n | \\s+ \\#\n | \\s* : (\\s|$)\n | \\s* : [\\[\\]{},]\n | \\s* [\\[\\]{},]\n )\n ", + "name": "string.unquoted.plain.in.yaml", + "patterns": [ + { + "include": "#cfn-sub-parameters" + } + ] + } + ] + }, + "flow-scalar-plain-in-implicit-type": { + "patterns": [ + { + "captures": { + "1": { + "name": "constant.language.null.yaml" + }, + "2": { + "name": "constant.language.boolean.yaml" + }, + "3": { + "name": "constant.numeric.integer.yaml" + }, + "4": { + "name": "constant.numeric.float.yaml" + }, + "5": { + "name": "constant.other.timestamp.yaml" + }, + "6": { + "name": "constant.language.value.yaml" + }, + "7": { + "name": "constant.language.merge.yaml" + } + }, + "match": "(?x)\n (?x:\n (null|Null|NULL|~)\n | (y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|on|On|ON|off|Off|OFF)\n | (\n (?:\n [-+]? 0b [0-1_]+ # (base 2)\n | [-+]? 0 [0-7_]+ # (base 8)\n | [-+]? (?: 0|[1-9][0-9_]*) # (base 10)\n | [-+]? 0x [0-9a-fA-F_]+ # (base 16)\n | [-+]? [1-9] [0-9_]* (?: :[0-5]?[0-9])+ # (base 60)\n )\n )\n | (\n (?x:\n [-+]? (?: [0-9] [0-9_]*)? \\. [0-9.]* (?: [eE] [-+] [0-9]+)? # (base 10)\n | [-+]? [0-9] [0-9_]* (?: :[0-5]?[0-9])+ \\. [0-9_]* # (base 60)\n | [-+]? \\. (?: inf|Inf|INF) # (infinity)\n | \\. (?: nan|NaN|NAN) # (not a number)\n )\n )\n | (\n (?x:\n \\d{4} - \\d{2} - \\d{2} # (y-m-d)\n | \\d{4} # (year)\n - \\d{1,2} # (month)\n - \\d{1,2} # (day)\n (?: [Tt] | [ \\t]+) \\d{1,2} # (hour)\n : \\d{2} # (minute)\n : \\d{2} # (second)\n (?: \\.\\d*)? # (fraction)\n (?:\n (?:[ \\t]*) Z\n | [-+] \\d{1,2} (?: :\\d{1,2})?\n )? # (time zone)\n )\n )\n | (=)\n | (<<)\n )\n (?:\n (?=\n \\s* $\n | \\s+ \\#\n | \\s* : (\\s|$)\n | \\s* : [\\[\\]{},]\n | \\s* [\\[\\]{},]\n )\n )\n " + } + ] + }, + "flow-scalar-plain-out": { + "patterns": [ + { + "include": "#flow-scalar-plain-out-implicit-type" + }, + { + "begin": "(?x)\n [^\\s[-?:,\\[\\]{}#&*!|>'\"%@`]]\n | [?:-] \\S\n ", + "end": "(?x)\n (?=\n \\s* $\n | \\s+ \\#\n | \\s* : (\\s|$)\n )\n ", + "name": "string.unquoted.plain.out.yaml", + "patterns": [ + { + "include": "#cfn-sub-parameters" + } + ] + } + ] + }, + "flow-scalar-plain-out-implicit-type": { + "patterns": [ + { + "captures": { + "1": { + "name": "constant.language.null.yaml" + }, + "2": { + "name": "constant.language.boolean.yaml" + }, + "3": { + "name": "constant.numeric.integer.yaml" + }, + "4": { + "name": "constant.numeric.float.yaml" + }, + "5": { + "name": "constant.other.timestamp.yaml" + }, + "6": { + "name": "constant.language.value.yaml" + }, + "7": { + "name": "constant.language.merge.yaml" + } + }, + "match": "(?x)\n (?x:\n (null|Null|NULL|~)\n | (y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|on|On|ON|off|Off|OFF)\n | (\n (?:\n [-+]? 0b [0-1_]+ # (base 2)\n | [-+]? 0 [0-7_]+ # (base 8)\n | [-+]? (?: 0|[1-9][0-9_]*) # (base 10)\n | [-+]? 0x [0-9a-fA-F_]+ # (base 16)\n | [-+]? [1-9] [0-9_]* (?: :[0-5]?[0-9])+ # (base 60)\n )\n )\n | (\n (?x:\n [-+]? (?: [0-9] [0-9_]*)? \\. [0-9.]* (?: [eE] [-+] [0-9]+)? # (base 10)\n | [-+]? [0-9] [0-9_]* (?: :[0-5]?[0-9])+ \\. [0-9_]* # (base 60)\n | [-+]? \\. (?: inf|Inf|INF) # (infinity)\n | \\. (?: nan|NaN|NAN) # (not a number)\n )\n )\n | (\n (?x:\n \\d{4} - \\d{2} - \\d{2} # (y-m-d)\n | \\d{4} # (year)\n - \\d{1,2} # (month)\n - \\d{1,2} # (day)\n (?: [Tt] | [ \\t]+) \\d{1,2} # (hour)\n : \\d{2} # (minute)\n : \\d{2} # (second)\n (?: \\.\\d*)? # (fraction)\n (?:\n (?:[ \\t]*) Z\n | [-+] \\d{1,2} (?: :\\d{1,2})?\n )? # (time zone)\n )\n )\n | (=)\n | (<<)\n )\n (?x:\n (?=\n \\s* $\n | \\s+ \\#\n | \\s* : (\\s|$)\n )\n )\n " + } + ] + }, + "flow-scalar-single-quoted": { + "begin": "'", + "beginCaptures": { + "0": { + "name": "punctuation.definition.string.begin.yaml" + } + }, + "end": "'(?!')", + "endCaptures": { + "0": { + "name": "punctuation.definition.string.end.yaml" + } + }, + "name": "string.quoted.single.yaml", + "patterns": [ + { + "include": "#cfn-sub-parameters" + }, + { + "match": "''", + "name": "constant.character.escape.single-quoted.yaml" + } + ] + }, + "flow-sequence": { + "begin": "\\[", + "beginCaptures": { + "0": { + "name": "punctuation.definition.sequence.begin.yaml" + } + }, + "end": "\\]", + "endCaptures": { + "0": { + "name": "punctuation.definition.sequence.end.yaml" + } + }, + "name": "meta.flow-sequence.yaml", + "patterns": [ + { + "include": "#prototype" + }, + { + "match": ",", + "name": "punctuation.separator.sequence.yaml" + }, + { + "include": "#flow-pair" + }, + { + "include": "#flow-node" + } + ] + }, + "flow-value": { + "patterns": [ + { + "begin": "\\G(?![},\\]])", + "end": "(?=[},\\]])", + "name": "meta.flow-pair.value.yaml", + "patterns": [ + { + "include": "#flow-node" + } + ] + } + ] + }, + "node": { + "patterns": [ + { + "include": "#block-node" + } + ] + }, + "property": { + "begin": "(?=!|&)", + "end": "(?!\\G)", + "name": "meta.property.yaml", + "patterns": [ + { + "captures": { + "1": { + "name": "keyword.control.property.anchor.yaml" + }, + "2": { + "name": "punctuation.definition.anchor.yaml" + }, + "3": { + "name": "entity.name.type.anchor.yaml" + }, + "4": { + "name": "invalid.illegal.character.anchor.yaml" + } + }, + "match": "\\G((&))([^\\s\\[\\]/{/},]+)(\\S+)?" + }, + { + "include": "#cfn-functions" + }, + { + "match": "(?x)\n \\G\n (?:\n ! < (?: %[0-9A-Fa-f]{2} | [0-9A-Za-z\\-#;/?:@&=+$,_.!~*'()\\[\\]] )+ >\n | (?:!(?:[0-9A-Za-z\\-]*!)?) (?: %[0-9A-Fa-f]{2} | [0-9A-Za-z\\-#;/?:@&=+$_.~*'()] )+\n | !\n )\n (?=\\ |\\t|$)\n ", + "name": "storage.type.tag-handle.yaml" + }, + { + "match": "\\S+", + "name": "invalid.illegal.tag-handle.yaml" + } + ] + }, + "prototype": { + "patterns": [ + { + "include": "#comment" + }, + { + "include": "#property" + } + ] + } + } +}