Skip to content
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

feat(vscode): Import Logic Apps from Cloud to Local VSCode #5693

Draft
wants to merge 39 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
ffc6834
feat(vscode): Add cloud to local gesture (#4878)
Jaden-Codes Jun 27, 2024
8535e9b
feat(vscode): Developing Cloud to Local feature (#5046)
Jaden-Codes Jul 19, 2024
95920db
converts auth parameters to MSI when deploying
JoaquimMalcampo Aug 20, 2024
77ef2ba
initial changes to include connection resolution
Sep 9, 2024
d518977
Revert "converts auth parameters to MSI when deploying"
Sep 9, 2024
995aec8
feat(vscode): Add cloud to local gesture (#4878)
Jaden-Codes Jun 27, 2024
688281f
feat(vscode): Developing Cloud to Local feature (#5046)
Jaden-Codes Jul 19, 2024
710fd14
initial changes to include connection resolution
Sep 9, 2024
1185b19
destructuring fix
Sep 9, 2024
f9fcd28
Merge branch 'jmalcampo/cloud_to_local' of https://github.com/Azure/L…
Sep 9, 2024
1f7a669
reformatting and adding parameterize setting handling
Sep 11, 2024
8d0d717
added LA type prompt and added helper functions
Sep 11, 2024
ce44ac2
Merge branch 'main' into jmalcampo/cloud_to_local
Sep 16, 2024
00811ea
happy path working
Sep 16, 2024
9dc0834
Merge branch 'main' into jmalcampo/cloud_to_local
Sep 16, 2024
62aa172
Merge branch 'main' into jmalcampo/cloud_to_local
Sep 20, 2024
6a82685
resolving comments
Sep 23, 2024
865ef10
cleaned up zipFileStep and cleanLocalSettings
Sep 23, 2024
a585261
Merge branch 'main' into jmalcampo/cloud_to_local
Sep 23, 2024
2663bb2
added telemetry
Sep 23, 2024
835a0d2
does not parameterize if setting is null
Sep 24, 2024
d4982fb
moved logic to execution step
Sep 26, 2024
3fc9a5e
Merge branch 'main' into jmalcampo/cloud_to_local
Sep 26, 2024
cb2de83
added telemetry to execution step
Sep 26, 2024
a4bf794
Merge branch 'main' into jmalcampo/cloud_to_local
Oct 29, 2024
1f16e1b
Merge branch 'main' into jmalcampo/cloud_to_local
Oct 30, 2024
56eb504
Wizard and local setting logic changes
Nov 2, 2024
db0925d
Merge branch 'main' into jmalcampo/cloud_to_local
Nov 2, 2024
86f1502
changed logic to excluding files and rename select folder step
Nov 4, 2024
3c1baa2
Merge branch 'main' into jmalcampo/cloud_to_local
Nov 5, 2024
b4561d5
Merge branch 'main' into jmalcampo/cloud_to_local
Nov 11, 2024
ab3713a
add read me placeholder and open after import
Nov 12, 2024
5e5ffb7
Merge branch 'main' into jmalcampo/cloud_to_local
Nov 12, 2024
532c421
changed test
Nov 12, 2024
4bb4def
added comments to steps
Nov 12, 2024
736bf7f
reverted bundleFeed changes and add more param setting support
Nov 14, 2024
264013a
moved readme to assets and add check for param setting
Nov 19, 2024
4e2f433
Merge branch 'main' into jmalcampo/cloud_to_local
Nov 19, 2024
f719b99
add import read me
Dec 18, 2024
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils';
import type { IFunctionWizardContext, IProjectWizardContext } from '@microsoft/vscode-extension-logic-apps';
import path from 'path';
import { unzipLogicAppArtifacts } from '../../../utils/taskUtils';
import * as fse from 'fs-extra';

export class ExtractPackageStep extends AzureWizardPromptStep<IFunctionWizardContext> {
/**
* Unzips package contents to package path and removes unnecessary files
* @param context - Project wizard context containing user selections and settings
*/
public async prompt(context: IFunctionWizardContext): Promise<void> {
try {
const data: Buffer | Buffer[] = fse.readFileSync(context.packagePath);
await unzipLogicAppArtifacts(data, context.projectPath);

const projectFiles = fse.readdirSync(context.projectPath);
const filesToExclude = [];
const excludedFiles = ['.vscode', 'obj', 'bin', 'local.settings.json', 'host.json'];
const excludedExt = ['.csproj'];

projectFiles.forEach((fileName) => {
if (excludedExt.includes(path.extname(fileName))) {
filesToExclude.push(path.join(context.projectPath, fileName));
}
});

excludedFiles.forEach((excludedFile) => {
if (fse.existsSync(path.join(context.projectPath, excludedFile))) {
filesToExclude.push(path.join(context.projectPath, excludedFile));
}
});

filesToExclude.forEach((path) => {
fse.removeSync(path);
context.telemetry.properties.excludedFile = `Excluded ${path.basename} from package`;
});

// Create README.md file
const readMePath = path.join(__dirname, 'assets', 'readmes', 'importReadMe.md');
const readMeContent = fse.readFileSync(readMePath, 'utf8');
fse.writeFileSync(path.join(context.projectPath, 'README.md'), readMeContent);
} catch (error) {
context.telemetry.properties.error = error.message;
console.error(`Failed to extract contents of package to ${context.projectPath}`, error);
}
}

/**
* Checks if this step should prompt the user
* @param context - Project wizard context containing user selections and settings
* @returns True if user should be prompted, otherwise false
*/
public shouldPrompt(context: IProjectWizardContext): boolean {
return context.packagePath !== undefined && context.projectPath !== undefined;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,22 @@ export class NewCodeProjectTypeStep extends AzureWizardPromptStep<IProjectWizard
public hideStepCount = true;
private readonly templateId?: string;
private readonly functionSettings?: { [key: string]: string | undefined };
private readonly skipWorkflowStateTypeStep?: boolean;

/**
* The constructor initializes the NewCodeProjectTypeStep object with optional templateId and functionSettings parameters.
* @param templateId - The ID of the template for the code project.
* @param functionSettings - The settings for the functions in the code project.
*/
public constructor(templateId: string | undefined, functionSettings: { [key: string]: string | undefined } | undefined) {
public constructor(
templateId: string | undefined,
functionSettings: { [key: string]: string | undefined } | undefined,
skipWorkflowStateTypeStep: any
) {
super();
this.templateId = templateId;
this.functionSettings = functionSettings;
this.skipWorkflowStateTypeStep = skipWorkflowStateTypeStep;
}

/**
Expand Down Expand Up @@ -153,13 +159,15 @@ export class NewCodeProjectTypeStep extends AzureWizardPromptStep<IProjectWizard
executeSteps.push(new WorkflowProjectCreateStep());
await addInitVSCodeSteps(context, executeSteps, false);

promptSteps.push(
await WorkflowStateTypeStep.create(context, {
isProjectWizard: true,
templateId: this.templateId,
triggerSettings: this.functionSettings,
})
);
if (!this.skipWorkflowStateTypeStep) {
promptSteps.push(
await WorkflowStateTypeStep.create(context, {
isProjectWizard: true,
templateId: this.templateId,
triggerSettings: this.functionSettings,
})
);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { AzureWizardExecuteStep, callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils';
import type { IFunctionWizardContext, ILocalSettingsJson, IProjectWizardContext } from '@microsoft/vscode-extension-logic-apps';
import { parameterizeConnectionsInProjectLoadSetting } from '../../../../constants';
import path from 'path';
import {
changeAuthTypeToRaw,
cleanLocalSettings,
extractConnectionSettings,
mergeAppSettings,
parameterizeConnectionsDuringImport,
updateConnectionKeys,
} from '../cloudToLocalHelper';
import { getGlobalSetting } from '../../../utils/vsCodeConfig/settings';
import { writeFormattedJson } from '../../../utils/fs';
import { getConnectionsJson } from '../../../utils/codeless/connection';
import { getLocalSettingsJson } from '../../../utils/appSettings/localSettings';
import AdmZip from 'adm-zip';
import { extend, isEmptyString } from '@microsoft/logic-apps-shared';
import { Uri, window, workspace } from 'vscode';
import { localize } from '../../../../localize';
import * as fse from 'fs-extra';
import { getContainingWorkspace } from '../../../utils/workspace';
import { ext } from '../../../../extensionVariables';

interface ICachedTextDocument {
projectPath: string;
textDocumentPath: string;
}

const cacheKey = 'azLAPostExtractReadMe';

export function runPostExtractStepsFromCache(): void {
const cachedDocument: ICachedTextDocument | undefined = ext.context.globalState.get(cacheKey);
if (cachedDocument) {
try {
runPostExtractSteps(cachedDocument);
} finally {
ext.context.globalState.update(cacheKey, undefined);
}
}
}

export class ProcessPackageStep extends AzureWizardExecuteStep<IProjectWizardContext> {
public priority = 200;

/**
* Executes the step to integrate the package into the new Logic App workspace
* @param context The context object for the project wizard.
* @returns A Promise that resolves to void.
*/
public async execute(context: IProjectWizardContext): Promise<void> {
const localSettingsPath = path.join(context.projectPath, 'local.settings.json');
const parameterizeConnectionsSetting = getGlobalSetting(parameterizeConnectionsInProjectLoadSetting);

let appSettings: ILocalSettingsJson = {};
let zipSettings: ILocalSettingsJson = {};
let connectionsData: any = {};

try {
const connectionsString = await getConnectionsJson(context.projectPath);

// merge the app settings from local.settings.json and the settings from the zip file
appSettings = await getLocalSettingsJson(context, localSettingsPath, false);
const zipEntries = await this.getPackageEntries(context.packagePath);
const zipSettingsBuffer = zipEntries.find((entry) => entry.entryName === 'local.settings.json');
if (zipSettingsBuffer) {
context.telemetry.properties.localSettingsInZip = 'Local settings found in the zip file';
zipSettings = JSON.parse(zipSettingsBuffer.getData().toString('utf8'));
await writeFormattedJson(localSettingsPath, mergeAppSettings(appSettings, zipSettings));
}

if (isEmptyString(connectionsString)) {
context.telemetry.properties.noConnectionsInZip = 'No connections found in the zip file';
return;
}

connectionsData = JSON.parse(connectionsString);
if (Object.keys(connectionsData).length && connectionsData.managedApiConnections) {
/** Extract details from connections and add to local.settings.json
* independent of the parameterizeConnectionsInProject setting */
appSettings = await getLocalSettingsJson(context, localSettingsPath, false);
await writeFormattedJson(localSettingsPath, extend(appSettings, await extractConnectionSettings(context)));

if (parameterizeConnectionsSetting) {
await parameterizeConnectionsDuringImport(context as IFunctionWizardContext, appSettings.Values);
}

await changeAuthTypeToRaw(context, parameterizeConnectionsSetting);
await updateConnectionKeys(context);
await cleanLocalSettings(context);
}

// OpenFolder will restart the extension host so we will cache README to open on next activation
const readMePath = path.join(context.projectPath, 'README.md');
const postExtractCache: ICachedTextDocument = { projectPath: context.projectPath, textDocumentPath: readMePath };
ext.context.globalState.update(cacheKey, postExtractCache);
// Delete cached information if the extension host was not restarted after 5 seconds
setTimeout(() => {
ext.context.globalState.update(cacheKey, undefined);
}, 5 * 1000);
runPostExtractSteps(postExtractCache);
} catch (error) {
context.telemetry.properties.error = error.message;
}
}

/**
* Determines whether this step should be executed based on the user's input.
* @param context The context object for the project wizard.
* @returns A boolean value indicating whether this step should be executed.
*/
public shouldExecute(context: IFunctionWizardContext): boolean {
return context.packagePath !== undefined;
}

private async getPackageEntries(zipFilePath: string) {
const zip = new AdmZip(zipFilePath);
return zip.getEntries();
}
}

function runPostExtractSteps(cache: ICachedTextDocument): void {
callWithTelemetryAndErrorHandling('postExtractPackage', async (context: IActionContext) => {
context.telemetry.suppressIfSuccessful = true;

if (getContainingWorkspace(cache.projectPath)) {
if (await fse.pathExists(cache.textDocumentPath)) {
window.showTextDocument(await workspace.openTextDocument(Uri.file(cache.textDocumentPath)));
}
}
context.telemetry.properties.finishedImportingProject = 'Finished importing project';
window.showInformationMessage(localize('finishedImporting', 'Finished importing project.'));
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { extractConnectionDetails, changeAuthTypeToRaw } from '../cloudToLocalHelper';
import type { ConnectionReferenceModel } from '@microsoft/vscode-extension-logic-apps';
import { describe, it, expect, vi } from 'vitest';
import { beforeEach } from 'vitest';

vi.mock('fs');
describe('extractConnectionDetails and ChangAuthToRaw are being tested for cloudToLocal.', () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe('extractConnectionDetails for ConnectionReferenceModel', () => {
it('should extract connection details correctly', () => {
let connectionObject = {
managedApiConnections: {
connKey: {
api: {
id: '/subscriptions/346751b2-0de1-405c-ad29-acb7ba73797f/providers/Microsoft.Web/locations/eastus2/managedApis/applicationinsights',
},
connection: {
id: '/subscriptions/346751b2-0de1-405c-ad29-acb7ba73797f/resourceGroups/vs-code-debug/providers/Microsoft.Web/connections/applicationinsights',
},
authentication: {
type: 'ManagedServiceIdentity',
},
connectionRuntimeUrl: 'runtime-url',
},
},
};
const newconnection = JSON.stringify(connectionObject);
const parsedconnection: ConnectionReferenceModel = JSON.parse(newconnection);

const expectedDetails = [
{
WORKFLOWS_LOCATION_NAME: 'eastus2',
WORKFLOWS_RESOURCE_GROUP_NAME: 'vs-code-debug',
WORKFLOWS_SUBSCRIPTION_ID: '346751b2-0de1-405c-ad29-acb7ba73797f',
},
];

extractConnectionDetails(parsedconnection).then((result) => {
expect(result).toEqual(expectedDetails);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
extensionCommand,
funcVersionSetting,
projectLanguageSetting,
projectOpenBehaviorSetting,
projectTemplateKeySetting,
} from '../../../constants';
import { localize } from '../../../localize';
import { addLocalFuncTelemetry, tryGetLocalFuncVersion, tryParseFuncVersion } from '../../utils/funcCoreTools/funcVersion';
import { showPreviewWarning } from '../../utils/taskUtils';
import { getGlobalSetting, getWorkspaceSetting } from '../../utils/vsCodeConfig/settings';
import { OpenBehaviorStep } from '../createNewProject/OpenBehaviorStep';
import { NewCodeProjectTypeStep } from './CodeProjectBase/NewCodeProjectTypeStep';
import { SelectPackageStep } from '../createNewProject/createProjectSteps/SelectPackageStep';
import { OpenFolderStepCodeProject } from './CodeProjectBase/OpenFolderStepCodeProject';
import { SetLogicAppName } from './CodeProjectBase/SetLogicAppNameStep';
import { setWorkspaceName } from './CodeProjectBase/SetWorkspaceName';
import { AzureWizard } from '@microsoft/vscode-azext-utils';
import type { IActionContext } from '@microsoft/vscode-azext-utils';
import { latestGAVersion, OpenBehavior } from '@microsoft/vscode-extension-logic-apps';
import type { ICreateFunctionOptions, IFunctionWizardContext, ProjectLanguage } from '@microsoft/vscode-extension-logic-apps';
import { ProcessPackageStep } from './CodeProjectBase/ProcessPackageStep';
import { SelectFolderForNewWorkspaceStep } from '../createNewProject/createProjectSteps/SelectFolderForNewWorkspaceStep';
import { ExtractPackageStep } from './CodeProjectBase/ExtractPackageStep';

const openFolder = true;

export async function cloudToLocalCommand(
context: IActionContext,
options: ICreateFunctionOptions = {
folderPath: undefined,
language: undefined,
version: undefined,
templateId: undefined,
functionName: undefined,
functionSettings: undefined,
suppressOpenFolder: !openFolder,
}
): Promise<void> {
addLocalFuncTelemetry(context);
showPreviewWarning(extensionCommand.cloudToLocal);

const language: ProjectLanguage | string = (options.language as ProjectLanguage) || getGlobalSetting(projectLanguageSetting);
const version: string = options.version || getGlobalSetting(funcVersionSetting) || (await tryGetLocalFuncVersion()) || latestGAVersion;
const projectTemplateKey: string | undefined = getGlobalSetting(projectTemplateKeySetting);
const wizardContext: Partial<IFunctionWizardContext> & IActionContext = Object.assign(context, options, {
language,
version: tryParseFuncVersion(version),
projectTemplateKey,
projectPath: options.folderPath,
});

if (options.suppressOpenFolder) {
wizardContext.openBehavior = OpenBehavior.dontOpen;
} else if (!wizardContext.openBehavior) {
wizardContext.openBehavior = getWorkspaceSetting(projectOpenBehaviorSetting);
context.telemetry.properties.openBehaviorFromSetting = String(!!wizardContext.openBehavior);
}

const wizard: AzureWizard<IFunctionWizardContext> = new AzureWizard(wizardContext, {
title: localize('createLogicAppWorkspaceFromPackage', 'Create New Logic App Workspace from Package'),
promptSteps: [
new SelectPackageStep(),
new SelectFolderForNewWorkspaceStep(),
new setWorkspaceName(),
new SetLogicAppName(),
new NewCodeProjectTypeStep(options.templateId, options.functionSettings, true),
new ExtractPackageStep(),
new OpenBehaviorStep(),
],
executeSteps: [new ProcessPackageStep(), new OpenFolderStepCodeProject()],
hideStepCount: true,
});
try {
await wizard.prompt();
await wizard.execute();
} catch (error) {
context.telemetry.properties.error = error.message;
console.error('Error during wizard execution:', error);
}
}
Loading
Loading