Skip to content

Commit 09b66ad

Browse files
committed
feat(cloudformation): Add comprehensive CloudFormation LSP integration
- Add CloudFormation Language Server Protocol support with multiple provider options - Include CloudFormation explorer with stack, resource, and change set management - Add stack deployment, validation, and change set workflows with S3 upload support - Include drift detection and diff visualization capabilities - Add CloudFormation environment and project management with cfn-init integration - Include telemetry and authentication handling - Add comprehensive test coverage for all CloudFormation features - Update package configurations and language syntax highlighting
1 parent 5657bc1 commit 09b66ad

File tree

113 files changed

+13017
-187
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

113 files changed

+13017
-187
lines changed

CONTRIBUTING.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,11 @@ Unlike the user setting overrides, not all of these environment variables have t
527527

528528
- `SSMDOCUMENT_LANGUAGESERVER_PORT`: The port the ssm document language server should start debugging on
529529

530+
#### CloudFormation LSP
531+
532+
- `__CLOUDFORMATIONLSP_PATH`: for aws.dev.cloudformationLsp.path
533+
- `__CLOUDFORMATIONLSP_CLOUDFORMATION_ENDPOINT`: for aws.dev.cloudformationLsp.cloudformationEndpoint
534+
530535
#### CI/Testing
531536

