Skip to content

Commit

Permalink
fix(lambda): template parameter prompter not available from all entry…
Browse files Browse the repository at this point in the history
…points #6240

## Problem
SAM CLI guided deploy support template parameter override for both sync
and deploy command. However, the AppBuilder wizard UI only support this
feature for SAM deploy trigger from SAM template context menu or
AppBuilder project node menu button.

## Solution
- Implement nested wizard for template parameter for both sync and
deploy action for all entry points.
- Refactor DeployWizard class for consistency with SyncWizard class
- Add unit test for validating correct backward flow and state
restoration for both wizard.
  • Loading branch information
vicheey authored Dec 16, 2024
1 parent a4c3443 commit 525181b
Show file tree
Hide file tree
Showing 9 changed files with 987 additions and 395 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import * as vscode from 'vscode'
import { Wizard } from '../../../shared/wizards/wizard'
import { createExitPrompter } from '../../../shared/ui/common/exitPrompter'
import * as CloudFormation from '../../../shared/cloudformation/cloudformation'
import { createInputBox } from '../../../shared/ui/inputPrompter'
import { createCommonButtons } from '../../../shared/ui/buttons'
import { getRecentResponse, updateRecentResponse } from '../../../shared/sam/utils'
import { getParameters } from '../../../lambda/config/parameterUtils'

export interface TemplateParametersForm {
[key: string]: any
}

export class TemplateParametersWizard extends Wizard<TemplateParametersForm> {
template: vscode.Uri
preloadedTemplate: CloudFormation.Template | undefined
samTemplateParameters: Map<string, { required: boolean }> | undefined
samCommandUrl: vscode.Uri
commandMementoRootKey: string

public constructor(template: vscode.Uri, samCommandUrl: vscode.Uri, commandMementoRootKey: string) {
super({ exitPrompterProvider: createExitPrompter })
this.template = template
this.samCommandUrl = samCommandUrl
this.commandMementoRootKey = commandMementoRootKey
}

public override async init(): Promise<this> {
this.samTemplateParameters = await getParameters(this.template)
this.preloadedTemplate = await CloudFormation.load(this.template.fsPath)
const samTemplateNames = new Set<string>(this.samTemplateParameters?.keys() ?? [])

samTemplateNames.forEach((name) => {
if (this.preloadedTemplate) {
const defaultValue = this.preloadedTemplate.Parameters
? (this.preloadedTemplate.Parameters[name]?.Default as string)
: undefined
this.form[name].bindPrompter(() =>
this.createParamPromptProvider(name, defaultValue).transform(async (item) => {
await updateRecentResponse(this.commandMementoRootKey, this.template.fsPath, name, item)
return item
})
)
}
})

return this
}

createParamPromptProvider(name: string, defaultValue: string | undefined) {
return createInputBox({
title: `Specify SAM Template parameter value for ${name}`,
buttons: createCommonButtons(this.samCommandUrl),
value: getRecentResponse(this.commandMementoRootKey, this.template.fsPath, name) ?? defaultValue,
})
}
}
304 changes: 95 additions & 209 deletions packages/core/src/shared/sam/deploy.ts

Large diffs are not rendered by default.

