diff --git a/src/commands/deployment/onboard.test.ts b/src/commands/deployment/onboard.test.ts index ed7b873ac..80be04b97 100644 --- a/src/commands/deployment/onboard.test.ts +++ b/src/commands/deployment/onboard.test.ts @@ -78,7 +78,7 @@ const testPopulatedVal = ( const configYaml: ConfigYaml = { introspection: { azure: { - key: Promise.resolve("key") + key: "key" } } }; diff --git a/src/commands/deployment/onboard.ts b/src/commands/deployment/onboard.ts index a1d7a2797..6db253139 100644 --- a/src/commands/deployment/onboard.ts +++ b/src/commands/deployment/onboard.ts @@ -40,7 +40,7 @@ export interface CommandOptions { */ export const populateValues = (opts: CommandOptions): CommandOptions => { const config = Config(); - const { azure } = config.introspection!; + const azure = config.introspection ? config.introspection.azure : undefined; opts.storageAccountName = opts.storageAccountName || azure?.account_name || undefined; diff --git a/src/commands/deployment/validate.test.ts b/src/commands/deployment/validate.test.ts index 3de9a76ba..6ea33ded3 100644 --- a/src/commands/deployment/validate.test.ts +++ b/src/commands/deployment/validate.test.ts @@ -142,7 +142,7 @@ describe("Validate deployment configuration", () => { introspection: { azure: { account_name: uuid(), - key: Promise.resolve(uuid()), + key: uuid(), partition_key: uuid(), table_name: uuid() } @@ -210,7 +210,7 @@ describe("test runSelfTest function", () => { const config: ConfigYaml = { introspection: { azure: { - key: Promise.resolve(uuid()), + key: uuid(), table_name: undefined } } @@ -229,7 +229,7 @@ describe("test runSelfTest function", () => { const config: ConfigYaml = { introspection: { azure: { - key: Promise.resolve(uuid()), + key: uuid(), table_name: undefined } } @@ -244,7 +244,7 @@ describe("test runSelfTest function", () => { const config: ConfigYaml = { introspection: { azure: { - key: Promise.resolve(uuid()), + key: uuid(), table_name: undefined } } @@ -279,7 +279,7 @@ describe("Validate missing deployment.storage configuration", () => { introspection: { azure: { account_name: undefined, - key: Promise.resolve(uuid()) + key: uuid() } } }; @@ -292,7 +292,7 @@ describe("Validate missing deployment.storage configuration", () => { const config: ConfigYaml = { introspection: { azure: { - key: Promise.resolve(uuid()), + key: uuid(), table_name: undefined } } @@ -306,7 +306,7 @@ describe("Validate missing deployment.storage configuration", () => { const config: ConfigYaml = { introspection: { azure: { - key: Promise.resolve(uuid()), + key: uuid(), partition_key: undefined } } @@ -319,9 +319,7 @@ describe("Validate missing deployment.storage configuration", () => { test("missing deployment.storage.key configuration", async () => { const config: ConfigYaml = { introspection: { - azure: { - key: Promise.resolve(undefined) - } + azure: {} } }; await expect(isValidConfig(config)).rejects.toThrow(); @@ -332,9 +330,7 @@ describe("Validate missing deployment.pipeline configuration", () => { test("missing deployment.pipeline configuration", async () => { const config: ConfigYaml = { introspection: { - azure: { - key: Promise.resolve(undefined) - } + azure: {} } }; await expect(isValidConfig(config)).rejects.toThrow(); @@ -348,9 +344,7 @@ describe("Validate missing deployment.pipeline configuration", () => { org: undefined }, introspection: { - azure: { - key: Promise.resolve(undefined) - } + azure: {} } }; await expect(isValidConfig(config)).rejects.toThrow(); @@ -365,9 +359,7 @@ describe("Validate missing deployment.pipeline configuration", () => { project: undefined }, introspection: { - azure: { - key: Promise.resolve(undefined) - } + azure: {} } }; await expect(isValidConfig(config)).rejects.toThrow(); diff --git a/src/commands/init.md b/src/commands/init.md index cf5b8fd42..f5d8ba46a 100644 --- a/src/commands/init.md +++ b/src/commands/init.md @@ -15,6 +15,12 @@ shall be no default values. These are the questions 1. Organization Name of Azure dev-op account 2. Project Name of Azure dev-op account 3. Personal Access Token (guides) +4. Would like to have introspection configuration setup? If yes + 1. Storage Account Name + 1. Storage Table Name + 1. Storage Partition Key + 1. Storage Access Key + 1. Key Vault Name (optional) This tool shall verify these values by making an API call to Azure dev-op. They shall be written to `config.yaml` regardless the verification is successful or diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts index 707df719b..80891b3ed 100644 --- a/src/commands/init.test.ts +++ b/src/commands/init.test.ts @@ -14,6 +14,7 @@ import { execute, getConfig, handleInteractiveMode, + handleIntrospectionInteractive, prompt, validatePersonalAccessToken } from "./init"; @@ -161,6 +162,9 @@ describe("test validatePersonalAccessToken function", () => { expect(result).toBe(false); done(); }); + it("negative test, no values in parameter", async () => { + await expect(validatePersonalAccessToken({})).rejects.toThrow(); + }); }); const testHandleInteractiveModeFunc = async ( @@ -171,12 +175,16 @@ const testHandleInteractiveModeFunc = async ( access_token: "", org: "", project: "" + }, + introspection: { + azure: {} } }); jest.spyOn(init, "prompt").mockResolvedValueOnce({ azdo_org_name: "org_name", azdo_pat: "pat", - azdo_project_name: "project" + azdo_project_name: "project", + toSetupIntrospectionConfig: true }); jest .spyOn(init, "validatePersonalAccessToken") @@ -184,6 +192,7 @@ const testHandleInteractiveModeFunc = async ( const tmpFile = path.join(createTempDir(), "config.yaml"); jest.spyOn(config, "defaultConfigFile").mockReturnValueOnce(tmpFile); + jest.spyOn(init, "handleIntrospectionInteractive").mockResolvedValueOnce(); await handleInteractiveMode(); const content = fs.readFileSync(tmpFile, "utf8"); @@ -209,7 +218,8 @@ describe("test prompt function", () => { const answers = { azdo_org_name: "org", azdo_pat: "pat", - azdo_project_name: "project" + azdo_project_name: "project", + toSetupIntrospectionConfig: true }; jest.spyOn(inquirer, "prompt").mockResolvedValueOnce(answers); const ans = await prompt({}); @@ -217,3 +227,42 @@ describe("test prompt function", () => { done(); }); }); + +const testHandleIntrospectionInteractive = async ( + withIntrospection = false, + withKeyVault = false +): Promise => { + const config: ConfigYaml = {}; + if (!withIntrospection) { + config["introspection"] = { + azure: {} + }; + } + jest.spyOn(inquirer, "prompt").mockResolvedValueOnce({ + azdo_storage_account_name: "storagetest", + azdo_storage_table_name: "storagetabletest", + azdo_storage_partition_key: "test1234key", + azdo_storage_access_key: "accessKey", + azdo_storage_key_vault_name: withKeyVault ? "keyvault" : "" + }); + await handleIntrospectionInteractive(config); + expect(config.introspection?.azure?.account_name).toBe("storagetest"); + expect(config.introspection?.azure?.table_name).toBe("storagetabletest"); + expect(config.introspection?.azure?.partition_key).toBe("test1234key"); + expect(config.introspection?.azure?.key).toBe("accessKey"); + + if (withKeyVault) { + expect(config.key_vault_name).toBe("keyvault"); + } else { + expect(config.key_vault_name).toBeUndefined(); + } +}; + +describe("test handleIntrospectionInteractive function", () => { + it("positive test", async () => { + await testHandleIntrospectionInteractive(false); + await testHandleIntrospectionInteractive(true); + await testHandleIntrospectionInteractive(false, true); + await testHandleIntrospectionInteractive(true, true); + }); +}); diff --git a/src/commands/init.ts b/src/commands/init.ts index 34c61c77e..2f7860a84 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -11,13 +11,9 @@ import { saveConfiguration } from "../config"; import { build as buildCmd, exit as exitCmd } from "../lib/commandBuilder"; +import * as promptBuilder from "../lib/promptBuilder"; import { deepClone } from "../lib/util"; -import { - hasValue, - validateAccessToken, - validateOrgName, - validateProjectName -} from "../lib/validator"; +import { hasValue } from "../lib/validator"; import { logger } from "../logger"; import { ConfigYaml } from "../types"; import decorator from "./init.decorator.json"; @@ -31,6 +27,7 @@ interface Answer { azdo_org_name: string; azdo_project_name: string; azdo_pat: string; + toSetupIntrospectionConfig: boolean; } /** @@ -52,34 +49,17 @@ export const handleFileConfig = (file: string): void => { */ export const prompt = async (curConfig: ConfigYaml): Promise => { const questions = [ - { - default: curConfig.azure_devops?.org || undefined, - message: "Enter organization name\n", - name: "azdo_org_name", - type: "input", - validate: validateOrgName - }, - { - default: curConfig.azure_devops?.project || undefined, - message: "Enter project name\n", - name: "azdo_project_name", - type: "input", - validate: validateProjectName - }, - { - default: curConfig.azure_devops?.access_token || undefined, - mask: "*", - message: "Enter your AzDO personal access token\n", - name: "azdo_pat", - type: "password", - validate: validateAccessToken - } + promptBuilder.azureOrgName(curConfig.azure_devops?.org), + promptBuilder.azureProjectName(curConfig.azure_devops?.project), + promptBuilder.azureAccessToken(curConfig.azure_devops?.access_token), + promptBuilder.askToSetupIntrospectionConfig(false) ]; const answers = await inquirer.prompt(questions); return { azdo_org_name: answers.azdo_org_name as string, azdo_pat: answers.azdo_pat as string, - azdo_project_name: answers.azdo_project_name as string + azdo_project_name: answers.azdo_project_name as string, + toSetupIntrospectionConfig: answers.toSetupIntrospectionConfig }; }; @@ -92,7 +72,7 @@ export const getConfig = (): ConfigYaml => { loadConfiguration(); return Config(); } catch (_) { - // current config is not found. + logger.info("current config is not found."); return { azure_devops: { access_token: "", @@ -134,20 +114,68 @@ export const validatePersonalAccessToken = async ( } }; +export const isIntrospectionAzureDefined = (curConfig: ConfigYaml): boolean => { + if (!curConfig.introspection) { + return false; + } + const intro = curConfig.introspection; + return intro.azure !== undefined; +}; + +export const handleIntrospectionInteractive = async ( + curConfig: ConfigYaml +): Promise => { + if (!isIntrospectionAzureDefined(curConfig)) { + curConfig.introspection = { + azure: {} + }; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const azure = curConfig.introspection!.azure!; + + const ans = await inquirer.prompt([ + promptBuilder.azureStorageAccountName(azure.account_name), + promptBuilder.azureStorageTableName(azure.table_name), + promptBuilder.azureStoragePartitionKey(azure.partition_key), + promptBuilder.azureStorageAccessKey(azure.key), + promptBuilder.azureKeyVaultName(curConfig.key_vault_name) + ]); + azure["account_name"] = ans.azdo_storage_account_name; + azure["table_name"] = ans.azdo_storage_table_name; + azure["partition_key"] = ans.azdo_storage_partition_key; + azure.key = ans.azdo_storage_access_key; + + const keyVaultName = ans.azdo_storage_key_vault_name.trim(); + if (keyVaultName) { + curConfig["key_vault_name"] = keyVaultName; + } else { + delete curConfig["key_vault_name"]; + } +}; + /** * Handles the interactive mode of the command. */ export const handleInteractiveMode = async (): Promise => { - const curConfig = deepClone(getConfig()); + const conf = getConfig(); + if (conf.introspection && conf.introspection.azure) { + delete conf.introspection.azure.key; + } + const curConfig = deepClone(conf); const answer = await prompt(curConfig); - curConfig["azure_devops"] = curConfig.azure_devops || {}; curConfig.azure_devops.org = answer.azdo_org_name; curConfig.azure_devops.project = answer.azdo_project_name; curConfig.azure_devops["access_token"] = answer.azdo_pat; + if (answer.toSetupIntrospectionConfig) { + await handleIntrospectionInteractive(curConfig); + } + const data = yaml.safeDump(curConfig); + fs.writeFileSync(defaultConfigFile(), data); logger.info("Successfully constructed SPK configuration file."); const ok = await validatePersonalAccessToken(curConfig.azure_devops); diff --git a/src/config.ts b/src/config.ts index f735d9a4f..9eb98aeb4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,6 @@ import yaml from "js-yaml"; import * as os from "os"; import path from "path"; import { writeVersion } from "./lib/fileutils"; -import { getSecret } from "./lib/azure/keyvault"; import { logger } from "./logger"; import { AzurePipelinesYaml, @@ -86,29 +85,6 @@ export const loadConfigurationFromLocalEnv = (configObj: T): T => { return configObj; }; -const getKeyVaultSecret = async ( - keyVaultName: string | undefined, - storageAccountName: string | undefined -): Promise => { - logger.debug(`Fetching key from key vault`); - let keyVaultKey: string | undefined; - - // fetch storage access key from key vault when it is configured - if ( - keyVaultName !== undefined && - keyVaultName !== null && - storageAccountName !== undefined - ) { - keyVaultKey = await getSecret(keyVaultName, `${storageAccountName}Key`); - } - - if (keyVaultKey === undefined) { - keyVaultKey = await spkConfig.introspection?.azure?.key; - } - - return keyVaultKey; -}; - /** * Fetches the absolute default directory of the spk global config */ @@ -168,21 +144,7 @@ export const Config = (): ConfigYaml => { } } - const introspectionAzure = { - ...spkConfig.introspection?.azure, - get key(): Promise { - const accountName = spkConfig.introspection?.azure?.account_name; - return getKeyVaultSecret(spkConfig.key_vault_name, accountName); - } - }; - - return { - ...spkConfig, - introspection: { - ...spkConfig.introspection, - azure: introspectionAzure - } - }; + return spkConfig; }; /** diff --git a/src/lib/setup/servicePrincipalService.test.ts b/src/lib/azure/servicePrincipalService.test.ts similarity index 57% rename from src/lib/setup/servicePrincipalService.test.ts rename to src/lib/azure/servicePrincipalService.test.ts index 715606d3e..426d41c72 100644 --- a/src/lib/setup/servicePrincipalService.test.ts +++ b/src/lib/azure/servicePrincipalService.test.ts @@ -1,5 +1,4 @@ import * as shell from "../shell"; -import { RequestContext, WORKSPACE } from "./constants"; import { azCLILogin, createWithAzCLI } from "./servicePrincipalService"; import * as servicePrincipalService from "./servicePrincipalService"; @@ -29,30 +28,14 @@ describe("test createWithAzCLI function", () => { jest .spyOn(shell, "exec") .mockReturnValueOnce(Promise.resolve(JSON.stringify(result))); - const rc: RequestContext = { - accessToken: "pat", - orgName: "orgName", - projectName: "project", - workspace: WORKSPACE - }; - await createWithAzCLI(rc); - expect(rc.createServicePrincipal).toBeTruthy(); - expect(rc.servicePrincipalPassword).toBe(result.password); - expect(rc.servicePrincipalTenantId).toBe(result.tenant); + const sp = await createWithAzCLI(); + expect(sp.id).toBe(result.appId); + expect(sp.password).toBe(result.password); + expect(sp.tenantId).toBe(result.tenant); }); it("negative test", async () => { - jest - .spyOn(servicePrincipalService, "azCLILogin") - .mockReturnValueOnce(Promise.resolve()); - jest - .spyOn(shell, "exec") - .mockReturnValueOnce(Promise.reject(Error("fake"))); - const rc: RequestContext = { - accessToken: "pat", - orgName: "orgName", - projectName: "project", - workspace: WORKSPACE - }; - await expect(createWithAzCLI(rc)).rejects.toThrow(); + jest.spyOn(servicePrincipalService, "azCLILogin").mockResolvedValueOnce(); + jest.spyOn(shell, "exec").mockRejectedValueOnce(Error("fake")); + await expect(createWithAzCLI()).rejects.toThrow(); }); }); diff --git a/src/lib/setup/servicePrincipalService.ts b/src/lib/azure/servicePrincipalService.ts similarity index 78% rename from src/lib/setup/servicePrincipalService.ts rename to src/lib/azure/servicePrincipalService.ts index aff28cef9..d2287aba6 100644 --- a/src/lib/setup/servicePrincipalService.ts +++ b/src/lib/azure/servicePrincipalService.ts @@ -1,6 +1,11 @@ import { logger } from "../../logger"; import { exec } from "../shell"; -import { RequestContext } from "./constants"; + +export interface ServicePrincipal { + id: string; + password: string; + tenantId: string; +} /** * Login to az command line tool. This is done by @@ -24,20 +29,19 @@ export const azCLILogin = async (): Promise => { * this service principal should have contributor privileges. * Request context will have the service principal information * when service principal is successfully created. - * - * @param rc Request Context */ -export const createWithAzCLI = async (rc: RequestContext): Promise => { +export const createWithAzCLI = async (): Promise => { await azCLILogin(); try { logger.info("attempting to create service principal with az command line"); const result = await exec("az", ["ad", "sp", "create-for-rbac"]); const oResult = JSON.parse(result); - rc.createServicePrincipal = true; - rc.servicePrincipalId = oResult.appId; - rc.servicePrincipalPassword = oResult.password; - rc.servicePrincipalTenantId = oResult.tenant; logger.info("Successfully created service principal with az command line"); + return { + id: oResult.appId, + password: oResult.password, + tenantId: oResult.tenant + }; } catch (err) { logger.error("Unable to create service principal with az command line"); logger.error(err); diff --git a/src/lib/setup/subscriptionService.test.ts b/src/lib/azure/subscriptionService.test.ts similarity index 70% rename from src/lib/setup/subscriptionService.test.ts rename to src/lib/azure/subscriptionService.test.ts index 93b98652d..295f701a7 100644 --- a/src/lib/setup/subscriptionService.test.ts +++ b/src/lib/azure/subscriptionService.test.ts @@ -1,7 +1,11 @@ +import uuid = require("uuid"); import * as restAuth from "@azure/ms-rest-nodeauth"; -import { RequestContext } from "./constants"; import { getSubscriptions } from "./subscriptionService"; +const principalId = uuid(); +const principalPassword = uuid(); +const principalTenantId = uuid(); + jest.mock("@azure/arm-subscriptions", () => { class MockClient { constructor() { @@ -33,16 +37,13 @@ describe("test getSubscriptions function", () => { .mockImplementationOnce(async () => { return {}; }); - const rc: RequestContext = { - accessToken: "pat", - orgName: "org", - projectName: "project", - workspace: "test", - servicePrincipalId: "fakeId", - servicePrincipalPassword: "fakePassword", - servicePrincipalTenantId: "fakeTenantId" - }; - const result = await getSubscriptions(rc); + + const result = await getSubscriptions( + principalId, + principalPassword, + principalTenantId + ); + expect(result).toStrictEqual([ { id: "1234567890-abcdef", @@ -57,12 +58,7 @@ describe("test getSubscriptions function", () => { throw Error("fake"); }); await expect( - getSubscriptions({ - accessToken: "pat", - orgName: "org", - projectName: "project", - workspace: "test" - }) + getSubscriptions(principalId, principalPassword, principalTenantId) ).rejects.toThrow(); }); }); diff --git a/src/lib/setup/subscriptionService.ts b/src/lib/azure/subscriptionService.ts similarity index 74% rename from src/lib/setup/subscriptionService.ts rename to src/lib/azure/subscriptionService.ts index 3912953ef..13749e7d1 100644 --- a/src/lib/setup/subscriptionService.ts +++ b/src/lib/azure/subscriptionService.ts @@ -4,7 +4,6 @@ import { loginWithServicePrincipalSecret } from "@azure/ms-rest-nodeauth"; import { logger } from "../../logger"; -import { RequestContext } from "./constants"; export interface SubscriptionItem { id: string; @@ -14,29 +13,34 @@ export interface SubscriptionItem { /** * Returns a list of subscriptions based on the service principal credentials. * - * @param rc Request Context + * @param servicePrincipalId Service Principal Id + * @param servicePrincipalPassword Service Principal Password + * @param servicePrincipalTenantId Service Principal TenantId */ export const getSubscriptions = ( - rc: RequestContext + servicePrincipalId: string, + servicePrincipalPassword: string, + servicePrincipalTenantId: string ): Promise => { logger.info("attempting to get subscription list"); return new Promise((resolve, reject) => { if ( - !rc.servicePrincipalId || - !rc.servicePrincipalPassword || - !rc.servicePrincipalTenantId + !servicePrincipalId || + !servicePrincipalPassword || + !servicePrincipalTenantId ) { reject(Error("Service Principal information was missing.")); } else { loginWithServicePrincipalSecret( - rc.servicePrincipalId, - rc.servicePrincipalPassword, - rc.servicePrincipalTenantId + servicePrincipalId, + servicePrincipalPassword, + servicePrincipalTenantId ) .then(async (creds: ApplicationTokenCredentials) => { const client = new SubscriptionClient(creds); const subsciptions = await client.subscriptions.list(); const result: SubscriptionItem[] = []; + (subsciptions || []).forEach(s => { if (s.subscriptionId && s.displayName) { result.push({ diff --git a/src/lib/i18n.json b/src/lib/i18n.json new file mode 100644 index 000000000..f39b6e95c --- /dev/null +++ b/src/lib/i18n.json @@ -0,0 +1,18 @@ +{ + "prompt": { + "createServicePrincipal": "Do you want to create a service principal?", + "orgName": "Enter organization name", + "personalAccessToken": "Enter your AzDO personal access token", + "projectName": "Enter project name", + "selectSubscriptionId": "Select one of the subscription", + "servicePrincipalId": "Enter Service Principal Id", + "servicePrincipalPassword": "Enter Service Principal Password", + "servicePrincipalTenantId": "Enter Service Principal Tenant Id", + "setupIntrospectionConfig": "Do you want to setup introspection configuration?", + "storageAccountName": "Enter storage account name", + "storageTableName": "Enter storage table name", + "storagePartitionKey": "Enter storage partition key", + "storageAccessKey": "Enter storage access key", + "storageKeVaultName": "Enter key vault name (have the value as empty and hit enter key to skip)" + } +} diff --git a/src/lib/promptBuilder.ts b/src/lib/promptBuilder.ts new file mode 100644 index 000000000..d47715c33 --- /dev/null +++ b/src/lib/promptBuilder.ts @@ -0,0 +1,181 @@ +import { QuestionCollection } from "inquirer"; +import i18n from "./i18n.json"; +import * as validator from "../lib/validator"; + +export const azureOrgName = ( + defaultValue?: string | undefined +): QuestionCollection => { + return { + default: defaultValue, + message: `${i18n.prompt.orgName}\n`, + name: "azdo_org_name", + type: "input", + validate: validator.validateOrgName + }; +}; + +export const azureProjectName = ( + defaultValue?: string | undefined +): QuestionCollection => { + return { + default: defaultValue, + message: `${i18n.prompt.projectName}\n`, + name: "azdo_project_name", + type: "input", + validate: validator.validateProjectName + }; +}; + +export const azureAccessToken = ( + defaultValue?: string | undefined +): QuestionCollection => { + return { + default: defaultValue, + mask: "*", + message: `${i18n.prompt.personalAccessToken}\n`, + name: "azdo_pat", + type: "password", + validate: validator.validateAccessToken + }; +}; + +export const askToSetupIntrospectionConfig = ( + defaultValue = false +): QuestionCollection => { + return { + default: defaultValue, + message: i18n.prompt.setupIntrospectionConfig, + name: "toSetupIntrospectionConfig", + type: "confirm" + }; +}; + +export const askToCreateServicePrincipal = ( + defaultValue = false +): QuestionCollection => { + return { + default: defaultValue, + message: i18n.prompt.createServicePrincipal, + name: "create_service_principal", + type: "confirm" + }; +}; + +export const servicePrincipalId = ( + defaultValue?: string | undefined +): QuestionCollection => { + return { + default: defaultValue, + message: `${i18n.prompt.servicePrincipalId}\n`, + name: "az_sp_id", + type: "input", + validate: validator.validateServicePrincipalId + }; +}; + +export const servicePrincipalPassword = ( + defaultValue?: string | undefined +): QuestionCollection => { + return { + default: defaultValue, + mask: "*", + message: `${i18n.prompt.servicePrincipalPassword}\n`, + name: "az_sp_password", + type: "password", + validate: validator.validateServicePrincipalPassword + }; +}; + +export const servicePrincipalTenantId = ( + defaultValue?: string | undefined +): QuestionCollection => { + return { + default: defaultValue, + message: `${i18n.prompt.servicePrincipalTenantId}\n`, + name: "az_sp_tenant", + type: "input", + validate: validator.validateServicePrincipalTenantId + }; +}; + +export const servicePrincipal = ( + id?: string | undefined, + pwd?: string | undefined, + tenantId?: string | undefined +): QuestionCollection[] => { + return [ + servicePrincipalId(id), + servicePrincipalPassword(pwd), + servicePrincipalTenantId(tenantId) + ]; +}; + +export const chooseSubscriptionId = (names: string[]): QuestionCollection => { + return { + choices: names, + message: `${i18n.prompt.selectSubscriptionId}\n`, + name: "az_subscription", + type: "list" + }; +}; + +export const azureStorageAccountName = ( + defaultValue?: string | undefined +): QuestionCollection => { + return { + default: defaultValue, + message: `${i18n.prompt.storageAccountName}\n`, + name: "azdo_storage_account_name", + type: "input", + validate: validator.validateStorageAccountName + }; +}; + +export const azureStorageTableName = ( + defaultValue?: string | undefined +): QuestionCollection => { + return { + default: defaultValue, + message: `${i18n.prompt.storageTableName}\n`, + name: "azdo_storage_table_name", + type: "input", + validate: validator.validateStorageTableName + }; +}; + +export const azureStoragePartitionKey = ( + defaultValue?: string | undefined +): QuestionCollection => { + return { + default: defaultValue, + message: `${i18n.prompt.storagePartitionKey}\n`, + name: "azdo_storage_partition_key", + type: "input", + validate: validator.validateStoragePartitionKey + }; +}; + +export const azureKeyVaultName = ( + defaultValue?: string | undefined +): QuestionCollection => { + return { + default: defaultValue, + message: `${i18n.prompt.storageKeVaultName}\n`, + name: "azdo_storage_key_vault_name", + type: "input", + validate: validator.validateStorageKeyVaultName + }; +}; + +export const azureStorageAccessKey = ( + defaultValue?: string | undefined +): QuestionCollection => { + return { + default: defaultValue, + mask: "*", + message: `${i18n.prompt.storageAccessKey}\n`, + name: "azdo_storage_access_key", + type: "password", + validate: validator.validateStorageAccessKey + }; +}; diff --git a/src/lib/setup/prompt.test.ts b/src/lib/setup/prompt.test.ts index 44af1f85e..aadc437f5 100644 --- a/src/lib/setup/prompt.test.ts +++ b/src/lib/setup/prompt.test.ts @@ -12,8 +12,8 @@ import { promptForACRName, promptForSubscriptionId } from "./prompt"; -import * as servicePrincipalService from "./servicePrincipalService"; -import * as subscriptionService from "./subscriptionService"; +import * as servicePrincipalService from "../azure/servicePrincipalService"; +import * as subscriptionService from "../azure/subscriptionService"; describe("test prompt function", () => { it("positive test: No App Creation", async () => { @@ -50,7 +50,11 @@ describe("test prompt function", () => { jest .spyOn(servicePrincipalService, "createWithAzCLI") - .mockReturnValueOnce(Promise.resolve()); + .mockResolvedValueOnce({ + id: "b510c1ff-358c-4ed4-96c8-eb23f42bb65b", + password: "a510c1ff-358c-4ed4-96c8-eb23f42bbc5b", + tenantId: "72f988bf-86f1-41af-91ab-2d7cd011db47" + }); jest.spyOn(subscriptionService, "getSubscriptions").mockResolvedValueOnce([ { id: "72f988bf-86f1-41af-91ab-2d7cd011db48", @@ -61,9 +65,13 @@ describe("test prompt function", () => { const ans = await prompt(); expect(ans).toStrictEqual({ accessToken: "pat", + createServicePrincipal: true, acrName: "testACR", orgName: "org", projectName: "project", + 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: true, diff --git a/src/lib/setup/prompt.ts b/src/lib/setup/prompt.ts index 2fbacb42b..47918e7cf 100644 --- a/src/lib/setup/prompt.ts +++ b/src/lib/setup/prompt.ts @@ -1,5 +1,6 @@ import fs from "fs"; import inquirer from "inquirer"; +import * as promptBuilder from "../promptBuilder"; import { validateAccessToken, validateACRName, @@ -11,33 +12,31 @@ import { validateSubscriptionId } from "../validator"; import { + ACR_NAME, DEFAULT_PROJECT_NAME, RequestContext, - WORKSPACE, - ACR_NAME + WORKSPACE } from "./constants"; -import { createWithAzCLI } from "./servicePrincipalService"; -import { getSubscriptions } from "./subscriptionService"; +import { createWithAzCLI } from "../azure/servicePrincipalService"; +import { getSubscriptions } from "../azure/subscriptionService"; export const promptForSubscriptionId = async ( rc: RequestContext ): Promise => { - const subscriptions = await getSubscriptions(rc); + const subscriptions = await getSubscriptions( + rc.servicePrincipalId as string, + rc.servicePrincipalPassword as string, + rc.servicePrincipalTenantId as string + ); 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); + const ans = await inquirer.prompt([ + promptBuilder.chooseSubscriptionId(subscriptions.map(s => s.name)) + ]); const found = subscriptions.find( s => s.name === (ans.az_subscription as string) ); @@ -55,31 +54,10 @@ export const promptForSubscriptionId = async ( export const promptForServicePrincipal = async ( rc: RequestContext ): Promise => { - const questions = [ - { - message: "Enter Service Principal Id\n", - name: "az_sp_id", - type: "input", - validate: validateServicePrincipalId - }, - { - mask: "*", - message: "Enter Service Principal Password\n", - name: "az_sp_password", - type: "password", - validate: validateServicePrincipalPassword - }, - { - message: "Enter Service Principal Tenant Id\n", - name: "az_sp_tenant", - type: "input", - validate: validateServicePrincipalTenantId - } - ]; - const answers = await inquirer.prompt(questions); - rc.servicePrincipalId = answers.az_sp_id as string; - rc.servicePrincipalPassword = answers.az_sp_password as string; - rc.servicePrincipalTenantId = answers.az_sp_tenant as string; + const answers = await inquirer.prompt(promptBuilder.servicePrincipal()); + rc.servicePrincipalId = answers.az_sp_id; + rc.servicePrincipalPassword = answers.az_sp_password; + rc.servicePrincipalTenantId = answers.az_sp_tenant; }; /** @@ -111,18 +89,15 @@ export const promptForACRName = async (rc: RequestContext): Promise => { export const promptForServicePrincipalCreation = async ( rc: RequestContext ): Promise => { - const questions = [ - { - default: true, - message: `Do you want to create a service principal?`, - name: "create_service_principal", - type: "confirm" - } - ]; + const questions = [promptBuilder.askToCreateServicePrincipal(true)]; const answers = await inquirer.prompt(questions); if (answers.create_service_principal) { rc.toCreateSP = true; - await createWithAzCLI(rc); + const sp = await createWithAzCLI(); + rc.createServicePrincipal = true; + rc.servicePrincipalId = sp.id; + rc.servicePrincipalPassword = sp.password; + rc.servicePrincipalTenantId = sp.tenantId; } else { rc.toCreateSP = false; await promptForServicePrincipal(rc); @@ -137,26 +112,9 @@ export const promptForServicePrincipalCreation = async ( */ export const prompt = async (): Promise => { const questions = [ - { - message: "Enter organization name\n", - name: "azdo_org_name", - type: "input", - validate: validateOrgName - }, - { - default: DEFAULT_PROJECT_NAME, - message: "Enter name of project to be created\n", - name: "azdo_project_name", - type: "input", - validate: validateProjectName - }, - { - mask: "*", - message: "Enter your Azure DevOps personal access token\n", - name: "azdo_pat", - type: "password", - validate: validateAccessToken - }, + promptBuilder.azureOrgName(), + promptBuilder.azureProjectName(), + promptBuilder.azureAccessToken(), { default: true, message: `Do you like create a sample application repository?`, diff --git a/src/lib/validator.test.ts b/src/lib/validator.test.ts index 619c64f50..ee1ea9d50 100644 --- a/src/lib/validator.test.ts +++ b/src/lib/validator.test.ts @@ -12,11 +12,17 @@ import { validateACRName, validateForNonEmptyValue, validateOrgName, + validatePassword, validatePrereqs, validateProjectName, validateServicePrincipalId, validateServicePrincipalPassword, validateServicePrincipalTenantId, + validateStorageAccountName, + validateStorageAccessKey, + validateStorageKeyVaultName, + validateStoragePartitionKey, + validateStorageTableName, validateSubscriptionId } from "./validator"; @@ -224,14 +230,80 @@ describe("test validateServicePrincipal functions", () => { describe("test validateSubscriptionId function", () => { it("sanity test", () => { - expect(validateSubscriptionId("")).toBe("Must enter a Subscription Id."); + expect(validateSubscriptionId("")).toBe( + "Must enter a subscription identifier." + ); expect(validateSubscriptionId("xyz")).toBe( - "The value for Subscription Id is invalid." + "The value for subscription identifier is invalid." ); expect(validateSubscriptionId("abc123-456")).toBeTruthy(); }); }); +describe("test validateStorageAccountName test", () => { + it("sanity test", () => { + expect(validateStorageAccountName("")).toBe( + "Must enter a storage account name." + ); + expect(validateStorageAccountName("XYZ123")).toBe( + "The value for storage account name is invalid. Lowercase letters and numbers are allowed." + ); + expect(validateStorageAccountName("ab")).toBe( + "The value for storage account name is invalid. It has to be between 3 and 24 characters long" + ); + expect(validateStorageAccountName("12345678a".repeat(3))).toBe( + "The value for storage account name is invalid. It has to be between 3 and 24 characters long" + ); + expect(validateStorageAccountName("abc123456")).toBeTruthy(); + }); +}); + +describe("test validateStorageTableName test", () => { + it("sanity test", () => { + expect(validateStorageTableName("")).toBe( + "Must enter a storage table name." + ); + expect(validateStorageTableName("XYZ123*")).toBe( + "The value for storage table name is invalid. It has to be alphanumeric and start with an alphabet." + ); + expect(validateStorageTableName("1XYZ123")).toBe( + "The value for storage table name is invalid. It has to be alphanumeric and start with an alphabet." + ); + expect(validateStorageTableName("ab")).toBe( + "The value for storage table name is invalid. It has to be between 3 and 63 characters long" + ); + expect(validateStorageTableName("a123456789".repeat(7))).toBe( + "The value for storage table name is invalid. It has to be between 3 and 63 characters long" + ); + expect(validateStorageTableName("abc123456")).toBeTruthy(); + }); +}); + +describe("test validatePassword test", () => { + it("sanity test", () => { + expect(validatePassword("")).toBe("Must enter a value."); + expect(validatePassword("1234567")).toBe( + "Must be more than 8 characters long." + ); + expect(validatePassword("abcd1234")).toBeTruthy(); + expect(validatePassword("abcdefg123456678")).toBeTruthy(); + }); +}); + +describe("test validateStoragePartitionKey test", () => { + it("sanity test", () => { + expect(validateStoragePartitionKey("")).toBe( + "Must enter a storage partition key." + ); + ["abc\\", "abc/", "abc?", "abc#"].forEach(s => { + expect(validateStoragePartitionKey(s)).toBe( + "The value for storage partition key is invalid. /, \\, # and ? characters are not allowed." + ); + }); + expect(validateStoragePartitionKey("abcdefg123456678")).toBeTruthy(); + }); +}); + describe("test validateACRName function", () => { it("sanity test", () => { expect(validateACRName("")).toBe( @@ -250,3 +322,36 @@ describe("test validateACRName function", () => { expect(validateACRName("abc12356")).toBeTruthy(); }); }); + +describe("test validateStorageKeyVaultName function", () => { + it("sanity test", () => { + expect(validateStorageKeyVaultName("ab*")).toBe( + "The value for Key Value Name is invalid." + ); + expect(validateStorageKeyVaultName("1abc0")).toBe( + "Key Value Name must start with a letter." + ); + expect(validateStorageKeyVaultName("abc0-")).toBe( + "Key Value Name must end with letter or digit." + ); + expect(validateStorageKeyVaultName("a--b")).toBe( + "Key Value Name cannot contain consecutive hyphens." + ); + expect(validateStorageKeyVaultName("ab")).toBe( + "The value for Key Vault Name is invalid because it has to be between 3 and 24 characters long." + ); + expect(validateStorageKeyVaultName("a12345678".repeat(3))).toBe( + "The value for Key Vault Name is invalid because it has to be between 3 and 24 characters long." + ); + expect(validateStorageKeyVaultName("abc-12356")).toBeTruthy(); + }); +}); + +describe("test validateStorageAccessKey function", () => { + it("sanity test", () => { + expect(validateStorageAccessKey("")).toBe( + "Must enter an Storage Access Key." + ); + expect(validateStorageAccessKey("abc-12356")).toBeTruthy(); + }); +}); diff --git a/src/lib/validator.ts b/src/lib/validator.ts index 2d6f69f54..8d7667f5d 100644 --- a/src/lib/validator.ts +++ b/src/lib/validator.ts @@ -120,6 +120,25 @@ export const isAlphaNumeric = (value: string): boolean => { return !!value.match(/^[a-zA-Z0-9]+$/); }; +export const isDashAlphaNumeric = (value: string): boolean => { + return !!value.match(/^[a-zA-Z0-9-]+$/); +}; + +/** + * Returns true if password is proper. Typical password validation + * + * @param value password + */ +export const validatePassword = (value: string): string | boolean => { + if (!hasValue(value)) { + return "Must enter a value."; + } + if (value.length < 8) { + return "Must be more than 8 characters long."; + } + return true; +}; + /** * Returns true if project name is proper. * @@ -235,10 +254,63 @@ export const validateServicePrincipalTenantId = ( */ export const validateSubscriptionId = (value: string): string | boolean => { if (!hasValue(value)) { - return "Must enter a Subscription Id."; + return "Must enter a subscription identifier."; } if (!isDashHex(value)) { - return "The value for Subscription Id is invalid."; + return "The value for subscription identifier is invalid."; + } + return true; +}; + +/** + * Returns true if storage account name is valid. + * + * @param value storage account name . + */ +export const validateStorageAccountName = (value: string): string | boolean => { + if (!hasValue(value)) { + return "Must enter a storage account name."; + } + if (!value.match(/^[a-z0-9]+$/)) { + return "The value for storage account name is invalid. Lowercase letters and numbers are allowed."; + } + if (value.length < 3 || value.length > 24) { + return "The value for storage account name is invalid. It has to be between 3 and 24 characters long"; + } + return true; +}; + +/** + * Returns true if storage table name is valid. + * + * @param value storage table name. + */ +export const validateStorageTableName = (value: string): string | boolean => { + if (!hasValue(value)) { + return "Must enter a storage table name."; + } + if (!value.match(/^[A-Za-z][A-Za-z0-9]*$/)) { + return "The value for storage table name is invalid. It has to be alphanumeric and start with an alphabet."; + } + if (value.length < 3 || value.length > 63) { + return "The value for storage table name is invalid. It has to be between 3 and 63 characters long"; + } + return true; +}; + +/** + * Returns true if storage partition key is valid. + * + * @param value storage partition key. + */ +export const validateStoragePartitionKey = ( + value: string +): string | boolean => { + if (!hasValue(value)) { + return "Must enter a storage partition key."; + } + if (value.match(/[/\\#?]/)) { + return "The value for storage partition key is invalid. /, \\, # and ? characters are not allowed."; } return true; }; @@ -260,3 +332,34 @@ export const validateACRName = (value: string): string | boolean => { } return true; }; + +export const validateStorageKeyVaultName = ( + value: string +): string | boolean => { + if (!hasValue(value)) { + return true; // optional + } + if (!isDashAlphaNumeric(value)) { + return "The value for Key Value Name is invalid."; + } + if (!value.match(/^[a-zA-Z]/)) { + return "Key Value Name must start with a letter."; + } + if (!value.match(/[a-zA-Z0-9]$/)) { + return "Key Value Name must end with letter or digit."; + } + if (value.indexOf("--") !== -1) { + return "Key Value Name cannot contain consecutive hyphens."; + } + if (value.length < 3 || value.length > 24) { + return "The value for Key Vault Name is invalid because it has to be between 3 and 24 characters long."; + } + return true; +}; + +export const validateStorageAccessKey = (value: string): string | boolean => { + if (!hasValue(value)) { + return "Must enter an Storage Access Key."; + } + return true; +}; diff --git a/src/types.d.ts b/src/types.d.ts index ff6d103af..05dec7543 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -197,7 +197,7 @@ export interface ConfigYaml { account_name?: string; table_name?: string; partition_key?: string; - key: Promise; + key?: string; source_repo_access_token?: string; service_principal_id?: string; service_principal_secret?: string; diff --git a/technical-docs/designs/initialization/interactiveModeForIntrospectionConfig.md b/technical-docs/designs/initialization/interactiveModeForIntrospectionConfig.md index 38d130db9..74c454d10 100644 --- a/technical-docs/designs/initialization/interactiveModeForIntrospectionConfig.md +++ b/technical-docs/designs/initialization/interactiveModeForIntrospectionConfig.md @@ -12,10 +12,11 @@ Authors: --- -| Revision | Date | Author | Remarks | -| -------: | ------------ | ----------- | --------------------------------- | -| 0.1 | Mar-16, 2020 | Dennis Seah | Initial Draft | -| 0.2 | Mar-17, 2020 | Dennis Seah | Having key vault name as optional | +| Revision | Date | Author | Remarks | +| -------: | ------------ | ----------- | ----------------------------------------- | +| 0.1 | Mar-16, 2020 | Dennis Seah | Initial Draft | +| 0.2 | Mar-17, 2020 | Dennis Seah | Having key vault name as optional | +| 1.0 | Mar-18, 2020 | Dennis Seah | Reviewed and this doc is committed to git | ## 1. Overview