diff --git a/extensions/sql-migration/src/api/sqlUtils.ts b/extensions/sql-migration/src/api/sqlUtils.ts index ddd9068b66..63d58e16df 100644 --- a/extensions/sql-migration/src/api/sqlUtils.ts +++ b/extensions/sql-migration/src/api/sqlUtils.ts @@ -68,7 +68,19 @@ const query_login_tables_sql = ` LEFT JOIN sys.sql_logins sl ON sp.principal_id = sl.principal_id WHERE sp.type NOT IN ('G', 'R') AND sp.type_desc IN ( 'SQL_LOGIN' - --, 'WINDOWS_LOGIN' + ) AND sp.name NOT LIKE '##%##' + ORDER BY sp.name;`; + +const query_login_tables_include_windows_auth_sql = ` + SELECT + sp.name as login, + sp.type_desc as login_type, + sp.default_database_name, + case when sp.is_disabled = 1 then 'Disabled' else 'Enabled' end as status + FROM sys.server_principals sp + LEFT JOIN sys.sql_logins sl ON sp.principal_id = sl.principal_id + WHERE sp.type NOT IN ('G', 'R') AND sp.type_desc IN ( + 'SQL_LOGIN', 'WINDOWS_LOGIN' ) AND sp.name NOT LIKE '##%##' ORDER BY sp.name;`; @@ -351,15 +363,18 @@ export async function getDatabasesList(connectionProfile: azdata.connection.Conn } } -export async function collectSourceLogins(sourceConnectionId: string): Promise { +export async function collectSourceLogins( + sourceConnectionId: string, + includeWindowsAuth: boolean = true): Promise { const ownerUri = await azdata.connection.getUriForConnection(sourceConnectionId); const queryProvider = azdata.dataprotocol.getProvider( 'MSSQL', azdata.DataProviderType.QueryProvider); + const query = includeWindowsAuth ? query_login_tables_include_windows_auth_sql : query_login_tables_sql; const results = await queryProvider.runQueryAndReturn( ownerUri, - query_login_tables_sql); + query); return results.rows.map(row => { return { @@ -374,7 +389,8 @@ export async function collectSourceLogins(sourceConnectionId: string): Promise { + password: string, + includeWindowsAuth: boolean = true): Promise { const connectionProfile = getConnectionProfile( targetServer.properties.fullyQualifiedDomainName, @@ -388,10 +404,11 @@ export async function collectTargetLogins( 'MSSQL', azdata.DataProviderType.QueryProvider); + const query = includeWindowsAuth ? query_login_tables_include_windows_auth_sql : query_login_tables_sql; const ownerUri = await azdata.connection.getUriForConnection(result.connectionId); const results = await queryProvider.runQueryAndReturn( ownerUri, - query_login_tables_sql); + query); return results.rows.map(row => getSqlString(row[0])) ?? []; } diff --git a/extensions/sql-migration/src/constants/strings.ts b/extensions/sql-migration/src/constants/strings.ts index 32e75dd5a3..c5d681334f 100644 --- a/extensions/sql-migration/src/constants/strings.ts +++ b/extensions/sql-migration/src/constants/strings.ts @@ -302,7 +302,7 @@ export function LOGIN_WIZARD_TITLE(instanceName: string): string { } export const LOGIN_MIGRATIONS_TARGET_SELECTION_PAGE_DESCRIPTION = localize('sql.login.migration.wizard.target.description', "Select the target Azure SQL Managed Instance, Azure SQL VM, or Azure SQL database(s) where you want to migrate your logins."); export const LOGIN_MIGRATIONS_TARGET_SELECTION_PAGE_PREVIEW_WARNING = localize('sql.login.migration.wizard.target.data.migration.warning', "Please note that login migration feature is in private preview mode."); -export const LOGIN_MIGRATIONS_TARGET_SELECTION_PAGE_DATA_MIGRATION_WARNING = localize('sql.login.migration.wizard.target.data.migration.warning', "You must successfully migrate all your database(s) to the target before starting the login migration else the migration will fail. Also if the source and target database names are not same then some permissions may not be applied properly. Learn more"); +export const LOGIN_MIGRATIONS_TARGET_SELECTION_PAGE_DATA_MIGRATION_WARNING = localize('sql.login.migration.wizard.target.data.migration.warning', "We recommend migrating your databases(s) to the Azure SQL target before starting the login migration to avoid failures in the process. Nevertheless, you can run this migration process whenever want you want if your goal is to update the user mapping for recently migrated databases.\n\n If the source and database names are not the same, then it is possible that some permissions may not be applied properly."); export function LOGIN_MIGRATIONS_TARGET_SELECTION_PAGE_PERMISSIONS_WARNING(userName: string, instanceName: string): string { if (!userName || !userName.length) { return localize('sql.login.migration.wizard.target.permission.warning', "Please ensure that the current user has sysadmin permissions to get all login information for the current instance ({0}).", instanceName); @@ -337,6 +337,7 @@ export function LOGIN_MIGRATIONS_GET_LOGINS_ERROR(message: string): string { return localize('sql.migration.wizard.target.login.error', "Error getting login information: {0}", message); } export const SELECT_LOGIN_TO_CONTINUE = localize('sql.migration.select.database.to.continue', "Please select 1 or more logins for migration"); +export const ENTER_AAD_DOMAIN_NAME = localize('sql.login.migration.enter.AAD.domain.name.to.continue', "Azure Active Directory (AAD) Domain name is required to migrate Windows login. Please enter an AAD Domain Name or deselect windows login(s)."); export const LOGIN_MIGRATE_BUTTON_TEXT = localize('sql.migration.start.login.migration.button', "Migrate"); export function LOGIN_MIGRATIONS_GET_CONNECTION_STRING(dataSource: string, id: string, pass: string): string { return localize('sql.login.migration.get.connection.string', "data source={0};initial catalog=master;user id={1};password={2};TrustServerCertificate=True;Integrated Security=false;", dataSource, id, pass); @@ -352,18 +353,29 @@ export const STARTING_LOGIN_MIGRATION = localize('sql.migration.starting.login', export const STARTING_LOGIN_MIGRATION_FAILED = localize('sql.migration.starting.login.failed', "Validating and migrating logins failed"); export const ESTABLISHING_USER_MAPPINGS = localize('sql.login.migration.establish.user.mappings', "Validating and migrating logins completed.\n\nEstablishing user mappings."); export const ESTABLISHING_USER_MAPPINGS_FAILED = localize('sql.login.migration.establish.user.mappings.failed', "Establishing user mappings failed"); -export const MIGRATE_SERVER_ROLES_AND_SET_PERMISSIONS = localize('sql.login.migration.migrate.server.roles.and.set.permissions', "Establishing user mappings completed.\n\nCurrently, migrating server roles, establishing server mappings and setting permissions. This will take some time."); -export const MIGRATE_SERVER_ROLES_AND_SET_PERMISSIONS_FAILED = localize('sql.login.migration.migrate.server.roles.and.set.permissions.failed', "Migrating server roles, establishing server mappings and setting permissions failed."); +export const MIGRATING_SERVER_ROLES_AND_SET_PERMISSIONS = localize('sql.login.migration.migrate.server.roles.and.set.permissions', "Establishing user mappings completed.\n\nCurrently, migrating server roles, establishing server mappings and setting permissions. This will take some time."); +export const MIGRATING_SERVER_ROLES_AND_SET_PERMISSIONS_FAILED = localize('sql.login.migration.migrate.server.roles.and.set.permissions.failed', "Migrating server roles, establishing server mappings and setting permissions failed."); export const LOGIN_MIGRATIONS_COMPLETE = localize('sql.login.migration.complete', "Completed migrating logins"); export const LOGIN_MIGRATIONS_FAILED = localize('sql.login.migration.failed', "Migrating logins failed"); export function LOGIN_MIGRATIONS_ERROR(message: string): string { - return localize('sql.login.migration..error', "Login migration error: {0}", message); + return localize('sql.login.migration.error', "Login migration error: {0}", message); } export const LOGINS_FOUND = localize('sql.login.migration.logins.found', "Login found"); export const LOGINS_NOT_FOUND = localize('sql.login.migration.logins.not.found', "Login not found"); export const LOGIN_MIGRATION_STATUS_SUCCEEDED = localize('sql.login.migration.status.succeeded', "Succeeded"); export const LOGIN_MIGRATION_STATUS_FAILED = localize('sql.login.migration.status.failed', "Failed"); export const LOGIN_MIGRATION_STATUS_IN_PROGRESS = localize('sql.login.migration.status.in.progress', "In progress"); +export const LOGIN_MIGRATIONS_AAD_DOMAIN_NAME_INPUT_BOX_LABEL = localize('sql.login.migration.aad.domain.name.input.box.label', "Azure Active Directory Domain Name (only required to migrate Windows Authenication Logins)"); +export const LOGIN_MIGRATIONS_AAD_DOMAIN_NAME_INPUT_BOX_PLACEHOLDER = localize('sql.login.migration.aad.domain.name.input.box.placeholder', "Enter AAD Domain Name"); +export function LOGIN_MIGRATIONS_LOGIN_STATUS_DETAILS_TITLE(loginName: string): string { + return localize('sql.login.migration.login.status.details.title', "Migration status details for {0}", loginName); +} +export const NOT_STARTED = localize('sql.login.migration.steps.not.started', "Not started"); +export const MIGRATE_LOGINS = localize('sql.login.migration.steps.migrate.logins', "Migrate logins"); +export const ESTABLISH_USER_MAPPINGS = localize('sql.login.migration.steps.migrate.logins', "Establish user mappings"); +export const MIGRATE_SERVER_ROLES_AND_SET_PERMISSIONS = localize('sql.login.migration.steps.migrate.logins', "Migrate server roles, set login and server permissions"); +export const LOGIN_MIGRATION_COMPLETED = localize('sql.login.migration.steps.migrate.logins', "Login migration completed"); + // Azure SQL Target export const AZURE_SQL_TARGET_PAGE_TITLE = localize('sql.migration.wizard.target.title', "Azure SQL target"); @@ -1317,3 +1329,29 @@ export const SQLDB_COL_COPY_DURATION = localize('sql.migration.sqldb.column.copy export const SQLDB_COL_PARRALEL_COPY_TYPE = localize('sql.migration.sqldb.column.parallelcopytype', 'Parallel copy type'); export const SQLDB_COL_USED_PARALLEL_COPIES = localize('sql.migration.sqldb.column.usedparallelcopies', 'Used parallel copies'); export const SQLDB_COL_COPY_START = localize('sql.migration.sqldb.column.copystart', 'Copy start'); + +// Multi Step Status Dialog +export const COPY_RESULTS = localize('sql.migration.multi.step.status.dialog.copy.results', "Copy results"); +export const MULTI_STEP_RESULTS_HEADING = localize('sql.migration.multi.step.status.dialog.heading', "Step details"); +export const STEPS_TITLE = localize('sql.migration.multi.step.status.steps.title', "Steps"); +export const RUNNING_MULTI_STEPS_HEADING = localize('sql.migration.running.multi.steps.heading', "We are running the following steps:"); +export const COMPLETED_MULTI_STEPS_HEADING = localize('sql.migration.completed.multi.steps.heading', "We ran the following steps:"); +export const SOME_STEPS_ARE_STILL_RUNNING = localize('sql.migration.multi.step.some.steps.are.still.running', "Some steps are still running."); +export const ALL_STEPS_SUCCEEDED = localize('sql.migration.multi.step.all.steps.succeeded', "All steps succeeded."); +export function ALL_STEPS_COMPLETED_ERRORS(msg: string): string { + return localize( + 'sql.migration.multi.step.all.steps.completed.errors', + "All steps completed with the following error(s):{0}{1}", EOL, msg); +} +export function RESULTS_INFO_BOX_STATUS(state: string | undefined, errors?: string[]): string { + const status = state ?? ''; + if (errors && errors.length > 0) { + return localize( + 'sql.migration.multi.step.status.errors', + "Step status: {0}{1}{2}", status, EOL, errors.join(EOL)); + } else { + return localize( + 'sql.migration.multi.step.status', + "Step status: {0}", status); + } +} diff --git a/extensions/sql-migration/src/dashboard/dashboardTab.ts b/extensions/sql-migration/src/dashboard/dashboardTab.ts index 096d12da6d..0e7443b31d 100644 --- a/extensions/sql-migration/src/dashboard/dashboardTab.ts +++ b/extensions/sql-migration/src/dashboard/dashboardTab.ts @@ -141,7 +141,7 @@ export class DashboardTab extends TabBase { const toolbar = view.modelBuilder.toolbarContainer(); toolbar.addToolbarItems([ { component: this.createNewMigrationButton() }, - // { component: this.createNewLoginMigrationButton() }, // TODO Enable when login migrations is ready for public consumption + { component: this.createNewLoginMigrationButton() }, { component: this.createNewSupportRequestButton() }, { component: this.createFeedbackButton() }, ]); diff --git a/extensions/sql-migration/src/dashboard/migrationsListTab.ts b/extensions/sql-migration/src/dashboard/migrationsListTab.ts index c68dd385b6..58b932d809 100644 --- a/extensions/sql-migration/src/dashboard/migrationsListTab.ts +++ b/extensions/sql-migration/src/dashboard/migrationsListTab.ts @@ -143,8 +143,8 @@ export class MigrationsListTab extends TabBase { }).component(); toolbar.addToolbarItems([ - // { component: this.createNewLoginMigrationButton(), toolbarSeparatorAfter: true }, { component: this.createNewMigrationButton(), toolbarSeparatorAfter: true }, + { component: this.createNewLoginMigrationButton(), toolbarSeparatorAfter: true }, { component: this.createNewSupportRequestButton() }, { component: this.createFeedbackButton(), toolbarSeparatorAfter: true }, { component: this._refreshLoader }, diff --git a/extensions/sql-migration/src/dialog/generic/multiStepStatusDialog.ts b/extensions/sql-migration/src/dialog/generic/multiStepStatusDialog.ts new file mode 100644 index 0000000000..234e984d08 --- /dev/null +++ b/extensions/sql-migration/src/dialog/generic/multiStepStatusDialog.ts @@ -0,0 +1,342 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as vscode from 'vscode'; +import * as constants from '../../constants/strings'; +import { EOL } from 'os'; +import { IconPathHelper } from '../../constants/iconPathHelper'; + +const DialogName = 'MultiStepStatusDialog'; + +enum MultiStepResultIndex { + message = 0, + icon = 1, + status = 2, + errors = 3, + state = 4, +} + +export enum MultiStepState { + Pending = 'Pending', + Running = 'Running', + Succeeded = 'Succeeded', + Failed = 'Failed', + Canceled = 'Canceled', +} + +export interface MultiStepResult { + stepName: string; + state: MultiStepState; + errors: string[]; +} + +export function GetMultiStepStatusString(state: MultiStepState) { + switch (state) { + case MultiStepState.Canceled: + return constants.VALIDATION_STATE_CANCELED; + case MultiStepState.Failed: + return constants.VALIDATION_STATE_FAILED; + case MultiStepState.Pending: + return constants.VALIDATION_STATE_PENDING; + case MultiStepState.Running: + return constants.VALIDATION_STATE_RUNNING; + case MultiStepState.Succeeded: + return constants.VALIDATION_STATE_SUCCEEDED; + default: + return "" + } +} + +export class MultiStepStatusDialog { + private _dialog: azdata.window.Dialog | undefined; + private _disposables: vscode.Disposable[] = []; + private _isOpen: boolean = false; + private _resultsTable!: azdata.TableComponent; + private _startLoader!: azdata.LoadingComponent; + private _copyButton!: azdata.ButtonComponent; + private _headingText!: azdata.TextComponent; + private _results: any[][] = []; + private _errors: string[] = []; + private _onClosed: () => void; + private _areStepsComplete = false; + + constructor( + onClosed: () => void) { + this._onClosed = onClosed; + } + + public async openDialog(dialogTitle: string, results?: MultiStepResult[], areStepsComplete: boolean = false): Promise { + if (!this._isOpen) { + this._isOpen = true; + this._dialog = azdata.window.createModelViewDialog( + dialogTitle, + DialogName, + 600); + this._dialog.cancelButton.hidden = true; + + const promise = this._initializeDialog(this._dialog); + azdata.window.openDialog(this._dialog); + await promise; + + this._areStepsComplete = areStepsComplete; + return this._loadResults(results); + } + } + + private async _initializeDialog(dialog: azdata.window.Dialog): Promise { + return new Promise((resolve, reject) => { + dialog.registerContent(async (view) => { + try { + + this._disposables.push( + dialog.cancelButton.onClick( + e => { + this._onClosed(); + })); + + this._disposables.push( + dialog.okButton.onClick( + e => this._onClosed())); + + this._headingText = view.modelBuilder.text() + .withProps({ + value: constants.RUNNING_MULTI_STEPS_HEADING, + CSSStyles: { + 'font-size': '13px', + 'font-weight': '400', + 'margin-bottom': '10px', + }, + }) + .component(); + this._startLoader = view.modelBuilder.loadingComponent() + .withProps({ + loading: false, + CSSStyles: { 'margin': '5px 0 0 10px' } + }) + .component(); + const headingContainer = view.modelBuilder.flexContainer() + .withLayout({ + flexFlow: 'row', + justifyContent: 'flex-start', + }) + .withItems([this._headingText, this._startLoader], { flex: '0 0 auto' }) + .component(); + + this._resultsTable = await this._createResultsTable(view); + + this._copyButton = view.modelBuilder.button() + .withProps({ + iconPath: IconPathHelper.copy, + iconHeight: 18, + iconWidth: 18, + width: 88, + label: constants.COPY_RESULTS, + }).component(); + + this._disposables.push( + this._copyButton.onDidClick( + async (e) => this._copyResults())); + + const resultsHeading = view.modelBuilder.text() + .withProps({ + value: constants.MULTI_STEP_RESULTS_HEADING, + CSSStyles: { + 'font-size': '16px', + 'font-weight': '600', + 'margin': '0px 0px 0px 0px' + }, + }) + .component(); + + const toolbar = view.modelBuilder.toolbarContainer() + .withToolbarItems([ + { component: resultsHeading, toolbarSeparatorAfter: true }, + { component: this._copyButton }]) + .component(); + + const resultsText = view.modelBuilder.inputBox() + .withProps({ + inputType: 'text', + height: 200, + multiline: true, + CSSStyles: { 'overflow': 'none auto' } + }) + .component(); + + this._disposables.push( + this._resultsTable.onRowSelected( + async (e) => await this._updateResultsInfoBox(resultsText))); + + const flex = view.modelBuilder.flexContainer() + .withItems([ + headingContainer, + this._resultsTable, + toolbar, + resultsText], + { flex: '0 0 auto' }) + .withProps({ CSSStyles: { 'margin': '0 0 0 15px' } }) + .withLayout({ + flexFlow: 'column', + height: '100%', + width: 565, + }).component(); + + this._disposables.push( + view.onClosed(e => + this._disposables.forEach( + d => { try { d.dispose(); } catch { } }))); + + dialog.okButton.focused = true; + + await view.initializeModel(flex); + resolve(); + } catch (ex) { + reject(ex); + } + }); + }); + } + + private async _loadResults(results?: MultiStepResult[]): Promise { + if (this._areStepsComplete) { + this._startLoader.loading = false; + this._headingText.value = constants.COMPLETED_MULTI_STEPS_HEADING; + } else { + this._startLoader.loading = true; + this._headingText.value = constants.RUNNING_MULTI_STEPS_HEADING; + } + + await this._initializeResults(results); + } + + private async _copyResults(): Promise { + const errorsText = this._errors.join(EOL); + const msg = + !this._areStepsComplete ? constants.SOME_STEPS_ARE_STILL_RUNNING : + errorsText.length === 0 ? constants.ALL_STEPS_SUCCEEDED : + constants.ALL_STEPS_COMPLETED_ERRORS(errorsText); + return vscode.env.clipboard.writeText(msg); + } + + private async _updateResultsInfoBox(text: azdata.InputBoxComponent): Promise { + const selectedRows: number[] = this._resultsTable.selectedRows ?? []; + const statusMessages: string[] = []; + if (selectedRows.length > 0) { + for (let i = 0; i < selectedRows.length; i++) { + const row = selectedRows[i]; + const results: any[] = this._results[row]; + const status = results[MultiStepResultIndex.status]; + const errors = results[MultiStepResultIndex.errors]; + statusMessages.push( + constants.RESULTS_INFO_BOX_STATUS(GetMultiStepStatusString(status), errors)); + } + } + + const msg = statusMessages.length > 0 + ? statusMessages.join(EOL) + : ''; + text.value = msg; + } + + private async _createResultsTable(view: azdata.ModelView): Promise { + return view.modelBuilder.table() + .withProps({ + columns: [ + { + value: 'step', + name: constants.STEPS_TITLE, + type: azdata.ColumnType.text, + width: 380, + headerCssClass: 'no-borders', + cssClass: 'no-borders align-with-header', + }, + { + value: 'image', + name: '', + type: azdata.ColumnType.icon, + width: 20, + headerCssClass: 'no-borders display-none', + cssClass: 'no-borders align-with-header', + }, + { + value: 'message', + name: constants.STATUS, + type: azdata.ColumnType.text, + width: 150, + headerCssClass: 'no-borders', + cssClass: 'no-borders align-with-header', + }, + ], + data: [], + width: 580, + height: 300, + CSSStyles: { + 'margin-top': '10px', + 'margin-bottom': '10px', + }, + }) + .component(); + } + + private async _initializeResults(results?: MultiStepResult[]): Promise { + this._results = []; + + if (results && results.length > 0) { + for (let row = 0; row < results.length; row++) { + this._addStepResult(results[row].stepName, results[row].state, results[row].errors); + } + } + + await this._updateTable(); + } + + private _addStepResult(message: string, state: MultiStepState, errors: string[] = []): void { + const status = GetMultiStepStatusString(state); + const statusMsg = state === MultiStepState.Failed && errors.length > 0 + ? constants.VALIDATE_IR_VALIDATION_STATUS_ERROR_COUNT(status, errors.length) + : status; + + const statusMessage = errors.length > 0 + ? constants.VALIDATE_IR_VALIDATION_STATUS_ERROR(status, errors) + : statusMsg; + + this._results.push([ + message, + { + icon: this._getValidationStateImage(state), + title: statusMessage, + }, + statusMsg, + errors, + state]); + + this._errors.push(...errors); + } + + private _getValidationStateImage(state: MultiStepState): azdata.IconPath { + switch (state) { + case MultiStepState.Canceled: + return IconPathHelper.cancel; + case MultiStepState.Failed: + return IconPathHelper.error; + case MultiStepState.Running: + return IconPathHelper.inProgressMigration; + case MultiStepState.Succeeded: + return IconPathHelper.completedMigration; + case MultiStepState.Pending: + default: + return IconPathHelper.notStartedMigration; + } + } + + private async _updateTable() { + const data = this._results.map(row => [ + row[MultiStepResultIndex.message], + row[MultiStepResultIndex.icon], + row[MultiStepResultIndex.status]]); + await this._resultsTable.updateProperty('data', data); + } +} diff --git a/extensions/sql-migration/src/models/loginMigrationModel.ts b/extensions/sql-migration/src/models/loginMigrationModel.ts new file mode 100644 index 0000000000..8787eacce3 --- /dev/null +++ b/extensions/sql-migration/src/models/loginMigrationModel.ts @@ -0,0 +1,235 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as mssql from 'mssql'; +import { MultiStepResult, MultiStepState } from '../dialog/generic/multiStepStatusDialog'; +import * as constants from '../constants/strings'; +import { LoginTableInfo } from '../api/sqlUtils'; + +type ExceptionMap = { [login: string]: any } + +export enum LoginType { + Windows_Login = 'windows_login', + SQL_Login = 'sql_login', +} + +export enum LoginMigrationStep { + NotStarted = -1, + MigrateLogins = 0, + EstablishUserMapping = 1, + MigrateServerRolesAndSetPermissions = 2, + MigrationCompleted = 3, +} + +export function GetLoginMigrationStepString(step: LoginMigrationStep): string { + switch (step) { + case LoginMigrationStep.NotStarted: + return constants.NOT_STARTED; + case LoginMigrationStep.MigrateLogins: + return constants.MIGRATE_LOGINS; + case LoginMigrationStep.EstablishUserMapping: + return constants.ESTABLISH_USER_MAPPINGS; + case LoginMigrationStep.MigrateServerRolesAndSetPermissions: + return constants.MIGRATE_SERVER_ROLES_AND_SET_PERMISSIONS; + case LoginMigrationStep.MigrationCompleted: + return constants.LOGIN_MIGRATION_COMPLETED; + default: + return ""; + } +} + +export interface LoginMigrationStepState { + loginName: string; + stepName: LoginMigrationStep; + status: MultiStepState; + errors: string[]; +} + +export interface Login { + loginName: string; + overallStatus: MultiStepState; + statusPerStep: Map; +} + +export class LoginMigrationModel { + public resultsPerStep: Map; + public collectedSourceLogins: boolean = false; + public collectedTargetLogins: boolean = false;; + public loginsOnSource: LoginTableInfo[] = []; + public loginsOnTarget: string[] = []; + private _currentStepIdx: number = 0;; + private _logins: Map; + private _loginMigrationSteps: LoginMigrationStep[] = []; + + constructor() { + this.resultsPerStep = new Map(); + this._logins = new Map(); + this.SetLoginMigrationSteps(); + } + + public get currentStep(): LoginMigrationStep { + return this._currentStepIdx >= this._loginMigrationSteps.length ? LoginMigrationStep.MigrationCompleted : this._loginMigrationSteps[this._currentStepIdx]; + } + + public get isMigrationComplete(): boolean { + return this._currentStepIdx === this._loginMigrationSteps.length; + } + + public AddLoginMigrationResults(step: LoginMigrationStep, newResult: mssql.StartLoginMigrationResult): void { + const exceptionMap = this._getExceptionMapWithNormalizedKeys(newResult.exceptionMap); + this._currentStepIdx = this._loginMigrationSteps.findIndex(s => s === step) + 1; + + for (const loginName of this._logins.keys()) { + const status = loginName in exceptionMap ? MultiStepState.Failed : MultiStepState.Succeeded; + const errors = loginName in exceptionMap ? this._extractErrors(exceptionMap, loginName) : []; + this._addStepStateForLogin(loginName, step, status, errors); + + if (this.isMigrationComplete) { + const loginStatus = this._didAnyStepFail(loginName) ? MultiStepState.Failed : MultiStepState.Succeeded; + this._markLoginStatus(loginName, loginStatus); + } + } + } + + public ReportException(step: LoginMigrationStep, error: any): void { + this._currentStepIdx = this._loginMigrationSteps.findIndex(s => s === step) + 1; + + for (const loginName of this._logins.keys()) { + // Mark current step as failed with the error message and mark remaining messages as canceled + let errors = [error.message]; + this._addStepStateForLogin(loginName, step, MultiStepState.Failed, errors); + this._markRemainingSteps(loginName, MultiStepState.Canceled); + this._markLoginStatus(loginName, MultiStepState.Failed); + } + + this._markMigrationComplete(); + } + + public GetLoginMigrationResults(loginName: string): MultiStepResult[] { + let loginResults: MultiStepResult[] = []; + let login = this._getLogin(loginName); + + for (const step of this._loginMigrationSteps) { + // The default steps and state will be added if no steps have completed + let stepResult: MultiStepResult = { + stepName: GetLoginMigrationStepString(step), + state: MultiStepState.Pending, + errors: [], + } + + // If the step has completed, then the login will have the stored status + if (login?.statusPerStep.has(step)) { + let stepStatus = login!.statusPerStep.get(step); + stepResult.state = stepStatus!.status; + stepResult.errors = stepStatus!.errors; + } else if (step === this.currentStep) { + stepResult.state = MultiStepState.Running; + } + + loginResults.push(stepResult); + } + + return loginResults; + } + + public AddNewLogins(logins: string[]) { + logins.forEach(login => this._addNewLogin(login)); + } + + public SetLoginMigrationSteps(steps: LoginMigrationStep[] = []) { + this._loginMigrationSteps = []; + + if (steps.length === 0) { + this._loginMigrationSteps.push(LoginMigrationStep.MigrateLogins); + this._loginMigrationSteps.push(LoginMigrationStep.EstablishUserMapping); + this._loginMigrationSteps.push(LoginMigrationStep.MigrateServerRolesAndSetPermissions); + } else { + this._loginMigrationSteps = steps; + } + } + + + private _getLogin(loginName: string) { + return this._logins.get(loginName.toLocaleLowerCase()); + } + + private _addNewLogin(loginName: string, status: MultiStepState = MultiStepState.Pending) { + let newLogin: Login = { + loginName: loginName, + overallStatus: status, + statusPerStep: new Map(), + } + + this._logins.set(loginName.toLocaleLowerCase(), newLogin); + } + + private _addStepStateForLogin(loginName: string, step: LoginMigrationStep, stepStatus: MultiStepState, errors: string[] = []) { + const loginExist = this._logins.has(loginName); + + if (!loginExist) { + this._addNewLogin(loginName, MultiStepState.Running); + } + + const login = this._getLogin(loginName); + + if (login) { + login.overallStatus = MultiStepState.Running; + login.statusPerStep.set( + step, + { + loginName: loginName, + stepName: step, + status: stepStatus, + errors: errors + } + ); + } + } + + private _markLoginStatus(loginName: string, status: MultiStepState) { + const loginExist = this._logins.has(loginName); + + if (!loginExist) { + this._addNewLogin(loginName, MultiStepState.Running); + } + + let login = this._getLogin(loginName); + + if (login) { + login.overallStatus = status; + } + } + + private _didAnyStepFail(loginName: string) { + const login = this._getLogin(loginName); + if (login) { + return Object.values(login.statusPerStep).every(status => status === MultiStepState.Failed); + } + + return false; + } + + private _getExceptionMapWithNormalizedKeys(exceptionMap: ExceptionMap): ExceptionMap { + return Object.keys(exceptionMap).reduce((result: ExceptionMap, key: string) => { + result[key.toLocaleLowerCase()] = exceptionMap[key]; + return result; + }, {}); + } + + private _extractErrors(exceptionMap: ExceptionMap, loginName: string): string[] { + return exceptionMap[loginName].map((exception: any) => typeof exception.InnerException !== 'undefined' + && exception.InnerException !== null ? exception.InnerException.Message : exception.Message); + } + + private _markMigrationComplete() { + this._currentStepIdx = this._loginMigrationSteps.length; + } + + private _markRemainingSteps(loginName: string, status: MultiStepState) { + for (let i = this._currentStepIdx; i < this._loginMigrationSteps.length; i++) { + this._addStepStateForLogin(loginName, this._loginMigrationSteps[i], status, []); + } + } +} diff --git a/extensions/sql-migration/src/models/stateMachine.ts b/extensions/sql-migration/src/models/stateMachine.ts index 2589ab472a..83adf9e77c 100644 --- a/extensions/sql-migration/src/models/stateMachine.ts +++ b/extensions/sql-migration/src/models/stateMachine.ts @@ -15,6 +15,7 @@ import { sendSqlMigrationActionEvent, TelemetryAction, TelemetryViews, logError import { hashString, deepClone } from '../api/utils'; import { SKURecommendationPage } from '../wizard/skuRecommendationPage'; import { excludeDatabases, getConnectionProfile, LoginTableInfo, SourceDatabaseInfo, TargetDatabaseInfo } from '../api/sqlUtils'; +import { LoginMigrationModel, LoginMigrationStep } from './loginMigrationModel'; const localize = nls.loadMessageBundle(); export enum ValidateIrState { @@ -249,6 +250,7 @@ export class MigrationStateModel implements Model, vscode.Disposable { public _aadDomainName!: string; public _loginMigrationsResult!: mssql.StartLoginMigrationResult; public _loginMigrationsError: any; + public _loginMigrationModel: LoginMigrationModel; public readonly _refreshGetSkuRecommendationIntervalInMinutes = 10; public readonly _performanceDataQueryIntervalInSeconds = 30; @@ -303,6 +305,7 @@ export class MigrationStateModel implements Model, vscode.Disposable { this._skuTargetPercentile = 95; this._skuEnablePreview = false; this._skuEnableElastic = false; + this._loginMigrationModel = new LoginMigrationModel(); } public get validationTargetResults(): ValidationResult[] { @@ -559,23 +562,22 @@ export class MigrationStateModel implements Model, vscode.Disposable { public async migrateLogins(): Promise { try { + this._loginMigrationModel.AddNewLogins(this._loginsForMigration.map(row => row.loginName)); + const sourceConnectionString = await this.getSourceConnectionString(); const targetConnectionString = await this.getTargetConnectionString(); - console.log('Starting Login Migration at: ', new Date()); - - console.time("migrateLogins") var response = (await this.migrationService.migrateLogins( sourceConnectionString, targetConnectionString, this._loginsForMigration.map(row => row.loginName), this._aadDomainName ))!; - console.timeEnd("migrateLogins") - this.updateLoginMigrationResults(response) + this.updateLoginMigrationResults(response); + this._loginMigrationModel.AddLoginMigrationResults(LoginMigrationStep.MigrateLogins, response); } catch (error) { - console.log('Failed Login Migration at: ', new Date()); logError(TelemetryViews.LoginMigrationWizard, 'StartLoginMigrationFailed', error); + this._loginMigrationModel.ReportException(LoginMigrationStep.MigrateLogins, error); this._loginMigrationsError = error; return false; } @@ -589,19 +591,18 @@ export class MigrationStateModel implements Model, vscode.Disposable { const sourceConnectionString = await this.getSourceConnectionString(); const targetConnectionString = await this.getTargetConnectionString(); - console.time("establishUserMapping") var response = (await this.migrationService.establishUserMapping( sourceConnectionString, targetConnectionString, this._loginsForMigration.map(row => row.loginName), this._aadDomainName ))!; - console.timeEnd("establishUserMapping") - this.updateLoginMigrationResults(response) + this.updateLoginMigrationResults(response); + this._loginMigrationModel.AddLoginMigrationResults(LoginMigrationStep.EstablishUserMapping, response); } catch (error) { - console.log('Failed Login Migration at: ', new Date()); logError(TelemetryViews.LoginMigrationWizard, 'StartLoginMigrationFailed', error); + this._loginMigrationModel.ReportException(LoginMigrationStep.MigrateLogins, error); this._loginMigrationsError = error; return false; } @@ -615,24 +616,19 @@ export class MigrationStateModel implements Model, vscode.Disposable { const sourceConnectionString = await this.getSourceConnectionString(); const targetConnectionString = await this.getTargetConnectionString(); - console.time("migrateServerRolesAndSetPermissions") var response = (await this.migrationService.migrateServerRolesAndSetPermissions( sourceConnectionString, targetConnectionString, this._loginsForMigration.map(row => row.loginName), this._aadDomainName ))!; - console.timeEnd("migrateServerRolesAndSetPermissions") - this.updateLoginMigrationResults(response) + this.updateLoginMigrationResults(response); + this._loginMigrationModel.AddLoginMigrationResults(LoginMigrationStep.MigrateServerRolesAndSetPermissions, response); - console.log('Ending Login Migration at: ', new Date()); - console.log('Login migration response: ', response); - - console.log('AKMA DEBUG response: ', response); } catch (error) { - console.log('Failed Login Migration at: ', new Date()); logError(TelemetryViews.LoginMigrationWizard, 'StartLoginMigrationFailed', error); + this._loginMigrationModel.ReportException(LoginMigrationStep.MigrateLogins, error); this._loginMigrationsError = error; return false; } @@ -1408,6 +1404,10 @@ export class MigrationStateModel implements Model, vscode.Disposable { } return ""; } + + public get isWindowsAuthMigrationSupported(): boolean { + return this._targetType === MigrationTargetType.SQLMI; + } } export interface ServerAssessment { diff --git a/extensions/sql-migration/src/wizard/loginMigrationStatusPage.ts b/extensions/sql-migration/src/wizard/loginMigrationStatusPage.ts index 8995098e26..3d29c2dc1a 100644 --- a/extensions/sql-migration/src/wizard/loginMigrationStatusPage.ts +++ b/extensions/sql-migration/src/wizard/loginMigrationStatusPage.ts @@ -11,8 +11,8 @@ import * as constants from '../constants/strings'; import { debounce, getPipelineStatusImage } from '../api/utils'; import * as styles from '../constants/styles'; import { IconPathHelper } from '../constants/iconPathHelper'; -import { EOL } from 'os'; import { LoginMigrationStatusCodes } from '../constants/helper'; +import { MultiStepStatusDialog } from '../dialog/generic/multiStepStatusDialog'; export class LoginMigrationStatusPage extends MigrationWizardPage { private _view!: azdata.ModelView; @@ -268,32 +268,7 @@ export class LoginMigrationStatusPage extends MigrationWizardPage { switch (buttonState?.column) { case 3: const loginName = this._migratingLoginsTable!.data[rowState.row][0]; - const status = this._migratingLoginsTable!.data[rowState.row][3].title; - const statusMessage = constants.LOGIN_MIGRATION_STATUS_LABEL(status); - var errors = []; - - if (this.migrationStateModel._loginMigrationsResult?.exceptionMap) { - const exception_key = Object.keys(this.migrationStateModel._loginMigrationsResult.exceptionMap).find(key => key.toLocaleLowerCase() === loginName.toLocaleLowerCase()); - if (exception_key) { - for (var exception of this.migrationStateModel._loginMigrationsResult.exceptionMap[exception_key]) { - if (Array.isArray(exception)) { - for (var inner_exception of exception) { - errors.push(inner_exception.Message); - } - } else { - errors.push(exception.Message); - } - } - } - } - - const unique_errors = new Set(errors); - - // TODO AKMA: Make errors prettier (spacing between errors is weird) - this.showDialogMessage( - constants.DATABASE_MIGRATION_STATUS_TITLE, - statusMessage, - [...unique_errors].join(EOL)); + await this._showLoginDetailsDialog(loginName); break; } })); @@ -402,14 +377,14 @@ export class LoginMigrationStatusPage extends MigrationWizardPage { } await this._migrationProgressDetails.updateProperties({ - 'value': constants.MIGRATE_SERVER_ROLES_AND_SET_PERMISSIONS + 'value': constants.MIGRATING_SERVER_ROLES_AND_SET_PERMISSIONS }); result = await this.migrationStateModel.migrateServerRolesAndSetPermissions(); if (!result) { await this._migrationProgressDetails.updateProperties({ - 'value': constants.MIGRATE_SERVER_ROLES_AND_SET_PERMISSIONS_FAILED + 'value': constants.MIGRATING_SERVER_ROLES_AND_SET_PERMISSIONS_FAILED }); return false; @@ -436,4 +411,15 @@ export class LoginMigrationStatusPage extends MigrationWizardPage { this.wizard.doneButton.enabled = true; return result; } + + private async _showLoginDetailsDialog(loginName: string): Promise { + this.wizard.message = { text: '' }; + const dialog = new MultiStepStatusDialog( + () => { }); + + const loginResults = this.migrationStateModel._loginMigrationModel.GetLoginMigrationResults(loginName); + const isMigrationComplete = this.migrationStateModel._loginMigrationModel.isMigrationComplete; + + await dialog.openDialog(constants.LOGIN_MIGRATIONS_LOGIN_STATUS_DETAILS_TITLE(loginName), loginResults, isMigrationComplete); + } } diff --git a/extensions/sql-migration/src/wizard/loginMigrationTargetSelectionPage.ts b/extensions/sql-migration/src/wizard/loginMigrationTargetSelectionPage.ts index 4db0b8d3f1..c14ffe36d0 100644 --- a/extensions/sql-migration/src/wizard/loginMigrationTargetSelectionPage.ts +++ b/extensions/sql-migration/src/wizard/loginMigrationTargetSelectionPage.ts @@ -14,7 +14,7 @@ import { WIZARD_INPUT_COMPONENT_WIDTH } from './wizardController'; import * as utils from '../api/utils'; import { azureResource } from 'azurecore'; import { AzureSqlDatabaseServer, SqlVMServer } from '../api/azure'; -import { collectTargetDatabaseInfo, TargetDatabaseInfo, isSysAdmin } from '../api/sqlUtils'; +import { collectSourceLogins, collectTargetLogins, isSysAdmin, LoginTableInfo } from '../api/sqlUtils'; export class LoginMigrationTargetSelectionPage extends MigrationWizardPage { private _view!: azdata.ModelView; @@ -328,6 +328,14 @@ export class LoginMigrationTargetSelectionPage extends MigrationWizardPage { await this.populateAzureAccountsDropdown(); await this.populateSubscriptionDropdown(); await this.populateLocationDropdown(); + + // Collect source login info here, as it will speed up loading the next page + const sourceLogins: LoginTableInfo[] = []; + sourceLogins.push(...await collectSourceLogins( + this.migrationStateModel.sourceConnectionId, + this.migrationStateModel.isWindowsAuthMigrationSupported)); + this.migrationStateModel._loginMigrationModel.collectedSourceLogins = true; + this.migrationStateModel._loginMigrationModel.loginsOnSource = sourceLogins; console.log(this.migrationStateModel._targetType); })); @@ -599,18 +607,22 @@ export class LoginMigrationTargetSelectionPage extends MigrationWizardPage { const targetDatabaseServer = this.migrationStateModel._targetServerInstance as AzureSqlDatabaseServer; const userName = this.migrationStateModel._targetUserName; const password = this.migrationStateModel._targetPassword; - const targetDatabases: TargetDatabaseInfo[] = []; + const loginsOnTarget: string[] = []; if (targetDatabaseServer && userName && password) { try { connectionButtonLoadingContainer.loading = true; await utils.updateControlDisplay(this._connectionResultsInfoBox, false); this.wizard.nextButton.enabled = false; - targetDatabases.push( - ...await collectTargetDatabaseInfo( + loginsOnTarget.push( + ...await collectTargetLogins( targetDatabaseServer, userName, - password)); - await this._showConnectionResults(targetDatabases); + password, + this.migrationStateModel.isWindowsAuthMigrationSupported)); + this.migrationStateModel._loginMigrationModel.collectedTargetLogins = true; + this.migrationStateModel._loginMigrationModel.loginsOnTarget = loginsOnTarget; + + await this._showConnectionResults(loginsOnTarget); } catch (error) { this.wizard.message = { level: azdata.window.MessageLevel.Error, @@ -618,7 +630,7 @@ export class LoginMigrationTargetSelectionPage extends MigrationWizardPage { description: constants.SQL_TARGET_CONNECTION_ERROR(error.message), }; await this._showConnectionResults( - targetDatabases, + loginsOnTarget, constants.AZURE_SQL_TARGET_CONNECTION_ERROR_TITLE); } finally { @@ -653,19 +665,16 @@ export class LoginMigrationTargetSelectionPage extends MigrationWizardPage { } private async _showConnectionResults( - databases: TargetDatabaseInfo[], + logins: string[], errorMessage?: string): Promise { const hasError = errorMessage !== undefined; - const hasDatabases = databases.length > 0; this._connectionResultsInfoBox.style = hasError ? 'error' - : hasDatabases - ? 'success' - : 'warning'; + : 'success'; this._connectionResultsInfoBox.text = hasError ? constants.SQL_TARGET_CONNECTION_ERROR(errorMessage) - : constants.SQL_TARGET_CONNECTION_SUCCESS_LOGINS(databases.length.toLocaleString()); + : constants.SQL_TARGET_CONNECTION_SUCCESS_LOGINS(logins.length.toLocaleString()); await utils.updateControlDisplay(this._connectionResultsInfoBox, true); if (!hasError) { diff --git a/extensions/sql-migration/src/wizard/loginSelectorPage.ts b/extensions/sql-migration/src/wizard/loginSelectorPage.ts index c32f659431..8ee4c3a025 100644 --- a/extensions/sql-migration/src/wizard/loginSelectorPage.ts +++ b/extensions/sql-migration/src/wizard/loginSelectorPage.ts @@ -14,6 +14,7 @@ import { collectSourceLogins, collectTargetLogins, LoginTableInfo } from '../api import { AzureSqlDatabaseServer } from '../api/azure'; import { IconPathHelper } from '../constants/iconPathHelper'; import * as utils from '../api/utils'; +import { LoginType } from '../models/loginMigrationModel'; export class LoginSelectorPage extends MigrationWizardPage { private _view!: azdata.ModelView; @@ -24,9 +25,11 @@ export class LoginSelectorPage extends MigrationWizardPage { private _disposables: vscode.Disposable[] = []; private _isCurrentPage: boolean; private _refreshResultsInfoBox!: azdata.InfoBoxComponent; + private _windowsAuthInfoBox!: azdata.InfoBoxComponent; private _refreshButton!: azdata.ButtonComponent; private _refreshLoading!: azdata.LoadingComponent; private _filterTableValue!: string; + private _aadDomainNameContainer!: azdata.FlexContainer; constructor(wizard: azdata.window.Wizard, migrationStateModel: MigrationStateModel) { super(wizard, azdata.window.createWizardPage(constants.LOGIN_MIGRATIONS_SELECT_LOGINS_PAGE_TITLE), migrationStateModel); @@ -59,9 +62,11 @@ export class LoginSelectorPage extends MigrationWizardPage { text: '', level: azdata.window.MessageLevel.Error }; + if (pageChangeInfo.newPage < pageChangeInfo.lastPage) { return true; } + if (this.selectedLogins().length === 0) { this.wizard.message = { text: constants.SELECT_LOGIN_TO_CONTINUE, @@ -69,10 +74,23 @@ export class LoginSelectorPage extends MigrationWizardPage { }; return false; } + + if (this.selectedWindowsLogins() && !this.migrationStateModel._aadDomainName) { + this.wizard.message = { + text: constants.ENTER_AAD_DOMAIN_NAME, + level: azdata.window.MessageLevel.Error + }; + return false; + } + return true; }); - await this._loadLoginList(); + // Dispaly windows auth info box if windows auth is not supported + await utils.updateControlDisplay(this._windowsAuthInfoBox, !this.migrationStateModel.isWindowsAuthMigrationSupported); + + // Refresh login list + await this._loadLoginList(false); // load unfiltered table list and pre-select list of logins saved in state await this._filterTableList('', this.migrationStateModel._loginsForMigration); @@ -110,6 +128,40 @@ export class LoginSelectorPage extends MigrationWizardPage { return searchContainer; } + private createAadDomainNameComponent(): azdata.FlexContainer { + // target user name + const aadDomainNameLabel = this._view.modelBuilder.text() + .withProps({ + value: constants.LOGIN_MIGRATIONS_AAD_DOMAIN_NAME_INPUT_BOX_LABEL, + requiredIndicator: false, + CSSStyles: { ...styles.LABEL_CSS } + }).component(); + + const aadDomainNameInputBox = this._view.modelBuilder.inputBox() + .withProps({ + width: '300px', + inputType: 'text', + placeHolder: constants.LOGIN_MIGRATIONS_AAD_DOMAIN_NAME_INPUT_BOX_PLACEHOLDER, + required: false, + }).component(); + + this._disposables.push( + aadDomainNameInputBox.onTextChanged( + (value: string) => { + this.migrationStateModel._aadDomainName = value ?? ''; + })); + + this._aadDomainNameContainer = this._view.modelBuilder.flexContainer() + .withItems([ + aadDomainNameLabel, + aadDomainNameInputBox]) + .withLayout({ flexFlow: 'column' }) + .withProps({ CSSStyles: { 'margin': '10px 0px 0px 0px', 'display': 'none' } }) + .component(); + + return this._aadDomainNameContainer; + } + @debounce(500) private async _filterTableList(value: string, selectedList?: LoginTableInfo[]): Promise { this._filterTableValue = value; @@ -143,13 +195,14 @@ export class LoginSelectorPage extends MigrationWizardPage { public async createRootContainer(view: azdata.ModelView): Promise { - const windowsAuthInfoBox = this._view.modelBuilder.infoBox() + this._windowsAuthInfoBox = this._view.modelBuilder.infoBox() .withProps({ style: 'information', text: constants.LOGIN_MIGRATIONS_SELECT_LOGINS_WINDOWS_AUTH_WARNING, - CSSStyles: { ...styles.BODY_CSS } + CSSStyles: { ...styles.BODY_CSS, 'display': 'none', } }).component(); + this._refreshButton = this._view.modelBuilder.button() .withProps({ buttonType: azdata.ButtonType.Normal, @@ -158,7 +211,7 @@ export class LoginSelectorPage extends MigrationWizardPage { iconPath: IconPathHelper.refresh, label: constants.DATABASE_TABLE_REFRESH_LABEL, width: 70, - CSSStyles: { 'margin': '15px 0 0 0' }, + CSSStyles: { 'margin': '15px 5px 0 0' }, }) .component(); @@ -281,32 +334,29 @@ export class LoginSelectorPage extends MigrationWizardPage { height: '100%', }).withProps({ CSSStyles: { - 'margin': '0px 28px 0px 28px' + 'margin': '-20px 28px 0px 28px' } }).component(); - flex.addItem(windowsAuthInfoBox, { flex: '0 0 auto' }); + flex.addItem(this._windowsAuthInfoBox, { flex: '0 0 auto' }); flex.addItem(refreshContainer, { flex: '0 0 auto' }); flex.addItem(this.createSearchComponent(), { flex: '0 0 auto' }); flex.addItem(this._loginCount, { flex: '0 0 auto' }); flex.addItem(this._loginSelectorTable); + flex.addItem(this.createAadDomainNameComponent(), { flex: '0 0 auto', CSSStyles: { 'margin-top': '8px' } }); return flex; } - private async _loadLoginList(): Promise { - this._refreshLoading.loading = true; - this.wizard.nextButton.enabled = false; - await utils.updateControlDisplay(this._refreshResultsInfoBox, true); - this._refreshResultsInfoBox.text = constants.LOGIN_MIGRATION_REFRESHING_LOGIN_DATA; - this._refreshResultsInfoBox.style = 'information'; - + private async _getSourceLogins() { const stateMachine: MigrationStateModel = this.migrationStateModel; - const selectedLogins: LoginTableInfo[] = stateMachine._loginsForMigration || []; const sourceLogins: LoginTableInfo[] = []; - const targetLogins: string[] = []; // execute a query against the source to get the logins try { - sourceLogins.push(...await collectSourceLogins(stateMachine.sourceConnectionId)); + sourceLogins.push(...await collectSourceLogins( + stateMachine.sourceConnectionId, + stateMachine.isWindowsAuthMigrationSupported)); + stateMachine._loginMigrationModel.collectedSourceLogins = true; + stateMachine._loginMigrationModel.loginsOnSource = sourceLogins; } catch (error) { this._refreshLoading.loading = false; this._refreshResultsInfoBox.style = 'error'; @@ -317,11 +367,22 @@ export class LoginSelectorPage extends MigrationWizardPage { description: constants.LOGIN_MIGRATIONS_GET_LOGINS_ERROR(error.message), }; } + } + + private async _getTargetLogins() { + const stateMachine: MigrationStateModel = this.migrationStateModel; + const targetLogins: string[] = []; // execute a query against the target to get the logins try { if (this.isTargetInstanceSet()) { - targetLogins.push(...await collectTargetLogins(stateMachine._targetServerInstance as AzureSqlDatabaseServer, stateMachine._targetUserName, stateMachine._targetPassword)); + targetLogins.push(...await collectTargetLogins( + stateMachine._targetServerInstance as AzureSqlDatabaseServer, + stateMachine._targetUserName, + stateMachine._targetPassword, + stateMachine.isWindowsAuthMigrationSupported)); + stateMachine._loginMigrationModel.collectedTargetLogins = true; + stateMachine._loginMigrationModel.loginsOnTarget = targetLogins; } else { // TODO AKMA : Emit telemetry here saying target info is empty @@ -336,7 +397,41 @@ export class LoginSelectorPage extends MigrationWizardPage { description: constants.LOGIN_MIGRATIONS_GET_LOGINS_ERROR(error.message), }; } + } + private async _markRefreshDataStart() { + this._refreshLoading.loading = true; + this.wizard.nextButton.enabled = false; + await utils.updateControlDisplay(this._refreshResultsInfoBox, true); + this._refreshResultsInfoBox.text = constants.LOGIN_MIGRATION_REFRESHING_LOGIN_DATA; + this._refreshResultsInfoBox.style = 'information'; + } + + private _markRefreshDataComplete(numSourceLogins: number, numTargetLogins: number) { + this._refreshLoading.loading = false; + this._refreshResultsInfoBox.text = constants.LOGIN_MIGRATION_REFRESH_LOGIN_DATA_SUCCESSFUL(numSourceLogins, numTargetLogins); + this._refreshResultsInfoBox.style = 'success'; + this.updateNextButton(); + } + + private async _loadLoginList(runQuery: boolean = true): Promise { + const stateMachine: MigrationStateModel = this.migrationStateModel; + const selectedLogins: LoginTableInfo[] = stateMachine._loginsForMigration || []; + + // Get source logins if caller asked us to or if we haven't collected in the past + if (runQuery || !stateMachine._loginMigrationModel.collectedSourceLogins) { + await this._markRefreshDataStart(); + await this._getSourceLogins(); + } + + // Get target logins if caller asked us to or if we haven't collected in the past + if (runQuery || !stateMachine._loginMigrationModel.collectedTargetLogins) { + await this._markRefreshDataStart(); + await this._getTargetLogins(); + } + + const sourceLogins: LoginTableInfo[] = stateMachine._loginMigrationModel.loginsOnSource; + const targetLogins: string[] = stateMachine._loginMigrationModel.loginsOnTarget; this._loginNames = []; this._loginTableValues = sourceLogins.map(row => { @@ -357,10 +452,7 @@ export class LoginSelectorPage extends MigrationWizardPage { }) || []; await this._filterTableList(this._filterTableValue); - this._refreshLoading.loading = false; - this._refreshResultsInfoBox.text = constants.LOGIN_MIGRATION_REFRESH_LOGIN_DATA_SUCCESSFUL(sourceLogins.length, targetLogins.length); - this._refreshResultsInfoBox.style = 'success'; - this.updateNextButton(); + this._markRefreshDataComplete(sourceLogins.length, targetLogins.length); } public selectedLogins(): LoginTableInfo[] { @@ -379,6 +471,10 @@ export class LoginSelectorPage extends MigrationWizardPage { || []; } + private selectedWindowsLogins(): boolean { + return this.selectedLogins().some(logins => logins.loginType.toLocaleLowerCase() === LoginType.Windows_Login); + } + private async updateValuesOnSelection() { const selectedLogins = this.selectedLogins() || []; await this._loginCount.updateProperties({ @@ -387,8 +483,12 @@ export class LoginSelectorPage extends MigrationWizardPage { this._loginSelectorTable.data?.length || 0) }); + // Display AAD Domain Name input box if windows logins selected, else disable + const hasSelectedWindowsLogins = this.selectedWindowsLogins() + await utils.updateControlDisplay(this._aadDomainNameContainer, hasSelectedWindowsLogins); + await this._loginSelectorTable.updateProperty("height", hasSelectedWindowsLogins ? 600 : 650); + this.migrationStateModel._loginsForMigration = selectedLogins; - this.migrationStateModel._aadDomainName = ""; this.updateNextButton(); }