diff --git a/src/commands/infra/generate.test.ts b/src/commands/infra/generate.test.ts index a7142bbf3..ea95b1b12 100644 --- a/src/commands/infra/generate.test.ts +++ b/src/commands/infra/generate.test.ts @@ -197,11 +197,9 @@ describe("test gitClone function", () => { describe("Validate remote git source", () => { test("Validating that a git source is cloned to .spk/templates", async () => { - jest - .spyOn(generate, "checkRemoteGitExist") - .mockReturnValueOnce(Promise.resolve()); - jest.spyOn(generate, "gitFetchPull").mockReturnValueOnce(Promise.resolve()); - jest.spyOn(generate, "gitCheckout").mockReturnValueOnce(Promise.resolve()); + jest.spyOn(generate, "checkRemoteGitExist").mockResolvedValueOnce(); + jest.spyOn(generate, "gitFetchPull").mockResolvedValueOnce(); + jest.spyOn(generate, "gitCheckout").mockResolvedValueOnce(); const mockParentPath = "src/commands/infra/mocks/discovery-service"; const mockProjectPath = "src/commands/infra/mocks/discovery-service/west"; @@ -272,13 +270,11 @@ describe("fetch execute function", () => { .spyOn(generate, "validateDefinition") .mockReturnValueOnce(DefinitionYAMLExistence.PARENT_ONLY); jest.spyOn(generate, "validateTemplateSources").mockReturnValueOnce({}); - jest - .spyOn(generate, "validateRemoteSource") - .mockReturnValueOnce(Promise.resolve()); + jest.spyOn(generate, "validateRemoteSource").mockResolvedValueOnce(); jest .spyOn(infraCommon, "getSourceFolderNameFromURL") .mockImplementationOnce(() => { - throw new Error("Fake"); + throw Error("Fake"); }); const exitFn = jest.fn(); await execute( @@ -296,9 +292,7 @@ describe("fetch execute function", () => { jest .spyOn(generate, "validateDefinition") .mockReturnValueOnce(DefinitionYAMLExistence.BOTH_EXIST); - jest - .spyOn(generate, "validateRemoteSource") - .mockReturnValueOnce(Promise.resolve()); + jest.spyOn(generate, "validateRemoteSource").mockResolvedValueOnce(); jest.spyOn(generate, "validateTemplateSources").mockReturnValueOnce({}); jest .spyOn(generate, "generateConfig") @@ -324,11 +318,9 @@ describe("test validateRemoteSource function", () => { jest .spyOn(infraCommon, "getSourceFolderNameFromURL") .mockReturnValueOnce("sourceFolder"); - jest - .spyOn(generate, "checkRemoteGitExist") - .mockReturnValueOnce(Promise.resolve()); - jest.spyOn(generate, "gitClone").mockReturnValueOnce(Promise.resolve()); - jest.spyOn(generate, "gitCheckout").mockReturnValueOnce(Promise.resolve()); + jest.spyOn(generate, "checkRemoteGitExist").mockResolvedValueOnce(); + jest.spyOn(generate, "gitClone").mockResolvedValueOnce(); + jest.spyOn(generate, "gitCheckout").mockResolvedValueOnce(); await validateRemoteSource({ source: "source", @@ -339,17 +331,11 @@ describe("test validateRemoteSource function", () => { jest .spyOn(infraCommon, "getSourceFolderNameFromURL") .mockReturnValueOnce("sourceFolder"); - jest - .spyOn(generate, "checkRemoteGitExist") - .mockReturnValueOnce(Promise.resolve()); + jest.spyOn(generate, "checkRemoteGitExist").mockResolvedValueOnce(); jest .spyOn(generate, "gitClone") - .mockReturnValueOnce( - Promise.reject(new Error("refusing to merge unrelated histories")) - ); - jest - .spyOn(generate, "retryRemoteValidate") - .mockReturnValueOnce(Promise.resolve()); + .mockRejectedValueOnce(Error("refusing to merge unrelated histories")); + jest.spyOn(generate, "retryRemoteValidate").mockResolvedValueOnce(); await validateRemoteSource({ source: "source", @@ -360,15 +346,11 @@ describe("test validateRemoteSource function", () => { jest .spyOn(infraCommon, "getSourceFolderNameFromURL") .mockReturnValueOnce("sourceFolder"); - jest - .spyOn(generate, "checkRemoteGitExist") - .mockReturnValueOnce(Promise.resolve()); + jest.spyOn(generate, "checkRemoteGitExist").mockResolvedValueOnce(); jest .spyOn(generate, "gitClone") - .mockReturnValueOnce(Promise.reject(new Error("Authentication failed"))); - jest - .spyOn(generate, "retryRemoteValidate") - .mockReturnValueOnce(Promise.resolve()); + .mockRejectedValueOnce(Error("Authentication failed")); + jest.spyOn(generate, "retryRemoteValidate").mockResolvedValueOnce(); await validateRemoteSource({ source: "source", @@ -379,15 +361,11 @@ describe("test validateRemoteSource function", () => { jest .spyOn(infraCommon, "getSourceFolderNameFromURL") .mockReturnValueOnce("sourceFolder"); - jest - .spyOn(generate, "checkRemoteGitExist") - .mockReturnValueOnce(Promise.resolve()); + jest.spyOn(generate, "checkRemoteGitExist").mockResolvedValueOnce(); jest .spyOn(generate, "gitClone") - .mockReturnValueOnce(Promise.reject(new Error("other error"))); - jest - .spyOn(generate, "retryRemoteValidate") - .mockReturnValueOnce(Promise.resolve()); + .mockRejectedValueOnce(Error("other error")); + jest.spyOn(generate, "retryRemoteValidate").mockResolvedValueOnce(); try { await validateRemoteSource({ @@ -396,9 +374,7 @@ describe("test validateRemoteSource function", () => { }); expect(true).toBe(false); } catch (err) { - expect(err.message).toBe( - "Failure error thrown during retry Error: Unable to determine error from supported retry cases other error" - ); + expect(err.errorCode).toBe(1100); } }); }); @@ -413,21 +389,11 @@ describe("test retryRemoteValidate function", () => { }); it("negative test", async () => { jest.spyOn(fsExtra, "removeSync").mockReturnValueOnce(); - jest - .spyOn(generate, "gitClone") - .mockReturnValueOnce(Promise.reject(new Error("error"))); + jest.spyOn(generate, "gitClone").mockRejectedValueOnce(Error("error")); - try { - await retryRemoteValidate( - "source", - "sourcePath", - "safeLoggingUrl", - "0.1" - ); - expect(true).toBe(false); - } catch (err) { - expect(err).toBeDefined(); - } + await expect( + retryRemoteValidate("source", "sourcePath", "safeLoggingUrl", "0.1") + ).rejects.toThrow(); }); }); diff --git a/src/commands/infra/generate.ts b/src/commands/infra/generate.ts index 10421a9d8..8a8aff8d6 100644 --- a/src/commands/infra/generate.ts +++ b/src/commands/infra/generate.ts @@ -22,6 +22,8 @@ import { spkTemplatesPath, } from "./infra_common"; import { copyTfTemplate } from "./scaffold"; +import { build as buildError } from "../../lib/errorBuilder"; +import { errorStatusCode } from "../../lib/errorStatusCode"; interface CommandOptions { project: string | undefined; @@ -182,14 +184,16 @@ export const checkRemoteGitExist = async ( ): Promise => { // Checking for git remote if (!fs.existsSync(sourcePath)) { - throw new Error(`${sourcePath} does not exist`); + throw buildError(errorStatusCode.GIT_OPS_ERR, { + errorKey: "infra-git-source-no-exist", + values: [sourcePath], + }); } const result = await simpleGit(sourcePath).listRemote([source]); if (!result) { logger.error(result); - throw new Error(`Unable to clone the source remote repository. \ -The remote repo may not exist or you do not have the rights to access it`); + throw buildError(errorStatusCode.GIT_OPS_ERR, "infra-err-git-clone-failed"); } logger.info(`Remote source repo: ${safeLoggingUrl} exists.`); @@ -287,13 +291,21 @@ export const validateRemoteSource = async ( version ); } else { - throw new Error( - `Unable to determine error from supported retry cases ${err.message}` + throw buildError( + errorStatusCode.GIT_OPS_ERR, + "infra-err-validating-remote-git", + err ); } } catch (retryError) { - throw new Error(`Failure error thrown during retry ${retryError}`); + throw buildError( + errorStatusCode.GIT_OPS_ERR, + "infra-err-retry-validating-remote-git", + err + ); } + } else { + throw err; } } }; diff --git a/src/commands/infra/scaffold.test.ts b/src/commands/infra/scaffold.test.ts index 5040797a5..bca73bcdb 100644 --- a/src/commands/infra/scaffold.test.ts +++ b/src/commands/infra/scaffold.test.ts @@ -170,12 +170,12 @@ describe("test validate function", () => { ); expect(true).toBe(false); } catch (err) { - expect(err.message).toBe("Value for source is missing."); + expect(err.errorCode).toBe(1001); } }); it("name, template, version is missing", () => { ["name", "template", "version"].forEach((key) => { - try { + expect(() => { validateValues( {}, { @@ -185,12 +185,7 @@ describe("test validate function", () => { version: key === "version" ? "" : uuid(), } ); - expect(true).toBe(false); - } catch (err) { - expect(err.message).toBe( - "Values for name, version and/or 'template are missing." - ); - } + }).toThrow(); }); }); }); diff --git a/src/commands/infra/scaffold.ts b/src/commands/infra/scaffold.ts index 682a2cffe..c1d8ef09e 100644 --- a/src/commands/infra/scaffold.ts +++ b/src/commands/infra/scaffold.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ import commander from "commander"; import fs from "fs"; import fsextra from "fs-extra"; @@ -19,6 +18,8 @@ import { VARIABLES_TF, } from "./infra_common"; import decorator from "./scaffold.decorator.json"; +import { build as buildError, log as logError } from "../../lib/errorBuilder"; +import { errorStatusCode } from "../../lib/errorStatusCode"; export interface CommandOptions { name: string; @@ -47,11 +48,17 @@ template repo and access token was not specified in spk-config.yml. Checking pas if (!opts.source) { // since access_token and infra_repository are missing, we cannot construct source for them - throw new Error("Value for source is missing."); + throw buildError( + errorStatusCode.VALIDATION_ERR, + "infra-scaffold-cmd-src-missing" + ); } } if (!opts.name || !opts.version || !opts.template) { - throw new Error("Values for name, version and/or 'template are missing."); + throw buildError( + errorStatusCode.VALIDATION_ERR, + "infra-scaffold-cmd-values-missing" + ); } logger.info(`All required options are configured via command line for \ scaffolding, expecting public remote repository for terraform templates \ @@ -60,6 +67,8 @@ or PAT embedded in source URL.`); // Construct the source based on the the passed configurations of spk-config.yaml export const constructSource = (config: ConfigYaml): string => { + // config.azure_devops exists because validateValues function checks it + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const devops = config.azure_devops!; const source = `https://spk:${devops.access_token}@${devops.infra_repository}`; logger.info( @@ -93,10 +102,14 @@ export const copyTfTemplate = async ( } logger.info(`Terraform template files copied from ${templatePath}`); } catch (err) { - logger.error( - `Unable to find Terraform environment. Please check template path.` + throw buildError( + errorStatusCode.ENV_SETTING_ERR, + { + errorKey: "infra-err-locate-tf-env", + values: [templatePath], + }, + err ); - throw err; } }; @@ -106,18 +119,15 @@ export const copyTfTemplate = async ( * @param templatePath Path to the variables.tf file */ export const validateVariablesTf = (templatePath: string): void => { - try { - if (!fs.existsSync(templatePath)) { - throw new Error( - `Provided Terraform ${VARIABLES_TF} path is invalid or cannot be found: ${templatePath}` - ); - } - logger.info( - `Terraform ${VARIABLES_TF} file found. Attempting to generate ${DEFINITION_YAML} file.` - ); - } catch (_) { - throw new Error(`Unable to validate Terraform ${VARIABLES_TF}.`); + if (!fs.existsSync(templatePath)) { + throw buildError(errorStatusCode.ENV_SETTING_ERR, { + errorKey: "infra-err-tf-path-not-found", + values: [VARIABLES_TF, templatePath], + }); } + logger.info( + `Terraform ${VARIABLES_TF} file found. Attempting to generate ${DEFINITION_YAML} file.` + ); }; /** @@ -281,15 +291,18 @@ export const scaffold = (values: CommandOptions): void => { }); fs.writeFileSync(confPath, definitionYaml, "utf8"); } else { - logger.error(`Unable to generate cluster definition.`); + throw Error(`Unable to generate cluster definition.`); } } else { - logger.error(`Unable to read variable file: ${tfVariableFile}.`); + throw Error(`Unable to read variable file: ${tfVariableFile}.`); } } } catch (err) { - logger.warn("Unable to create scaffold"); - throw err; + throw buildError( + errorStatusCode.EXE_FLOW_ERR, + "infra-err-create-scaffold", + err + ); } }; @@ -308,7 +321,7 @@ export const removeTemplateFiles = (envPath: string): void => { fs.unlinkSync(path.join(envPath, f)); }); } catch (e) { - logger.error(`cannot read ${envPath}`); + logger.warn(`cannot read ${envPath}`); // TOFIX: I guess we are ok with files not removed. } }; @@ -342,8 +355,9 @@ export const execute = async ( removeTemplateFiles(opts.name); await exitFn(0); } catch (err) { - logger.error("Error occurred while generating scaffold"); - logger.error(err); + logError( + buildError(errorStatusCode.CMD_EXE_ERR, "infra-scaffold-cmd-failed", err) + ); await exitFn(1); } }; diff --git a/src/lib/errorBuilder.test.ts b/src/lib/errorBuilder.test.ts new file mode 100644 index 000000000..43f8c8ede --- /dev/null +++ b/src/lib/errorBuilder.test.ts @@ -0,0 +1,140 @@ +import { build, log } from "./errorBuilder"; +import i18n from "./i18n.json"; +import { errorStatusCode } from "./errorStatusCode"; + +const errors = i18n.errors; + +describe("test getErrorMessage function", () => { + it("positive test: string", () => { + const oErr = build( + errorStatusCode.CMD_EXE_ERR, + "infra-scaffold-cmd-failed" + ); + expect(oErr.message).toBe( + `infra-scaffold-cmd-failed: ${errors["infra-scaffold-cmd-failed"]}` + ); + }); + it("positive test: object", () => { + const oErr = build(errorStatusCode.CMD_EXE_ERR, { + errorKey: "infra-err-locate-tf-env", + values: ["test"], + }); + expect(oErr.message).toBe( + `infra-err-locate-tf-env: ${errors["infra-err-locate-tf-env"].replace( + "{0}", + "test" + )}` + ); + }); + it("negative test: invalid test", () => { + const oErr = build( + errorStatusCode.CMD_EXE_ERR, + "infra-scaffold-cmd-failedxxxxx" + ); + expect(oErr.message).toBe("infra-scaffold-cmd-failedxxxxx"); + }); +}); + +describe("test build function", () => { + it("positive test: without error", () => { + const err = build(errorStatusCode.CMD_EXE_ERR, "infra-scaffold-cmd-failed"); + expect(err.errorCode).toBe(errorStatusCode.CMD_EXE_ERR); + expect(err.message).toBe( + `infra-scaffold-cmd-failed: ${errors["infra-scaffold-cmd-failed"]}` + ); + expect(err.details).toBeUndefined(); + expect(err.parent).toBeUndefined(); + }); + it("positive test: with Error", () => { + const err = build( + errorStatusCode.CMD_EXE_ERR, + "infra-scaffold-cmd-failed", + Error("test") + ); + expect(err.errorCode).toBe(errorStatusCode.CMD_EXE_ERR); + expect(err.message).toBe( + `infra-scaffold-cmd-failed: ${errors["infra-scaffold-cmd-failed"]}` + ); + expect(err.details).toBe("test"); + expect(err.parent).toBeUndefined(); + }); + it("positive test: with ErrorChain", () => { + const e = build( + errorStatusCode.CMD_EXE_ERR, + "infra-scaffold-cmd-src-missing" + ); + const err = build( + errorStatusCode.CMD_EXE_ERR, + "infra-scaffold-cmd-failed", + e + ); + expect(err.errorCode).toBe(errorStatusCode.CMD_EXE_ERR); + expect(err.message).toBe( + `infra-scaffold-cmd-failed: ${errors["infra-scaffold-cmd-failed"]}` + ); + expect(err.details).toBeUndefined(); + expect(err.parent).toStrictEqual(e); + }); +}); + +describe("test message function", () => { + it("positive test: one error chain", () => { + const messages: string[] = []; + const oError = build( + errorStatusCode.CMD_EXE_ERR, + "infra-scaffold-cmd-src-missing" + ); + oError.messages(messages); + expect(messages).toStrictEqual([ + `code: 1000\nmessage: infra-scaffold-cmd-src-missing: ${errors["infra-scaffold-cmd-src-missing"]}`, + ]); + }); + it("positive test: one error chain with details", () => { + const messages: string[] = []; + const oError = build( + 1000, + "infra-scaffold-cmd-src-missing", + Error("test message") + ); + oError.messages(messages); + expect(messages).toStrictEqual([ + `code: 1000\nmessage: infra-scaffold-cmd-src-missing: ${errors["infra-scaffold-cmd-src-missing"]}\ndetails: test message`, + ]); + }); + it("positive test: multiple error chains", () => { + const messages: string[] = []; + const oError = build( + 1000, + "infra-scaffold-cmd-src-missing", + build( + 1001, + "infra-scaffold-cmd-values-missing", + build( + errorStatusCode.ENV_SETTING_ERR, + "infra-err-validating-remote-git" + ) + ) + ); + oError.messages(messages); + expect(messages).toStrictEqual([ + `code: 1000\nmessage: infra-scaffold-cmd-src-missing: ${errors["infra-scaffold-cmd-src-missing"]}`, + ` code: 1001\n message: infra-scaffold-cmd-values-missing: ${errors["infra-scaffold-cmd-values-missing"]}`, + ` code: 1010\n message: infra-err-validating-remote-git: ${errors["infra-err-validating-remote-git"]}`, + ]); + }); +}); + +describe("test log function", () => { + it("test: Error chain object", () => { + const oError = build( + errorStatusCode.CMD_EXE_ERR, + "infra-scaffold-cmd-failed" + ); + expect(log(oError)).toBe( + `\ncode: 1000\nmessage: infra-scaffold-cmd-failed: ${errors["infra-scaffold-cmd-failed"]}` + ); + }); + it("test: Error object", () => { + expect(log(Error("test message"))).toBe("test message"); + }); +}); diff --git a/src/lib/errorBuilder.ts b/src/lib/errorBuilder.ts new file mode 100644 index 000000000..69521376a --- /dev/null +++ b/src/lib/errorBuilder.ts @@ -0,0 +1,124 @@ +import i18n from "./i18n.json"; +import { errorStatusCode } from "./errorStatusCode"; +import { logger } from "../logger"; + +const errors: { [key: string]: string } = i18n.errors; + +interface ErrorParam { + errorKey: string; + values: string[]; +} +class ErrorChain extends Error { + errorCode: number; + details: string | undefined; + parent: ErrorChain | undefined; + + constructor(code: number, errorInstance: string | ErrorParam) { + super(""); + this.errorCode = code; + this.message = this.getErrorMessage(errorInstance); + } + + /** + * Returns error message + * + * @param errorInstance Error instance + */ + getErrorMessage(errorInstance: string | ErrorParam): string { + let key = ""; + let values: string[] | undefined = undefined; + + if (typeof errorInstance === "string") { + key = errorInstance; + } else { + key = errorInstance.errorKey; + values = errorInstance.values; + } + + // if key is found in i18n json + if (key in errors) { + let results = errors[key]; + if (values) { + values.forEach((val, i) => { + const re = new RegExp("\\{" + i + "}", "g"); + results = results.replace(re, val); + }); + } + return `${key}: ${results}`; + } + return key; + } + /** + * Generates error messages and have them in messages array. + * + * @param messages string of messages + * @param padding Padding to be added to the beginning of messages + */ + messages(results: string[], padding?: string): void { + padding = padding || ""; + let mes = + `${padding}code: ${this.errorCode}\n` + + `${padding}message: ${this.message}`; + if (this.details) { + mes += `\n${padding}details: ${this.details}`; + } + + results.push(mes); + if (this.parent) { + this.parent.messages(results, padding + " "); + } + } +} + +const isErrorChainObject = (o: Error | ErrorChain): boolean => { + return o instanceof ErrorChain; +}; + +/** + * Builds an error object + * + * @param code spk error code + * @param errorKey Error key. e.g. "infra-scaffold-cmd-src-missing" or can be an object + * to support string substitution in error message + * { + * errorKey: "infra-scaffold-cmd-src-missing", + * values: ["someValue"] + * } + * @param error: Parent error object. + */ +export const build = ( + code: errorStatusCode, + errorKey: string | ErrorParam, + error?: Error | ErrorChain +): ErrorChain => { + const oError = new ErrorChain(code, errorKey); + + if (error) { + if (isErrorChainObject(error)) { + oError.parent = error as ErrorChain; + } else { + const e = error as Error; + oError.details = e ? e.message : ""; + } + } + + return oError; +}; + +/** + * Writing error log. + * + * @param err Error object + */ +export const log = (err: Error | ErrorChain): string => { + if (isErrorChainObject(err)) { + const messages: string[] = []; + (err as ErrorChain).messages(messages); + const msg = "\n" + messages.join("\n"); + logger.error(msg); + return msg; + } else { + logger.error(err.message); + return err.message; + } +}; diff --git a/src/lib/errorStatusCode.ts b/src/lib/errorStatusCode.ts new file mode 100644 index 000000000..675b662ea --- /dev/null +++ b/src/lib/errorStatusCode.ts @@ -0,0 +1,10 @@ +// please do not change the status code numbers +// you can add new ones but not changing the existing ones + +export enum errorStatusCode { + CMD_EXE_ERR = 1000, + VALIDATION_ERR = 1001, + EXE_FLOW_ERR = 1002, + ENV_SETTING_ERR = 1010, + GIT_OPS_ERR = 1100, +} diff --git a/src/lib/i18n.json b/src/lib/i18n.json index f39b6e95c..586e26afa 100644 --- a/src/lib/i18n.json +++ b/src/lib/i18n.json @@ -14,5 +14,17 @@ "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)" + }, + "errors": { + "infra-scaffold-cmd-failed": "Scaffold Command was not successfully executed.", + "infra-scaffold-cmd-src-missing": "Value for source is required because it cannot be constructed with properties in spk-config.yaml. Provide value for source.", + "infra-scaffold-cmd-values-missing": "Values for name, version and/or 'template were missing. Provide value for values for them.", + "infra-err-validating-remote-git": "Could not determine error when validating remote git source.", + "infra-err-retry-validating-remote-git": "Failure error thrown during retrying validating remote git source.", + "infra-err-locate-tf-env": "Could not find Terraform environment. Ensure template path {0} exists.", + "infra-err-tf-path-not-found": "Provided Terraform {0} path is invalid or cannot be found: {1}", + "infra-err-create-scaffold": "Could not create scaffold", + "infra-err-git-clone-failed": "Could not clone the source remote repository. The remote repo might not exist or you did not have the rights to access it", + "infra-git-source-no-exist": "Source path, {0} did not exist." } } diff --git a/src/lib/setup/pipelineService.ts b/src/lib/setup/pipelineService.ts index 6f668f427..972edcf51 100644 --- a/src/lib/setup/pipelineService.ts +++ b/src/lib/setup/pipelineService.ts @@ -109,7 +109,7 @@ export const deletePipeline = async ( }; /** - * Returns latest build ststus of pipeline. + * Returns latest build status of pipeline. * * @param buildApi Build API client * @param projectName Project name @@ -130,7 +130,7 @@ export const getPipelineBuild = async ( }; /** - * Polls build ststus of pipeline. + * Polls build status of pipeline. * * @param buildApi Build API client * @param projectName Project name