532537
- `GITHUB_ACTION`: The name of the current GitHub Action workflow step that is running
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { Disposable } from 'vscode'
7+
import { LanguageClient } from 'vscode-languageclient/node'
8+
import { StacksManager } from '../stacks/stacksManager'
9+
import { ResourcesManager } from '../resources/resourcesManager'
10+
import { CloudFormationRegionManager } from '../explorer/regionManager'
11+
import globals from '../../../shared/extensionGlobals'
12+
import * as jose from 'jose'
13+
import * as crypto from 'crypto'
14+
15+
export const encryptionKey = crypto.randomBytes(32)
16+
17+
export class AwsCredentialsService implements Disposable {
18+
private authChangeListener: Disposable
19+
private client: LanguageClient | undefined
20+
21+
constructor(
22+
private stacksManager: StacksManager,
23+
private resourcesManager: ResourcesManager,
24+
private regionManager: CloudFormationRegionManager
25+
) {
26+
this.authChangeListener = globals.awsContext.onDidChangeContext(() => {
27+
void this.updateCredentialsFromActiveConnection()
28+
})
29+
}
30+
31+
async initialize(client: LanguageClient): Promise<void> {
32+
this.client = client
33+
await this.updateCredentialsFromActiveConnection()
34+
}
35+
36+
private async updateCredentialsFromActiveConnection(): Promise<void> {
37+
if (!this.client) {
38+
return
39+
}
40+
41+
const credentials = await globals.awsContext.getCredentials()
42+
const profileName = globals.awsContext.getCredentialProfileName()
43+
44+
if (credentials && profileName) {
45+
const encryptedRequest = await this.createEncryptedCredentialsRequest({
46+
profile: profileName.replaceAll('profile:', ''),
47+
region: this.regionManager.getSelectedRegion(),
48+
accessKeyId: credentials.accessKeyId,
49+
secretAccessKey: credentials.secretAccessKey,
50+
sessionToken: credentials.sessionToken,
51+
})
52+
53+
await this.client.sendRequest('aws/credentials/iam/update', encryptedRequest)
54+
}
55+
56+
void this.stacksManager.reload()
57+
void this.resourcesManager.reload()
58+
}
59+
60+
async updateRegion(): Promise<void> {
61+
await this.updateCredentialsFromActiveConnection()
62+
}
63+
64+
private async createEncryptedCredentialsRequest(data: any): Promise<any> {
65+
const payload = new TextEncoder().encode(JSON.stringify({ data }))
66+
67+
const jwt = await new jose.CompactEncrypt(payload)
68+
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
69+
.encrypt(encryptionKey)
70+
71+
return {
72+
data: jwt,
73+
encrypted: true,
74+
}
75+
}
76+
77+
dispose(): void {
78+
this.authChangeListener.dispose()
79+
}
80+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { LanguageClient } from 'vscode-languageclient/node'
7+
import {
8+
ParsedCfnEnvironmentFile,
9+
ParseCfnEnvironmentFilesParams,
10+
ParseCfnEnvironmentFilesRequest,
11+
} from './cfnEnvironmentRequestType'
12+
13+
export async function parseCfnEnvironmentFiles(
14+
client: LanguageClient,
15+
params: ParseCfnEnvironmentFilesParams
16+
): Promise<ParsedCfnEnvironmentFile[]> {
17+
const result = await client.sendRequest(ParseCfnEnvironmentFilesRequest, params)
18+
return result.parsedFiles
19+
}
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { Disposable, Uri, window, workspace, commands } from 'vscode'
7+
import { Auth } from '../../../auth/auth'
8+
import { commandKey, extractErrorMessage, formatMessage, toString } from '../utils'
9+
import {
10+
CfnConfig,
11+
CfnEnvironmentConfig,
12+
CfnEnvironmentLookup,
13+
DeploymentConfig,
14+
CfnEnvironmentFileSelectorItem as DeploymentFileDetail,
15+
CfnEnvironmentFileSelectorItem,
16+
} from './cfnProjectTypes'
17+
import path from 'path'
18+
import fs from '../../../shared/fs/fs'
19+
import { CfnEnvironmentSelector } from '../ui/cfnEnvironmentSelector'
20+
import { CfnEnvironmentFileSelector } from '../ui/cfnEnvironmentFileSelector'
21+
import globals from '../../../shared/extensionGlobals'
22+
import { TemplateParameter } from '../stacks/actions/stackActionRequestType'
23+
import { validateParameterValue } from '../stacks/actions/stackActionInputValidation'
24+
import { getLogger } from '../../../shared/logger/logger'
25+
import { DocumentInfo } from './cfnEnvironmentRequestType'
26+
import { parseCfnEnvironmentFiles } from './cfnEnvironmentApi'
27+
import { LanguageClient } from 'vscode-languageclient/node'
28+
import { Parameter } from '@aws-sdk/client-cloudformation'
29+
import { convertRecordToParameters, convertRecordToTags } from './utils'
30+
31+
export class CfnEnvironmentManager implements Disposable {
32+
private readonly cfnProjectPath = 'cfn-project'
33+
private readonly configFile = 'cfn-config.json'
34+
private readonly environmentsDirectory = 'environments'
35+
private readonly selectedEnvironmentKey = 'aws.cloudformation.selectedEnvironment'
36+
private readonly auth = Auth.instance
37+
private listeners: (() => void)[] = []
38+
39+
private readonly initializeOption = 'Initialize Project'
40+
41+
constructor(
42+
private readonly client: LanguageClient,
43+
private readonly environmentSelector: CfnEnvironmentSelector,
44+
private readonly environmentFileSelector: CfnEnvironmentFileSelector
45+
) {}
46+
47+
public addListener(listener: () => void): void {
48+
this.listeners.push(listener)
49+
}
50+
51+
public getSelectedEnvironmentName(): string | undefined {
52+
return globals.context.workspaceState.get(this.selectedEnvironmentKey)
53+
}
54+
55+
private notifyListeners(): void {
56+
for (const listener of this.listeners) {
57+
listener()
58+
}
59+
}
60+
61+
public async promptInitializeIfNeeded(operation: string): Promise<boolean> {
62+
if (!(await this.isProjectInitialized())) {
63+
const choice = await window.showWarningMessage(
64+
`You must initialize your CFN Project to perform ${operation}`,
65+
this.initializeOption
66+
)
67+
68+
if (choice === this.initializeOption) {
69+
void commands.executeCommand(commandKey('init.initializeProject'))
70+
}
71+
return true
72+
}
73+
74+
return false
75+
}
76+
77+
public async selectEnvironment(): Promise<void> {
78+
if (await this.promptInitializeIfNeeded('Environment Selection')) {
79+
return
80+
}
81+
82+
let environmentLookup: CfnEnvironmentLookup
83+
84+
try {
85+
environmentLookup = await this.fetchAvailableEnvironments()
86+
} catch (error) {
87+
void window.showErrorMessage(
88+
formatMessage(`Failed to retrieve environments from configuration: ${toString(error)}`)
89+
)
90+
return
91+
}
92+
93+
const environmentName = await this.environmentSelector.selectEnvironment(environmentLookup)
94+
95+
if (environmentName) {
96+
await this.setSelectedEnvironment(environmentName, environmentLookup)
97+
}
98+
}
99+
100+
private async isProjectInitialized(): Promise<boolean> {
101+
const configPath = await this.getConfigPath()
102+
const projectDirectory = await this.getProjectDir()
103+
104+
return (await fs.existsFile(configPath)) && (await fs.existsDir(projectDirectory))
105+
}
106+
107+
private async setSelectedEnvironment(
108+
environmentName: string,
109+
environmentLookup: CfnEnvironmentLookup
110+
): Promise<void> {
111+
const environment = environmentLookup[environmentName]
112+
113+
if (environment) {
114+
await globals.context.workspaceState.update(this.selectedEnvironmentKey, environmentName)
115+
116+
await this.syncEnvironmentWithProfile(environment)
117+
}
118+
119+
this.notifyListeners()
120+
}
121+
122+
private async syncEnvironmentWithProfile(environment: CfnEnvironmentConfig) {
123+
const profileName = environment.profile
124+
125+
const currentConnection = await this.auth.getConnection({ id: `profile:${profileName}` })
126+
127+
if (!currentConnection) {
128+
void window.showErrorMessage(formatMessage(`No connection found for profile: ${profileName}`))
129+
return
130+
}
131+
132+
await this.auth.useConnection(currentConnection)
133+
}
134+
135+
public async fetchAvailableEnvironments(): Promise<CfnEnvironmentLookup> {
136+
const configPath = await this.getConfigPath()
137+
const config = JSON.parse(await fs.readFileText(configPath)) as CfnConfig
138+
139+
return config.environments
140+
}
141+
142+
public async selectEnvironmentFile(
143+
templateUri: string,
144+
requiredParameters: TemplateParameter[]
145+
): Promise<CfnEnvironmentFileSelectorItem | undefined> {
146+
const environmentName = this.getSelectedEnvironmentName()
147+
const selectorItems: CfnEnvironmentFileSelectorItem[] = []
148+
149+
if (!environmentName) {
150+
return undefined
151+
}
152+
153+
try {
154+
const environmentDir = await this.getEnvironmentDir(environmentName)
155+
const files = await fs.readdir(environmentDir)
156+
157+
const filesToParse: DocumentInfo[] = await Promise.all(
158+
files
159+
.filter(
160+
([fileName]) =>
161+
fileName.endsWith('.json') || fileName.endsWith('.yaml') || fileName.endsWith('.yml')
162+
)
163+
.map(async ([fileName]) => {
164+
const filePath = path.join(environmentDir, fileName)
165+
const content = await fs.readFileText(filePath)
166+
const type = fileName.endsWith('.json') ? 'JSON' : 'YAML'
167+
168+
return {
169+
type,
170+
content,
171+
fileName,
172+
}
173+
})
174+
)
175+
176+
const environmentFiles = await parseCfnEnvironmentFiles(this.client, { documents: filesToParse })
177+
178+
for (const deploymentFile of environmentFiles) {
179+
const item = await this.createEnvironmentFileSelectorItem(
180+
deploymentFile.fileName,
181+
deploymentFile.deploymentConfig,
182+
requiredParameters,
183+
templateUri
184+
)
185+
if (item) {
186+
selectorItems.push(item)
187+
}
188+
}
189+
} catch (error) {
190+
void window.showErrorMessage(`Error loading deployment files: ${extractErrorMessage(error)}`)
191+
return undefined
192+
}
193+
194+
return await this.environmentFileSelector.selectEnvironmentFile(selectorItems, requiredParameters.length)
195+
}
196+
197+
private async createEnvironmentFileSelectorItem(
198+
fileName: string,
199+
deploymentConfig: DeploymentConfig,
200+
requiredParameters: TemplateParameter[],
201+
templateUri: string
202+
): Promise<DeploymentFileDetail | undefined> {
203+
try {
204+
return {
205+
fileName: fileName,
206+
hasMatchingTemplatePath:
207+
workspace.asRelativePath(Uri.parse(templateUri)) === deploymentConfig.templateFilePath,
208+
compatibleParameters: this.getCompatibleParams(deploymentConfig, requiredParameters),
209+
optionalFlags: {
210+
tags: deploymentConfig.tags ? convertRecordToTags(deploymentConfig.tags) : undefined,
211+
includeNestedStacks: deploymentConfig.includeNestedStacks,
212+
importExistingResources: deploymentConfig.importExistingResources,
213+
onStackFailure: deploymentConfig.onStackFailure,
214+
},
215+
}
216+
} catch (error) {
217+
getLogger().warn(`Failed to create selector item ${fileName}:`, error)
218+
}
219+
}
220+
221+
private getCompatibleParams(
222+
deploymentConfig: DeploymentConfig,
223+
requiredParameters: TemplateParameter[]
224+
): Parameter[] | undefined {
225+
if (deploymentConfig.parameters && requiredParameters.length > 0) {
226+
const parameters = deploymentConfig.parameters
227+
228+
// Filter only parameters that are in template and are valid
229+
const validParams = requiredParameters.filter((templateParam) => {
230+
if (!(templateParam.name in parameters)) {
231+
return false
232+
}
233+
const value = parameters[templateParam.name]
234+
return validateParameterValue(value, templateParam) === undefined
235+
})
236+
237+
const validParameterNames = validParams.map((p) => p.name)
238+
const filteredParameters = Object.fromEntries(
239+
Object.entries(parameters).filter(([key]) => validParameterNames.includes(key))
240+
)
241+
242+
return convertRecordToParameters(filteredParameters)
243+
}
244+
}
245+
246+
public async getEnvironmentDir(environmentName: string): Promise<string> {
247+
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
248+
if (!workspaceRoot) {
249+
throw new Error('No workspace folder found')
250+
}
251+
return path.join(workspaceRoot, this.cfnProjectPath, this.environmentsDirectory, environmentName)
252+
}
253+
254+
private async getConfigPath(): Promise<string> {
255+
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
256+
if (!workspaceRoot) {
257+
throw new Error('No workspace folder found')
258+
}
259+
return path.join(workspaceRoot, this.cfnProjectPath, this.configFile)
260+
}
261+
262+
private async getProjectDir(): Promise<string> {
263+
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
264+
if (!workspaceRoot) {
265+
throw new Error('No workspace folder found')
266+
}
267+
return path.join(workspaceRoot, this.cfnProjectPath)
268+
}
269+
270+
dispose(): void {
271+
// No resources to dispose
272+
}
273+
}

0 commit comments

Comments
 (0)