From 5135f7fccbe9674aa04027ef068cd5116dfcfdc3 Mon Sep 17 00:00:00 2001 From: Dennis Seah Date: Tue, 10 Mar 2020 15:19:12 -0700 Subject: [PATCH 1/3] [FEATURE] Get subscription id in spk setup command --- package.json | 1 + src/commands/setup.md | 3 + src/commands/setup.ts | 4 +- src/lib/setup/constants.ts | 1 + src/lib/setup/prompt.test.ts | 85 +++++++++++++++++++++-- src/lib/setup/prompt.ts | 36 +++++++++- src/lib/setup/setupLog.test.ts | 4 ++ src/lib/setup/setupLog.ts | 1 + src/lib/setup/subscriptionService.test.ts | 69 ++++++++++++++++++ src/lib/setup/subscriptionService.ts | 45 ++++++++++++ src/lib/validator.test.ts | 13 +++- src/lib/validator.ts | 15 ++++ yarn.lock | 9 +++ 13 files changed, 278 insertions(+), 8 deletions(-) create mode 100644 src/lib/setup/subscriptionService.test.ts create mode 100644 src/lib/setup/subscriptionService.ts diff --git a/package.json b/package.json index 4c81e206a..bc62f73f6 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ }, "dependencies": { "@azure/arm-storage": "^10.1.0", + "@azure/arm-subscriptions": "^2.0.0", "@azure/identity": "^1.0.0", "@azure/keyvault-secrets": "^4.0.0", "@azure/ms-rest-nodeauth": "^3.0.0", diff --git a/src/commands/setup.md b/src/commands/setup.md index ddcf77ea5..d51d5a28b 100644 --- a/src/commands/setup.md +++ b/src/commands/setup.md @@ -18,6 +18,9 @@ for a few questions 1. have the command line tool to create it. Azure command line tool shall be used 2. provide the Service Principal Id, Password and Tenant Id. + 2. Subscription Id is automatically retrieved with the Service Principal + credential. In case, there are two or more subscription, you will be + prompt to select one of them. It can also run in a non interactive mode by providing a file that contains answers to the above questions. diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 9c4923573..07ef77918 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -41,6 +41,7 @@ export const createSPKConfig = (rc: IRequestContext) => { azure: { service_principal_id: rc.servicePrincipalId, service_principal_secret: rc.servicePrincipalPassword, + subscription_id: rc.subscriptionId, tenant_id: rc.servicePrincipalTenantId } } @@ -50,8 +51,7 @@ export const createSPKConfig = (rc: IRequestContext) => { access_token: rc.accessToken, org: rc.orgName, project: rc.projectName - }, - introspection: {} + } }; fs.writeFileSync(defaultConfigFile(), yaml.safeDump(data)); }; diff --git a/src/lib/setup/constants.ts b/src/lib/setup/constants.ts index b6e8d9e07..0bd2097da 100644 --- a/src/lib/setup/constants.ts +++ b/src/lib/setup/constants.ts @@ -13,6 +13,7 @@ export interface IRequestContext { servicePrincipalId?: string; servicePrincipalPassword?: string; servicePrincipalTenantId?: string; + subscriptionId?: string; error?: string; } diff --git a/src/lib/setup/prompt.test.ts b/src/lib/setup/prompt.test.ts index 3f7326a43..6e2273c2e 100644 --- a/src/lib/setup/prompt.test.ts +++ b/src/lib/setup/prompt.test.ts @@ -4,9 +4,10 @@ import os from "os"; import path from "path"; import uuid from "uuid/v4"; import { createTempDir } from "../../lib/ioUtil"; -import { DEFAULT_PROJECT_NAME, WORKSPACE } from "./constants"; -import { getAnswerFromFile, prompt } from "./prompt"; +import { DEFAULT_PROJECT_NAME, IRequestContext, WORKSPACE } from "./constants"; +import { getAnswerFromFile, prompt, promptForSubscriptionId } from "./prompt"; import * as servicePrincipalService from "./servicePrincipalService"; +import * as subscriptionService from "./subscriptionService"; describe("test prompt function", () => { it("positive test: No App Creation", async () => { @@ -40,11 +41,19 @@ describe("test prompt function", () => { jest .spyOn(servicePrincipalService, "createWithAzCLI") .mockReturnValueOnce(Promise.resolve()); + jest.spyOn(subscriptionService, "getSubscriptions").mockResolvedValueOnce([ + { + id: "72f988bf-86f1-41af-91ab-2d7cd011db48", + name: "test" + } + ]); + const ans = await prompt(); expect(ans).toStrictEqual({ accessToken: "pat", orgName: "org", projectName: "project", + subscriptionId: "72f988bf-86f1-41af-91ab-2d7cd011db48", toCreateAppRepo: true, toCreateSP: true, workspace: WORKSPACE @@ -66,6 +75,12 @@ describe("test prompt function", () => { az_sp_password: "a510c1ff-358c-4ed4-96c8-eb23f42bbc5b", az_sp_tenant: "72f988bf-86f1-41af-91ab-2d7cd011db47" }); + jest.spyOn(subscriptionService, "getSubscriptions").mockResolvedValueOnce([ + { + id: "72f988bf-86f1-41af-91ab-2d7cd011db48", + name: "test" + } + ]); const ans = await prompt(); expect(ans).toStrictEqual({ accessToken: "pat", @@ -74,6 +89,7 @@ describe("test prompt function", () => { servicePrincipalId: "b510c1ff-358c-4ed4-96c8-eb23f42bb65b", servicePrincipalPassword: "a510c1ff-358c-4ed4-96c8-eb23f42bbc5b", servicePrincipalTenantId: "72f988bf-86f1-41af-91ab-2d7cd011db47", + subscriptionId: "72f988bf-86f1-41af-91ab-2d7cd011db48", toCreateAppRepo: true, toCreateSP: false, workspace: WORKSPACE @@ -153,7 +169,8 @@ describe("test getAnswerFromFile function", () => { "az_create_app=true", "az_sp_id=b510c1ff-358c-4ed4-96c8-eb23f42bb65b", "az_sp_password=a510c1ff-358c-4ed4-96c8-eb23f42bbc5b", - "az_sp_tenant=72f988bf-86f1-41af-91ab-2d7cd011db47" + "az_sp_tenant=72f988bf-86f1-41af-91ab-2d7cd011db47", + "az_subscription_id=72f988bf-86f1-41af-91ab-2d7cd011db48" ]; fs.writeFileSync(file, data.join("\n")); const requestContext = getAnswerFromFile(file); @@ -171,6 +188,9 @@ describe("test getAnswerFromFile function", () => { expect(requestContext.servicePrincipalTenantId).toBe( "72f988bf-86f1-41af-91ab-2d7cd011db47" ); + expect(requestContext.subscriptionId).toBe( + "72f988bf-86f1-41af-91ab-2d7cd011db48" + ); }); it("negative test: with app creation, incorrect SP values", () => { const dir = createTempDir(); @@ -179,7 +199,8 @@ describe("test getAnswerFromFile function", () => { "azdo_org_name=orgname", "azdo_pat=pat", "azdo_project_name=project", - "az_create_app=true" + "az_create_app=true", + "az_subscription_id=72f988bf-86f1-41af-91ab-2d7cd011db48" ]; [".", ".##", ".abc"].forEach((v, i) => { if (i === 0) { @@ -199,4 +220,60 @@ describe("test getAnswerFromFile function", () => { }).toThrow(); }); }); + it("negative test: with app creation, incorrect subscription id value", () => { + const dir = createTempDir(); + const file = path.join(dir, "testfile"); + const data = [ + "azdo_org_name=orgname", + "azdo_pat=pat", + "azdo_project_name=project", + "az_create_app=true", + "az_sp_id=b510c1ff-358c-4ed4-96c8-eb23f42bb65b", + "az_sp_password=a510c1ff-358c-4ed4-96c8-eb23f42bbc5b", + "az_sp_tenant=72f988bf-86f1-41af-91ab-2d7cd011db47", + "az_subscription_id=xyz" + ]; + fs.writeFileSync(file, data.join("\n")); + expect(() => { + getAnswerFromFile(file); + }).toThrow(); + }); +}); + +describe("test promptForSubscriptionId function", () => { + it("no subscriptions", async () => { + jest + .spyOn(subscriptionService, "getSubscriptions") + .mockResolvedValueOnce([]); + const mockRc: IRequestContext = { + accessToken: "pat", + orgName: "org", + projectName: "project", + workspace: WORKSPACE + }; + await expect(promptForSubscriptionId(mockRc)).rejects.toThrow(); + }); + it("2 subscriptions", async () => { + jest.spyOn(subscriptionService, "getSubscriptions").mockResolvedValueOnce([ + { + id: "123345", + name: "subscription1" + }, + { + id: "12334567890", + name: "subscription2" + } + ]); + jest.spyOn(inquirer, "prompt").mockResolvedValueOnce({ + az_subscription: "subscription2" + }); + const mockRc: IRequestContext = { + accessToken: "pat", + orgName: "org", + projectName: "project", + workspace: WORKSPACE + }; + await promptForSubscriptionId(mockRc); + expect(mockRc.subscriptionId).toBe("12334567890"); + }); }); diff --git a/src/lib/setup/prompt.ts b/src/lib/setup/prompt.ts index b3efff998..e83034fa7 100644 --- a/src/lib/setup/prompt.ts +++ b/src/lib/setup/prompt.ts @@ -1,15 +1,41 @@ import fs from "fs"; import inquirer from "inquirer"; + import { validateAccessToken, validateOrgName, validateProjectName, validateServicePrincipalId, validateServicePrincipalPassword, - validateServicePrincipalTenantId + validateServicePrincipalTenantId, + validateSubscriptionId } from "../validator"; import { DEFAULT_PROJECT_NAME, IRequestContext, WORKSPACE } from "./constants"; import { createWithAzCLI } from "./servicePrincipalService"; +import { getSubscriptions } from "./subscriptionService"; + +export const promptForSubscriptionId = async (rc: IRequestContext) => { + const subscriptions = await getSubscriptions(rc); + if (subscriptions.length === 0) { + throw Error("no subscriptions found"); + } + if (subscriptions.length === 1) { + rc.subscriptionId = subscriptions[0].id; + } else { + const questions = [ + { + choices: subscriptions.map(s => s.name), + message: "Select one of the subscription\n", + name: "az_subscription", + type: "list" + } + ]; + const ans = await inquirer.prompt(questions); + rc.subscriptionId = subscriptions.find( + s => s.name === ans.az_subscription + )!.id; + } +}; /** * Prompts for service principal identifer, password and tenant identifer. @@ -71,6 +97,7 @@ export const promptForServicePrincipalCreation = async ( rc.toCreateSP = false; await promptForServicePrincipal(rc); } + await promptForSubscriptionId(rc); }; /** @@ -145,6 +172,12 @@ const validationServicePrincipalInfoFromFile = ( throw new Error(vSPTenantId); } } + + const vSubscriptionId = validateSubscriptionId(map.az_subscription_id); + if (typeof vSubscriptionId === "string") { + throw new Error(vSubscriptionId); + } + rc.subscriptionId = map.az_subscription_id; } }; @@ -205,5 +238,6 @@ export const getAnswerFromFile = (file: string): IRequestContext => { rc.toCreateAppRepo = map.az_create_app === "true"; validationServicePrincipalInfoFromFile(rc, map); + return rc; }; diff --git a/src/lib/setup/setupLog.test.ts b/src/lib/setup/setupLog.test.ts index 6cbe04fed..098429644 100644 --- a/src/lib/setup/setupLog.test.ts +++ b/src/lib/setup/setupLog.test.ts @@ -21,6 +21,7 @@ const positiveTest = (logExist?: boolean, withAppCreation = false) => { projectName: "projectName", scaffoldHLD: true, scaffoldManifest: true, + subscriptionId: "72f988bf-86f1-41af-91ab-2d7cd011db48", workspace: "workspace" }; @@ -45,6 +46,7 @@ const positiveTest = (logExist?: boolean, withAppCreation = false) => { "az_sp_id=b510c1ff-358c-4ed4-96c8-eb23f42bb65b", "az_sp_password=********", "az_sp_tenant=72f988bf-86f1-41af-91ab-2d7cd011db47", + "az_subscription_id=72f988bf-86f1-41af-91ab-2d7cd011db48", "workspace: workspace", "Project Created: yes", "High Level Definition Repo Scaffolded: yes", @@ -63,6 +65,7 @@ const positiveTest = (logExist?: boolean, withAppCreation = false) => { "az_sp_id=", "az_sp_password=", "az_sp_tenant=", + "az_subscription_id=72f988bf-86f1-41af-91ab-2d7cd011db48", "workspace: workspace", "Project Created: yes", "High Level Definition Repo Scaffolded: yes", @@ -119,6 +122,7 @@ describe("test create function", () => { "az_sp_id=", "az_sp_password=", "az_sp_tenant=", + "az_subscription_id=", "workspace: workspace", "Project Created: yes", "High Level Definition Repo Scaffolded: yes", diff --git a/src/lib/setup/setupLog.ts b/src/lib/setup/setupLog.ts index 33d431a00..65c6d8c09 100644 --- a/src/lib/setup/setupLog.ts +++ b/src/lib/setup/setupLog.ts @@ -20,6 +20,7 @@ export const create = (rc: IRequestContext | undefined, file?: string) => { `az_sp_id=${rc.servicePrincipalId || ""}`, `az_sp_password=${rc.servicePrincipalPassword ? "********" : ""}`, `az_sp_tenant=${rc.servicePrincipalTenantId || ""}`, + `az_subscription_id=${rc.subscriptionId || ""}`, `workspace: ${rc.workspace}`, `Project Created: ${getBooleanVal(rc.createdProject)}`, `High Level Definition Repo Scaffolded: ${getBooleanVal(rc.scaffoldHLD)}`, diff --git a/src/lib/setup/subscriptionService.test.ts b/src/lib/setup/subscriptionService.test.ts new file mode 100644 index 000000000..a07692a20 --- /dev/null +++ b/src/lib/setup/subscriptionService.test.ts @@ -0,0 +1,69 @@ +import { + Subscription, + SubscriptionClientOptions +} from "@azure/arm-subscriptions/src/models"; +import { ApplicationTokenCredentials } from "@azure/ms-rest-nodeauth"; +import * as restAuth from "@azure/ms-rest-nodeauth"; +import { getSubscriptions } from "./subscriptionService"; + +jest.mock("@azure/arm-subscriptions", () => { + class MockClient { + constructor( + cred: ApplicationTokenCredentials, + options?: SubscriptionClientOptions + ) { + return { + subscriptions: { + list: () => { + return [ + { + displayName: "test", + subscriptionId: "1234567890-abcdef" + } + ]; + } + } + }; + } + } + return { + SubscriptionClient: MockClient + }; +}); + +describe("test getSubscriptions function", () => { + it("positive test: one value", async () => { + jest + .spyOn(restAuth, "loginWithServicePrincipalSecret") + .mockImplementationOnce(async () => { + return {}; + }); + const result = await getSubscriptions({ + accessToken: "pat", + orgName: "org", + projectName: "project", + workspace: "test" + }); + expect(result).toStrictEqual([ + { + id: "1234567890-abcdef", + name: "test" + } + ]); + }); + it("negative test", async () => { + jest + .spyOn(restAuth, "loginWithServicePrincipalSecret") + .mockImplementationOnce(async () => { + throw Error("fake"); + }); + await expect( + getSubscriptions({ + accessToken: "pat", + orgName: "org", + projectName: "project", + workspace: "test" + }) + ).rejects.toThrow(); + }); +}); diff --git a/src/lib/setup/subscriptionService.ts b/src/lib/setup/subscriptionService.ts new file mode 100644 index 000000000..252d14db1 --- /dev/null +++ b/src/lib/setup/subscriptionService.ts @@ -0,0 +1,45 @@ +import { SubscriptionClient } from "@azure/arm-subscriptions"; +import { + ApplicationTokenCredentials, + loginWithServicePrincipalSecret +} from "@azure/ms-rest-nodeauth"; +import { logger } from "../../logger"; +import { IRequestContext } from "./constants"; + +export interface ISubscriptionItem { + id: string; + name: string; +} + +/** + * Returns a list of subscriptions based on the service principal credentials. + * + * @param rc Request Context + */ +export const getSubscriptions = ( + rc: IRequestContext +): Promise => { + logger.info("attempting to get subscription list"); + return new Promise((resolve, reject) => { + loginWithServicePrincipalSecret( + rc.servicePrincipalId!, + rc.servicePrincipalPassword!, + rc.servicePrincipalTenantId! + ) + .then(async (creds: ApplicationTokenCredentials) => { + const client = new SubscriptionClient(creds); + const subsciptions = await client.subscriptions.list(); + const result = (subsciptions || []).map(s => { + return { + id: s.subscriptionId!, + name: s.displayName! + }; + }); + logger.info("Successfully acquored subscription list"); + resolve(result); + }) + .catch(err => { + reject(err); + }); + }); +}; diff --git a/src/lib/validator.test.ts b/src/lib/validator.test.ts index 55eab927f..600a15035 100644 --- a/src/lib/validator.test.ts +++ b/src/lib/validator.test.ts @@ -13,7 +13,8 @@ import { validateProjectName, validateServicePrincipalId, validateServicePrincipalPassword, - validateServicePrincipalTenantId + validateServicePrincipalTenantId, + validateSubscriptionId } from "./validator"; describe("Tests on validator helper functions", () => { @@ -222,3 +223,13 @@ describe("test validateServicePrincipal functions", () => { }); }); }); + +describe("test validateSubscriptionId function", () => { + it("sanity test", () => { + expect(validateSubscriptionId("")).toBe("Must enter a Subscription Id."); + expect(validateSubscriptionId("xyz")).toBe( + "The value for Subscription Id is invalid." + ); + expect(validateSubscriptionId("abc123-456")).toBeTruthy(); + }); +}); diff --git a/src/lib/validator.ts b/src/lib/validator.ts index 081f1147c..3767f29de 100644 --- a/src/lib/validator.ts +++ b/src/lib/validator.ts @@ -226,3 +226,18 @@ export const validateServicePrincipalTenantId = ( ): string | boolean => { return validateServicePrincipal(value, "Service Principal Tenant Id"); }; + +/** + * Returns true if subscription identifier is valid + * + * @param value subscription identifier. + */ +export const validateSubscriptionId = (value: string): string | boolean => { + if (!hasValue(value)) { + return "Must enter a Subscription Id."; + } + if (!isDashHex(value)) { + return "The value for Subscription Id is invalid."; + } + return true; +}; diff --git a/yarn.lock b/yarn.lock index 06f45932b..a8cf27907 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18,6 +18,15 @@ "@azure/ms-rest-js" "^2.0.4" tslib "^1.10.0" +"@azure/arm-subscriptions@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@azure/arm-subscriptions/-/arm-subscriptions-2.0.0.tgz#4202740b7f65a9d0f16f7903579a615f5de45a92" + integrity sha512-+ys2glK5YgwZ9KhwWblfAQIPABtiB5OdKEpPOpcvr7B5ygYTwZuSUNObX9MRu/MyiRo1zDlUvlxHltBphq/bLQ== + dependencies: + "@azure/ms-rest-azure-js" "^2.0.1" + "@azure/ms-rest-js" "^2.0.4" + tslib "^1.10.0" + "@azure/core-asynciterator-polyfill@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@azure/core-asynciterator-polyfill/-/core-asynciterator-polyfill-1.0.0.tgz#dcccebb88406e5c76e0e1d52e8cc4c43a68b3ee7" From 596ce8fc4d405d0c7cdc87c10707fcd5c900cdc7 Mon Sep 17 00:00:00 2001 From: Dennis Seah Date: Tue, 10 Mar 2020 15:25:18 -0700 Subject: [PATCH 2/3] fix typo and test --- src/commands/setup.md | 2 +- src/commands/setup.test.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/setup.md b/src/commands/setup.md index d51d5a28b..72c7f2aca 100644 --- a/src/commands/setup.md +++ b/src/commands/setup.md @@ -19,7 +19,7 @@ for a few questions be used 2. provide the Service Principal Id, Password and Tenant Id. 2. Subscription Id is automatically retrieved with the Service Principal - credential. In case, there are two or more subscription, you will be + credential. In case, there are two or more subscriptions, you will be prompt to select one of them. It can also run in a non interactive mode by providing a file that contains diff --git a/src/commands/setup.test.ts b/src/commands/setup.test.ts index d417afd4b..e50ad318a 100644 --- a/src/commands/setup.test.ts +++ b/src/commands/setup.test.ts @@ -44,6 +44,7 @@ describe("test createSPKConfig function", () => { rc.servicePrincipalId = "1eba2d04-1506-4278-8f8c-b1eb2fc462a8"; rc.servicePrincipalPassword = "e4c19d72-96d6-4172-b195-66b3b1c36db1"; rc.servicePrincipalTenantId = "72f988bf-86f1-41af-91ab-2d7cd011db47"; + rc.subscriptionId = "72f988bf-86f1-41af-91ab-2d7cd011db48"; createSPKConfig(rc); const data = readYaml(tmpFile); @@ -56,6 +57,7 @@ describe("test createSPKConfig function", () => { azure: { service_principal_id: rc.servicePrincipalId, service_principal_secret: rc.servicePrincipalPassword, + subscription_id: rc.subscriptionId, tenant_id: rc.servicePrincipalTenantId } }); From 22f5308f9d9d3d2c5bdd2ca50629f76aa9c8207c Mon Sep 17 00:00:00 2001 From: Dennis Seah Date: Tue, 10 Mar 2020 16:45:47 -0700 Subject: [PATCH 3/3] fix typo --- src/lib/setup/subscriptionService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/setup/subscriptionService.ts b/src/lib/setup/subscriptionService.ts index 252d14db1..1bef26bb9 100644 --- a/src/lib/setup/subscriptionService.ts +++ b/src/lib/setup/subscriptionService.ts @@ -35,7 +35,7 @@ export const getSubscriptions = ( name: s.displayName! }; }); - logger.info("Successfully acquored subscription list"); + logger.info("Successfully acquired subscription list"); resolve(result); }) .catch(err => {