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:
Yohan Lasorsa 2019-08-07 23:15:12 +02:00 коммит произвёл chris
Родитель 7b361ea22d
Коммит c74dedddf4
13 изменённых файлов: 260 добавлений и 60 удалений

4
.npmignore Normal file
Просмотреть файл

@ -0,0 +1,4 @@
*.spec.ts
*.test.json
__mocks__
__tests__

11
collection.test.json Normal file
Просмотреть файл

@ -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"
},
"devDependencies": {
"@schematics/angular": "^8.2.0",
"@types/chai": "^4.0.4",
"@types/conf": "^2.1.0",
"@types/configstore": "^4.0.0",

139
src/ng-add/index.spec.ts Normal file
Просмотреть файл

@ -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.
*--------------------------------------------------------------------------------------------*/
import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
import { confirm } from '../util/prompt/confirm';
import { loginToAzure } from '../util/azure/auth';
import { DeviceTokenCredentials } from '@azure/ms-rest-nodeauth';
import { selectSubscription } from '../util/azure/subscription';
import { getResourceGroup } from '../util/azure/resource-group';
import { getAccount, getAzureStorageClient } from '../util/azure/account';
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';
export function ngAdd(_options: AddOptions): Rule {
@ -22,38 +23,38 @@ export function ngAdd(_options: AddOptions): Rule {
export function addDeployAzure(_options: AddOptions): Rule {
return async (tree: Tree, _context: SchematicContext) => {
// TODO: if azure.json already exists: get data / delete / error
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();
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();
};
}

Просмотреть файл

@ -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`,
pipeline
);
spinner.start('setting container to be publicly available static site');
spinner.start('Setting container to be publicly available static site');
await setStaticSiteToPublic(serviceURL);
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';
const azureJsonFile = 'azure.json';
export interface AzureDeployConfig {
subscription: string;
resourceGroupName: string;
@ -26,18 +28,30 @@ export interface AzureJSON {
hosting: AzureHostingConfig[];
}
export function generateAzureJson(tree: Tree, appDeployConfig: AppDeployConfig, azureDeployConfig: AzureDeployConfig) {
const path = 'azure.json';
const azureJson: AzureJSON = tree.exists(path) ? safeReadJSON(path, tree) : emptyAzureJson();
export function readAzureJson(tree: Tree): AzureJSON | null {
return tree.exists(azureJsonFile) ? safeReadJSON(azureJsonFile, tree) : null;
}
if (azureJson.hosting.find(config => config.app.project === appDeployConfig.project)) {
// TODO: if exists - update?
throw new SchematicsException(`Target ${ appDeployConfig.project } already exists in ${ path }`);
export function generateAzureJson(tree: Tree, appDeployConfig: AppDeployConfig, azureDeployConfig: AzureDeployConfig) {
const azureJson: AzureJSON = readAzureJson(tree) || emptyAzureJson();
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) => {
@ -68,7 +82,6 @@ function safeReadJSON(path: string, tree: Tree) {
}
}
function generateHostingConfig(appDeployConfig: AppDeployConfig, azureDeployConfig: AzureDeployConfig) {
return {
app: appDeployConfig,