160 changes: 110 additions & 50 deletions packages/core/src/shared/sam/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import * as vscode from 'vscode'
import * as path from 'path'
import * as localizedText from '../localizedText'
import { DefaultS3Client } from '../clients/s3Client'
import { Wizard } from '../wizards/wizard'
import { DataQuickPickItem, createMultiPick, createQuickPick } from '../ui/pickerPrompter'
import { DefaultCloudFormationClient } from '../clients/cloudFormationClient'
import * as CloudFormation from '../cloudformation/cloudformation'
Expand All @@ -28,14 +27,14 @@ import { createExitPrompter } from '../ui/common/exitPrompter'
import { getConfigFileUri, SamConfig, validateSamSyncConfig, writeSamconfigGlobal } from './config'
import { cast, Optional } from '../utilities/typeConstructors'
import { pushIf, toRecord } from '../utilities/collectionUtils'
import { getOverriddenParameters } from '../../lambda/config/parameterUtils'
import { getParameters } from '../../lambda/config/parameterUtils'
import { addTelemetryEnvVar } from './cli/samCliInvokerUtils'
import { samSyncParamUrl, samSyncUrl, samUpgradeUrl } from '../constants'
import { openUrl } from '../utilities/vsCodeUtils'
import { showOnce } from '../utilities/messages'
import { IamConnection } from '../../auth/connection'
import { CloudFormationTemplateRegistry } from '../fs/templateRegistry'
import { TreeNode } from '../treeview/resourceTreeDataProvider'
import { isTreeNode, TreeNode } from '../treeview/resourceTreeDataProvider'
import { getSpawnEnv } from '../env/resolveEnv'
import {
getProjectRoot,
Expand All @@ -52,13 +51,19 @@ import { ParamsSource, createSyncParamsSourcePrompter } from '../ui/sam/paramsSo
import { createEcrPrompter } from '../ui/sam/ecrPrompter'
import { BucketSource, createBucketNamePrompter, createBucketSourcePrompter } from '../ui/sam/bucketPrompter'
import { runInTerminal } from './processTerminal'
import {
TemplateParametersForm,
TemplateParametersWizard,
} from '../../awsService/appBuilder/wizards/templateParametersWizard'
import { CompositeWizard } from '../wizards/compositeWizard'

export interface SyncParams {
readonly paramsSource: ParamsSource
readonly region: string
readonly deployType: 'infra' | 'code'
readonly projectRoot: vscode.Uri
readonly template: TemplateItem
readonly templateParameters: any
readonly stackName: string
readonly bucketSource: BucketSource
readonly bucketName: string
Expand Down Expand Up @@ -147,7 +152,30 @@ export const syncFlagItems: DataQuickPickItem<string>[] = [
},
]

export class SyncWizard extends Wizard<SyncParams> {
export enum SamSyncEntryPoints {
SamTemplateFile,
SamConfigFile,
RegionNodeContextMenu,
AppBuilderNodeButton,
CommandPalette,
}

function getSyncEntryPoint(arg: vscode.Uri | AWSTreeNodeBase | TreeNode | undefined) {
if (arg instanceof vscode.Uri) {
if (arg.path.endsWith('samconfig.toml')) {
return SamSyncEntryPoints.SamConfigFile
}
return SamSyncEntryPoints.SamTemplateFile
} else if (arg instanceof AWSTreeNodeBase) {
return SamSyncEntryPoints.RegionNodeContextMenu
} else if (isTreeNode(arg)) {
return SamSyncEntryPoints.AppBuilderNodeButton
} else {
return SamSyncEntryPoints.CommandPalette
}
}

export class SyncWizard extends CompositeWizard<SyncParams> {
registry: CloudFormationTemplateRegistry
public constructor(
state: Pick<SyncParams, 'deployType'> & Partial<SyncParams>,
Expand All @@ -156,17 +184,38 @@ export class SyncWizard extends Wizard<SyncParams> {
) {
super({ initState: state, exitPrompterProvider: shouldPromptExit ? createExitPrompter : undefined })
this.registry = registry
}

public override async init(): Promise<this> {
this.form.template.bindPrompter(() => createTemplatePrompter(this.registry, syncMementoRootKey, samSyncUrl))
this.form.templateParameters.bindPrompter(
async ({ template }) =>
this.createWizardPrompter<TemplateParametersWizard, TemplateParametersForm>(
TemplateParametersWizard,
template!.uri,
samSyncUrl,
syncMementoRootKey
),
{
showWhen: async ({ template }) => {
const samTemplateParameters = await getParameters(template!.uri)
return !!samTemplateParameters && samTemplateParameters.size > 0
},
}
)

this.form.projectRoot.setDefault(({ template }) => getProjectRoot(template))

this.form.paramsSource.bindPrompter(async ({ projectRoot }) => {
const existValidSamConfig: boolean | undefined = await validateSamSyncConfig(projectRoot)
return createSyncParamsSourcePrompter(existValidSamConfig)
})

this.form.region.bindPrompter(() => createRegionPrompter().transform((r) => r.id), {
showWhen: ({ paramsSource }) =>
paramsSource === ParamsSource.Specify || paramsSource === ParamsSource.SpecifyAndSave,
})

this.form.stackName.bindPrompter(
({ region }) =>
createStackPrompter(new DefaultCloudFormationClient(region!), syncMementoRootKey, samSyncUrl),
Expand Down Expand Up @@ -210,6 +259,7 @@ export class SyncWizard extends Wizard<SyncParams> {
paramsSource === ParamsSource.Specify || paramsSource === ParamsSource.SpecifyAndSave,
}
)
return this
}
}

Expand Down Expand Up @@ -296,30 +346,22 @@ export async function saveAndBindArgs(args: SyncParams): Promise<{ readonly boun
return { boundArgs }
}

async function loadLegacyParameterOverrides(template: TemplateItem) {
try {
const params = await getOverriddenParameters(template.uri)
if (!params) {
return
}

return [...params.entries()].map(([k, v]) => `${k}=${v}`)
} catch (err) {
getLogger().warn(`sam: unable to load legacy parameter overrides: %s`, err)
}
}

export async function runSamSync(args: SyncParams) {
telemetry.record({ lambdaPackageType: args.ecrRepoUri !== undefined ? 'Image' : 'Zip' })

const { path: samCliPath, parsedVersion } = await getSamCliPathAndVersion()
const { boundArgs } = await saveAndBindArgs(args)
const overrides = await loadLegacyParameterOverrides(args.template)
if (overrides !== undefined) {
// Leaving this out of the definitions file as this is _very_ niche and specific to the
// implementation. Plus we would have to redefine `sam_sync` to add it.
telemetry.record({ isUsingTemplatesJson: true } as any)
boundArgs.push('--parameter-overrides', ...overrides)

if (!!args.templateParameters && Object.entries(args.templateParameters).length > 0) {
const templateParameters = new Map<string, string>(Object.entries(args.templateParameters))
const paramsToSet: string[] = []
for (const [key, value] of templateParameters.entries()) {
if (value) {
await updateRecentResponse(syncMementoRootKey, args.template.uri.fsPath, key, value)
paramsToSet.push(`ParameterKey=${key},ParameterValue=${value}`)
}
}
paramsToSet.length > 0 && boundArgs.push('--parameter-overrides', paramsToSet.join(' '))
}

// '--no-watch' was not added until https://github.com/aws/aws-sam-cli/releases/tag/v1.77.0
Expand Down Expand Up @@ -431,21 +473,30 @@ export async function prepareSyncParams(
): Promise<Partial<SyncParams>> {
// Skip creating dependency layers by default for backwards compat
const baseParams: Partial<SyncParams> = { skipDependencyLayer: true }
const entryPoint = getSyncEntryPoint(arg)

if (arg instanceof AWSTreeNodeBase) {
// "Deploy" command was invoked on a regionNode.
return { ...baseParams, region: arg.regionCode }
} else if (arg instanceof vscode.Uri) {
if (arg.path.endsWith('samconfig.toml')) {
// "Deploy" command was invoked on a samconfig.toml file.
// TODO: add step to verify samconfig content to skip param source prompter
const config = await SamConfig.fromConfigFileUri(arg)
switch (entryPoint) {
case SamSyncEntryPoints.SamTemplateFile: {
const entryPointArg = arg as vscode.Uri
const template = {
uri: entryPointArg,
data: await CloudFormation.load(entryPointArg.fsPath, validate),
}

return {
...baseParams,
template: template,
projectRoot: getProjectRootUri(template.uri),
}
}
case SamSyncEntryPoints.SamConfigFile: {
const config = await SamConfig.fromConfigFileUri(arg as vscode.Uri)
const params = getSyncParamsFromConfig(config)
const projectRoot = vscode.Uri.joinPath(config.location, '..')
const templateUri = params.templatePath
? vscode.Uri.file(path.resolve(projectRoot.fsPath, params.templatePath))
: undefined
const template = templateUri
const samConfigFileTemplate = templateUri
? {
uri: templateUri,
data: await CloudFormation.load(templateUri.fsPath),
Expand All @@ -454,29 +505,38 @@ export async function prepareSyncParams(
// Always use the dependency layer if the user specified to do so
const skipDependencyLayer = !config.getCommandParam('sync', 'dependency_layer')

return { ...baseParams, ...params, template, projectRoot, skipDependencyLayer } as SyncParams
return {
...baseParams,
...params,
template: samConfigFileTemplate,
projectRoot,
skipDependencyLayer,
} as SyncParams
}

// "Deploy" command was invoked on a template.yaml file.
const template = {
uri: arg,
data: await CloudFormation.load(arg.fsPath, validate),
case SamSyncEntryPoints.RegionNodeContextMenu: {
const entryPointArg = arg as AWSTreeNodeBase
return { ...baseParams, region: entryPointArg.regionCode }
}

return { ...baseParams, template, projectRoot: getProjectRootUri(template.uri) }
} else if (arg && arg.getTreeItem()) {
// "Deploy" command was invoked on a TreeNode on the AppBuilder.
const templateUri = (arg.getTreeItem() as vscode.TreeItem).resourceUri
if (templateUri) {
const template = {
uri: templateUri,
data: await CloudFormation.load(templateUri.fsPath, validate),
case SamSyncEntryPoints.AppBuilderNodeButton: {
const entryPointArg = arg as TreeNode
const templateUri = (entryPointArg.getTreeItem() as vscode.TreeItem).resourceUri
if (templateUri) {
const template = {
uri: templateUri,
data: await CloudFormation.load(templateUri.fsPath, validate),
}
return {
...baseParams,
template,
projectRoot: getProjectRootUri(templateUri),
}
}
return { ...baseParams, template, projectRoot: getProjectRootUri(template.uri) }
return baseParams
}
case SamSyncEntryPoints.CommandPalette:
default:
return baseParams
}

return baseParams
}

export type SamSyncResult = {
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/shared/ui/wizardPrompter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { Prompter, PromptResult } from './prompter'
* - {@link SingleNestedWizard}
* - {@link DoubleNestedWizard}
*/

// eslint-disable-next-line @typescript-eslint/naming-convention
export const WIZARD_PROMPTER = 'WIZARD_PROMPTER'
export class WizardPrompter<T> extends Prompter<T> {
public get recentItem(): any {
return undefined
Expand Down Expand Up @@ -56,6 +59,7 @@ export class WizardPrompter<T> extends Prompter<T> {
}
}

// eslint-disable-next-line @typescript-eslint/naming-convention
protected async promptUser(): Promise<PromptResult<T>> {
this.response = await this.wizard.run()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,10 @@ describe('DeployTypeWizard', function () {
assert.strictEqual(picker.items.length, 2)
picker.acceptItem(picker.items[1])
})
.handleInputBox('Specify SAM parameter value for SourceBucketName', (inputBox) => {
.handleInputBox('Specify SAM Template parameter value for SourceBucketName', (inputBox) => {
inputBox.acceptValue('my-source-bucket-name')
})
.handleInputBox('Specify SAM parameter value for DestinationBucketName', (inputBox) => {
.handleInputBox('Specify SAM Template parameter value for DestinationBucketName', (inputBox) => {
inputBox.acceptValue('my-destination-bucket-name')
})
.handleQuickPick('Specify parameter source for deploy', async (quickPick) => {
Expand Down Expand Up @@ -98,6 +98,12 @@ describe('DeployTypeWizard', function () {
assert.strictEqual(picker.items.length, 2)
picker.acceptItem(picker.items[0])
})
.handleInputBox('Specify SAM Template parameter value for SourceBucketName', (inputBox) => {
inputBox.acceptValue('my-source-bucket-name')
})
.handleInputBox('Specify SAM Template parameter value for DestinationBucketName', (inputBox) => {
inputBox.acceptValue('my-destination-bucket-name')
})
.handleQuickPick('Specify parameter source for sync', async (quickPick) => {
// Need time to check samconfig.toml file and generate options
await quickPick.untilReady()
Expand Down
Loading

0 comments on commit 525181b

Please sign in to comment.