diff --git a/input1.txt b/input1.txt new file mode 100644 index 000000000..7936f28c5 --- /dev/null +++ b/input1.txt @@ -0,0 +1,11 @@ +azdo_org_name=veseah +azdo_project_name=SPK12345 +azdo_pat=doegx7fx26zj5gwgzlbz4rphh5h5dzwqelxaucibyvgxp45vhlna +az_create_app=true +az_create_sp=false +az_sp_id=53441b1b-132e-4dba-992f-9009d86ddfa3 +az_sp_password=e79ff863-5b90-4340-b9e9-f7cede7a03bd +az_sp_tenant=72f988bf-86f1-41af-91ab-2d7cd011db47 +az_subscription_id=dd831253-787f-4dc8-8eb0-ac9d052177d9 +az_acr_name=quickStartACR + diff --git a/src/commands/project/create-variable-group.ts b/src/commands/project/create-variable-group.ts index 064211d7e..5474f857d 100644 --- a/src/commands/project/create-variable-group.ts +++ b/src/commands/project/create-variable-group.ts @@ -233,7 +233,7 @@ export const setVariableGroupInBedrockFile = ( const bedrockFile = Bedrock(rootProjectPath); if (typeof bedrockFile === "undefined") { - throw new Error(`Bedrock file does not exist.`); + throw Error(`Bedrock file does not exist.`); } logger.verbose( @@ -259,10 +259,10 @@ export const setVariableGroupInBedrockFile = ( */ export const updateLifeCyclePipeline = (rootProjectPath: string): void => { if (!hasValue(rootProjectPath)) { - throw new Error("Project root path is not valid"); + throw Error("Project root path is not valid"); } - const fileName: string = PROJECT_PIPELINE_FILENAME; + const fileName = PROJECT_PIPELINE_FILENAME; const absProjectRoot = path.resolve(rootProjectPath); // Get bedrock.yaml diff --git a/src/commands/setup.md b/src/commands/setup.md index 35c7c0853..0a3829750 100644 --- a/src/commands/setup.md +++ b/src/commands/setup.md @@ -21,9 +21,6 @@ for a few questions 2. Subscription Id is automatically retrieved with the Service Principal credential. In case, there are two or more subscriptions, you will be prompt to select one of them. - 3. Create a resource group, `quick-start-rg` if it does not exist. - 4. Create a Azure Container Registry, `quickStartACR` in resource group, - `quick-start-rg` if it does not exist. It can also run in a non interactive mode by providing a file that contains answers to the above questions. @@ -58,7 +55,15 @@ The followings shall be created already exists. 1. And initial commit shall be made to this repo 5. A High Level Definition (HLD) to Manifest pipeline. -6. A Service Principal (if requested) +6. If user chose to create sample app repo + 1. A Service Principal (if requested) + 2. A resource group, `quick-start-rg` if it does not exist. + 3. A Azure Container Registry, `quickStartACR` in resource group, + `quick-start-rg` if it does not exist. + 4. A Git Repo, `quick-start-helm`, it shall be deleted and recreated if is + already exists. + 5. A Git Repo, `quick-start-app`, it shall be deleted and recreated if is + already exists. ## Setup log diff --git a/src/commands/setup.test.ts b/src/commands/setup.test.ts index 36167f3e0..12dbd7177 100644 --- a/src/commands/setup.test.ts +++ b/src/commands/setup.test.ts @@ -80,8 +80,10 @@ const testExecuteFunc = async ( // eslint-disable-next-line @typescript-eslint/no-explicit-any .mockReturnValueOnce(Promise.resolve({} as any)); jest.spyOn(fsUtil, "createDirectory").mockReturnValueOnce(); - jest.spyOn(scaffold, "hldRepo").mockReturnValueOnce(Promise.resolve()); - jest.spyOn(scaffold, "manifestRepo").mockReturnValueOnce(Promise.resolve()); + jest.spyOn(scaffold, "hldRepo").mockResolvedValueOnce(); + jest.spyOn(scaffold, "manifestRepo").mockResolvedValueOnce(); + jest.spyOn(scaffold, "helmRepo").mockResolvedValueOnce(); + jest.spyOn(scaffold, "appRepo").mockResolvedValueOnce(); jest .spyOn(pipelineService, "createHLDtoManifestPipeline") .mockReturnValueOnce(Promise.resolve()); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index ae742f57d..a93bec0d6 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -7,7 +7,6 @@ import { create as createACR } from "../lib/azure/containerRegistryService"; import { create as createResourceGroup } from "../lib/azure/resourceService"; import { build as buildCmd, exit as exitCmd } from "../lib/commandBuilder"; import { - ACR, RequestContext, RESOURCE_GROUP, RESOURCE_GROUP_LOCATION, @@ -18,10 +17,16 @@ import { getGitApi } from "../lib/setup/gitService"; import { createHLDtoManifestPipeline } from "../lib/setup/pipelineService"; import { createProjectIfNotExist } from "../lib/setup/projectService"; import { getAnswerFromFile, prompt } from "../lib/setup/prompt"; -import { hldRepo, manifestRepo } from "../lib/setup/scaffold"; +import { + appRepo, + helmRepo, + hldRepo, + manifestRepo +} from "../lib/setup/scaffold"; import { create as createSetupLog } from "../lib/setup/setupLog"; import { logger } from "../logger"; import decorator from "./setup.decorator.json"; +import { IGitApi } from "azure-devops-node-api/GitApi"; interface CommandOptions { file: string | undefined; @@ -79,6 +84,40 @@ export const getErrorMessage = ( return err.toString(); }; +export const createAppRepoTasks = async ( + gitAPI: IGitApi, + rc: RequestContext +): Promise => { + if ( + rc.toCreateAppRepo && + rc.servicePrincipalId && + rc.servicePrincipalPassword && + rc.servicePrincipalTenantId && + rc.subscriptionId && + rc.acrName + ) { + rc.createdResourceGroup = await createResourceGroup( + rc.servicePrincipalId, + rc.servicePrincipalPassword, + rc.servicePrincipalTenantId, + rc.subscriptionId, + RESOURCE_GROUP, + RESOURCE_GROUP_LOCATION + ); + rc.createdACR = await createACR( + rc.servicePrincipalId, + rc.servicePrincipalPassword, + rc.servicePrincipalTenantId, + rc.subscriptionId, + RESOURCE_GROUP, + rc.acrName, + RESOURCE_GROUP_LOCATION + ); + await helmRepo(gitAPI, rc); + await appRepo(gitAPI, rc); + } +}; + /** * Executes the command, can all exit function with 0 or 1 * when command completed successfully or failed respectively. @@ -106,32 +145,7 @@ export const execute = async ( await hldRepo(gitAPI, requestContext); await manifestRepo(gitAPI, requestContext); await createHLDtoManifestPipeline(buildAPI, requestContext); - - if ( - requestContext.toCreateAppRepo && - requestContext.servicePrincipalId && - requestContext.servicePrincipalPassword && - requestContext.servicePrincipalTenantId && - requestContext.subscriptionId - ) { - requestContext.createdResourceGroup = await createResourceGroup( - requestContext.servicePrincipalId, - requestContext.servicePrincipalPassword, - requestContext.servicePrincipalTenantId, - requestContext.subscriptionId, - RESOURCE_GROUP, - RESOURCE_GROUP_LOCATION - ); - requestContext.createdACR = await createACR( - requestContext.servicePrincipalId, - requestContext.servicePrincipalPassword, - requestContext.servicePrincipalTenantId, - requestContext.subscriptionId, - RESOURCE_GROUP, - ACR, - RESOURCE_GROUP_LOCATION - ); - } + await createAppRepoTasks(gitAPI, requestContext); createSetupLog(requestContext); await exitFn(0); diff --git a/src/lib/azure/containerRegistryService.test.ts b/src/lib/azure/containerRegistryService.test.ts index a42d6fd33..2ce1c6dd9 100644 --- a/src/lib/azure/containerRegistryService.test.ts +++ b/src/lib/azure/containerRegistryService.test.ts @@ -1,4 +1,7 @@ -import { RegistriesCreateResponse } from "@azure/arm-containerregistry/src/models"; +import { + RegistriesCreateResponse, + RegistriesListResponse +} from "@azure/arm-containerregistry/src/models"; import * as restAuth from "@azure/ms-rest-nodeauth"; import { @@ -17,15 +20,15 @@ jest.mock("@azure/arm-containerregistry", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any return {} as any; }, - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - list: () => { + list: (): Promise => { return [ { id: "/subscriptions/dd831253-787f-4dc8-8eb0-ac9d052177d9/resourceGroups/bedrockSPK/providers/Microsoft.ContainerRegistry/registries/acrWest", name: "acrWest" } - ]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any; } } }; diff --git a/src/lib/azure/resourceService.test.ts b/src/lib/azure/resourceService.test.ts index 4f088aaef..342b53dd6 100644 --- a/src/lib/azure/resourceService.test.ts +++ b/src/lib/azure/resourceService.test.ts @@ -1,4 +1,7 @@ -import { ResourceGroupsCreateOrUpdateResponse } from "@azure/arm-resources/src/models"; +import { + ResourceGroupsCreateOrUpdateResponse, + ResourceGroupsListResponse +} from "@azure/arm-resources/src/models"; import * as restAuth from "@azure/ms-rest-nodeauth"; import { create, getResourceGroups, isExist } from "./resourceService"; import * as resourceService from "./resourceService"; @@ -16,15 +19,15 @@ jest.mock("@azure/arm-resources", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any return {} as any; }, - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - list: () => { + list: (): Promise => { return [ { id: "1234567890-abcdef", location: RESOURCE_GROUP_LOCATION, name: "test" } - ]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any; } } }; diff --git a/src/lib/pipelines/variableGroup.test.ts b/src/lib/pipelines/variableGroup.test.ts index 22d21ad85..af97c5b02 100644 --- a/src/lib/pipelines/variableGroup.test.ts +++ b/src/lib/pipelines/variableGroup.test.ts @@ -3,9 +3,9 @@ import { VariableGroupParameters } from "azure-devops-node-api/interfaces/TaskAgentInterfaces"; import uuid from "uuid/v4"; +import * as azdoClient from "../azdoClient"; import { readYaml } from "../../config"; import * as config from "../../config"; -import * as azdoClient from "../azdoClient"; import { disableVerboseLogging, enableVerboseLogging, @@ -17,6 +17,7 @@ import { addVariableGroupWithKeyVaultMap, authorizeAccessToAllPipelines, buildVariablesMap, + deleteVariableGroup, doAddVariableGroup } from "./variableGroup"; @@ -403,3 +404,32 @@ describe("buildVariablesMap", () => { expect(Object.keys(secretsMap).length).toBe(0); }); }); + +describe("test deleteVariableGroup function", () => { + it("positive test: group found", async () => { + const delFn = jest.fn(); + jest.spyOn(azdoClient, "getTaskAgentApi").mockResolvedValue({ + deleteVariableGroup: delFn, + getVariableGroups: () => [ + { + id: "test" + } + ] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + const deleted = await deleteVariableGroup({}, "test"); + expect(delFn).toBeCalledTimes(1); + expect(deleted).toBeTruthy(); + }); + it("positive test: no matching groups found", async () => { + const delFn = jest.fn(); + jest.spyOn(azdoClient, "getTaskAgentApi").mockResolvedValue({ + deleteVariableGroup: delFn, + getVariableGroups: () => [] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + const deleted = await deleteVariableGroup({}, "test"); + expect(delFn).toBeCalledTimes(0); + expect(deleted).toBeFalsy(); + }); +}); diff --git a/src/lib/pipelines/variableGroup.ts b/src/lib/pipelines/variableGroup.ts index ad80bfcb8..1cb3d3b44 100644 --- a/src/lib/pipelines/variableGroup.ts +++ b/src/lib/pipelines/variableGroup.ts @@ -238,3 +238,26 @@ export const addVariableGroupWithKeyVaultMap = async ( throw err; } }; + +/** + * Deletes variable group + * + * @param opts optionally override spk config with Azure DevOps access options + * @param name Name of group to be deleted. + * @returns true if group exists and deleted. + */ +export const deleteVariableGroup = async ( + opts: AzureDevOpsOpts, + name: string +): Promise => { + const taskClient = await getTaskAgentApi(opts); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const project = opts.project!; + + const groups = await taskClient.getVariableGroups(project, name); + if (groups && groups.length > 0 && groups[0].id) { + await taskClient.deleteVariableGroup(project, groups[0].id); + return true; + } + return false; +}; diff --git a/src/lib/setup/constants.ts b/src/lib/setup/constants.ts index 8ac6e3508..6271c8fb1 100644 --- a/src/lib/setup/constants.ts +++ b/src/lib/setup/constants.ts @@ -3,11 +3,14 @@ export interface RequestContext { projectName: string; accessToken: string; workspace: string; + acrName?: string; toCreateAppRepo?: boolean; toCreateSP?: boolean; createdProject?: boolean; scaffoldHLD?: boolean; scaffoldManifest?: boolean; + scaffoldHelm?: boolean; + scaffoldAppService?: boolean; createdHLDtoManifestPipeline?: boolean; createServicePrincipal?: boolean; servicePrincipalId?: string; @@ -21,6 +24,7 @@ export interface RequestContext { export const MANIFEST_REPO = "quick-start-manifest"; export const HLD_REPO = "quick-start-hld"; +export const HELM_REPO = "quick-start-helm"; export const APP_REPO = "quick-start-app"; export const DEFAULT_PROJECT_NAME = "BedrockRocks"; export const APP_REPO_LIFECYCLE = "quick-start-lifecycle"; @@ -28,7 +32,8 @@ export const WORKSPACE = "quick-start-env"; export const SP_USER_NAME = "service_account"; export const RESOURCE_GROUP = "quick-start-rg"; export const RESOURCE_GROUP_LOCATION = "westus2"; -export const ACR = "quickStartACR"; +export const ACR_NAME = "quickStartACR"; +export const VARIABLE_GROUP = "quick-start-vg"; export const SETUP_LOG = "setup.log"; export const HLD_DEFAULT_GIT_URL = diff --git a/src/lib/setup/helmTemplates.ts b/src/lib/setup/helmTemplates.ts new file mode 100644 index 000000000..4803c5d1d --- /dev/null +++ b/src/lib/setup/helmTemplates.ts @@ -0,0 +1,67 @@ +export const chartTemplate = `description: Simple Helm Chart For Integration Tests +name: @@CHART_APP_NAME@@-app +version: 0.1.0`; + +export const valuesTemplate = `# Default values for test app. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +image: + repository: @@ACR_NAME@@.azurecr.io/@@CHART_APP_NAME@@ + tag: latest + pullPolicy: IfNotPresent + +service: + type: ClusterIP + port: 80 + containerPort: 8080 +`; + +export const mainTemplate = `--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: { { .Chart.Name } } +spec: + replicas: { { .Values.replicaCount } } + selector: + matchLabels: + app.kubernetes.io/name: { { .Chart.Name } } + app.kubernetes.io/instance: { { .Release.Name } } + minReadySeconds: { { .Values.minReadySeconds } } + strategy: + type: RollingUpdate # describe how we do rolling updates + rollingUpdate: + maxUnavailable: 1 # When updating take one pod down at a time + maxSurge: 1 # When updating never have more than one extra pod. If replicas = 2 then never 3 pods when updating + template: + metadata: + labels: + app: { { .Chart.Name } } + app.kubernetes.io/name: { { .Chart.Name } } + app.kubernetes.io/instance: { { .Release.Name } } + annotations: + prometheus.io/port: "{{ .Values.service.containerPort}}" + prometheus.io/scrape: "true" + spec: + containers: + - name: { { .Chart.Name } } + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: { { .Values.image.pullPolicy } } + ports: + - containerPort: { { .Values.service.containerPort } } +--- +apiVersion: v1 +kind: Service +metadata: + name: { { .Chart.Name } } + labels: + app: { { .Chart.Name } } +spec: + type: LoadBalancer + ports: + - port: 8080 + name: http + selector: + app: { { .Chart.Name } } +`; diff --git a/src/lib/setup/prompt.test.ts b/src/lib/setup/prompt.test.ts index d7447c9b3..0baa06aed 100644 --- a/src/lib/setup/prompt.test.ts +++ b/src/lib/setup/prompt.test.ts @@ -5,7 +5,12 @@ import path from "path"; import uuid from "uuid/v4"; import { createTempDir } from "../../lib/ioUtil"; import { DEFAULT_PROJECT_NAME, RequestContext, WORKSPACE } from "./constants"; -import { getAnswerFromFile, prompt, promptForSubscriptionId } from "./prompt"; +import { + getAnswerFromFile, + prompt, + promptForACRName, + promptForSubscriptionId +} from "./prompt"; import * as servicePrincipalService from "./servicePrincipalService"; import * as subscriptionService from "./subscriptionService"; @@ -38,6 +43,10 @@ describe("test prompt function", () => { jest.spyOn(inquirer, "prompt").mockResolvedValueOnce({ "create_service_principal": true }); + jest.spyOn(inquirer, "prompt").mockResolvedValueOnce({ + "acr_name": "testACR" + }); + jest .spyOn(servicePrincipalService, "createWithAzCLI") .mockReturnValueOnce(Promise.resolve()); @@ -51,6 +60,7 @@ describe("test prompt function", () => { const ans = await prompt(); expect(ans).toStrictEqual({ accessToken: "pat", + acrName: "testACR", orgName: "org", projectName: "project", subscriptionId: "72f988bf-86f1-41af-91ab-2d7cd011db48", @@ -75,6 +85,9 @@ describe("test prompt function", () => { "az_sp_password": "a510c1ff-358c-4ed4-96c8-eb23f42bbc5b", "az_sp_tenant": "72f988bf-86f1-41af-91ab-2d7cd011db47" }); + jest.spyOn(inquirer, "prompt").mockResolvedValueOnce({ + "acr_name": "testACR" + }); jest.spyOn(subscriptionService, "getSubscriptions").mockResolvedValueOnce([ { id: "72f988bf-86f1-41af-91ab-2d7cd011db48", @@ -84,6 +97,7 @@ describe("test prompt function", () => { const ans = await prompt(); expect(ans).toStrictEqual({ accessToken: "pat", + acrName: "testACR", orgName: "org", projectName: "project", servicePrincipalId: "b510c1ff-358c-4ed4-96c8-eb23f42bb65b", @@ -277,3 +291,19 @@ describe("test promptForSubscriptionId function", () => { expect(mockRc.subscriptionId).toBe("12334567890"); }); }); + +describe("test promptForACRName function", () => { + it("positive test", async () => { + const mockRc: RequestContext = { + accessToken: "pat", + orgName: "org", + projectName: "project", + workspace: WORKSPACE + }; + jest.spyOn(inquirer, "prompt").mockResolvedValueOnce({ + "acr_name": "testACR" + }); + await promptForACRName(mockRc); + expect(mockRc.acrName).toBe("testACR"); + }); +}); diff --git a/src/lib/setup/prompt.ts b/src/lib/setup/prompt.ts index ec51026f5..2fbacb42b 100644 --- a/src/lib/setup/prompt.ts +++ b/src/lib/setup/prompt.ts @@ -2,6 +2,7 @@ import fs from "fs"; import inquirer from "inquirer"; import { validateAccessToken, + validateACRName, validateOrgName, validateProjectName, validateServicePrincipalId, @@ -9,7 +10,12 @@ import { validateServicePrincipalTenantId, validateSubscriptionId } from "../validator"; -import { DEFAULT_PROJECT_NAME, RequestContext, WORKSPACE } from "./constants"; +import { + DEFAULT_PROJECT_NAME, + RequestContext, + WORKSPACE, + ACR_NAME +} from "./constants"; import { createWithAzCLI } from "./servicePrincipalService"; import { getSubscriptions } from "./subscriptionService"; @@ -76,6 +82,26 @@ export const promptForServicePrincipal = async ( rc.servicePrincipalTenantId = answers.az_sp_tenant as string; }; +/** + * Prompts for ACR name, default value is "quickStartACR". + * This is needed bacause ACR name has to be unique within Azure. + * + * @param rc Request Context + */ +export const promptForACRName = async (rc: RequestContext): Promise => { + const questions = [ + { + default: ACR_NAME, + message: `Enter Azure Container Register Name. The registry name must be unique within Azure\n`, + name: "acr_name", + type: "input", + validate: validateACRName + } + ]; + const answers = await inquirer.prompt(questions); + rc.acrName = answers.acr_name as string; +}; + /** * Prompts for creating service principal. User can choose * Yes or No. @@ -149,6 +175,7 @@ export const prompt = async (): Promise => { if (rc.toCreateAppRepo) { await promptForServicePrincipalCreation(rc); + await promptForACRName(rc); } return rc; }; @@ -230,6 +257,12 @@ export const getAnswerFromFile = (file: string): RequestContext => { throw new Error(vToken); } + const acrName = map.az_acr_name || ACR_NAME; + const vACRName = validateACRName(acrName); + if (typeof vACRName === "string") { + throw new Error(vACRName); + } + const rc: RequestContext = { accessToken: map.azdo_pat, orgName: map.azdo_org_name, @@ -237,6 +270,7 @@ export const getAnswerFromFile = (file: string): RequestContext => { servicePrincipalId: map.az_sp_id, servicePrincipalPassword: map.az_sp_password, servicePrincipalTenantId: map.az_sp_tenant, + acrName, workspace: WORKSPACE }; diff --git a/src/lib/setup/scaffold.test.ts b/src/lib/setup/scaffold.test.ts index fae4c9e1f..1b1df3b31 100644 --- a/src/lib/setup/scaffold.test.ts +++ b/src/lib/setup/scaffold.test.ts @@ -2,10 +2,28 @@ import * as fs from "fs-extra"; import * as path from "path"; import simpleGit from "simple-git/promise"; +import * as cmdCreateVariableGroup from "../../commands/project/create-variable-group"; +import * as projectInit from "../../commands/project/init"; +import * as createService from "../../commands/service/create"; +import * as variableGroup from "../../lib/pipelines/variableGroup"; import { createTempDir } from "../ioUtil"; -import { HLD_REPO, RequestContext, MANIFEST_REPO } from "./constants"; +import { + APP_REPO, + HELM_REPO, + HLD_REPO, + MANIFEST_REPO, + RequestContext +} from "./constants"; import * as gitService from "./gitService"; -import { hldRepo, manifestRepo } from "./scaffold"; +import { + appRepo, + helmRepo, + hldRepo, + initService, + manifestRepo, + setupVariableGroup +} from "./scaffold"; +import * as scaffold from "./scaffold"; const createRequestContext = (workspace: string): RequestContext => { return { @@ -67,9 +85,9 @@ describe("test hldRepo function", () => { expect(fs.statSync(folder).isDirectory()).toBeTruthy(); ["component.yaml", "manifest-generation.yaml"].forEach(f => { - const readmeMdPath = path.join(folder, f); - expect(fs.existsSync(readmeMdPath)).toBe(true); - expect(fs.statSync(readmeMdPath).isFile()).toBeTruthy(); + const sPath = path.join(folder, f); + expect(fs.existsSync(sPath)).toBe(true); + expect(fs.statSync(sPath).isFile()).toBeTruthy(); }); }); it("negative test", async () => { @@ -82,3 +100,95 @@ describe("test hldRepo function", () => { ).rejects.toThrow(); }); }); + +describe("test helmRepo function", () => { + it("positive test", async () => { + const tempDir = createTempDir(); + jest + .spyOn(gitService, "createRepoInAzureOrg") + .mockReturnValueOnce({} as any); + jest + .spyOn(gitService, "commitAndPushToRemote") + .mockReturnValueOnce({} as any); + const git = simpleGit(); + git.init = jest.fn(); + + await helmRepo({} as any, createRequestContext(tempDir)); + const folder = path.join(tempDir, HELM_REPO); + expect(fs.existsSync(folder)).toBe(true); + expect(fs.statSync(folder).isDirectory()).toBeTruthy(); + + const folderAppChart = path.join(folder, APP_REPO, "chart"); + ["Chart.yaml", "values.yaml"].forEach(f => { + const sPath = path.join(folderAppChart, f); + expect(fs.existsSync(sPath)).toBe(true); + expect(fs.statSync(sPath).isFile()).toBeTruthy(); + }); + const folderAppChartTemplates = path.join(folderAppChart, "templates"); + ["all-in-one.yaml"].forEach(f => { + const sPath = path.join(folderAppChartTemplates, f); + expect(fs.existsSync(sPath)).toBe(true); + expect(fs.statSync(sPath).isFile()).toBeTruthy(); + }); + }); + it("negative test", async () => { + const tempDir = createTempDir(); + jest.spyOn(gitService, "createRepoInAzureOrg").mockImplementation(() => { + throw new Error("fake"); + }); + await expect( + helmRepo({} as any, createRequestContext(tempDir)) + ).rejects.toThrow(); + }); +}); + +describe("test appRepo function", () => { + it("positive test", async () => { + const tempDir = createTempDir(); + jest + .spyOn(gitService, "createRepoInAzureOrg") + .mockReturnValueOnce({} as any); + jest + .spyOn(gitService, "commitAndPushToRemote") + .mockReturnValueOnce({} as any); + const git = simpleGit(); + git.init = jest.fn(); + + jest.spyOn(scaffold, "setupVariableGroup").mockResolvedValueOnce(); + jest.spyOn(scaffold, "initService").mockResolvedValueOnce(); + jest.spyOn(projectInit, "initialize").mockImplementationOnce(async () => { + fs.createFileSync("README.md"); + }); + + await appRepo({} as any, createRequestContext(tempDir)); + const folder = path.join(tempDir, APP_REPO); + expect(fs.existsSync(folder)).toBe(true); + expect(fs.statSync(folder).isDirectory()).toBeTruthy(); + }); + it("sanity test, initService", async () => { + jest.spyOn(createService, "createService").mockResolvedValueOnce(); + await initService("test"); + }); + it("sanity test on setupVariableGroup", async () => { + jest + .spyOn(variableGroup, "deleteVariableGroup") + .mockResolvedValueOnce(true); + jest.spyOn(cmdCreateVariableGroup, "create").mockResolvedValueOnce({}); + jest + .spyOn(cmdCreateVariableGroup, "setVariableGroupInBedrockFile") + .mockReturnValueOnce(); + jest + .spyOn(cmdCreateVariableGroup, "updateLifeCyclePipeline") + .mockReturnValueOnce(); + await setupVariableGroup(createRequestContext("/dummy")); + }); + it("negative test", async () => { + const tempDir = createTempDir(); + jest.spyOn(gitService, "createRepoInAzureOrg").mockImplementation(() => { + throw new Error("fake"); + }); + await expect( + appRepo({} as any, createRequestContext(tempDir)) + ).rejects.toThrow(); + }); +}); diff --git a/src/lib/setup/scaffold.ts b/src/lib/setup/scaffold.ts index 3a61516e5..4933d8d00 100644 --- a/src/lib/setup/scaffold.ts +++ b/src/lib/setup/scaffold.ts @@ -1,18 +1,32 @@ import { IGitApi } from "azure-devops-node-api/GitApi"; import fs from "fs-extra"; +import path from "path"; import simplegit from "simple-git/promise"; import { initialize as hldInitialize } from "../../commands/hld/init"; +import { + create as createVariableGroup, + setVariableGroupInBedrockFile, + updateLifeCyclePipeline +} from "../../commands/project/create-variable-group"; +import { initialize as projectInitialize } from "../../commands/project/init"; +import { createService } from "../../commands/service/create"; +import { AzureDevOpsOpts } from "../../lib/git"; +import { deleteVariableGroup } from "../../lib/pipelines/variableGroup"; import { logger } from "../../logger"; import { + APP_REPO, + HELM_REPO, HLD_DEFAULT_COMPONENT_NAME, HLD_DEFAULT_DEF_PATH, HLD_DEFAULT_GIT_URL, HLD_REPO, + MANIFEST_REPO, RequestContext, - MANIFEST_REPO + VARIABLE_GROUP } from "./constants"; import { createDirectory, moveToAbsPath, moveToRelativePath } from "./fsUtil"; import { commitAndPushToRemote, createRepoInAzureOrg } from "./gitService"; +import { chartTemplate, mainTemplate, valuesTemplate } from "./helmTemplates"; export const createRepo = async ( gitApi: IGitApi, @@ -45,6 +59,7 @@ export const manifestRepo = async ( const curFolder = process.cwd(); try { + logger.info(`creating git repo ${repoName} in project ${rc.projectName}`); const git = await createRepo( gitApi, repoName, @@ -57,6 +72,7 @@ export const manifestRepo = async ( await commitAndPushToRemote(git, rc, repoName); rc.scaffoldManifest = true; + logger.info("Completed scaffold Manifest Repo"); } finally { moveToAbsPath(curFolder); @@ -78,6 +94,7 @@ export const hldRepo = async ( const curFolder = process.cwd(); try { + logger.info(`creating git repo ${repoName} in project ${rc.projectName}`); const git = await createRepo( gitApi, repoName, @@ -96,8 +113,146 @@ export const hldRepo = async ( await commitAndPushToRemote(git, rc, repoName); rc.scaffoldHLD = true; + logger.info("Completed scaffold HLD Repo"); } finally { moveToAbsPath(curFolder); } }; + +/** + * Create chart directory and add helm chart files + * + * @param acrName Azure Container Registry Name. + */ +export const createChartArtifacts = (acrName: string): void => { + createDirectory(APP_REPO); + moveToRelativePath(APP_REPO); + createDirectory("chart"); + moveToRelativePath("chart"); + + const chart = chartTemplate.replace("@@CHART_APP_NAME@@", APP_REPO); + fs.writeFileSync(path.join(process.cwd(), "Chart.yaml"), chart); + + const values = valuesTemplate + .replace("@@CHART_APP_NAME@@", APP_REPO) + .replace("@@ACR_NAME@@", acrName); + fs.writeFileSync(path.join(process.cwd(), "values.yaml"), values); + + createDirectory("templates"); + moveToRelativePath("templates"); + + fs.writeFileSync(path.join(process.cwd(), "all-in-one.yaml"), mainTemplate); +}; + +export const helmRepo = async ( + gitApi: IGitApi, + rc: RequestContext +): Promise => { + logger.info("Scaffolding helm Repo"); + const repoName = HELM_REPO; + const curFolder = process.cwd(); + + try { + logger.info(`creating git repo ${repoName} in project ${rc.projectName}`); + const git = await createRepo( + gitApi, + repoName, + rc.projectName, + rc.workspace + ); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + createChartArtifacts(rc.acrName!); + moveToAbsPath(curFolder); + moveToRelativePath(rc.workspace); + moveToRelativePath(repoName); + + await git.add("./*"); + await commitAndPushToRemote(git, rc, repoName); + rc.scaffoldHelm = true; + + logger.info("Completed scaffold helm Repo"); + } finally { + moveToAbsPath(curFolder); + } +}; + +export const setupVariableGroup = async (rc: RequestContext): Promise => { + const accessOpts: AzureDevOpsOpts = { + orgName: rc.orgName, + personalAccessToken: rc.accessToken, + project: rc.projectName + }; + + await deleteVariableGroup(accessOpts, VARIABLE_GROUP); + await createVariableGroup( + VARIABLE_GROUP, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + rc.acrName!, + HLD_DEFAULT_GIT_URL, + rc.servicePrincipalId, + rc.servicePrincipalPassword, + rc.servicePrincipalTenantId, + accessOpts + ); + logger.info(`Successfully created variable group, ${VARIABLE_GROUP}`); + + setVariableGroupInBedrockFile(".", VARIABLE_GROUP); + updateLifeCyclePipeline("."); +}; + +export const initService = async (repoName: string): Promise => { + await createService(".", repoName, { + displayName: repoName, + gitPush: false, + helmChartChart: "", + helmChartRepository: "", + helmConfigAccessTokenVariable: "ACCESS_TOKEN_SECRET", + helmConfigBranch: "master", + helmConfigGit: HLD_DEFAULT_GIT_URL, + helmConfigPath: `${repoName}/chart`, + k8sBackend: "", + k8sBackendPort: "80", + k8sPort: 0, + maintainerEmail: "", + maintainerName: "", + middlewares: "", + middlewaresArray: [], + packagesDir: "", + pathPrefix: "", + pathPrefixMajorVersion: "", + ringNames: ["master"], + variableGroups: [VARIABLE_GROUP] + }); +}; + +export const appRepo = async ( + gitApi: IGitApi, + rc: RequestContext +): Promise => { + logger.info("Scaffolding app Repo"); + const repoName = APP_REPO; + const curFolder = process.cwd(); + + try { + logger.info(`creating git repo ${repoName} in project ${rc.projectName}`); + const git = await createRepo( + gitApi, + repoName, + rc.projectName, + rc.workspace + ); + + await projectInitialize("."); + await git.add("./*"); + await commitAndPushToRemote(git, rc, repoName); + + await setupVariableGroup(rc); + await initService(repoName); + + rc.scaffoldAppService = true; + logger.info("Completed scaffold app Repo"); + } finally { + moveToAbsPath(curFolder); + } +}; diff --git a/src/lib/setup/setupLog.test.ts b/src/lib/setup/setupLog.test.ts index 0b2f0508a..2c98a7220 100644 --- a/src/lib/setup/setupLog.test.ts +++ b/src/lib/setup/setupLog.test.ts @@ -21,7 +21,10 @@ const positiveTest = (logExist?: boolean, withAppCreation = false): void => { createdResourceGroup: false, orgName: "orgName", projectName: "projectName", + acrName: "testacr", + scaffoldAppService: true, scaffoldHLD: true, + scaffoldHelm: true, scaffoldManifest: true, subscriptionId: "72f988bf-86f1-41af-91ab-2d7cd011db48", workspace: "workspace" @@ -51,9 +54,12 @@ const positiveTest = (logExist?: boolean, withAppCreation = false): void => { "az_sp_password=********", "az_sp_tenant=72f988bf-86f1-41af-91ab-2d7cd011db47", "az_subscription_id=72f988bf-86f1-41af-91ab-2d7cd011db48", + "az_acr_name=testacr", "workspace: workspace", "Project Created: yes", "High Level Definition Repo Scaffolded: yes", + "Helm Repo Scaffolded: yes", + "Sample App Repo Scaffolded: yes", "Manifest Repo Scaffolded: yes", "HLD to Manifest Pipeline Created: yes", "Service Principal Created: no", @@ -72,9 +78,12 @@ const positiveTest = (logExist?: boolean, withAppCreation = false): void => { "az_sp_password=", "az_sp_tenant=", "az_subscription_id=72f988bf-86f1-41af-91ab-2d7cd011db48", + "az_acr_name=testacr", "workspace: workspace", "Project Created: yes", "High Level Definition Repo Scaffolded: yes", + "Helm Repo Scaffolded: yes", + "Sample App Repo Scaffolded: yes", "Manifest Repo Scaffolded: yes", "HLD to Manifest Pipeline Created: yes", "Service Principal Created: no", @@ -113,7 +122,9 @@ describe("test create function", () => { error: "things broke", orgName: "orgName", projectName: "projectName", + scaffoldAppService: true, scaffoldHLD: true, + scaffoldHelm: true, scaffoldManifest: true, workspace: "workspace" }, @@ -131,9 +142,12 @@ describe("test create function", () => { "az_sp_password=", "az_sp_tenant=", "az_subscription_id=", + "az_acr_name=", "workspace: workspace", "Project Created: yes", "High Level Definition Repo Scaffolded: yes", + "Helm Repo Scaffolded: yes", + "Sample App Repo Scaffolded: yes", "Manifest Repo Scaffolded: yes", "HLD to Manifest Pipeline Created: yes", "Service Principal Created: no", diff --git a/src/lib/setup/setupLog.ts b/src/lib/setup/setupLog.ts index edef69523..d685c18c5 100644 --- a/src/lib/setup/setupLog.ts +++ b/src/lib/setup/setupLog.ts @@ -21,9 +21,12 @@ export const create = (rc: RequestContext | undefined, file?: string): void => { `az_sp_password=${rc.servicePrincipalPassword ? "********" : ""}`, `az_sp_tenant=${rc.servicePrincipalTenantId || ""}`, `az_subscription_id=${rc.subscriptionId || ""}`, + `az_acr_name=${rc.acrName || ""}`, `workspace: ${rc.workspace}`, `Project Created: ${getBooleanVal(rc.createdProject)}`, `High Level Definition Repo Scaffolded: ${getBooleanVal(rc.scaffoldHLD)}`, + `Helm Repo Scaffolded: ${getBooleanVal(rc.scaffoldHelm)}`, + `Sample App Repo Scaffolded: ${getBooleanVal(rc.scaffoldAppService)}`, `Manifest Repo Scaffolded: ${getBooleanVal(rc.scaffoldManifest)}`, `HLD to Manifest Pipeline Created: ${getBooleanVal( rc.createdHLDtoManifestPipeline diff --git a/src/lib/validator.test.ts b/src/lib/validator.test.ts index 388a2f688..619c64f50 100644 --- a/src/lib/validator.test.ts +++ b/src/lib/validator.test.ts @@ -9,6 +9,7 @@ import { isPortNumberString, ORG_NAME_VIOLATION, validateAccessToken, + validateACRName, validateForNonEmptyValue, validateOrgName, validatePrereqs, @@ -230,3 +231,22 @@ describe("test validateSubscriptionId function", () => { expect(validateSubscriptionId("abc123-456")).toBeTruthy(); }); }); + +describe("test validateACRName function", () => { + it("sanity test", () => { + expect(validateACRName("")).toBe( + "Must enter an Azure Container Registry Name." + ); + expect(validateACRName("xyz-")).toBe( + "The value for Azure Container Registry Name is invalid." + ); + expect(validateACRName("1")).toBe( + "The value for Azure Container Registry Name is invalid because it has to be between 5 and 50 characters long." + ); + expect(validateACRName("1234567890a".repeat(10))).toBe( + "The value for Azure Container Registry Name is invalid because it has to be between 5 and 50 characters long." + ); + + expect(validateACRName("abc12356")).toBeTruthy(); + }); +}); diff --git a/src/lib/validator.ts b/src/lib/validator.ts index 31ab3331e..2d6f69f54 100644 --- a/src/lib/validator.ts +++ b/src/lib/validator.ts @@ -116,6 +116,10 @@ export const isDashHex = (value: string): boolean => { return !!value.match(/^[a-f0-9-]+$/); }; +export const isAlphaNumeric = (value: string): boolean => { + return !!value.match(/^[a-zA-Z0-9]+$/); +}; + /** * Returns true if project name is proper. * @@ -238,3 +242,21 @@ export const validateSubscriptionId = (value: string): string | boolean => { } return true; }; + +/** + * Returns true if Azure Container Registry Name is valid + * + * @param value Azure Container Registry Name. + */ +export const validateACRName = (value: string): string | boolean => { + if (!hasValue(value)) { + return "Must enter an Azure Container Registry Name."; + } + if (!isAlphaNumeric(value)) { + return "The value for Azure Container Registry Name is invalid."; + } + if (value.length < 5 || value.length > 50) { + return "The value for Azure Container Registry Name is invalid because it has to be between 5 and 50 characters long."; + } + return true; +};