Adding extension APIs for managing Azure Resources (subscriptions, resource groups, locations and SQL server) (#17321)
This commit is contained in:
Родитель
e66bb301e5
Коммит
b66395cfcd
|
@ -123,6 +123,9 @@
|
|||
"yargs": "https://registry.npmjs.org/yargs/-/yargs-3.32.0.tgz"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/arm-resources": "^5.0.0",
|
||||
"@azure/arm-subscriptions": "^5.0.0",
|
||||
"@azure/arm-sql":"^9.0.0",
|
||||
"@microsoft/ads-adal-library": "1.0.13",
|
||||
"core-js": "^2.4.1",
|
||||
"decompress-zip": "^0.2.2",
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import * as LocalizedConstants from '../constants/localizedConstants';
|
||||
import { AzureStringLookup } from '../azure/azureStringLookup';
|
||||
|
@ -22,6 +27,9 @@ import VscodeWrapper from '../controllers/vscodeWrapper';
|
|||
import { QuestionTypes, IQuestion, IPrompter, INameValueChoice } from '../prompts/question';
|
||||
import { Tenant } from '@microsoft/ads-adal-library';
|
||||
import { AzureAccount } from '../../lib/ads-adal-library/src';
|
||||
import { Subscription } from '@azure/arm-subscriptions';
|
||||
import * as mssql from 'vscode-mssql';
|
||||
import * as azureUtils from './utils';
|
||||
|
||||
function getAppDataPath(): string {
|
||||
let platform = process.platform;
|
||||
|
@ -79,7 +87,11 @@ export class AzureController {
|
|||
private _vscodeWrapper: VscodeWrapper;
|
||||
private credentialStoreInitialized = false;
|
||||
|
||||
constructor(context: vscode.ExtensionContext, prompter: IPrompter, logger?: AzureLogger) {
|
||||
constructor(
|
||||
context: vscode.ExtensionContext,
|
||||
prompter: IPrompter,
|
||||
logger?: AzureLogger,
|
||||
private _subscriptionClientFactory: azureUtils.SubscriptionClientFactory = azureUtils.defaultSubscriptionClientFactory) {
|
||||
this.context = context;
|
||||
this.prompter = prompter;
|
||||
if (!this.logger) {
|
||||
|
@ -239,6 +251,30 @@ export class AzureController {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Azure sessions with subscriptions, tenant and token for each given account
|
||||
*/
|
||||
public async getAccountSessions(account: IAccount): Promise<mssql.IAzureAccountSession[]> {
|
||||
let sessions: mssql.IAzureAccountSession[] = [];
|
||||
const tenants = <Tenant[]>account.properties.tenants;
|
||||
for (const tenantId of tenants.map(t => t.id)) {
|
||||
const token = await this.getAccountSecurityToken(account, tenantId, providerSettings.resources.azureManagementResource);
|
||||
const subClient = this._subscriptionClientFactory(token);
|
||||
const newSubPages = await subClient.subscriptions.list();
|
||||
const array = await azureUtils.getAllValues<Subscription, mssql.IAzureAccountSession>(newSubPages, (nextSub) => {
|
||||
return {
|
||||
subscription: nextSub,
|
||||
tenantId: tenantId,
|
||||
account: account,
|
||||
token: token
|
||||
};
|
||||
});
|
||||
sessions = sessions.concat(array);
|
||||
}
|
||||
|
||||
return sessions.sort((a, b) => (a.subscription.displayName || '').localeCompare(b.subscription.displayName || ''));
|
||||
}
|
||||
|
||||
private async createAuthCodeGrant(): Promise<AzureCodeGrant> {
|
||||
let azureLogger = new AzureLogger();
|
||||
await this.initializeCredentialStore();
|
||||
|
@ -279,4 +315,40 @@ export class AzureController {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Verifies if the token still valid, refreshes the token for given account
|
||||
* @param session
|
||||
*/
|
||||
public async checkAndRefreshToken(
|
||||
session: mssql.IAzureAccountSession,
|
||||
accountStore: AccountStore): Promise<void> {
|
||||
if (session.account && AzureController.isTokenInValid(session.token?.token, session.token.expiresOn)) {
|
||||
const token = await this.refreshToken(session.account, accountStore,
|
||||
providerSettings.resources.azureManagementResource);
|
||||
session.token = token;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if token is invalid or expired
|
||||
* @param token Token
|
||||
* @param token expiry
|
||||
*/
|
||||
public static isTokenInValid(token: string, expiresOn?: number): boolean {
|
||||
return (!token || this.isTokenExpired(expiresOn));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if token is expired
|
||||
* @param token expiry
|
||||
*/
|
||||
public static isTokenExpired(expiresOn?: number): boolean {
|
||||
if (!expiresOn) {
|
||||
return true;
|
||||
}
|
||||
const currentTime = new Date().getTime() / 1000;
|
||||
const maxTolerance = 2 * 60; // two minutes
|
||||
return (expiresOn - currentTime < maxTolerance);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Location } from '@azure/arm-subscriptions';
|
||||
import { ResourceGroup } from '@azure/arm-resources';
|
||||
import { Server } from '@azure/arm-sql';
|
||||
import * as mssql from 'vscode-mssql';
|
||||
import * as azureUtils from './utils';
|
||||
|
||||
export class AzureResourceController {
|
||||
|
||||
constructor(
|
||||
private _subscriptionClientFactory: azureUtils.SubscriptionClientFactory = azureUtils.defaultSubscriptionClientFactory,
|
||||
private _resourceManagementClientFactory: azureUtils.ResourceManagementClientFactory = azureUtils.defaultResourceManagementClientFactory,
|
||||
private _sqlManagementClientFactory: azureUtils.SqlManagementClientFactory = azureUtils.defaultSqlManagementClientFactory) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Azure locations for given session
|
||||
* @param session Azure session
|
||||
* @returns List of locations
|
||||
*/
|
||||
public async getLocations(session: mssql.IAzureAccountSession): Promise<Location[]> {
|
||||
const subClient = this._subscriptionClientFactory(session.token);
|
||||
if (session.subscription?.subscriptionId) {
|
||||
const locationsPages = await subClient.subscriptions.listLocations(session.subscription.subscriptionId);
|
||||
let locations = await azureUtils.getAllValues(locationsPages, (v) => v);
|
||||
return locations.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
} else {
|
||||
throw new Error('Invalid session');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates a Azure SQL server for given subscription, resource group and location
|
||||
* @param subscriptionId subscription Id
|
||||
* @param resourceGroupName resource group name
|
||||
* @param serverName SQL server name
|
||||
* @param parameters parameters for the SQL server
|
||||
* @returns name of the SQL server
|
||||
*/
|
||||
public async createOrUpdateServer(
|
||||
subscriptionId: string,
|
||||
resourceGroupName: string,
|
||||
serverName: string,
|
||||
parameters: Server,
|
||||
token: mssql.Token): Promise<string | undefined> {
|
||||
if (subscriptionId && resourceGroupName) {
|
||||
const sqlClient = this._sqlManagementClientFactory(token, subscriptionId);
|
||||
if (sqlClient) {
|
||||
const result = await sqlClient.servers.beginCreateOrUpdateAndWait(resourceGroupName,
|
||||
serverName, parameters);
|
||||
|
||||
return result.fullyQualifiedDomainName;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Azure resource groups for given subscription
|
||||
* @param session Azure session
|
||||
* @returns List of resource groups
|
||||
*/
|
||||
public async getResourceGroups(session: mssql.IAzureAccountSession): Promise<ResourceGroup[]> {
|
||||
if (session.subscription?.subscriptionId) {
|
||||
const resourceGroupClient = this._resourceManagementClientFactory(session.token, session.subscription.subscriptionId);
|
||||
const newGroupsPages = await resourceGroupClient.resourceGroups.list();
|
||||
let groups = await azureUtils.getAllValues(newGroupsPages, (v) => v);
|
||||
return groups.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
} else {
|
||||
throw new Error('Invalid session');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as coreAuth from '@azure/core-auth';
|
||||
import * as mssql from 'vscode-mssql';
|
||||
|
||||
/**
|
||||
* TokenCredential wrapper to only return the given token.
|
||||
* Azure clients usually get a type of credential with a getToken function.
|
||||
* Since in mssql extension we get the token differently, we need this wrapper class to just return
|
||||
* that token value
|
||||
*/
|
||||
export class TokenCredentialWrapper implements coreAuth.TokenCredential {
|
||||
|
||||
constructor(private _token: mssql.Token) {
|
||||
}
|
||||
|
||||
public getToken(_: string | string[], __?: coreAuth.GetTokenOptions): Promise<coreAuth.AccessToken | null> {
|
||||
return Promise.resolve({
|
||||
token: this._token.token,
|
||||
expiresOnTimestamp: this._token.expiresOn || 0
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { ResourceManagementClient } from '@azure/arm-resources';
|
||||
import { SqlManagementClient } from '@azure/arm-sql';
|
||||
import { SubscriptionClient } from '@azure/arm-subscriptions';
|
||||
import { PagedAsyncIterableIterator } from '@azure/core-paging';
|
||||
import { Token } from 'vscode-mssql';
|
||||
import { TokenCredentialWrapper } from './credentialWrapper';
|
||||
|
||||
/**
|
||||
* Helper method to convert azure results that comes as pages to an array
|
||||
* @param pages azure resources as pages
|
||||
* @param convertor a function to convert a value in page to the expected value to add to array
|
||||
* @returns array or Azure resources
|
||||
*/
|
||||
export async function getAllValues<T, TResult>(pages: PagedAsyncIterableIterator<T>, convertor: (input: T) => TResult): Promise<TResult[]> {
|
||||
let values: TResult[] = [];
|
||||
let newValue = await pages.next();
|
||||
while (!newValue.done) {
|
||||
values.push(convertor(newValue.value));
|
||||
newValue = await pages.next();
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export type SubscriptionClientFactory = (token: Token) => SubscriptionClient;
|
||||
export function defaultSubscriptionClientFactory(token: Token): SubscriptionClient {
|
||||
return new SubscriptionClient(new TokenCredentialWrapper(token));
|
||||
}
|
||||
|
||||
export type ResourceManagementClientFactory = (token: Token, subscriptionId: string) => ResourceManagementClient;
|
||||
export function defaultResourceManagementClientFactory(token: Token, subscriptionId: string): ResourceManagementClient {
|
||||
return new ResourceManagementClient(new TokenCredentialWrapper(token), subscriptionId);
|
||||
}
|
||||
|
||||
export type SqlManagementClientFactory = (token: Token, subscriptionId: string) => SqlManagementClient;
|
||||
export function defaultSqlManagementClientFactory(token: Token, subscriptionId: string): SqlManagementClient {
|
||||
return new SqlManagementClient(new TokenCredentialWrapper(token), subscriptionId);
|
||||
}
|
|
@ -722,9 +722,7 @@ export default class ConnectionManager {
|
|||
const self = this;
|
||||
// Check if the azure account token is present before sending connect request
|
||||
if (connectionCreds.authenticationType === Constants.azureMfa) {
|
||||
const currentTime = new Date().getTime() / 1000;
|
||||
const maxTolerance = 2 * 60; // two minutes
|
||||
if (!connectionCreds.azureAccountToken || connectionCreds.expiresOn - currentTime < maxTolerance) {
|
||||
if (AzureController.isTokenInValid(connectionCreds.azureAccountToken, connectionCreds.expiresOn)) {
|
||||
let account = this.accountStore.getAccount(connectionCreds.accountId);
|
||||
let profile = new ConnectionProfile(connectionCreds);
|
||||
let azureAccountToken = await this.azureController.refreshToken(account, this.accountStore, providerSettings.resources.databaseResource, profile.tenantId);
|
||||
|
@ -895,9 +893,7 @@ export default class ConnectionManager {
|
|||
|
||||
const expiry = profile.credentials.expiresOn;
|
||||
if (typeof expiry === 'number' && !Number.isNaN(expiry)) {
|
||||
const currentTime = new Date().getTime() / 1000;
|
||||
const maxTolerance = 2 * 60; // two minutes
|
||||
if (expiry - currentTime < maxTolerance) {
|
||||
if (AzureController.isTokenExpired(expiry)) {
|
||||
this.vscodeWrapper.logToOutputChannel(Utils.formatString(LocalizedConstants.msgAcessTokenExpired, profile.connectionId, uri));
|
||||
try {
|
||||
let connectionResult = await this.connect(uri, profile.credentials);
|
||||
|
|
|
@ -35,6 +35,8 @@ import { IConnectionInfo } from 'vscode-mssql';
|
|||
import { SchemaCompareService } from '../services/schemaCompareService';
|
||||
import { SqlTasksService } from '../services/sqlTasksService';
|
||||
import { AzureAccountService } from '../services/azureAccountService';
|
||||
import { AzureResourceService } from '../services/azureResourceService';
|
||||
import { AzureResourceController } from '../azure/azureResourceController';
|
||||
|
||||
/**
|
||||
* The main controller class that initializes the extension
|
||||
|
@ -61,6 +63,7 @@ export default class MainController implements vscode.Disposable {
|
|||
public dacFxService: DacFxService;
|
||||
public schemaCompareService: SchemaCompareService;
|
||||
public azureAccountService: AzureAccountService;
|
||||
public azureResourceService: AzureResourceService;
|
||||
|
||||
/**
|
||||
* The main controller constructor
|
||||
|
@ -153,7 +156,9 @@ export default class MainController implements vscode.Disposable {
|
|||
this.sqlTasksService = new SqlTasksService(SqlToolsServerClient.instance, this._untitledSqlDocumentService);
|
||||
this.dacFxService = new DacFxService(SqlToolsServerClient.instance);
|
||||
this.schemaCompareService = new SchemaCompareService(SqlToolsServerClient.instance);
|
||||
this.azureAccountService = new AzureAccountService(this._connectionMgr.azureController, this._context);
|
||||
const azureResourceController = new AzureResourceController();
|
||||
this.azureAccountService = new AzureAccountService(this._connectionMgr.azureController, this.connectionManager.accountStore);
|
||||
this.azureResourceService = new AzureResourceService(this._connectionMgr.azureController, azureResourceController, this.connectionManager.accountStore);
|
||||
|
||||
// Add handlers for VS Code generated commands
|
||||
this._vscodeWrapper.onDidCloseTextDocument(async (params) => await this.onDidCloseTextDocument(params));
|
||||
|
|
|
@ -76,6 +76,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<IExten
|
|||
return controller.connectionManager.connectionUI.addFirewallRule(connectionUri, connectionProfile);
|
||||
},
|
||||
azureAccountService: controller.azureAccountService,
|
||||
azureResourceService: controller.azureResourceService,
|
||||
createConnectionDetails: (connectionInfo: IConnectionInfo) => {
|
||||
return controller.connectionManager.createConnectionDetails(connectionInfo);
|
||||
},
|
||||
|
|
|
@ -4,27 +4,33 @@
|
|||
* ------------------------------------------------------------------------------------------ */
|
||||
|
||||
import * as mssql from 'vscode-mssql';
|
||||
import * as vscode from 'vscode';
|
||||
import { AccountStore } from '../azure/accountStore';
|
||||
import { AzureController } from '../azure/azureController';
|
||||
import providerSettings from '../azure/providerSettings';
|
||||
|
||||
export class AzureAccountService implements mssql.IAzureAccountService {
|
||||
|
||||
private _accountStore: AccountStore;
|
||||
constructor(
|
||||
private _azureController: AzureController,
|
||||
private _context: vscode.ExtensionContext) {
|
||||
this._accountStore = new AccountStore(this._context);
|
||||
private _accountStore: AccountStore) {
|
||||
}
|
||||
|
||||
public async addAccount(): Promise<mssql.IAccount> {
|
||||
return await this._azureController.addAccount(this._accountStore);
|
||||
}
|
||||
|
||||
public async getAccounts(): Promise<mssql.IAccount[]> {
|
||||
return await this._accountStore.getAccounts();
|
||||
}
|
||||
|
||||
public async getAccountSecurityToken(account: mssql.IAccount, tenantId: string | undefined): Promise<mssql.Token> {
|
||||
return await this._azureController.getAccountSecurityToken(account, tenantId, providerSettings.resources.azureManagementResource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Azure sessions with subscription, tenant and token for each given account
|
||||
*/
|
||||
public async getAccountSessions(account: mssql.IAccount): Promise<mssql.IAzureAccountSession[]> {
|
||||
return await this._azureController.getAccountSessions(account);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
/* --------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
* ------------------------------------------------------------------------------------------ */
|
||||
|
||||
import * as mssql from 'vscode-mssql';
|
||||
import { AccountStore } from '../azure/accountStore';
|
||||
import { AzureController } from '../azure/azureController';
|
||||
import { AzureResourceController } from '../azure/azureResourceController';
|
||||
import { Location } from '@azure/arm-subscriptions';
|
||||
import { ResourceGroup } from '@azure/arm-resources';
|
||||
import { Server } from '@azure/arm-sql';
|
||||
|
||||
export class AzureResourceService implements mssql.IAzureResourceService {
|
||||
|
||||
constructor(
|
||||
private _azureController: AzureController,
|
||||
private _azureResourceController: AzureResourceController,
|
||||
private _accountStore: AccountStore) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Azure locations for given subscription
|
||||
*/
|
||||
public async getLocations(session: mssql.IAzureAccountSession): Promise<Location[]> {
|
||||
await this._azureController.checkAndRefreshToken(session, this._accountStore);
|
||||
return await this._azureResourceController.getLocations(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Azure resource groups for given subscription
|
||||
*/
|
||||
public async getResourceGroups(session: mssql.IAzureAccountSession): Promise<ResourceGroup[]> {
|
||||
await this._azureController.checkAndRefreshToken(session, this._accountStore);
|
||||
return await this._azureResourceController.getResourceGroups(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates a Azure SQL server for given subscription, resource group and location
|
||||
*/
|
||||
public async createOrUpdateServer(
|
||||
session: mssql.IAzureAccountSession,
|
||||
resourceGroupName: string,
|
||||
serverName: string,
|
||||
parameters: Server): Promise<string | undefined> {
|
||||
await this._azureController.checkAndRefreshToken(session, this._accountStore);
|
||||
return await this._azureResourceController.createOrUpdateServer(session.subscription.subscriptionId,
|
||||
resourceGroupName, serverName, parameters, session.token);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
/* --------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
* ------------------------------------------------------------------------------------------ */
|
||||
|
||||
import { AzureController } from '../src/azure/azureController';
|
||||
import * as assert from 'assert';
|
||||
|
||||
suite('Azure Controller Tests', () => {
|
||||
|
||||
const currentTime = new Date().getTime() / 1000;
|
||||
|
||||
test('isTokenInValid should return true for undefined token', () => {
|
||||
const actual = AzureController.isTokenInValid(undefined, currentTime);
|
||||
const expected = true;
|
||||
assert.strictEqual(actual, expected);
|
||||
});
|
||||
|
||||
test('isTokenInValid should return true for empty token', () => {
|
||||
const actual = AzureController.isTokenInValid('', currentTime);
|
||||
const expected = true;
|
||||
assert.strictEqual(actual, expected);
|
||||
});
|
||||
|
||||
test('isTokenInValid should return true for undefined expiresOn', () => {
|
||||
const actual = AzureController.isTokenInValid('token', undefined);
|
||||
const expected = true;
|
||||
assert.strictEqual(actual, expected);
|
||||
});
|
||||
|
||||
test('isTokenInValid should return true for expired token', () => {
|
||||
const actual = AzureController.isTokenInValid('token', currentTime - (4 * 60));
|
||||
const expected = true;
|
||||
assert.strictEqual(actual, expected);
|
||||
});
|
||||
|
||||
test('isTokenInValid should return false for valid token', () => {
|
||||
const actual = AzureController.isTokenInValid('token', currentTime + (3 * 60));
|
||||
const expected = false;
|
||||
assert.strictEqual(actual, expected);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,150 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import * as TypeMoq from 'typemoq';
|
||||
import { IAccount, IAzureAccountSession } from 'vscode-mssql';
|
||||
import { SubscriptionClient, Subscription, Subscriptions, Location } from '@azure/arm-subscriptions';
|
||||
import { PagedAsyncIterableIterator } from '@azure/core-paging';
|
||||
import { ResourceGroup, ResourceGroups, ResourceManagementClient } from '@azure/arm-resources';
|
||||
import { AzureResourceController } from '../src/azure/azureResourceController';
|
||||
import { AzureAccountService } from '../src/services/azureAccountService';
|
||||
import { TokenCredentialWrapper } from '../src/azure/credentialWrapper';
|
||||
|
||||
export interface ITestContext {
|
||||
azureAccountService: TypeMoq.IMock<AzureAccountService>;
|
||||
accounts: IAccount[];
|
||||
session: IAzureAccountSession;
|
||||
subscriptionClient: TypeMoq.IMock<SubscriptionClient>;
|
||||
subscriptions: Subscription[];
|
||||
locations: Location[];
|
||||
groups: ResourceGroup[];
|
||||
}
|
||||
|
||||
export function createContext(): ITestContext {
|
||||
const accounts = [{
|
||||
key: undefined!,
|
||||
displayInfo: undefined!,
|
||||
properties: {
|
||||
tenants: [{
|
||||
id: '',
|
||||
displayName: ''
|
||||
}]
|
||||
},
|
||||
isStale: false,
|
||||
isSignedIn: true
|
||||
}];
|
||||
const subscriptions: Subscription[] = [{ subscriptionId: 'id1' }, { subscriptionId: 'id2' }];
|
||||
const locations: Location[] = [{ id: 'id1' }, { id: 'id2' }];
|
||||
const groups: ResourceGroup[] = [{ id: 'id1', location: 'l1' }, { id: 'id2', location: 'l2' }];
|
||||
const session0: IAzureAccountSession = {
|
||||
account: accounts[0],
|
||||
subscription: subscriptions[0],
|
||||
tenantId: 'tenantId',
|
||||
token: {
|
||||
key: '',
|
||||
token: '',
|
||||
tokenType: ''
|
||||
}
|
||||
};
|
||||
const session1: IAzureAccountSession = {
|
||||
account: accounts[0],
|
||||
subscription: subscriptions[1],
|
||||
tenantId: 'tenantId',
|
||||
token: {
|
||||
key: '',
|
||||
token: '',
|
||||
tokenType: ''
|
||||
}
|
||||
};
|
||||
const azureAccountService = TypeMoq.Mock.ofType(AzureAccountService, undefined, undefined);
|
||||
azureAccountService.setup(x => x.getAccounts()).returns( () => Promise.resolve(accounts));
|
||||
azureAccountService.setup(x => x.addAccount()).returns( () => Promise.resolve(accounts[0]));
|
||||
azureAccountService.setup(x => x.getAccountSecurityToken(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns( () => Promise.resolve({
|
||||
key: '',
|
||||
token: '',
|
||||
tokenType: ''
|
||||
}));
|
||||
azureAccountService.setup(x => x.getAccountSessions(TypeMoq.It.isAny())).returns(() => Promise.resolve([session0, session1]));
|
||||
|
||||
return {
|
||||
groups: groups,
|
||||
locations: locations,
|
||||
subscriptions: subscriptions,
|
||||
subscriptionClient: TypeMoq.Mock.ofType(SubscriptionClient, undefined, new TokenCredentialWrapper(session0.token)),
|
||||
session: session0,
|
||||
accounts: accounts,
|
||||
azureAccountService: azureAccountService
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
suite('Azure SQL client', function (): void {
|
||||
|
||||
test('Should return locations successfully', async function (): Promise<void> {
|
||||
const testContext = createContext();
|
||||
const azureSqlClient = new AzureResourceController(() => testContext.subscriptionClient.object);
|
||||
|
||||
let index = 0;
|
||||
let maxLength = testContext.locations.length;
|
||||
const pages: PagedAsyncIterableIterator<Location> = {
|
||||
next: () => {
|
||||
if (index < maxLength) {
|
||||
return Promise.resolve({ done: false, value: testContext.locations[index++] });
|
||||
} else {
|
||||
return Promise.resolve({ done: true, value: undefined });
|
||||
}
|
||||
},
|
||||
byPage: () => undefined!,
|
||||
[Symbol.asyncIterator]: undefined!
|
||||
};
|
||||
const subscriptions: Subscriptions = {
|
||||
listLocations: () => pages,
|
||||
list: () => undefined!,
|
||||
get: () => undefined!
|
||||
};
|
||||
testContext.subscriptionClient.setup(x => x.subscriptions).returns(() => subscriptions);
|
||||
|
||||
const result = await azureSqlClient.getLocations(testContext.session);
|
||||
assert.deepStrictEqual(result.length, testContext.locations.length);
|
||||
});
|
||||
|
||||
test('Should return resource groups successfully', async function (): Promise<void> {
|
||||
const testContext = createContext();
|
||||
const azureSqlClient = new AzureResourceController(undefined, () => groupClient.object);
|
||||
|
||||
let index = 0;
|
||||
let maxLength = testContext.groups.length;
|
||||
const pages: PagedAsyncIterableIterator<ResourceGroup> = {
|
||||
next: () => {
|
||||
if (index < maxLength) {
|
||||
return Promise.resolve({ done: false, value: testContext.groups[index++] });
|
||||
} else {
|
||||
return Promise.resolve({ done: true, value: undefined });
|
||||
}
|
||||
},
|
||||
byPage: () => undefined!,
|
||||
[Symbol.asyncIterator]: undefined!
|
||||
};
|
||||
const resourceGroups: ResourceGroups = {
|
||||
list: () => pages,
|
||||
get: () => undefined!,
|
||||
beginDelete: undefined!,
|
||||
beginDeleteAndWait: undefined!,
|
||||
beginExportTemplate: undefined!,
|
||||
beginExportTemplateAndWait: undefined!,
|
||||
checkExistence: undefined!,
|
||||
createOrUpdate: undefined!,
|
||||
update: undefined!
|
||||
};
|
||||
const groupClient = TypeMoq.Mock.ofType(ResourceManagementClient, undefined,
|
||||
new TokenCredentialWrapper(testContext.session.token), testContext.subscriptions[0].subscriptionId);
|
||||
groupClient.setup(x => x.resourceGroups).returns(() => resourceGroups);
|
||||
|
||||
const result = await azureSqlClient.getResourceGroups(testContext.session);
|
||||
assert.deepStrictEqual(result.length, testContext.groups.length);
|
||||
assert.deepStrictEqual(result[0].location, testContext.groups[0].location);
|
||||
});
|
||||
});
|
|
@ -7,6 +7,9 @@ declare module 'vscode-mssql' {
|
|||
|
||||
import * as vscode from 'vscode';
|
||||
import { RequestType } from 'vscode-languageclient';
|
||||
import { Subscription, Location } from '@azure/arm-subscriptions';
|
||||
import { ResourceGroup } from '@azure/arm-resources';
|
||||
import { Server } from '@azure/arm-sql';
|
||||
|
||||
/**
|
||||
* Covers defining what the vscode-mssql extension exports to other extensions
|
||||
|
@ -45,6 +48,11 @@ declare module 'vscode-mssql' {
|
|||
*/
|
||||
readonly azureAccountService: IAzureAccountService;
|
||||
|
||||
/**
|
||||
* Service for accessing Azure Resources functionality
|
||||
*/
|
||||
readonly azureResourceService: IAzureResourceService;
|
||||
|
||||
/**
|
||||
* Prompts the user to select an existing connection or create a new one, and then returns the result
|
||||
* @param ignoreFocusOut Whether the quickpick prompt ignores focus out (default false)
|
||||
|
@ -399,6 +407,13 @@ declare module 'vscode-mssql' {
|
|||
isSignedIn?: boolean;
|
||||
}
|
||||
|
||||
export interface IAzureAccountSession {
|
||||
subscription: Subscription,
|
||||
tenantId: string,
|
||||
account: IAccount,
|
||||
token: Token
|
||||
}
|
||||
|
||||
export interface TokenKey {
|
||||
/**
|
||||
* Account Key - uniquely identifies an account
|
||||
|
@ -437,6 +452,38 @@ declare module 'vscode-mssql' {
|
|||
* Returns an access token for given user and tenant
|
||||
*/
|
||||
getAccountSecurityToken(account: IAccount, tenantId: string | undefined): Promise<Token>;
|
||||
|
||||
/**
|
||||
* Returns Azure subscriptions with tenant and token for each given account
|
||||
*/
|
||||
getAccountSessions(account: IAccount): Promise<IAzureAccountSession[]>;
|
||||
}
|
||||
|
||||
export interface IAzureResourceService {
|
||||
|
||||
/**
|
||||
* Returns Azure resource groups for given subscription
|
||||
* @param session Azure session
|
||||
* @returns List of resource groups
|
||||
*/
|
||||
getResourceGroups(session: IAzureAccountSession): Promise<ResourceGroup[]>;
|
||||
|
||||
/**
|
||||
* Creates or updates a Azure SQL server for given subscription, resource group and location
|
||||
* @param session Azure session
|
||||
* @param resourceGroupName resource group name
|
||||
* @param serverName SQL server name
|
||||
* @param parameters parameters for the SQL server
|
||||
* @returns name of the SQL server
|
||||
*/
|
||||
createOrUpdateServer(session: IAzureAccountSession, resourceGroupName: string, serverName: string, parameters: Server): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Returns Azure locations for given session
|
||||
* @param session Azure session
|
||||
* @returns List of locations
|
||||
*/
|
||||
getLocations(session: IAzureAccountSession): Promise<Location[]>;
|
||||
}
|
||||
|
||||
export const enum TaskExecutionMode {
|
||||
|
|
146
yarn.lock
146
yarn.lock
|
@ -42,6 +42,126 @@
|
|||
resolved "https://registry.yarnpkg.com/@angular/upgrade/-/upgrade-2.1.2.tgz#fc9423f87fcae418a0ae3b8d56c615d93563d44f"
|
||||
integrity sha1-/JQj+H/K5BigrjuNVsYV2TVj1E8=
|
||||
|
||||
"@azure/abort-controller@^1.0.0":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@azure/abort-controller/-/abort-controller-1.0.4.tgz#fd3c4d46c8ed67aace42498c8e2270960250eafd"
|
||||
integrity sha512-lNUmDRVGpanCsiUN3NWxFTdwmdFI53xwhkTFfHDGTYk46ca7Ind3nanJc+U6Zj9Tv+9nTCWRBscWEW1DyKOpTw==
|
||||
dependencies:
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@azure/arm-resources@^5.0.0":
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@azure/arm-resources/-/arm-resources-5.0.1.tgz#be8031fae613207354e9842d68698f8a0dc7e250"
|
||||
integrity sha512-JbZtIqfEulsIA0rC3zM7jfF4KkOnye9aKcaO/jJqxJRm/gM6lAjEv7sL4njW8D+35l50P1f+UuH5OqN+UKJqNA==
|
||||
dependencies:
|
||||
"@azure/abort-controller" "^1.0.0"
|
||||
"@azure/core-auth" "^1.3.0"
|
||||
"@azure/core-client" "^1.5.0"
|
||||
"@azure/core-lro" "^2.2.0"
|
||||
"@azure/core-paging" "^1.2.0"
|
||||
"@azure/core-rest-pipeline" "^1.8.0"
|
||||
tslib "^2.2.0"
|
||||
|
||||
"@azure/arm-sql@^9.0.0":
|
||||
version "9.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@azure/arm-sql/-/arm-sql-9.0.0.tgz#2f966a8aa5861f26c90cf7005a4fce4446664966"
|
||||
integrity sha512-voEc9kP/S92lrNY68h+JSc67zZpSNlr3XYjmnShC/kMlNApy2io8ODsgpU9wuXdSDE++UUeRSNDT7pTCJff8eg==
|
||||
dependencies:
|
||||
"@azure/abort-controller" "^1.0.0"
|
||||
"@azure/core-auth" "^1.3.0"
|
||||
"@azure/core-client" "^1.0.0"
|
||||
"@azure/core-lro" "^2.2.0"
|
||||
"@azure/core-paging" "^1.2.0"
|
||||
"@azure/core-rest-pipeline" "^1.1.0"
|
||||
tslib "^2.2.0"
|
||||
|
||||
"@azure/arm-subscriptions@^5.0.0":
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@azure/arm-subscriptions/-/arm-subscriptions-5.0.0.tgz#0b9ba1bb5654c80a0c693f55ff95d9a200814a41"
|
||||
integrity sha512-kka1Gsy5fvQvYbe3gRsMl2hYCFMdQRHuOSSRUAsQUwAEqIJCu/hLZ/CNKcYusIMrA0SWzrjlFYVklo/uUKYolg==
|
||||
dependencies:
|
||||
"@azure/abort-controller" "^1.0.0"
|
||||
"@azure/core-auth" "^1.3.0"
|
||||
"@azure/core-client" "^1.0.0"
|
||||
"@azure/core-lro" "^2.2.0"
|
||||
"@azure/core-paging" "^1.2.0"
|
||||
"@azure/core-rest-pipeline" "^1.1.0"
|
||||
tslib "^2.2.0"
|
||||
|
||||
"@azure/core-asynciterator-polyfill@^1.0.0":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@azure/core-asynciterator-polyfill/-/core-asynciterator-polyfill-1.0.2.tgz#0dd3849fb8d97f062a39db0e5cadc9ffaf861fec"
|
||||
integrity sha512-3rkP4LnnlWawl0LZptJOdXNrT/fHp2eQMadoasa6afspXdpGrtPZuAQc2PD0cpgyuoXtUWyC3tv7xfntjGS5Dw==
|
||||
|
||||
"@azure/core-auth@^1.3.0":
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/@azure/core-auth/-/core-auth-1.3.2.tgz#6a2c248576c26df365f6c7881ca04b7f6d08e3d0"
|
||||
integrity sha512-7CU6DmCHIZp5ZPiZ9r3J17lTKMmYsm/zGvNkjArQwPkrLlZ1TZ+EUYfGgh2X31OLMVAQCTJZW4cXHJi02EbJnA==
|
||||
dependencies:
|
||||
"@azure/abort-controller" "^1.0.0"
|
||||
tslib "^2.2.0"
|
||||
|
||||
"@azure/core-client@^1.0.0", "@azure/core-client@^1.5.0":
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@azure/core-client/-/core-client-1.5.0.tgz#7aabb87d20e08db3683a117191c844bc19adb74e"
|
||||
integrity sha512-YNk8i9LT6YcFdFO+RRU0E4Ef+A8Y5lhXo6lz61rwbG8Uo7kSqh0YqK04OexiilM43xd6n3Y9yBhLnb1NFNI9dA==
|
||||
dependencies:
|
||||
"@azure/abort-controller" "^1.0.0"
|
||||
"@azure/core-asynciterator-polyfill" "^1.0.0"
|
||||
"@azure/core-auth" "^1.3.0"
|
||||
"@azure/core-rest-pipeline" "^1.5.0"
|
||||
"@azure/core-tracing" "1.0.0-preview.13"
|
||||
"@azure/logger" "^1.0.0"
|
||||
tslib "^2.2.0"
|
||||
|
||||
"@azure/core-lro@^2.2.0":
|
||||
version "2.2.4"
|
||||
resolved "https://registry.yarnpkg.com/@azure/core-lro/-/core-lro-2.2.4.tgz#42fbf4ae98093c59005206a4437ddcd057c57ca1"
|
||||
integrity sha512-e1I2v2CZM0mQo8+RSix0x091Av493e4bnT22ds2fcQGslTHzM2oTbswkB65nP4iEpCxBrFxOSDPKExmTmjCVtQ==
|
||||
dependencies:
|
||||
"@azure/abort-controller" "^1.0.0"
|
||||
"@azure/core-tracing" "1.0.0-preview.13"
|
||||
"@azure/logger" "^1.0.0"
|
||||
tslib "^2.2.0"
|
||||
|
||||
"@azure/core-paging@^1.2.0":
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@azure/core-paging/-/core-paging-1.2.1.tgz#1b884f563b6e49971e9a922da3c7a20931867b54"
|
||||
integrity sha512-UtH5iMlYsvg+nQYIl4UHlvvSrsBjOlRF4fs0j7mxd3rWdAStrKYrh2durOpHs5C9yZbVhsVDaisoyaf/lL1EVA==
|
||||
dependencies:
|
||||
"@azure/core-asynciterator-polyfill" "^1.0.0"
|
||||
tslib "^2.2.0"
|
||||
|
||||
"@azure/core-rest-pipeline@^1.1.0", "@azure/core-rest-pipeline@^1.5.0", "@azure/core-rest-pipeline@^1.8.0":
|
||||
version "1.8.0"
|
||||
resolved "https://registry.yarnpkg.com/@azure/core-rest-pipeline/-/core-rest-pipeline-1.8.0.tgz#ba73b45fc4bf7e625af2d79a92857c06b35f45e2"
|
||||
integrity sha512-o8eZr96erQpiq8EZhZU/SyN6ncOfZ6bexwN2nMm9WpDmZGvaq907kopADt8XvNhbEF7kRA1l901Pg8mXjWp3UQ==
|
||||
dependencies:
|
||||
"@azure/abort-controller" "^1.0.0"
|
||||
"@azure/core-auth" "^1.3.0"
|
||||
"@azure/core-tracing" "1.0.0-preview.13"
|
||||
"@azure/logger" "^1.0.0"
|
||||
form-data "^4.0.0"
|
||||
http-proxy-agent "^4.0.1"
|
||||
https-proxy-agent "^5.0.0"
|
||||
tslib "^2.2.0"
|
||||
uuid "^8.3.0"
|
||||
|
||||
"@azure/core-tracing@1.0.0-preview.13":
|
||||
version "1.0.0-preview.13"
|
||||
resolved "https://registry.yarnpkg.com/@azure/core-tracing/-/core-tracing-1.0.0-preview.13.tgz#55883d40ae2042f6f1e12b17dd0c0d34c536d644"
|
||||
integrity sha512-KxDlhXyMlh2Jhj2ykX6vNEU0Vou4nHr025KoSEiz7cS3BNiHNaZcdECk/DmLkEB0as5T7b/TpRcehJ5yV6NeXQ==
|
||||
dependencies:
|
||||
"@opentelemetry/api" "^1.0.1"
|
||||
tslib "^2.2.0"
|
||||
|
||||
"@azure/logger@^1.0.0":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@azure/logger/-/logger-1.0.3.tgz#6e36704aa51be7d4a1bae24731ea580836293c96"
|
||||
integrity sha512-aK4s3Xxjrx3daZr3VylxejK3vG5ExXck5WOHDJ8in/k9AqlfIyFMMT1uG7u8mNjX+QRILTIn0/Xgschfh/dQ9g==
|
||||
dependencies:
|
||||
tslib "^2.2.0"
|
||||
|
||||
"@babel/code-frame@^7.0.0":
|
||||
version "7.14.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.14.5.tgz#23b08d740e83f49c5e59945fbf1b43e80bbf4edb"
|
||||
|
@ -79,6 +199,11 @@
|
|||
axios "^0.21.1"
|
||||
qs "^6.9.4"
|
||||
|
||||
"@opentelemetry/api@^1.0.1":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.0.4.tgz#a167e46c10d05a07ab299fc518793b0cff8f6924"
|
||||
integrity sha512-BuJuXRSJNQ3QoKA6GWWDyuLpOUck+9hAXNMCnrloc1aWVoy6Xq6t9PUV08aBZ4Lutqq2LEHM486bpZqoViScog==
|
||||
|
||||
"@tootallnate/once@1":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
|
||||
|
@ -1182,7 +1307,7 @@ colors@^1.1.2:
|
|||
resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
|
||||
integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==
|
||||
|
||||
combined-stream@^1.0.6, combined-stream@~1.0.6:
|
||||
combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6:
|
||||
version "1.0.8"
|
||||
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
|
||||
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
|
||||
|
@ -2132,6 +2257,15 @@ forever-agent@~0.6.1:
|
|||
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
|
||||
integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
|
||||
|
||||
form-data@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
|
||||
integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
|
||||
dependencies:
|
||||
asynckit "^0.4.0"
|
||||
combined-stream "^1.0.8"
|
||||
mime-types "^2.1.12"
|
||||
|
||||
form-data@~2.3.2:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
|
||||
|
@ -5422,6 +5556,11 @@ tslib@^1.13.0, tslib@^1.8.1, tslib@^1.9.3:
|
|||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
|
||||
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
|
||||
|
||||
tslib@^2.0.0:
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
|
||||
integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
|
||||
|
||||
tslib@^2.2.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e"
|
||||
|
@ -5717,6 +5856,11 @@ uuid@^3.3.2:
|
|||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
|
||||
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
|
||||
|
||||
uuid@^8.3.0:
|
||||
version "8.3.2"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
|
||||
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
|
||||
|
||||
v8flags@^3.2.0:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-3.2.0.tgz#b243e3b4dfd731fa774e7492128109a0fe66d656"
|
||||
|
|
Загрузка…
Ссылка в новой задаче