Add Local and Remote Settings conversion#4387
Conversation
| context.activityChildren?.push( | ||
| new GenericTreeItem(undefined, { | ||
| contextValue: createUniversallyUniqueContextValue(['useExistingResourceGroupInfoItem', activitySuccessContext]), | ||
| label: localize('deletedSetting', `Deleted app setting "${connection.originalValue}"`), |
There was a problem hiding this comment.
Should all the activity child labels be present tense instead of past tense?
Left a similar comment on this PR as well with more context:
#4382 (comment)
| "azureFunctions.createNewProjectWithDockerfile": "Create New Containerized Project...", | ||
| "azureFunctions.createPythonVenv": "Create a virtual environment when creating a new Python project.", | ||
| "azureFunctions.createSlot": "Create Slot...", | ||
| "azureFunctions.convertLocalConnections": "Convert Local Project Connections to Identity-Based Connections...", |
There was a problem hiding this comment.
We may want to change the verbiage from "convert" to "add" considering we aren't deleting the old settings anymore.
| import { type IConvertConnectionsContext } from "./IConvertConnectionsContext"; | ||
| import { type Connection } from "./SelectConnectionsStep"; | ||
|
|
||
| export class ConvertSettingsStep extends AzureWizardExecuteStep<IConvertConnectionsContext> { |
There was a problem hiding this comment.
nit: I prefer having the step names following a <noun><verb>Step convention. The reason being, if you have multiple steps that are for Settings, they would all be listed together in the folder, like SettingsConvertStep, SettingsListStep, etc.
| public priority: number = 100; | ||
|
|
||
| public async execute(context: IConvertConnectionsContext): Promise<void> { | ||
| if (context.connections) { |
There was a problem hiding this comment.
I'd prefer to take this out of the if statement and just do nonNullProp(context, connections).
The reason being that this should only execute if !!context.connections evaluates to true, and if there is some reason that this is running when it is not, I'd rather the step throw an internal error than just silently skipping it, making it a harder to uncover error.
| ...getClientIdAndCredentialProperties(context, storageAccountName) | ||
| ); | ||
| context.roles?.push({ | ||
| scopeId: await getScopeHelper(context, storageAccountName, `resourceType eq 'Microsoft.Storage/storageAccounts'`), |
There was a problem hiding this comment.
Is the filter supposed to be resourceType eq on all of these? Can you explain this a little bit?
There was a problem hiding this comment.
Within the scope helper function I only want to look at resources that are the same resource type as the settings i am looking at. This is because resources can have the same names and that is the only information I have to find which resource the scope should be assigned to.
| roleDefinitionId: createRoleId(context.subscriptionId, CommonRoleDefinitions.storageBlobDataOwner), | ||
| roleDefinitionName: CommonRoleDefinitions.storageBlobDataOwner.roleName | ||
| }); | ||
| } else if (connection.name.includes('STORAGE')) { |
There was a problem hiding this comment.
I think it's safe to combine this and connection.name.includes('AzureWebJobsStorage') as one conditional?
if (connection.name.includes('AzureWebJobsStorage') || connection.name.includes('STORAGE') to save on a lot of space/copied logic.
There was a problem hiding this comment.
Yeah I thought about doing this. Originally I had AzureWebJobsStorage set to the __accountName property and only recently found out we shouldn't be using that. I'll combine the two.
| return picks; | ||
| } | ||
|
|
||
| private async getRemoteQuickPics(context: IConvertConnectionsContext): Promise<IAzureQuickPickItem<Connection>[]> { |
There was a problem hiding this comment.
nit:
| private async getRemoteQuickPics(context: IConvertConnectionsContext): Promise<IAzureQuickPickItem<Connection>[]> { | |
| private async getRemoteQuickPicks(context: IConvertConnectionsContext): Promise<IAzureQuickPickItem<Connection>[]> { |
| private async getRemoteQuickPics(context: IConvertConnectionsContext): Promise<IAzureQuickPickItem<Connection>[]> { | ||
| const picks: IAzureQuickPickItem<Connection>[] = []; | ||
|
|
||
| if (context.functionapp) { |
There was a problem hiding this comment.
Similar thing to what I had said in another step, but I'd prefer you use nonNullValue here because otherwise, it'll just return an empty array and we won't know if that's because there is no function app or if it's because the function app isn't listing app settings correctly.
| export async function convertLocalConnections(context: IActionContext, node?: AppSettingTreeItem): Promise<void> { | ||
| const connections: Connection[] = []; | ||
| if (node instanceof AppSettingTreeItem) { | ||
| connections.push({ name: node.id, value: node.value }) |
There was a problem hiding this comment.
| connections.push({ name: node.id, value: node.value }) | |
| connections.push({ name: node.id, value: node.value }); |
|
|
||
| const continueOn: MessageItem = { title: localize('continueOn', 'Continue') }; | ||
| const message: string = localize('setConnectionsProperty', 'Successfully converted local connections in order to use identity based connections you may need to set the connection property within your trigger.'); | ||
| await context.ui.showWarningMessage(message, { learnMoreLink: "https://aka.ms/AAuroke", modal: true }, continueOn); |
There was a problem hiding this comment.
I believe that we should show this message before they start converting. We can show it again after in the activity log, but if they're worried about messing up their project, it's a little bit late by this point.
| } | ||
| } | ||
|
|
||
| export async function convertRemoteConnectionsInternal(context: IActionContext, connections?: Connection[], node?: AppSettingTreeItem): Promise<void> { |
There was a problem hiding this comment.
Are these internal functions identical besides setting local to false?
There was a problem hiding this comment.
There are some slight differences beyond that but once I split up the ConvertSettingsStep into remote and local they will also use different execute steps.
| context.localSettingsPath = await getLocalSettingsFile(context, message); | ||
| } | ||
|
|
||
| const localSettings = await AzExtFsExtra.readJSON<ILocalSettingsJson>(nonNullProp(context, 'localSettingsPath')); |
There was a problem hiding this comment.
I feel like we should just make getting the settings from the local.settings.json a helper function.
| import { type SlotTreeItem } from "../../tree/SlotTreeItem"; | ||
| import { type Connection } from "./ConnectionsListStep"; | ||
|
|
||
| export interface IAddMIConnectionsContext extends ExecuteActivityContext, IResourceGroupWizardContext, ISubscriptionActionContext { |
There was a problem hiding this comment.
nit: No "👁️" needed here.................................
| public priority: number = 110; | ||
|
|
||
| public async execute(context: IAddMIConnectionsContext): Promise<void> { | ||
| if (!context.functionapp) { |
There was a problem hiding this comment.
nit: Why not just use nonNullProp?
| const promptSteps: AzureWizardPromptStep<IAddMIConnectionsContext>[] = []; | ||
| const executeSteps: AzureWizardExecuteStep<IAddMIConnectionsContext>[] = []; | ||
|
|
||
| promptSteps.push(new ConnectionsListStep()) |
There was a problem hiding this comment.
nit: You're not pushing steps in dynamically anymore so might as well just instantiate promptSteps and executeSteps with the steps that they need instead of creating empty arrays first,
| await node.runWithTemporaryDescription(context, localize('adding', 'Adding...'), async () => { | ||
| await addRemoteMIConnectionsInternal(context, undefined, node); | ||
| }); | ||
| } else { |
There was a problem hiding this comment.
Why do you need this else? What's the third case here?
| await context.ui.showWarningMessage(localize('rolesWillBeAssignedMessage', 'This command will assign a managed identity and roles would you like to continue?'), { modal: true }, continueOn); | ||
| await wizard.execute(); | ||
|
|
||
| const message: string = localize('setConnectionsProperty', 'Successfully added remote connections in order to use identity connections your application may require additional permisssions based on your code. You also may need to modify the connection property within your trigger.'); |
| import { ext } from '../extensionVariables'; | ||
| import { installOrUpdateFuncCoreTools } from '../funcCoreTools/installOrUpdateFuncCoreTools'; | ||
| import { uninstallFuncCoreTools } from '../funcCoreTools/uninstallFuncCoreTools'; | ||
| import { type DurableTaskSchedulerClient } from '../tree/durableTaskScheduler/DurableTaskSchedulerClient'; |
There was a problem hiding this comment.
You really just went and reorganized this whole file huh?
| } | ||
|
|
||
| private async getPicks(context: AddMIConnectionsContext): Promise<IAzureQuickPickItem<Connection>[]> { | ||
| if (context.functionapp) { |
There was a problem hiding this comment.
nit: return context.functionapp ? this.getRemoteQuickPicks(context) : this.getLocalQuickPicks(context)
| const localSettingsPath: string = await getLocalSettingsFile(context, message, workspaceFolder); | ||
| context.localSettingsPath = localSettingsPath; | ||
|
|
||
| if (await AzExtFsExtra.pathExists(localSettingsPath)) { |
There was a problem hiding this comment.
Now that you're using this function, I think you can get rid of the if statement above.
| await wizard.execute(); | ||
|
|
||
| const continueOn: MessageItem = { title: localize('continueOn', 'Continue') }; | ||
| const message: string = localize('setConnectionsProperty', 'Successfully added local connections. In order to use identity based connections, you may need to set the connection properties within your trigger.'); |
There was a problem hiding this comment.
Successfully added local connections. To use identity-based connections, you may need to configure the connection properties within your trigger.
| if (localSettingsPath) { | ||
| if (await AzExtFsExtra.pathExists(localSettingsPath)) { | ||
| const localSettingsUri: vscode.Uri = vscode.Uri.file(localSettingsPath); | ||
| let localSettings: ILocalSettingsJson = <ILocalSettingsJson>await AzExtFsExtra.readJSON(localSettingsPath); |
There was a problem hiding this comment.
Use your helper function here.
|
|
||
| const localSettings = await getLocalSettingsJson(context, localSettingsPath); | ||
| if (localSettings.Values) { | ||
| for (const [key, value] of Object.entries(localSettings.Values)) { |
There was a problem hiding this comment.
nit: If you want to be even cleaner, this can be a helper function since it's used by getRemoteQuickPicks too (don't hate me)
| const localSettingsPath: string | undefined = await getLocalSettingsFileNoPrompt(context, this._workspaceFolder); | ||
| if (localSettingsPath === undefined) { | ||
| return { properties: {} }; | ||
| } else { |
There was a problem hiding this comment.
You can use your helper function again in here (a couple lines below this).
It also may make more sense to export getLocalSettingsJson from here since it's more related to the LocalSettingsClient than anything else. You could consider adding it to a util file too.
| import { SettingsAddBaseStep } from "./SettingsAddBaseStep"; | ||
|
|
||
| export async function addLocalMIConnections(context: IActionContext, node?: AppSettingTreeItem): Promise<void> { | ||
| const connections: Connection[] = []; |
There was a problem hiding this comment.
I think it's possible to have multiple local project nodes, so if you enter through the command palette, you'll need something like:
if (!node) {
node = await pickLocalProject(); // not real code
}
| } | ||
| } | ||
| } | ||
|
|

Fixes #4376
For both local and remote there are multiple entry points:

Local
On the top level Local Project node in the workspace view and on each connection under the local settings node:
I have also added a warning node on the specific connections that need to be converted.
Remote

On the top level App Setting Item and on the specific connection:
After converting connections and assigning roles to the chosen managed identity the activity log will look like this:

Notes:
Todo: