feature: allow overwriting existing Azure config (#42)
* fix: letter casing * feature: allow to overwrite existing azure config * test: add ng-add unit tests
This commit is contained in:
Родитель
7b361ea22d
Коммит
c74dedddf4
|
@ -0,0 +1,4 @@
|
||||||
|
*.spec.ts
|
||||||
|
*.test.json
|
||||||
|
__mocks__
|
||||||
|
__tests__
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"$schema": "node_modules/@angular-devkit/schematics/collection-schema.json",
|
||||||
|
"schematics": {
|
||||||
|
"ng-add": {
|
||||||
|
"description": "Adds Angular Deploy Azure to the application without affecting any templates",
|
||||||
|
"factory": "./src/ng-add/index#ngAdd",
|
||||||
|
"schema": "./src/ng-add/schema.json",
|
||||||
|
"aliases": ["install"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -75,6 +75,7 @@
|
||||||
"typescript": "~3.4.0"
|
"typescript": "~3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@schematics/angular": "^8.2.0",
|
||||||
"@types/chai": "^4.0.4",
|
"@types/chai": "^4.0.4",
|
||||||
"@types/conf": "^2.1.0",
|
"@types/conf": "^2.1.0",
|
||||||
"@types/configstore": "^4.0.0",
|
"@types/configstore": "^4.0.0",
|
||||||
|
|
|
@ -0,0 +1,139 @@
|
||||||
|
/*---------------------------------------------------------------------------------------------
|
||||||
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||||
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||||
|
*--------------------------------------------------------------------------------------------*/
|
||||||
|
import { Tree } from '@angular-devkit/schematics';
|
||||||
|
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
|
||||||
|
import { Schema as WorkspaceOptions } from '@schematics/angular/workspace/schema';
|
||||||
|
import { Schema as ApplicationOptions } from '@schematics/angular/application/schema';
|
||||||
|
import { confirm } from '../util/prompt/confirm';
|
||||||
|
|
||||||
|
jest.mock('../util/azure/auth');
|
||||||
|
jest.mock('../util/azure/subscription');
|
||||||
|
jest.mock('../util/azure/resource-group');
|
||||||
|
jest.mock('../util/azure/account');
|
||||||
|
jest.mock('../util/prompt/confirm');
|
||||||
|
|
||||||
|
const collectionPath = require.resolve('../../collection.test.json');
|
||||||
|
|
||||||
|
const workspaceOptions: WorkspaceOptions = {
|
||||||
|
name: 'workspace',
|
||||||
|
newProjectRoot: 'tests',
|
||||||
|
version: '8.0.0'
|
||||||
|
};
|
||||||
|
|
||||||
|
const appOptions: ApplicationOptions = { name: 'test-app' };
|
||||||
|
|
||||||
|
describe('ng add @azure/ng-deploy', () => {
|
||||||
|
const testRunner = new SchematicTestRunner('schematics', collectionPath);
|
||||||
|
|
||||||
|
async function initAngularProject(): Promise<UnitTestTree> {
|
||||||
|
const appTree = await testRunner.runExternalSchematicAsync('@schematics/angular', 'workspace', workspaceOptions).toPromise();
|
||||||
|
return await testRunner.runExternalSchematicAsync('@schematics/angular', 'application', appOptions, appTree).toPromise();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fails with a missing tree', async () => {
|
||||||
|
await expect(testRunner.runSchematicAsync('ng-add', {}, Tree.empty()).toPromise()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds azure deploy to an existing project', async () => {
|
||||||
|
let appTree = await initAngularProject();
|
||||||
|
appTree = await testRunner.runSchematicAsync('ng-add', {}, appTree).toPromise()
|
||||||
|
const angularJson = JSON.parse(appTree.readContent('/angular.json'));
|
||||||
|
|
||||||
|
expect(angularJson.projects[appOptions.name].architect.deploy).toBeDefined();
|
||||||
|
expect(angularJson.projects[appOptions.name].architect.azureLogout).toBeDefined();
|
||||||
|
expect(appTree.files).toContain('/azure.json');
|
||||||
|
|
||||||
|
const azureJson = JSON.parse(appTree.readContent('/azure.json'));
|
||||||
|
expect(azureJson).toEqual({
|
||||||
|
hosting: [
|
||||||
|
{
|
||||||
|
app: {
|
||||||
|
configuration: "production",
|
||||||
|
path: "dist/test-app",
|
||||||
|
project: "test-app",
|
||||||
|
target: "build",
|
||||||
|
},
|
||||||
|
azureHosting: {
|
||||||
|
account: "fakeStorageAccount",
|
||||||
|
resourceGroupName: "fake-resource-group",
|
||||||
|
subscription: "fake-subscription-1234",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overwrite existing hosting config', async () => {
|
||||||
|
// Simulate existing app setup
|
||||||
|
let appTree = await initAngularProject();
|
||||||
|
appTree = await testRunner.runSchematicAsync('ng-add', {}, appTree).toPromise()
|
||||||
|
appTree.overwrite('/azure.json', appTree.readContent('azure.json').replace(/fake/g, 'existing'));
|
||||||
|
|
||||||
|
const confirmMock = confirm as jest.Mock;
|
||||||
|
confirmMock.mockClear();
|
||||||
|
confirmMock.mockImplementationOnce(() => Promise.resolve(true));
|
||||||
|
|
||||||
|
// Run ng add @azure/deploy on existing project
|
||||||
|
appTree = await testRunner.runSchematicAsync('ng-add', {}, appTree).toPromise()
|
||||||
|
|
||||||
|
expect(confirm).toHaveBeenCalledTimes(1);
|
||||||
|
expect(appTree.files).toContain('/azure.json');
|
||||||
|
|
||||||
|
const azureJson = JSON.parse(appTree.readContent('/azure.json'));
|
||||||
|
expect(azureJson).toEqual({
|
||||||
|
hosting: [
|
||||||
|
{
|
||||||
|
app: {
|
||||||
|
configuration: "production",
|
||||||
|
path: "dist/test-app",
|
||||||
|
project: "test-app",
|
||||||
|
target: "build",
|
||||||
|
},
|
||||||
|
azureHosting: {
|
||||||
|
account: "fakeStorageAccount",
|
||||||
|
resourceGroupName: "fake-resource-group",
|
||||||
|
subscription: "fake-subscription-1234",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep existing hosting config', async () => {
|
||||||
|
// Simulate existing app setup
|
||||||
|
let appTree = await initAngularProject();
|
||||||
|
appTree = await testRunner.runSchematicAsync('ng-add', {}, appTree).toPromise()
|
||||||
|
appTree.overwrite('/azure.json', appTree.readContent('azure.json').replace(/fake/g, 'existing'));
|
||||||
|
|
||||||
|
const confirmMock = confirm as jest.Mock;
|
||||||
|
confirmMock.mockClear();
|
||||||
|
confirmMock.mockImplementationOnce(() => Promise.resolve(false));
|
||||||
|
|
||||||
|
// Run ng add @azure/deploy on existing project
|
||||||
|
appTree = await testRunner.runSchematicAsync('ng-add', {}, appTree).toPromise()
|
||||||
|
|
||||||
|
expect(confirm).toHaveBeenCalledTimes(1);
|
||||||
|
expect(appTree.files).toContain('/azure.json');
|
||||||
|
|
||||||
|
const azureJson = JSON.parse(appTree.readContent('/azure.json'));
|
||||||
|
expect(azureJson).toEqual({
|
||||||
|
hosting: [
|
||||||
|
{
|
||||||
|
app: {
|
||||||
|
configuration: "production",
|
||||||
|
path: "dist/test-app",
|
||||||
|
project: "test-app",
|
||||||
|
target: "build",
|
||||||
|
},
|
||||||
|
azureHosting: {
|
||||||
|
account: "existingStorageAccount",
|
||||||
|
resourceGroupName: "existing-resource-group",
|
||||||
|
subscription: "existing-subscription-1234",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -3,13 +3,14 @@
|
||||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||||
*--------------------------------------------------------------------------------------------*/
|
*--------------------------------------------------------------------------------------------*/
|
||||||
import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
|
import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
|
||||||
|
import { confirm } from '../util/prompt/confirm';
|
||||||
import { loginToAzure } from '../util/azure/auth';
|
import { loginToAzure } from '../util/azure/auth';
|
||||||
import { DeviceTokenCredentials } from '@azure/ms-rest-nodeauth';
|
import { DeviceTokenCredentials } from '@azure/ms-rest-nodeauth';
|
||||||
import { selectSubscription } from '../util/azure/subscription';
|
import { selectSubscription } from '../util/azure/subscription';
|
||||||
import { getResourceGroup } from '../util/azure/resource-group';
|
import { getResourceGroup } from '../util/azure/resource-group';
|
||||||
import { getAccount, getAzureStorageClient } from '../util/azure/account';
|
import { getAccount, getAzureStorageClient } from '../util/azure/account';
|
||||||
import { AngularWorkspace } from '../util/workspace/angular-json';
|
import { AngularWorkspace } from '../util/workspace/angular-json';
|
||||||
import { generateAzureJson } from '../util/workspace/azure-json';
|
import { generateAzureJson, readAzureJson, getAzureHostingConfig } from '../util/workspace/azure-json';
|
||||||
import { AddOptions } from '../util/shared/types';
|
import { AddOptions } from '../util/shared/types';
|
||||||
|
|
||||||
export function ngAdd(_options: AddOptions): Rule {
|
export function ngAdd(_options: AddOptions): Rule {
|
||||||
|
@ -22,38 +23,38 @@ export function ngAdd(_options: AddOptions): Rule {
|
||||||
|
|
||||||
export function addDeployAzure(_options: AddOptions): Rule {
|
export function addDeployAzure(_options: AddOptions): Rule {
|
||||||
return async (tree: Tree, _context: SchematicContext) => {
|
return async (tree: Tree, _context: SchematicContext) => {
|
||||||
// TODO: if azure.json already exists: get data / delete / error
|
|
||||||
|
|
||||||
const project = new AngularWorkspace(tree, _options);
|
const project = new AngularWorkspace(tree, _options);
|
||||||
|
const azureJson = readAzureJson(tree);
|
||||||
|
const hostingConfig = azureJson ? getAzureHostingConfig(azureJson, project.projectName) : null;
|
||||||
|
|
||||||
|
if (!hostingConfig || await confirm(`Overwrite existing Azure config for ${ project.projectName }?`)) {
|
||||||
|
|
||||||
|
const auth = await loginToAzure(_context.logger);
|
||||||
|
const credentials = auth.credentials as DeviceTokenCredentials;
|
||||||
|
const subscription = await selectSubscription(auth.subscriptions, _options, _context.logger);
|
||||||
|
const resourceGroup = await getResourceGroup(credentials, subscription, _options, _context.logger);
|
||||||
|
const client = getAzureStorageClient(credentials, subscription);
|
||||||
|
const account = await getAccount(client, resourceGroup, _options, _context.logger);
|
||||||
|
|
||||||
|
const appDeployConfig = {
|
||||||
|
project: project.projectName,
|
||||||
|
target: project.target,
|
||||||
|
configuration: project.configuration,
|
||||||
|
path: project.path
|
||||||
|
};
|
||||||
|
|
||||||
|
const azureDeployConfig = {
|
||||||
|
subscription,
|
||||||
|
resourceGroupName: resourceGroup.name,
|
||||||
|
account
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: log url for account at Azure portal
|
||||||
|
generateAzureJson(tree, appDeployConfig, azureDeployConfig);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
const auth = await loginToAzure(_context.logger);
|
|
||||||
const credentials = auth.credentials as DeviceTokenCredentials;
|
|
||||||
project.addLogoutArchitect();
|
project.addLogoutArchitect();
|
||||||
|
|
||||||
const subscription = await selectSubscription(auth.subscriptions, _options, _context.logger);
|
|
||||||
|
|
||||||
const resourceGroup = await getResourceGroup(credentials, subscription, _options, _context.logger);
|
|
||||||
|
|
||||||
const client = getAzureStorageClient(credentials, subscription);
|
|
||||||
|
|
||||||
const account = await getAccount(client, resourceGroup, _options, _context.logger);
|
|
||||||
|
|
||||||
const appDeployConfig = {
|
|
||||||
project: project.projectName,
|
|
||||||
target: project.target,
|
|
||||||
configuration: project.configuration,
|
|
||||||
path: project.path
|
|
||||||
};
|
|
||||||
|
|
||||||
const azureDeployConfig = {
|
|
||||||
subscription,
|
|
||||||
resourceGroupName: resourceGroup.name,
|
|
||||||
account
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: log url for account at Azure portal
|
|
||||||
generateAzureJson(tree, appDeployConfig, azureDeployConfig);
|
|
||||||
|
|
||||||
project.addDeployArchitect();
|
project.addDeployArchitect();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
/*---------------------------------------------------------------------------------------------
|
|
||||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
||||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
||||||
*--------------------------------------------------------------------------------------------*/
|
|
||||||
import { Tree } from '@angular-devkit/schematics';
|
|
||||||
import { SchematicTestRunner } from '@angular-devkit/schematics/testing';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
|
|
||||||
const collectionPath = path.join(__dirname, '../collection.json');
|
|
||||||
|
|
||||||
|
|
||||||
describe('ng-deploy-azure', () => {
|
|
||||||
it('works', () => {
|
|
||||||
const runner = new SchematicTestRunner('schematics', collectionPath);
|
|
||||||
const tree = runner.runSchematic('ng-deploy-azure', {}, Tree.empty());
|
|
||||||
|
|
||||||
expect(tree.files).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
/*---------------------------------------------------------------------------------------------
|
||||||
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||||
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||||
|
*--------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
export const getAccount = () => 'fakeStorageAccount';
|
||||||
|
|
||||||
|
export const getAzureStorageClient = () => null;
|
|
@ -0,0 +1,9 @@
|
||||||
|
/*---------------------------------------------------------------------------------------------
|
||||||
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||||
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||||
|
*--------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
export const loginToAzure = () => Promise.resolve({
|
||||||
|
credentials: null,
|
||||||
|
subscriptions: []
|
||||||
|
});
|
|
@ -0,0 +1,10 @@
|
||||||
|
/*---------------------------------------------------------------------------------------------
|
||||||
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||||
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||||
|
*--------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
export const getResourceGroup = () => Promise.resolve({
|
||||||
|
id: '4321',
|
||||||
|
name: 'fake-resource-group',
|
||||||
|
location: 'westus'
|
||||||
|
});
|
|
@ -0,0 +1,7 @@
|
||||||
|
/*---------------------------------------------------------------------------------------------
|
||||||
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||||
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||||
|
*--------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
export const selectSubscription = () => Promise.resolve('fake-subscription-1234');
|
||||||
|
|
|
@ -181,7 +181,7 @@ export async function createAccount(
|
||||||
`https://${ account }.blob.core.windows.net`,
|
`https://${ account }.blob.core.windows.net`,
|
||||||
pipeline
|
pipeline
|
||||||
);
|
);
|
||||||
spinner.start('setting container to be publicly available static site');
|
spinner.start('Setting container to be publicly available static site');
|
||||||
await setStaticSiteToPublic(serviceURL);
|
await setStaticSiteToPublic(serviceURL);
|
||||||
spinner.succeed();
|
spinner.succeed();
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
/*---------------------------------------------------------------------------------------------
|
||||||
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||||
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||||
|
*--------------------------------------------------------------------------------------------*/
|
||||||
|
import { prompt } from 'inquirer';
|
||||||
|
|
||||||
|
export async function confirm(message: string, confirmByDefault: boolean = false): Promise<boolean> {
|
||||||
|
const { ok } = await prompt<{ ok: any }>([
|
||||||
|
{
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'ok',
|
||||||
|
default: confirmByDefault,
|
||||||
|
message
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
return ok;
|
||||||
|
}
|
|
@ -4,6 +4,8 @@
|
||||||
*--------------------------------------------------------------------------------------------*/
|
*--------------------------------------------------------------------------------------------*/
|
||||||
import { SchematicsException, Tree } from '@angular-devkit/schematics';
|
import { SchematicsException, Tree } from '@angular-devkit/schematics';
|
||||||
|
|
||||||
|
const azureJsonFile = 'azure.json';
|
||||||
|
|
||||||
export interface AzureDeployConfig {
|
export interface AzureDeployConfig {
|
||||||
subscription: string;
|
subscription: string;
|
||||||
resourceGroupName: string;
|
resourceGroupName: string;
|
||||||
|
@ -26,18 +28,30 @@ export interface AzureJSON {
|
||||||
hosting: AzureHostingConfig[];
|
hosting: AzureHostingConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateAzureJson(tree: Tree, appDeployConfig: AppDeployConfig, azureDeployConfig: AzureDeployConfig) {
|
export function readAzureJson(tree: Tree): AzureJSON | null {
|
||||||
const path = 'azure.json';
|
return tree.exists(azureJsonFile) ? safeReadJSON(azureJsonFile, tree) : null;
|
||||||
const azureJson: AzureJSON = tree.exists(path) ? safeReadJSON(path, tree) : emptyAzureJson();
|
}
|
||||||
|
|
||||||
if (azureJson.hosting.find(config => config.app.project === appDeployConfig.project)) {
|
export function generateAzureJson(tree: Tree, appDeployConfig: AppDeployConfig, azureDeployConfig: AzureDeployConfig) {
|
||||||
// TODO: if exists - update?
|
const azureJson: AzureJSON = readAzureJson(tree) || emptyAzureJson();
|
||||||
throw new SchematicsException(`Target ${ appDeployConfig.project } already exists in ${ path }`);
|
const existingHostingConfigIndex = getAzureHostingConfigIndex(azureJson, appDeployConfig.project);
|
||||||
|
const hostingConfig = generateHostingConfig(appDeployConfig, azureDeployConfig);
|
||||||
|
|
||||||
|
if (existingHostingConfigIndex >= 0) {
|
||||||
|
azureJson.hosting[existingHostingConfigIndex] = hostingConfig;
|
||||||
|
} else {
|
||||||
|
azureJson.hosting.push(hostingConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
azureJson.hosting.push(generateHostingConfig(appDeployConfig, azureDeployConfig));
|
overwriteIfExists(tree, azureJsonFile, stringifyFormatted(azureJson));
|
||||||
|
}
|
||||||
|
|
||||||
overwriteIfExists(tree, path, stringifyFormatted(azureJson));
|
export function getAzureHostingConfig(azureJson: AzureJSON, projectName: string): AzureHostingConfig | undefined {
|
||||||
|
return azureJson.hosting.find(config => config.app.project === projectName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAzureHostingConfigIndex(azureJson: AzureJSON, project: string): number {
|
||||||
|
return azureJson.hosting.findIndex(config => config.app.project === project);
|
||||||
}
|
}
|
||||||
|
|
||||||
const overwriteIfExists = (tree: Tree, path: string, content: string) => {
|
const overwriteIfExists = (tree: Tree, path: string, content: string) => {
|
||||||
|
@ -68,7 +82,6 @@ function safeReadJSON(path: string, tree: Tree) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function generateHostingConfig(appDeployConfig: AppDeployConfig, azureDeployConfig: AzureDeployConfig) {
|
function generateHostingConfig(appDeployConfig: AppDeployConfig, azureDeployConfig: AzureDeployConfig) {
|
||||||
return {
|
return {
|
||||||
app: appDeployConfig,
|
app: appDeployConfig,
|
||||||
|
|
Загрузка…
Ссылка в новой задаче