-
Notifications
You must be signed in to change notification settings - Fork 734
feat(cloudformation): Merge CloudFormation LSP integration with toolkit updates #8275
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feature/cloudformation
Are you sure you want to change the base?
Changes from 2 commits
09b66ad
89434cb
e19e476
d5a5bd9
44672d9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void> { | ||
| this.client = client | ||
| await this.updateCredentialsFromActiveConnection() | ||
| } | ||
|
|
||
| private async updateCredentialsFromActiveConnection(): Promise<void> { | ||
| 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<void> { | ||
| await this.updateCredentialsFromActiveConnection() | ||
| } | ||
|
|
||
| private async createEncryptedCredentialsRequest(data: any): Promise<any> { | ||
| 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() | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ParsedCfnEnvironmentFile[]> { | ||
| const result = await client.sendRequest(ParseCfnEnvironmentFilesRequest, params) | ||
| return result.parsedFiles | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<boolean> { | ||
| 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<void> { | ||
| 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<boolean> { | ||
| 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<void> { | ||
| 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<CfnEnvironmentLookup> { | ||
| 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<CfnEnvironmentFileSelectorItem | undefined> { | ||
| 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<DeploymentFileDetail | undefined> { | ||
| 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<string> { | ||
| const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: this can be moved to a util function There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will move to a util function |
||
| if (!workspaceRoot) { | ||
| throw new Error('No workspace folder found') | ||
| } | ||
| return path.join(workspaceRoot, this.cfnProjectPath, this.environmentsDirectory, environmentName) | ||
| } | ||
|
|
||
| private async getConfigPath(): Promise<string> { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: we can move this to shared/utils There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will move to shared/utils |
||
| 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<string> { | ||
| 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 | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
will this always send the credentials or only when the feature is used?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Its hard to say if the feature is being used, because there are a lot of just UI elements visualizing their resources. Which requires sending credentials to load, they might not interact with the UI other than just viewing.
This will send the credentials anytime it changes, as long as the LSP is active