Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions packages/core/src/awsService/cloudformation/auth/credentials.ts
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> {

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?

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

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

Choose a reason for hiding this comment

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

nit: this can be moved to a util function

Choose a reason for hiding this comment

The 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> {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: we can move this to shared/utils

Choose a reason for hiding this comment

The 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
}
}
Loading