diff --git a/extensions/arc/src/models/controllerModel.ts b/extensions/arc/src/models/controllerModel.ts index 37e0fdb688..9813a8fb33 100644 --- a/extensions/arc/src/models/controllerModel.ts +++ b/extensions/arc/src/models/controllerModel.ts @@ -61,10 +61,11 @@ export class ControllerModel { } /** - * Calls azdata login to set the context to this controller + * Calls azdata login to set the context to this controller and acquires a login session to prevent other + * calls from changing the context while commands for this session are being executed. * @param promptReconnect */ - public async azdataLogin(promptReconnect: boolean = false): Promise { + public async acquireAzdataSession(promptReconnect: boolean = false): Promise { let promptForValidClusterContext: boolean = false; try { const contexts = await getKubeConfigClusterContexts(this.info.kubeConfigFilePath); @@ -102,7 +103,7 @@ export class ControllerModel { } } - await this._azdataApi.azdata.login(this.info.url, this.info.username, this._password, this.azdataAdditionalEnvVars); + return this._azdataApi.azdata.acquireSession(this.info.url, this.info.username, this._password, this.azdataAdditionalEnvVars); } /** @@ -123,64 +124,68 @@ export class ControllerModel { } // create a new in progress promise object ControllerModel._refreshInProgress = new Deferred(); - await this.azdataLogin(promptReconnect); + const session = await this.acquireAzdataSession(promptReconnect); const newRegistrations: Registration[] = []; - await Promise.all([ - this._azdataApi.azdata.arc.dc.config.show().then(result => { - this._controllerConfig = result.result; - this.configLastUpdated = new Date(); - this._onConfigUpdated.fire(this._controllerConfig); - }).catch(err => { - // If an error occurs show a message so the user knows something failed but still - // fire the event so callers can know to update (e.g. so dashboards don't show the - // loading icon forever) - if (showErrors) { - vscode.window.showErrorMessage(loc.fetchConfigFailed(this.info.name, err)); - } - this._onConfigUpdated.fire(this._controllerConfig); - throw err; - }), - this._azdataApi.azdata.arc.dc.endpoint.list(this.azdataAdditionalEnvVars).then(result => { - this._endpoints = result.result; - this.endpointsLastUpdated = new Date(); - this._onEndpointsUpdated.fire(this._endpoints); - }).catch(err => { - // If an error occurs show a message so the user knows something failed but still - // fire the event so callers can know to update (e.g. so dashboards don't show the - // loading icon forever) - if (showErrors) { - vscode.window.showErrorMessage(loc.fetchEndpointsFailed(this.info.name, err)); - } - this._onEndpointsUpdated.fire(this._endpoints); - throw err; - }), - Promise.all([ - this._azdataApi.azdata.arc.postgres.server.list(this.azdataAdditionalEnvVars).then(result => { - newRegistrations.push(...result.result.map(r => { - return { - instanceName: r.name, - state: r.state, - instanceType: ResourceType.postgresInstances - }; - })); + try { + await Promise.all([ + this._azdataApi.azdata.arc.dc.config.show(this.azdataAdditionalEnvVars, session).then(result => { + this._controllerConfig = result.result; + this.configLastUpdated = new Date(); + this._onConfigUpdated.fire(this._controllerConfig); + }).catch(err => { + // If an error occurs show a message so the user knows something failed but still + // fire the event so callers hooking into this can handle the error (e.g. so dashboards don't show the + // loading icon forever) + if (showErrors) { + vscode.window.showErrorMessage(loc.fetchConfigFailed(this.info.name, err)); + } + this._onConfigUpdated.fire(this._controllerConfig); + throw err; }), - this._azdataApi.azdata.arc.sql.mi.list().then(result => { - newRegistrations.push(...result.result.map(r => { - return { - instanceName: r.name, - state: r.state, - instanceType: ResourceType.sqlManagedInstances - }; - })); + this._azdataApi.azdata.arc.dc.endpoint.list(this.azdataAdditionalEnvVars, session).then(result => { + this._endpoints = result.result; + this.endpointsLastUpdated = new Date(); + this._onEndpointsUpdated.fire(this._endpoints); + }).catch(err => { + // If an error occurs show a message so the user knows something failed but still + // fire the event so callers can know to update (e.g. so dashboards don't show the + // loading icon forever) + if (showErrors) { + vscode.window.showErrorMessage(loc.fetchEndpointsFailed(this.info.name, err)); + } + this._onEndpointsUpdated.fire(this._endpoints); + throw err; + }), + Promise.all([ + this._azdataApi.azdata.arc.postgres.server.list(this.azdataAdditionalEnvVars, session).then(result => { + newRegistrations.push(...result.result.map(r => { + return { + instanceName: r.name, + state: r.state, + instanceType: ResourceType.postgresInstances + }; + })); + }), + this._azdataApi.azdata.arc.sql.mi.list(this.azdataAdditionalEnvVars, session).then(result => { + newRegistrations.push(...result.result.map(r => { + return { + instanceName: r.name, + state: r.state, + instanceType: ResourceType.sqlManagedInstances + }; + })); + }) + ]).then(() => { + this._registrations = newRegistrations; + this.registrationsLastUpdated = new Date(); + this._onRegistrationsUpdated.fire(this._registrations); }) - ]).then(() => { - this._registrations = newRegistrations; - this.registrationsLastUpdated = new Date(); - this._onRegistrationsUpdated.fire(this._registrations); - }) - ]); - ControllerModel._refreshInProgress.resolve(); - ControllerModel._refreshInProgress = undefined; + ]); + } finally { + session.dispose(); + ControllerModel._refreshInProgress.resolve(); + ControllerModel._refreshInProgress = undefined; + } } public get endpoints(): azdataExt.DcEndpointListResult[] { diff --git a/extensions/arc/src/models/miaaModel.ts b/extensions/arc/src/models/miaaModel.ts index 00223afc36..2f5bdfd8fd 100644 --- a/extensions/arc/src/models/miaaModel.ts +++ b/extensions/arc/src/models/miaaModel.ts @@ -71,10 +71,11 @@ export class MiaaModel extends ResourceModel { return this._refreshPromise.promise; } this._refreshPromise = new Deferred(); + let session: azdataExt.AzdataSession | undefined = undefined; try { - await this.controllerModel.azdataLogin(); + session = await this.controllerModel.acquireAzdataSession(); try { - const result = await this._azdataApi.azdata.arc.sql.mi.show(this.info.name); + const result = await this._azdataApi.azdata.arc.sql.mi.show(this.info.name, this.controllerModel.azdataAdditionalEnvVars, session); this._config = result.result; this.configLastUpdated = new Date(); this._onConfigUpdated.fire(this._config); @@ -114,6 +115,7 @@ export class MiaaModel extends ResourceModel { this._refreshPromise.reject(err); throw err; } finally { + session?.dispose(); this._refreshPromise = undefined; } } diff --git a/extensions/arc/src/models/postgresModel.ts b/extensions/arc/src/models/postgresModel.ts index 0aad439598..43ce1fbb20 100644 --- a/extensions/arc/src/models/postgresModel.ts +++ b/extensions/arc/src/models/postgresModel.ts @@ -107,10 +107,10 @@ export class PostgresModel extends ResourceModel { return this._refreshPromise.promise; } this._refreshPromise = new Deferred(); - + let session: azdataExt.AzdataSession | undefined = undefined; try { - await this.controllerModel.azdataLogin(); - this._config = (await this._azdataApi.azdata.arc.postgres.server.show(this.info.name)).result; + session = await this.controllerModel.acquireAzdataSession(); + this._config = (await this._azdataApi.azdata.arc.postgres.server.show(this.info.name, this.controllerModel.azdataAdditionalEnvVars, session)).result; this.configLastUpdated = new Date(); this._onConfigUpdated.fire(this._config); this._refreshPromise.resolve(); @@ -118,6 +118,7 @@ export class PostgresModel extends ResourceModel { this._refreshPromise.reject(err); throw err; } finally { + session?.dispose(); this._refreshPromise = undefined; } } diff --git a/extensions/arc/src/test/mocks/fakeAzdataApi.ts b/extensions/arc/src/test/mocks/fakeAzdataApi.ts index edd0effa47..e9c6a95aee 100644 --- a/extensions/arc/src/test/mocks/fakeAzdataApi.ts +++ b/extensions/arc/src/test/mocks/fakeAzdataApi.ts @@ -75,9 +75,12 @@ export class FakeAzdataApi implements azdataExt.IAzdataApi { getPath(): Promise { throw new Error('Method not implemented.'); } - login(_endpoint: string, _username: string, _password: string): Promise> { + login(_endpoint: string, _username: string, _password: string): Promise> { return undefined; } + acquireSession(_endpoint: string, _username: string, _password: string): Promise { + return Promise.resolve({ dispose: () => { } }); + } version(): Promise> { throw new Error('Method not implemented.'); } diff --git a/extensions/arc/src/test/models/controllerModel.test.ts b/extensions/arc/src/test/models/controllerModel.test.ts index e8ff3fca4b..90d4fdabeb 100644 --- a/extensions/arc/src/test/models/controllerModel.test.ts +++ b/extensions/arc/src/test/models/controllerModel.test.ts @@ -43,7 +43,7 @@ describe('ControllerModel', function (): void { // Returning an undefined model here indicates that the dialog closed without clicking "Ok" - usually through the user clicking "Cancel" sinon.stub(ConnectToControllerDialog.prototype, 'waitForClose').returns(Promise.resolve(undefined)); const model = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }); - await should(model.azdataLogin()).be.rejectedWith(new UserCancelledError(loc.userCancelledError)); + await should(model.acquireAzdataSession()).be.rejectedWith(new UserCancelledError(loc.userCancelledError)); }); it('Reads password from cred store', async function (): Promise { @@ -58,13 +58,13 @@ describe('ControllerModel', function (): void { const azdataExtApiMock = TypeMoq.Mock.ofType(); const azdataMock = TypeMoq.Mock.ofType(); - azdataMock.setup(x => x.login(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve(undefined)); + azdataMock.setup(x => x.acquireSession(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve(undefined)); azdataExtApiMock.setup(x => x.azdata).returns(() => azdataMock.object); sinon.stub(vscode.extensions, 'getExtension').returns({ exports: azdataExtApiMock.object }); const model = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }); - await model.azdataLogin(); - azdataMock.verify(x => x.login(TypeMoq.It.isAny(), TypeMoq.It.isAny(), password, TypeMoq.It.isAny()), TypeMoq.Times.once()); + await model.acquireAzdataSession(); + azdataMock.verify(x => x.acquireSession(TypeMoq.It.isAny(), TypeMoq.It.isAny(), password, TypeMoq.It.isAny()), TypeMoq.Times.once()); }); it('Prompt for password when not in cred store', async function (): Promise { @@ -79,7 +79,7 @@ describe('ControllerModel', function (): void { const azdataExtApiMock = TypeMoq.Mock.ofType(); const azdataMock = TypeMoq.Mock.ofType(); - azdataMock.setup(x => x.login(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve(undefined)); + azdataMock.setup(x => x.acquireSession(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve(undefined)); azdataExtApiMock.setup(x => x.azdata).returns(() => azdataMock.object); sinon.stub(vscode.extensions, 'getExtension').returns({ exports: azdataExtApiMock.object }); @@ -89,8 +89,8 @@ describe('ControllerModel', function (): void { const model = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }); - await model.azdataLogin(); - azdataMock.verify(x => x.login(TypeMoq.It.isAny(), TypeMoq.It.isAny(), password, TypeMoq.It.isAny()), TypeMoq.Times.once()); + await model.acquireAzdataSession(); + azdataMock.verify(x => x.acquireSession(TypeMoq.It.isAny(), TypeMoq.It.isAny(), password, TypeMoq.It.isAny()), TypeMoq.Times.once()); }); it('Prompt for password when rememberPassword is true but prompt reconnect is true', async function (): Promise { @@ -104,7 +104,7 @@ describe('ControllerModel', function (): void { const azdataExtApiMock = TypeMoq.Mock.ofType(); const azdataMock = TypeMoq.Mock.ofType(); - azdataMock.setup(x => x.login(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve(undefined)); + azdataMock.setup(x => x.acquireSession(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve(undefined)); azdataExtApiMock.setup(x => x.azdata).returns(() => azdataMock.object); sinon.stub(vscode.extensions, 'getExtension').returns({ exports: azdataExtApiMock.object }); @@ -114,9 +114,9 @@ describe('ControllerModel', function (): void { const model = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }); - await model.azdataLogin(true); + await model.acquireAzdataSession(true); should(waitForCloseStub.called).be.true('waitForClose should have been called'); - azdataMock.verify(x => x.login(TypeMoq.It.isAny(), TypeMoq.It.isAny(), password, TypeMoq.It.isAny()), TypeMoq.Times.once()); + azdataMock.verify(x => x.acquireSession(TypeMoq.It.isAny(), TypeMoq.It.isAny(), password, TypeMoq.It.isAny()), TypeMoq.Times.once()); }); it('Prompt for password when we already have a password but prompt reconnect is true', async function (): Promise { @@ -130,7 +130,7 @@ describe('ControllerModel', function (): void { const azdataExtApiMock = TypeMoq.Mock.ofType(); const azdataMock = TypeMoq.Mock.ofType(); - azdataMock.setup(x => x.login(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve(undefined)); + azdataMock.setup(x => x.acquireSession(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve(undefined)); azdataExtApiMock.setup(x => x.azdata).returns(() => azdataMock.object); sinon.stub(vscode.extensions, 'getExtension').returns({ exports: azdataExtApiMock.object }); @@ -141,9 +141,9 @@ describe('ControllerModel', function (): void { // Set up original model with a password const model = new ControllerModel(new AzureArcTreeDataProvider(mockExtensionContext.object), { id: uuid(), url: '127.0.0.1', kubeConfigFilePath: '/path/to/.kube/config', kubeClusterContext: 'currentCluster', username: 'admin', name: 'arc', rememberPassword: true, resources: [] }, 'originalPassword'); - await model.azdataLogin(true); + await model.acquireAzdataSession(true); should(waitForCloseStub.called).be.true('waitForClose should have been called'); - azdataMock.verify(x => x.login(TypeMoq.It.isAny(), TypeMoq.It.isAny(), password, TypeMoq.It.isAny()), TypeMoq.Times.once()); + azdataMock.verify(x => x.acquireSession(TypeMoq.It.isAny(), TypeMoq.It.isAny(), password, TypeMoq.It.isAny()), TypeMoq.Times.once()); }); it('Model values are updated correctly when modified during reconnect', async function (): Promise { @@ -158,7 +158,7 @@ describe('ControllerModel', function (): void { const azdataExtApiMock = TypeMoq.Mock.ofType(); const azdataMock = TypeMoq.Mock.ofType(); - azdataMock.setup(x => x.login(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve(undefined)); + azdataMock.setup(x => x.acquireSession(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve(undefined)); azdataExtApiMock.setup(x => x.azdata).returns(() => azdataMock.object); sinon.stub(vscode.extensions, 'getExtension').returns({ exports: azdataExtApiMock.object }); @@ -199,10 +199,11 @@ describe('ControllerModel', function (): void { const waitForCloseStub = sinon.stub(ConnectToControllerDialog.prototype, 'waitForClose').returns(Promise.resolve( { controllerModel: newModel, password: newPassword })); - await model.azdataLogin(true); + await model.acquireAzdataSession(true); should(waitForCloseStub.called).be.true('waitForClose should have been called'); should((await treeDataProvider.getChildren()).length).equal(1, 'Tree Data provider should still only have 1 node'); should(model.info).deepEqual(newInfo, 'Model info should have been updated'); + }); }); diff --git a/extensions/arc/src/ui/dashboards/miaa/miaaComputeAndStoragePage.ts b/extensions/arc/src/ui/dashboards/miaa/miaaComputeAndStoragePage.ts index 521e126db7..bd0631dffb 100644 --- a/extensions/arc/src/ui/dashboards/miaa/miaaComputeAndStoragePage.ts +++ b/extensions/arc/src/ui/dashboards/miaa/miaaComputeAndStoragePage.ts @@ -129,13 +129,16 @@ export class MiaaComputeAndStoragePage extends DashboardPage { cancellable: false }, async (_progress, _token): Promise => { + let session: azdataExt.AzdataSession | undefined = undefined; try { - await this._miaaModel.controllerModel.azdataLogin(); + session = await this._miaaModel.controllerModel.acquireAzdataSession(); await this._azdataApi.azdata.arc.sql.mi.edit( - this._miaaModel.info.name, this.saveArgs); + this._miaaModel.info.name, this.saveArgs, this._miaaModel.controllerModel.azdataAdditionalEnvVars, session); } catch (err) { this.saveButton!.enabled = true; throw err; + } finally { + session?.dispose(); } await this._miaaModel.refresh(); diff --git a/extensions/arc/src/ui/dashboards/miaa/miaaDashboardOverviewPage.ts b/extensions/arc/src/ui/dashboards/miaa/miaaDashboardOverviewPage.ts index ae3f2e698a..1cb8238b7c 100644 --- a/extensions/arc/src/ui/dashboards/miaa/miaaDashboardOverviewPage.ts +++ b/extensions/arc/src/ui/dashboards/miaa/miaaDashboardOverviewPage.ts @@ -206,8 +206,13 @@ export class MiaaDashboardOverviewPage extends DashboardPage { cancellable: false }, async (_progress, _token) => { - await this._controllerModel.azdataLogin(); - return await this._azdataApi.azdata.arc.sql.mi.delete(this._miaaModel.info.name); + const session = await this._controllerModel.acquireAzdataSession(); + try { + return await this._azdataApi.azdata.arc.sql.mi.delete(this._miaaModel.info.name, this._controllerModel.azdataAdditionalEnvVars, session); + } finally { + session.dispose(); + } + } ); await this._controllerModel.refreshTreeNode(); diff --git a/extensions/arc/src/ui/dashboards/postgres/postgresComputeAndStoragePage.ts b/extensions/arc/src/ui/dashboards/postgres/postgresComputeAndStoragePage.ts index f12767eeaa..c867dbb5f4 100644 --- a/extensions/arc/src/ui/dashboards/postgres/postgresComputeAndStoragePage.ts +++ b/extensions/arc/src/ui/dashboards/postgres/postgresComputeAndStoragePage.ts @@ -155,18 +155,23 @@ export class PostgresComputeAndStoragePage extends DashboardPage { cancellable: false }, async (_progress, _token): Promise => { + let session: azdataExt.AzdataSession | undefined = undefined; try { - await this._postgresModel.controllerModel.azdataLogin(); + session = await this._postgresModel.controllerModel.acquireAzdataSession(); await this._azdataApi.azdata.arc.postgres.server.edit( this._postgresModel.info.name, this.saveArgs, - this._postgresModel.engineVersion + this._postgresModel.engineVersion, + this._postgresModel.controllerModel.azdataAdditionalEnvVars, + session ); } catch (err) { // If an error occurs while editing the instance then re-enable the save button since // the edit wasn't successfully applied this.saveButton!.enabled = true; throw err; + } finally { + session?.dispose(); } await this._postgresModel.refresh(); } diff --git a/extensions/arc/src/ui/dashboards/postgres/postgresOverviewPage.ts b/extensions/arc/src/ui/dashboards/postgres/postgresOverviewPage.ts index 442c5fcb7c..fb7609ff1b 100644 --- a/extensions/arc/src/ui/dashboards/postgres/postgresOverviewPage.ts +++ b/extensions/arc/src/ui/dashboards/postgres/postgresOverviewPage.ts @@ -151,16 +151,21 @@ export class PostgresOverviewPage extends DashboardPage { try { const password = await promptAndConfirmPassword(input => !input ? loc.enterANonEmptyPassword : ''); if (password) { - await this._postgresModel.controllerModel.azdataLogin(); - await this._azdataApi.azdata.arc.postgres.server.edit( - this._postgresModel.info.name, - { - adminPassword: true, - noWait: true - }, - this._postgresModel.engineVersion, - { 'AZDATA_PASSWORD': password } - ); + const session = await this._postgresModel.controllerModel.acquireAzdataSession(); + try { + await this._azdataApi.azdata.arc.postgres.server.edit( + this._postgresModel.info.name, + { + adminPassword: true, + noWait: true + }, + this._postgresModel.engineVersion, + Object.assign({ 'AZDATA_PASSWORD': password }, this._controllerModel.azdataAdditionalEnvVars), + session + ); + } finally { + session.dispose(); + } vscode.window.showInformationMessage(loc.passwordReset); } } catch (error) { @@ -188,8 +193,13 @@ export class PostgresOverviewPage extends DashboardPage { cancellable: false }, async (_progress, _token) => { - await this._postgresModel.controllerModel.azdataLogin(); - return await this._azdataApi.azdata.arc.postgres.server.delete(this._postgresModel.info.name); + const session = await this._postgresModel.controllerModel.acquireAzdataSession(); + try { + return await this._azdataApi.azdata.arc.postgres.server.delete(this._postgresModel.info.name, this._controllerModel.azdataAdditionalEnvVars, session); + } finally { + session.dispose(); + } + } ); await this._controllerModel.refreshTreeNode(); diff --git a/extensions/arc/src/ui/dashboards/postgres/postgresParametersPage.ts b/extensions/arc/src/ui/dashboards/postgres/postgresParametersPage.ts index f9957deeb7..ecdc2bb314 100644 --- a/extensions/arc/src/ui/dashboards/postgres/postgresParametersPage.ts +++ b/extensions/arc/src/ui/dashboards/postgres/postgresParametersPage.ts @@ -172,8 +172,14 @@ export class PostgresParametersPage extends DashboardPage { this.engineSettingUpdates!.forEach((value: string) => { this.engineSettings += value + ', '; }); - await this._azdataApi.azdata.arc.postgres.server.edit( - this._postgresModel.info.name, { engineSettings: this.engineSettings + `'` }); + const session = await this._postgresModel.controllerModel.acquireAzdataSession(); + try { + await this._azdataApi.azdata.arc.postgres.server.edit( + this._postgresModel.info.name, { engineSettings: this.engineSettings + `'` }); + } finally { + session.dispose(); + } + } catch (err) { // If an error occurs while editing the instance then re-enable the save button since // the edit wasn't successfully applied @@ -237,7 +243,9 @@ export class PostgresParametersPage extends DashboardPage { async (_progress, _token): Promise => { //all // azdata arc postgres server edit -n -e '' -re + let session: azdataExt.AzdataSession | undefined = undefined; try { + session = await this._postgresModel.controllerModel.acquireAzdataSession(); await this._azdataApi.azdata.arc.postgres.server.edit( this._postgresModel.info.name, { engineSettings: `'' -re` }); } catch (err) { @@ -245,6 +253,8 @@ export class PostgresParametersPage extends DashboardPage { // the edit wasn't successfully applied this.resetButton!.enabled = true; throw err; + } finally { + session?.dispose(); } await this._postgresModel.refresh(); } @@ -463,9 +473,14 @@ export class PostgresParametersPage extends DashboardPage { title: loc.updatingInstance(this._postgresModel.info.name), cancellable: false }, - (_progress, _token) => { - return this._azdataApi.azdata.arc.postgres.server.edit( - this._postgresModel.info.name, { engineSettings: name + '=' }); + async (_progress, _token) => { + const session = await this._postgresModel.controllerModel.acquireAzdataSession(); + try { + this._azdataApi.azdata.arc.postgres.server.edit( + this._postgresModel.info.name, { engineSettings: name + '=' }); + } finally { + session.dispose(); + } } ); diff --git a/extensions/azdata/src/api.ts b/extensions/azdata/src/api.ts index 2f35480617..4ceb23d785 100644 --- a/extensions/azdata/src/api.ts +++ b/extensions/azdata/src/api.ts @@ -45,47 +45,57 @@ export function getAzdataApi(localAzdataDiscovered: Promise { + create: async ( + namespace: string, + name: string, + connectivityMode: string, + resourceGroup: string, + location: string, + subscription: string, + profileName?: string, + storageClass?: string, + additionalEnvVars?: azdataExt.AdditionalEnvVars, + session?: azdataExt.AzdataSession) => { await localAzdataDiscovered; throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.dc.create(namespace, name, connectivityMode, resourceGroup, location, subscription, profileName, storageClass, additionalEnvVars); + return azdataToolService.localAzdata.arc.dc.create(namespace, name, connectivityMode, resourceGroup, location, subscription, profileName, storageClass, additionalEnvVars, session); }, endpoint: { - list: async (additionalEnvVars?: azdataExt.AdditionalEnvVars) => { + list: async (additionalEnvVars?: azdataExt.AdditionalEnvVars, session?: azdataExt.AzdataSession) => { await localAzdataDiscovered; throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.dc.endpoint.list(additionalEnvVars); + return azdataToolService.localAzdata.arc.dc.endpoint.list(additionalEnvVars, session); } }, config: { - list: async (additionalEnvVars?: azdataExt.AdditionalEnvVars) => { + list: async (additionalEnvVars?: azdataExt.AdditionalEnvVars, session?: azdataExt.AzdataSession) => { await localAzdataDiscovered; throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.dc.config.list(additionalEnvVars); + return azdataToolService.localAzdata.arc.dc.config.list(additionalEnvVars, session); }, - show: async (additionalEnvVars?: azdataExt.AdditionalEnvVars) => { + show: async (additionalEnvVars?: azdataExt.AdditionalEnvVars, session?: azdataExt.AzdataSession) => { await localAzdataDiscovered; throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.dc.config.show(additionalEnvVars); + return azdataToolService.localAzdata.arc.dc.config.show(additionalEnvVars, session); } } }, postgres: { server: { - delete: async (name: string, additionalEnvVars?: azdataExt.AdditionalEnvVars) => { + delete: async (name: string, additionalEnvVars?: azdataExt.AdditionalEnvVars, session?: azdataExt.AzdataSession) => { await localAzdataDiscovered; throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.postgres.server.delete(name, additionalEnvVars); + return azdataToolService.localAzdata.arc.postgres.server.delete(name, additionalEnvVars, session); }, - list: async (additionalEnvVars?: azdataExt.AdditionalEnvVars) => { + list: async (additionalEnvVars?: azdataExt.AdditionalEnvVars, session?: azdataExt.AzdataSession) => { await localAzdataDiscovered; throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.postgres.server.list(additionalEnvVars); + return azdataToolService.localAzdata.arc.postgres.server.list(additionalEnvVars, session); }, - show: async (name: string, additionalEnvVars?: azdataExt.AdditionalEnvVars) => { + show: async (name: string, additionalEnvVars?: azdataExt.AdditionalEnvVars, session?: azdataExt.AzdataSession) => { await localAzdataDiscovered; throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.postgres.server.show(name, additionalEnvVars); + return azdataToolService.localAzdata.arc.postgres.server.show(name, additionalEnvVars, session); }, edit: async ( name: string, @@ -103,29 +113,30 @@ export function getAzdataApi(localAzdataDiscovered: Promise { + additionalEnvVars?: azdataExt.AdditionalEnvVars, + session?: azdataExt.AzdataSession) => { await localAzdataDiscovered; throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.postgres.server.edit(name, args, engineVersion, additionalEnvVars); + return azdataToolService.localAzdata.arc.postgres.server.edit(name, args, engineVersion, additionalEnvVars, session); } } }, sql: { mi: { - delete: async (name: string, additionalEnvVars?: azdataExt.AdditionalEnvVars) => { + delete: async (name: string, additionalEnvVars?: azdataExt.AdditionalEnvVars, session?: azdataExt.AzdataSession) => { await localAzdataDiscovered; throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.sql.mi.delete(name, additionalEnvVars); + return azdataToolService.localAzdata.arc.sql.mi.delete(name, additionalEnvVars, session); }, - list: async (additionalEnvVars?: azdataExt.AdditionalEnvVars) => { + list: async (additionalEnvVars?: azdataExt.AdditionalEnvVars, session?: azdataExt.AzdataSession) => { await localAzdataDiscovered; throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.sql.mi.list(additionalEnvVars); + return azdataToolService.localAzdata.arc.sql.mi.list(additionalEnvVars, session); }, - show: async (name: string, additionalEnvVars?: azdataExt.AdditionalEnvVars) => { + show: async (name: string, additionalEnvVars?: azdataExt.AdditionalEnvVars, session?: azdataExt.AzdataSession) => { await localAzdataDiscovered; throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.sql.mi.show(name, additionalEnvVars); + return azdataToolService.localAzdata.arc.sql.mi.show(name, additionalEnvVars, session); }, edit: async ( name: string, @@ -136,11 +147,12 @@ export function getAzdataApi(localAzdataDiscovered: Promise { await localAzdataDiscovered; throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); - return azdataToolService.localAzdata.arc.sql.mi.edit(name, args, additionalEnvVars); + return azdataToolService.localAzdata.arc.sql.mi.edit(name, args, additionalEnvVars, session); } } } @@ -154,6 +166,10 @@ export function getAzdataApi(localAzdataDiscovered: Promise { + throwIfNoAzdataOrEulaNotAccepted(azdataToolService.localAzdata, isEulaAccepted(memento)); + return azdataToolService.localAzdata?.acquireSession(endpoint, username, password, additionEnvVars); + }, getSemVersion: async () => { await localAzdataDiscovered; throwIfNoAzdata(azdataToolService.localAzdata); diff --git a/extensions/azdata/src/azdata.ts b/extensions/azdata/src/azdata.ts index c6204f13ee..78cfeab6cd 100644 --- a/extensions/azdata/src/azdata.ts +++ b/extensions/azdata/src/azdata.ts @@ -13,6 +13,7 @@ import { getPlatformDownloadLink, getPlatformReleaseVersion } from './azdataRele import { executeCommand, executeSudoCommand, ExitCodeError, ProcessOutput } from './common/childProcess'; import { HttpClient } from './common/httpClient'; import Logger from './common/logger'; +import { Deferred } from './common/promise'; import { getErrorMessage, NoAzdataError, searchForCmd } from './common/utils'; import { azdataAcceptEulaKey, azdataConfigSection, azdataFound, azdataInstallKey, azdataUpdateKey, debugConfigKey, eulaAccepted, eulaUrl, microsoftPrivacyStatementUrl } from './constants'; import * as loc from './localizedConstants'; @@ -34,12 +35,29 @@ export interface IAzdataTool extends azdataExt.IAzdataApi { executeCommand(args: string[], additionalEnvVars?: azdataExt.AdditionalEnvVars): Promise> } +class AzdataSession implements azdataExt.AzdataSession { + + private _session = new Deferred(); + + public sessionEnded(): Promise { + return this._session.promise; + } + + public dispose(): void { + this._session.resolve(); + } +} + /** * An object to interact with the azdata tool installed on the box. */ export class AzdataTool implements azdataExt.IAzdataApi { private _semVersion: SemVer; + private _currentSession: azdataExt.AzdataSession | undefined = undefined; + private _currentlyExecutingCommands: Deferred[] = []; + private _queuedCommands: { deferred: Deferred, session?: azdataExt.AzdataSession }[] = []; + constructor(private _path: string, version: string) { this._semVersion = new SemVer(version); } @@ -62,7 +80,17 @@ export class AzdataTool implements azdataExt.IAzdataApi { public arc = { dc: { - create: (namespace: string, name: string, connectivityMode: string, resourceGroup: string, location: string, subscription: string, profileName?: string, storageClass?: string, additionalEnvVars?: azdataExt.AdditionalEnvVars): Promise> => { + create: ( + namespace: string, + name: string, + connectivityMode: string, + resourceGroup: string, + location: string, + subscription: string, + profileName?: string, + storageClass?: string, + additionalEnvVars?: azdataExt.AdditionalEnvVars, + session?: azdataExt.AzdataSession): Promise> => { const args = ['arc', 'dc', 'create', '--namespace', namespace, '--name', name, @@ -76,32 +104,32 @@ export class AzdataTool implements azdataExt.IAzdataApi { if (storageClass) { args.push('--storage-class', storageClass); } - return this.executeCommand(args, additionalEnvVars); + return this.executeCommand(args, additionalEnvVars, session); }, endpoint: { - list: (additionalEnvVars?: azdataExt.AdditionalEnvVars): Promise> => { - return this.executeCommand(['arc', 'dc', 'endpoint', 'list'], additionalEnvVars); + list: (additionalEnvVars?: azdataExt.AdditionalEnvVars, session?: azdataExt.AzdataSession): Promise> => { + return this.executeCommand(['arc', 'dc', 'endpoint', 'list'], additionalEnvVars, session); } }, config: { - list: (additionalEnvVars?: azdataExt.AdditionalEnvVars): Promise> => { - return this.executeCommand(['arc', 'dc', 'config', 'list'], additionalEnvVars); + list: (additionalEnvVars?: azdataExt.AdditionalEnvVars, session?: azdataExt.AzdataSession): Promise> => { + return this.executeCommand(['arc', 'dc', 'config', 'list'], additionalEnvVars, session); }, - show: (additionalEnvVars?: azdataExt.AdditionalEnvVars): Promise> => { - return this.executeCommand(['arc', 'dc', 'config', 'show'], additionalEnvVars); + show: (additionalEnvVars?: azdataExt.AdditionalEnvVars, session?: azdataExt.AzdataSession): Promise> => { + return this.executeCommand(['arc', 'dc', 'config', 'show'], additionalEnvVars, session); } } }, postgres: { server: { - delete: (name: string, additionalEnvVars?: azdataExt.AdditionalEnvVars): Promise> => { - return this.executeCommand(['arc', 'postgres', 'server', 'delete', '-n', name, '--force'], additionalEnvVars); + delete: (name: string, additionalEnvVars?: azdataExt.AdditionalEnvVars, session?: azdataExt.AzdataSession): Promise> => { + return this.executeCommand(['arc', 'postgres', 'server', 'delete', '-n', name, '--force'], additionalEnvVars, session); }, - list: (additionalEnvVars?: azdataExt.AdditionalEnvVars): Promise> => { - return this.executeCommand(['arc', 'postgres', 'server', 'list'], additionalEnvVars); + list: (additionalEnvVars?: azdataExt.AdditionalEnvVars, session?: azdataExt.AzdataSession): Promise> => { + return this.executeCommand(['arc', 'postgres', 'server', 'list'], additionalEnvVars, session); }, - show: (name: string, additionalEnvVars?: azdataExt.AdditionalEnvVars): Promise> => { - return this.executeCommand(['arc', 'postgres', 'server', 'show', '-n', name], additionalEnvVars); + show: (name: string, additionalEnvVars?: azdataExt.AdditionalEnvVars, session?: azdataExt.AzdataSession): Promise> => { + return this.executeCommand(['arc', 'postgres', 'server', 'show', '-n', name], additionalEnvVars, session); }, edit: ( name: string, @@ -119,7 +147,8 @@ export class AzdataTool implements azdataExt.IAzdataApi { workers?: number }, engineVersion?: string, - additionalEnvVars?: azdataExt.AdditionalEnvVars): Promise> => { + additionalEnvVars?: azdataExt.AdditionalEnvVars, + session?: azdataExt.AzdataSession): Promise> => { const argsArray = ['arc', 'postgres', 'server', 'edit', '-n', name]; if (args.adminPassword) { argsArray.push('--admin-password'); } if (args.coresLimit) { argsArray.push('--cores-limit', args.coresLimit); } @@ -133,20 +162,20 @@ export class AzdataTool implements azdataExt.IAzdataApi { if (args.replaceEngineSettings) { argsArray.push('--replace-engine-settings'); } if (args.workers) { argsArray.push('--workers', args.workers.toString()); } if (engineVersion) { argsArray.push('--engine-version', engineVersion); } - return this.executeCommand(argsArray, additionalEnvVars); + return this.executeCommand(argsArray, additionalEnvVars, session); } } }, sql: { mi: { - delete: (name: string, additionalEnvVars?: azdataExt.AdditionalEnvVars): Promise> => { - return this.executeCommand(['arc', 'sql', 'mi', 'delete', '-n', name], additionalEnvVars); + delete: (name: string, additionalEnvVars?: azdataExt.AdditionalEnvVars, session?: azdataExt.AzdataSession): Promise> => { + return this.executeCommand(['arc', 'sql', 'mi', 'delete', '-n', name], additionalEnvVars, session); }, - list: (additionalEnvVars?: azdataExt.AdditionalEnvVars): Promise> => { - return this.executeCommand(['arc', 'sql', 'mi', 'list'], additionalEnvVars); + list: (additionalEnvVars?: azdataExt.AdditionalEnvVars, session?: azdataExt.AzdataSession): Promise> => { + return this.executeCommand(['arc', 'sql', 'mi', 'list'], additionalEnvVars, session); }, - show: (name: string, additionalEnvVars?: azdataExt.AdditionalEnvVars): Promise> => { - return this.executeCommand(['arc', 'sql', 'mi', 'show', '-n', name], additionalEnvVars); + show: (name: string, additionalEnvVars?: azdataExt.AdditionalEnvVars, session?: azdataExt.AzdataSession): Promise> => { + return this.executeCommand(['arc', 'sql', 'mi', 'show', '-n', name], additionalEnvVars, session); }, edit: ( name: string, @@ -157,7 +186,8 @@ export class AzdataTool implements azdataExt.IAzdataApi { memoryRequest?: string, noWait?: boolean, }, - additionalEnvVars?: azdataExt.AdditionalEnvVars + additionalEnvVars?: azdataExt.AdditionalEnvVars, + session?: azdataExt.AzdataSession ): Promise> => { const argsArray = ['arc', 'sql', 'mi', 'edit', '-n', name]; if (args.coresLimit) { argsArray.push('--cores-limit', args.coresLimit); } @@ -165,14 +195,59 @@ export class AzdataTool implements azdataExt.IAzdataApi { if (args.memoryLimit) { argsArray.push('--memory-limit', args.memoryLimit); } if (args.memoryRequest) { argsArray.push('--memory-request', args.memoryRequest); } if (args.noWait) { argsArray.push('--no-wait'); } - return this.executeCommand(argsArray, additionalEnvVars); + return this.executeCommand(argsArray, additionalEnvVars, session); } } } }; - public login(endpoint: string, username: string, password: string, additionalEnvVars: azdataExt.AdditionalEnvVars = {}): Promise> { - return this.executeCommand(['login', '-e', endpoint, '-u', username], Object.assign({}, additionalEnvVars, { 'AZDATA_PASSWORD': password })); + public async login(endpoint: string, username: string, password: string, additionalEnvVars: azdataExt.AdditionalEnvVars = {}): Promise> { + // Since login changes the context we want to wait until all currently executing commands are finished before this is executed + while (this._currentlyExecutingCommands.length > 0) { + await this._currentlyExecutingCommands[0]; + } + // Logins need to be done outside the session aware logic so call impl directly + return this.executeCommandImpl(['login', '-e', endpoint, '-u', username], Object.assign({}, additionalEnvVars, { 'AZDATA_PASSWORD': password })); + } + + public async acquireSession(endpoint: string, username: string, password: string, additionalEnvVars?: azdataExt.AdditionalEnvVars): Promise { + const session = new AzdataSession(); + session.sessionEnded().then(async () => { + // Wait for all commands running for this session to end + while (this._currentlyExecutingCommands.length > 0) { + await this._currentlyExecutingCommands[0].promise; + } + this._currentSession = undefined; + // Start our next command now that we're all done with this session + // TODO: Should we check if the command has a session that hasn't started? That should never happen.. + // TODO: Look into kicking off multiple commands + this._queuedCommands.shift()?.deferred.resolve(); + }); + + // We're not in a session or waiting on anything so just set the current session right now + if (!this._currentSession && this._queuedCommands.length === 0) { + this._currentSession = session; + } else { + // We're in a session or another command is executing so add this to the end of the queued commands and wait our turn + const deferred = new Deferred(); + deferred.promise.then(() => { + this._currentSession = session; + // We've started a new session so look at all our queued commands and start + // the ones for this session now. + this._queuedCommands = this._queuedCommands.filter(c => { + if (c.session === this._currentSession) { + c.deferred.resolve(); + return false; + } + return true; + }); + }); + this._queuedCommands.push({ deferred, session: undefined }); + await deferred.promise; + } + + await this.login(endpoint, username, password, additionalEnvVars); + return session; } /** @@ -190,7 +265,33 @@ export class AzdataTool implements azdataExt.IAzdataApi { }; } - public async executeCommand(args: string[], additionalEnvVars?: azdataExt.AdditionalEnvVars): Promise> { + public async executeCommand(args: string[], additionalEnvVars?: azdataExt.AdditionalEnvVars, session?: azdataExt.AzdataSession): Promise> { + if (this._currentSession && this._currentSession !== session) { + const deferred = new Deferred(); + this._queuedCommands.push({ deferred, session: session }); + await deferred.promise; + } + const executingDeferred = new Deferred(); + this._currentlyExecutingCommands.push(executingDeferred); + try { + return await this.executeCommandImpl(args, additionalEnvVars); + } + finally { + this._currentlyExecutingCommands = this._currentlyExecutingCommands.filter(c => c !== executingDeferred); + executingDeferred.resolve(); + // If there isn't an active session and we still have queued commands then we have to manually kick off the next one + if (this._queuedCommands.length > 0 && !this._currentSession) { + this._queuedCommands.shift()?.deferred.resolve(); + } + } + } + + /** + * Executes the specified azdata command. This is NOT session-aware so should only be used for calls that don't care about a session + * @param args The args to pass to azdata + * @param additionalEnvVars Additional environment variables to set for this execution + */ + private async executeCommandImpl(args: string[], additionalEnvVars?: azdataExt.AdditionalEnvVars): Promise> { try { const output = JSON.parse((await executeAzdataCommand(`"${this._path}"`, args.concat(['--output', 'json']), additionalEnvVars)).stdout); return { diff --git a/extensions/azdata/src/test/api.test.ts b/extensions/azdata/src/test/api.test.ts index 8fd231cdaa..f23da28ef8 100644 --- a/extensions/azdata/src/test/api.test.ts +++ b/extensions/azdata/src/test/api.test.ts @@ -24,6 +24,7 @@ describe('api', function (): void { await assertRejected(api.azdata.getPath(), 'getPath'); await assertRejected(api.azdata.getSemVersion(), 'getSemVersion'); await assertRejected(api.azdata.login('', '', ''), 'login'); + await assertRejected(api.azdata.acquireSession('', '', ''), 'acquireSession'); await assertRejected(api.azdata.version(), 'version'); await assertRejected(api.azdata.arc.dc.create('', '', '', '', '', ''), 'arc dc create'); diff --git a/extensions/azdata/src/test/azdata.test.ts b/extensions/azdata/src/test/azdata.test.ts index 25abd7d500..7bd45559aa 100644 --- a/extensions/azdata/src/test/azdata.test.ts +++ b/extensions/azdata/src/test/azdata.test.ts @@ -3,6 +3,7 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as azdataExt from 'azdata-ext'; import * as should from 'should'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; @@ -16,6 +17,7 @@ import * as fs from 'fs'; import { AzdataReleaseInfo } from '../azdataReleaseInfo'; import * as TypeMoq from 'typemoq'; import { eulaAccepted } from '../constants'; +import { sleep } from './testUtils'; const oldAzdataMock = new azdata.AzdataTool('/path/to/azdata', '0.0.0'); const currentAzdataMock = new azdata.AzdataTool('/path/to/azdata', '9999.999.999'); @@ -170,18 +172,6 @@ describe('azdata', function () { }); }); }); - it('login', async function (): Promise { - const endpoint = 'myEndpoint'; - const username = 'myUsername'; - const password = 'myPassword'; - await azdataTool.login(endpoint, username, password); - verifyExecuteCommandCalledWithArgs(['login', endpoint, username]); - }); - it('version', async function (): Promise { - executeCommandStub.resolves({ stdout: '1.0.0', stderr: '' }); - await azdataTool.version(); - verifyExecuteCommandCalledWithArgs(['--version']); - }); it('general error throws', async function (): Promise { const err = new Error(); executeCommandStub.throws(err); @@ -228,12 +218,136 @@ describe('azdata', function () { }); }); + it('login', async function (): Promise { + const endpoint = 'myEndpoint'; + const username = 'myUsername'; + const password = 'myPassword'; + await azdataTool.login(endpoint, username, password); + verifyExecuteCommandCalledWithArgs(['login', endpoint, username]); + }); + + describe('acquireSession', function (): void { + it('calls login', async function (): Promise { + const endpoint = 'myEndpoint'; + const username = 'myUsername'; + const password = 'myPassword'; + const session = await azdataTool.acquireSession(endpoint, username, password); + session.dispose(); + verifyExecuteCommandCalledWithArgs(['login', endpoint, username]); + }); + + it('command executed under current session completes', async function (): Promise { + const session = await azdataTool.acquireSession('', '', ''); + try { + await azdataTool.arc.dc.config.show(undefined, session); + } finally { + session.dispose(); + } + verifyExecuteCommandCalledWithArgs(['login'], 0); + verifyExecuteCommandCalledWithArgs(['arc', 'dc', 'config', 'show'], 1); + }); + it('multiple commands executed under current session completes', async function (): Promise { + const session = await azdataTool.acquireSession('', '', ''); + try { + // Kick off multiple commands at the same time and then ensure that they both complete + await Promise.all([ + azdataTool.arc.dc.config.show(undefined, session), + azdataTool.arc.sql.mi.list(undefined, session) + ]); + } finally { + session.dispose(); + } + verifyExecuteCommandCalledWithArgs(['login'], 0); + verifyExecuteCommandCalledWithArgs(['arc', 'dc', 'config', 'show'], 1); + verifyExecuteCommandCalledWithArgs(['arc', 'sql', 'mi', 'list'], 2); + }); + it('command executed without session context is queued up until session is closed', async function (): Promise { + const session = await azdataTool.acquireSession('', '', ''); + let nonSessionCommand: Promise | undefined = undefined; + try { + // Start one command in the current session + await azdataTool.arc.dc.config.show(undefined, session); + // Verify that the command isn't executed until after the session is disposed + let isFulfilled = false; + nonSessionCommand = azdataTool.arc.sql.mi.list().then(() => isFulfilled = true); + await sleep(2000); + should(isFulfilled).equal(false, 'The command should not be completed yet'); + } finally { + session.dispose(); + } + await nonSessionCommand; + verifyExecuteCommandCalledWithArgs(['login'], 0); + verifyExecuteCommandCalledWithArgs(['arc', 'dc', 'config', 'show'], 1); + verifyExecuteCommandCalledWithArgs(['arc', 'sql', 'mi', 'list'], 2); + }); + it('multiple commands executed without session context are queued up until session is closed', async function (): Promise { + const session = await azdataTool.acquireSession('', '', ''); + let nonSessionCommand1: Promise | undefined = undefined; + let nonSessionCommand2: Promise | undefined = undefined; + try { + // Start one command in the current session + await azdataTool.arc.dc.config.show(undefined, session); + // Verify that neither command is completed until the session is closed + let isFulfilled = false; + nonSessionCommand1 = azdataTool.arc.sql.mi.list().then(() => isFulfilled = true); + nonSessionCommand2 = azdataTool.arc.postgres.server.list().then(() => isFulfilled = true); + await sleep(2000); + should(isFulfilled).equal(false, 'The commands should not be completed yet'); + } finally { + session.dispose(); + } + await Promise.all([nonSessionCommand1, nonSessionCommand2]); + verifyExecuteCommandCalledWithArgs(['login'], 0); + verifyExecuteCommandCalledWithArgs(['arc', 'dc', 'config', 'show'], 1); + verifyExecuteCommandCalledWithArgs(['arc', 'sql', 'mi', 'list'], 2); + verifyExecuteCommandCalledWithArgs(['arc', 'postgres', 'server', 'list'], 3); + }); + it('attempting to acquire a second session while a first is still active queues the second session', async function (): Promise { + const firstSession = await azdataTool.acquireSession('', '', ''); + let sessionPromise: Promise | undefined = undefined; + let secondSessionCommand: Promise | undefined = undefined; + try { + try { + // Start one command in the current session + await azdataTool.arc.dc.config.show(undefined, firstSession); + // Verify that none of the commands for the second session are completed before the first is disposed + let isFulfilled = false; + sessionPromise = azdataTool.acquireSession('', '', ''); + sessionPromise.then(session => { + isFulfilled = true; + secondSessionCommand = azdataTool.arc.sql.mi.list(undefined, session).then(() => isFulfilled = true); + }); + await sleep(2000); + should(isFulfilled).equal(false, 'The commands should not be completed yet'); + } finally { + firstSession.dispose(); + } + } finally { + (await sessionPromise)?.dispose(); + } + should(secondSessionCommand).not.equal(undefined, 'The second command should have been queued already'); + await secondSessionCommand!; + + + verifyExecuteCommandCalledWithArgs(['login'], 0); + verifyExecuteCommandCalledWithArgs(['arc', 'dc', 'config', 'show'], 1); + verifyExecuteCommandCalledWithArgs(['login'], 2); + verifyExecuteCommandCalledWithArgs(['arc', 'sql', 'mi', 'list'], 3); + }); + }); + + it('version', async function (): Promise { + executeCommandStub.resolves({ stdout: '1.0.0', stderr: '' }); + await azdataTool.version(); + verifyExecuteCommandCalledWithArgs(['--version']); + }); + /** * Verifies that the specified args were included in the call to executeCommand * @param args The args to check were included in the execute command call */ - function verifyExecuteCommandCalledWithArgs(args: string[]): void { - const commandArgs = executeCommandStub.args[0][1] as string[]; + function verifyExecuteCommandCalledWithArgs(args: string[], callIndex = 0): void { + const commandArgs = executeCommandStub.args[callIndex][1] as string[]; args.forEach(arg => should(commandArgs).containEql(arg)); } @@ -469,8 +583,8 @@ describe('azdata', function () { }); }); - describe('promptForEula', function(): void { - it('skipped because of config', async function(): Promise { + describe('promptForEula', function (): void { + it('skipped because of config', async function (): Promise { const configMock = TypeMoq.Mock.ofType(); configMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => azdata.AzdataDeployOption.dontPrompt); sinon.stub(vscode.workspace, 'getConfiguration').returns(configMock.object); @@ -479,7 +593,7 @@ describe('azdata', function () { should(result).be.false(); }); - it('always prompt if user requested', async function(): Promise { + it('always prompt if user requested', async function (): Promise { const configMock = TypeMoq.Mock.ofType(); configMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => azdata.AzdataDeployOption.dontPrompt); sinon.stub(vscode.workspace, 'getConfiguration').returns(configMock.object); @@ -490,7 +604,7 @@ describe('azdata', function () { should(showInformationMessage.calledOnce).be.true('showInformationMessage should have been called to prompt user'); }); - it('prompt if config set to do so', async function(): Promise { + it('prompt if config set to do so', async function (): Promise { const configMock = TypeMoq.Mock.ofType(); configMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => azdata.AzdataDeployOption.prompt); sinon.stub(vscode.workspace, 'getConfiguration').returns(configMock.object); @@ -501,7 +615,7 @@ describe('azdata', function () { should(showInformationMessage.calledOnce).be.true('showInformationMessage should have been called to prompt user'); }); - it('update config if user chooses not to prompt', async function(): Promise { + it('update config if user chooses not to prompt', async function (): Promise { const configMock = TypeMoq.Mock.ofType(); configMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => azdata.AzdataDeployOption.prompt); sinon.stub(vscode.workspace, 'getConfiguration').returns(configMock.object); @@ -513,7 +627,7 @@ describe('azdata', function () { should(showInformationMessage.calledOnce).be.true('showInformationMessage should have been called to prompt user'); }); - it('user accepted EULA', async function(): Promise { + it('user accepted EULA', async function (): Promise { const configMock = TypeMoq.Mock.ofType(); configMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => azdata.AzdataDeployOption.prompt); sinon.stub(vscode.workspace, 'getConfiguration').returns(configMock.object); @@ -525,7 +639,7 @@ describe('azdata', function () { should(showInformationMessage.calledOnce).be.true('showInformationMessage should have been called to prompt user'); }); - it('user accepted EULA - require user action', async function(): Promise { + it('user accepted EULA - require user action', async function (): Promise { const configMock = TypeMoq.Mock.ofType(); configMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => azdata.AzdataDeployOption.prompt); sinon.stub(vscode.workspace, 'getConfiguration').returns(configMock.object); @@ -538,7 +652,7 @@ describe('azdata', function () { }); }); - describe('isEulaAccepted', function(): void { + describe('isEulaAccepted', function (): void { const mementoMock = TypeMoq.Mock.ofType(); mementoMock.setup(x => x.get(TypeMoq.It.isAny())).returns(() => true); should(azdata.isEulaAccepted(mementoMock.object)).be.true(); diff --git a/extensions/azdata/src/test/testUtils.ts b/extensions/azdata/src/test/testUtils.ts index 6176dc2e78..fca339df70 100644 --- a/extensions/azdata/src/test/testUtils.ts +++ b/extensions/azdata/src/test/testUtils.ts @@ -18,3 +18,7 @@ export async function assertRejected(promise: Promise, message: string): Pr throw new Error(message); } +export async function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + diff --git a/extensions/azdata/src/typings/azdata-ext.d.ts b/extensions/azdata/src/typings/azdata-ext.d.ts index 74d8582e1b..f0b90b24a0 100644 --- a/extensions/azdata/src/typings/azdata-ext.d.ts +++ b/extensions/azdata/src/typings/azdata-ext.d.ts @@ -5,6 +5,7 @@ declare module 'azdata-ext' { import { SemVer } from 'semver'; + import * as vscode from 'vscode'; /** * Covers defining what the azdata extension exports to other extensions @@ -232,23 +233,25 @@ declare module 'azdata-ext' { code?: number } + export interface AzdataSession extends vscode.Disposable { } + export interface IAzdataApi { arc: { dc: { - create(namespace: string, name: string, connectivityMode: string, resourceGroup: string, location: string, subscription: string, profileName?: string, storageClass?: string, additionalEnvVars?: AdditionalEnvVars): Promise>, + create(namespace: string, name: string, connectivityMode: string, resourceGroup: string, location: string, subscription: string, profileName?: string, storageClass?: string, additionalEnvVars?: AdditionalEnvVars, session?: AzdataSession): Promise>, endpoint: { - list(additionalEnvVars?: AdditionalEnvVars): Promise> + list(additionalEnvVars?: AdditionalEnvVars, session?: AzdataSession): Promise> }, config: { - list(additionalEnvVars?: AdditionalEnvVars): Promise>, - show(additionalEnvVars?: AdditionalEnvVars): Promise> + list(additionalEnvVars?: AdditionalEnvVars, session?: AzdataSession): Promise>, + show(additionalEnvVars?: AdditionalEnvVars, session?: AzdataSession): Promise> } }, postgres: { server: { - delete(name: string, additionalEnvVars?: AdditionalEnvVars): Promise>, - list(additionalEnvVars?: AdditionalEnvVars): Promise>, - show(name: string, additionalEnvVars?: AdditionalEnvVars): Promise>, + delete(name: string, additionalEnvVars?: AdditionalEnvVars, session?: AzdataSession): Promise>, + list(additionalEnvVars?: AdditionalEnvVars, session?: AzdataSession): Promise>, + show(name: string, additionalEnvVars?: AdditionalEnvVars, session?: AzdataSession): Promise>, edit( name: string, args: { @@ -265,15 +268,16 @@ declare module 'azdata-ext' { workers?: number }, engineVersion?: string, - additionalEnvVars?: AdditionalEnvVars + additionalEnvVars?: AdditionalEnvVars, + session?: AzdataSession ): Promise> } }, sql: { mi: { - delete(name: string, additionalEnvVars?: AdditionalEnvVars): Promise>, - list(additionalEnvVars?: AdditionalEnvVars): Promise>, - show(name: string, additionalEnvVars?: AdditionalEnvVars): Promise>, + delete(name: string, additionalEnvVars?: AdditionalEnvVars, session?: AzdataSession): Promise>, + list(additionalEnvVars?: AdditionalEnvVars, session?: AzdataSession): Promise>, + show(name: string, additionalEnvVars?: AdditionalEnvVars, session?: AzdataSession): Promise>, edit( name: string, args: { @@ -283,13 +287,23 @@ declare module 'azdata-ext' { memoryRequest?: string, noWait?: boolean, }, - additionalEnvVars?: AdditionalEnvVars + additionalEnvVars?: AdditionalEnvVars, + session?: AzdataSession ): Promise> } } }, getPath(): Promise, - login(endpoint: string, username: string, password: string, additionalEnvVars?: AdditionalEnvVars): Promise>, + login(endpoint: string, username: string, password: string, additionalEnvVars?: AdditionalEnvVars): Promise>, + /** + * Acquires a session for the specified controller, which will log in to the specified controller and then block all other commands + * that are not part of the original session from executing until the session is released (disposed). + * @param endpoint + * @param username + * @param password + * @param additionalEnvVars + */ + acquireSession(endpoint: string, username: string, password: string, additionalEnvVars?: AdditionalEnvVars): Promise, /** * The semVersion corresponding to this installation of azdata. version() method should have been run * before fetching this value to ensure that correct value is returned. This is almost always correct unless