diff --git a/Tasks/ContainerStructureTestV0/Strings/resources.resjson/en-US/resources.resjson b/Tasks/ContainerStructureTestV0/Strings/resources.resjson/en-US/resources.resjson index 040cbf9bfd7e..bcc490b06a22 100644 --- a/Tasks/ContainerStructureTestV0/Strings/resources.resjson/en-US/resources.resjson +++ b/Tasks/ContainerStructureTestV0/Strings/resources.resjson/en-US/resources.resjson @@ -1,6 +1,6 @@ { "loc.friendlyName": "Container Structure Test", - "loc.helpMarkDown": "[Learn more about this task](https://go.microsoft.com/fwlink/?LinkID=613742)", + "loc.helpMarkDown": "[Learn more about this task](https://aka.ms/containerstructuretest)", "loc.description": "Uses container-structure-test (https://github.com/GoogleContainerTools/container-structure-test) to validate the structure of an image based on four categories of tests - command tests, file existence tests, file content tests and metadata tests", "loc.instanceNameFormat": "Container Structure Test $(testFile)", "loc.group.displayName.containerRepository": "Container Repository", @@ -12,6 +12,8 @@ "loc.input.help.tag": "The tag is used in pulling the image from docker registry service connection", "loc.input.label.configFile": "Config file path", "loc.input.help.configFile": "Config files path, that contains container structure tests. Either .yaml or .json files", + "loc.input.label.testRunTitle": "Test run title", + "loc.input.help.testRunTitle": "Provide a name for the Test Run.", "loc.input.label.failTaskOnFailedTests": "Fail task if there are test failures", "loc.input.help.failTaskOnFailedTests": "Fail the task if there are any test failures. Check this option to fail the task if test failures are detected.", "loc.messages.NoMatchingFilesFound": "No test files matching '%s' were found.", diff --git a/Tasks/ContainerStructureTestV0/containerregistry.ts b/Tasks/ContainerStructureTestV0/containerregistry.ts new file mode 100644 index 000000000000..c9a967e6f383 --- /dev/null +++ b/Tasks/ContainerStructureTestV0/containerregistry.ts @@ -0,0 +1,32 @@ +import RegistryAuthenticationToken from "docker-common-v2/registryauthenticationprovider/registryauthenticationtoken"; +import ContainerConnection from "docker-common-v2/containerconnection"; +import { getDockerRegistryEndpointAuthenticationToken } from "docker-common-v2/registryauthenticationprovider/registryauthenticationtoken"; +import * as dockerCommandUtils from "docker-common-v2/dockercommandutils"; + + +export class ContainerRegistry { + constructor(registryConnection: string) { + let registryAuthenticationToken: RegistryAuthenticationToken = getDockerRegistryEndpointAuthenticationToken(registryConnection); + this.connection = new ContainerConnection(); + this.connection.open(null, registryAuthenticationToken, true, false); + } + + public getQualifiedImageName(repository: string, tag: string){ + return `${this.connection.getQualifiedImageName(repository, true)}:${tag}` + } + + public async pull(repository: string, tag: string): Promise { + return new Promise((resolve, reject) => { + try { + const imageName = this.getQualifiedImageName(repository, tag); + dockerCommandUtils.command(this.connection, "pull", imageName, (output: any) => { + resolve(output); + }) + } catch (error) { + reject(error); + } + }); + } + + private connection: ContainerConnection; +} \ No newline at end of file diff --git a/Tasks/ContainerStructureTestV0/containerstructuretest.ts b/Tasks/ContainerStructureTestV0/containerstructuretest.ts index 837d51e44a47..ce0f71307b7f 100644 --- a/Tasks/ContainerStructureTestV0/containerstructuretest.ts +++ b/Tasks/ContainerStructureTestV0/containerstructuretest.ts @@ -1,36 +1,22 @@ import * as tl from 'azure-pipelines-task-lib/task'; -import { chmodSync, writeFileSync, existsSync } from 'fs'; import * as path from "path"; -import downloadutility = require("utility-common/downloadutility"); -import RegistryAuthenticationToken from "docker-common-v2/registryauthenticationprovider/registryauthenticationtoken"; -import ContainerConnection from "docker-common-v2/containerconnection"; -import { getDockerRegistryEndpointAuthenticationToken } from "docker-common-v2/registryauthenticationprovider/registryauthenticationtoken"; -import * as dockerCommandUtils from "docker-common-v2/dockercommandutils"; -let uuid = require('uuid'); - -interface TestSummary { - "Total": number; - "Pass": number; - "Fail": number; - "Results": TestResult[]; - -} - -interface TestResult { - "Name": string; - "Pass": boolean; - "Errors": string[] | undefined; -} +import { WebResponse } from 'utility-common-v2/restutilities'; +import { ContainerRegistry } from "./containerregistry"; +import { TestResultPublisher, TestSummary } from "./testresultspublisher"; +import { TestRunner } from "./testrunner"; const telemetryArea: string = 'TestExecution'; const telemetryFeature: string = 'PublishTestResultsTask'; const telemetryData: { [key: string]: any; } = <{ [key: string]: any; }>{}; - +const defaultRunTitlePrefix: string = 'ContainerStructureTest_TestResults_'; +const buildString = "build"; +const hostType = tl.getVariable("System.HostType").toLowerCase(); +const isBuild = hostType === buildString; +const osType = tl.osType().toLowerCase(); async function run() { let taskResult = true; try { - const osType = tl.osType().toLowerCase(); tl.debug(`Os Type: ${osType}`); telemetryData["OsType"] = osType; @@ -38,44 +24,50 @@ async function run() { throw new Error(tl.loc('NotSupportedOS', osType)); } + const artifactId = isBuild ? parseInt(tl.getVariable("Build.BuildId")) : parseInt(tl.getVariable("Release.ReleaseId")); const testFilePath = tl.getInput('configFile', true); const repository = tl.getInput('repository', true); + const testRunTitleInput = tl.getInput('testRunTitle'); + const endpointId = tl.getInput("dockerRegistryServiceConnection"); let tagInput = tl.getInput('tag'); const tag = tagInput ? tagInput : "latest"; - - const image = `${repository}:${tag}`; - let endpointId = tl.getInput("dockerRegistryServiceConnection"); + const testRunTitle = testRunTitleInput ? testRunTitleInput : `${defaultRunTitlePrefix}${artifactId}`; const failTaskOnFailedTests: boolean = tl.getInput('failTaskOnFailedTests').toLowerCase() == 'true' ? true : false; tl.setResourcePath(path.join(__dirname, 'task.json')); - let registryAuthenticationToken: RegistryAuthenticationToken = getDockerRegistryEndpointAuthenticationToken(endpointId); - let connection = new ContainerConnection(); - connection.open(null, registryAuthenticationToken, true, false); + // Establishing registry connection and pulling the container. + let containerRegistry = new ContainerRegistry(endpointId); tl.debug(`Successfully finished docker login`); - - await dockerPull(connection, image); + const image = `${containerRegistry.getQualifiedImageName(repository, tag)}`; + tl.debug(`Image: ${image}`) + await containerRegistry.pull(repository, tag); tl.debug(`Successfully finished docker pull`); - const downloadUrl = getContainerStructureTestRunnerDownloadPath(osType); - if (!downloadUrl) { - return; - } - - tl.debug(`Successfully downloaded : ${downloadUrl}`); - const runnerPath = await downloadTestRunner(downloadUrl); - const output: string = runContainerStructureTest(runnerPath, testFilePath, image); + // Running the container structure test on the above pulled container. + const testRunner = new TestRunner(testFilePath, image); + let resultObj: TestSummary = await testRunner.Run(); - if (!output || output.length <= 0) { - throw new Error("No output from runner"); + // Publishing the test results to TCM. + // Not failing task if there are any errors while publishing. + let testResultPublisher = new TestResultPublisher(); + try { + testResultPublisher.publishToTcm(resultObj, testRunTitle); + telemetryData["TCMPublishStatus"] = true; + tl.debug("Finished publishing the test results to TCM"); + } catch(error) { + telemetryData["TCMPublishError"] = error; } - tl.debug(`Successfully finished testing`); - let resultObj: TestSummary = JSON.parse(output); - tl.debug(`Total Tests: ${resultObj.Total}, Pass: ${resultObj.Pass}, Fail: ${resultObj.Fail}`); - - publishTheTestResultsToTCM(output); - publishTestResultsToMetadataStore(resultObj); + // Publishing the test results to Metadata Store. + try { + var response:WebResponse = await testResultPublisher.publishToMetaDataStore(resultObj, image); + console.log(`Publishing test data to metadata store. Status: ${response.statusCode} and Message : ${response.statusMessage}`) + tl.debug(`Response from publishing the test details to MetaData store: ${JSON.stringify(response)}`); + telemetryData["MetaDataPublishStatus"] = true; + } catch(error) { + telemetryData["MetaDataPublishError"] = error; + } if (failTaskOnFailedTests && resultObj && resultObj.Fail > 0) { taskResult = false; @@ -92,106 +84,6 @@ async function run() { } } -async function dockerPull(connection: ContainerConnection, imageName: string): Promise { - return new Promise((resolve, reject) => { - try { - dockerCommandUtils.command(connection, "pull", imageName, (output: any) => { - resolve(output); - }) - } catch (error) { - reject(error); - } - }); -} - -function createResultsFile(fileContent: string): string { - let resultFilePath: string = null; - try { - const agentTempDirectory = tl.getVariable('Agent.TempDirectory'); - resultFilePath = path.join(agentTempDirectory, uuid.v1() + '.json'); - - writeFileSync(resultFilePath, fileContent); - } catch (ex) { - tl.warning(`Exception while creating results file: ${ex}`); - return null; - } - - return resultFilePath; -} - -function getContainerStructureTestRunnerDownloadPath(osType: string): string { - switch (osType) { - case 'darwin': - return "https://storage.googleapis.com/container-structure-test/latest/container-structure-test-darwin-amd64"; - case 'linux': - return "https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-amd64"; - default: - return null; - } -} - -async function downloadTestRunner(downloadUrl: string): Promise { - const gcst = path.join(__dirname, "container-structure-test"); - return downloadutility.download(downloadUrl, gcst, false, true).then((res) => { - chmodSync(gcst, "777"); - if (!existsSync(gcst)) { - tl.error(tl.loc('FileNotFoundException', path)); - throw new Error(tl.loc('FileNotFoundException', path)); - } - telemetryData["DownloadStatus"] = true; - return gcst; - }).catch((reason) => { - telemetryData["DownloadStatus"] = false; - telemetryData["DownloadError"] = reason; - tl.error(tl.loc('DownloadException', reason)); - throw new Error(tl.loc('DownloadException', reason)); - }) -} - -function runContainerStructureTest(runnerPath: string, testFilePath: string, image: string): string { - let command = tl.tool(runnerPath); - command.arg(["test", "--image", image, "--config", testFilePath, "--json"]); - - const output = command.execSync(); - let jsonOutput: string; - - if (!output.error) { - jsonOutput = output.stdout; - } else { - tl.error(tl.loc('ErrorInExecutingCommand', output.error)); - throw new Error(tl.loc('ErrorInExecutingCommand', output.error)); - } - - tl.debug("Standard Output: " + output.stdout); - tl.debug("Standard Error: " + output.stderr); - tl.debug("Error from command executor: " + output.error); - tl.debug("Return code from command executor: " + output.code); - - return jsonOutput; -} - -function publishTheTestResultsToTCM(jsonResutlsString: string) { - let resultsFile = createResultsFile(jsonResutlsString); - - if (!resultsFile) { - tl.warning("Unable to create the resutls file, hence not publishg the test results"); - return; - } - - var properties = <{ [key: string]: string }>{}; - properties['type'] = "ContainerStructure"; - properties['mergeResults'] = "false"; - properties['runTitle'] = "Container Structure Tests"; - properties['resultFiles'] = resultsFile; - properties['testRunSystem'] = "VSTS-PTR"; - - tl.command('results.publish', properties, ''); - telemetryData["TCMPublishStatus"] = true; -} - -function publishTestResultsToMetadataStore(testSummary: TestSummary) { -} - function publishTelemetry() { try { console.log(`##vso[telemetry.publish area=${telemetryArea};feature=${telemetryFeature}]${JSON.stringify(telemetryData)}`); diff --git a/Tasks/ContainerStructureTestV0/make.json b/Tasks/ContainerStructureTestV0/make.json index 2f85e2f45558..3b2ff526a7a3 100644 --- a/Tasks/ContainerStructureTestV0/make.json +++ b/Tasks/ContainerStructureTestV0/make.json @@ -19,6 +19,12 @@ "type": "node", "dest": "./", "compile": true + }, + { + "module": "../Common/utility-common-v2", + "type": "node", + "dest" : "./", + "compile" : true } ] } \ No newline at end of file diff --git a/Tasks/ContainerStructureTestV0/package-lock.json b/Tasks/ContainerStructureTestV0/package-lock.json index e521fd01a402..722b40dfe5c9 100644 --- a/Tasks/ContainerStructureTestV0/package-lock.json +++ b/Tasks/ContainerStructureTestV0/package-lock.json @@ -14,6 +14,19 @@ "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.2.tgz", "integrity": "sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw==" }, + "@types/semver": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-5.5.0.tgz", + "integrity": "sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ==" + }, + "@types/uuid": { + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-3.4.5.tgz", + "integrity": "sha512-MNL15wC3EKyw1VLF+RoVO4hJJdk9t/Hlv3rt1OL65Qvuadm4BYo6g9ZJQqoq7X8NBFSsQXgAujWciovh2lpVjA==", + "requires": { + "@types/node": "*" + } + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -53,6 +66,36 @@ "uuid": "^3.0.1" } }, + "azure-pipelines-tool-lib": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/azure-pipelines-tool-lib/-/azure-pipelines-tool-lib-0.12.0.tgz", + "integrity": "sha512-JAlFvMTtEXISrnJY/kgq0LecLi089RqXRf/gMsXYbflmzszklkc+LUJpR0A7NDmJ+9/MWpKY/ZX+Q/zirYa7gw==", + "requires": { + "@types/semver": "^5.3.0", + "@types/uuid": "^3.0.1", + "azure-pipelines-task-lib": "^2.8.0", + "semver": "^5.3.0", + "semver-compare": "^1.0.0", + "typed-rest-client": "1.0.9", + "uuid": "^3.0.1" + }, + "dependencies": { + "typed-rest-client": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.0.9.tgz", + "integrity": "sha512-iOdwgmnP/tF6Qs+oY4iEtCf/3fnCDl7Gy9LGPJ4E3M4Wj3uaSko15FVwbsaBmnBqTJORnXBWVY5306D4HH8oiA==", + "requires": { + "tunnel": "0.0.4", + "underscore": "1.8.3" + } + }, + "underscore": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz", + "integrity": "sha1-Tz+1OxBuYJf8+ctBCfKl6b36UCI=" + } + } + }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -336,6 +379,33 @@ } } }, + "utility-common-v2": { + "version": "file:../../_build/Tasks/Common/utility-common-v2-2.0.0.tgz", + "requires": { + "azure-pipelines-task-lib": "2.8.0", + "azure-pipelines-tool-lib": "0.12.0", + "js-yaml": "3.6.1", + "semver": "^5.4.1", + "vso-node-api": "6.5.0" + }, + "dependencies": { + "underscore": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz", + "integrity": "sha1-Tz+1OxBuYJf8+ctBCfKl6b36UCI=" + }, + "vso-node-api": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/vso-node-api/-/vso-node-api-6.5.0.tgz", + "integrity": "sha512-hFjPLMJkq02zF8U+LhZ4airH0ivaiKzGdlNAQlYFB3lWuGH/UANUrl63DVPUQOyGw+7ZNQ+ufM44T6mWN92xyg==", + "requires": { + "tunnel": "0.0.4", + "typed-rest-client": "^0.12.0", + "underscore": "1.8.3" + } + } + } + }, "uuid": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", diff --git a/Tasks/ContainerStructureTestV0/package.json b/Tasks/ContainerStructureTestV0/package.json index 29c4dc74d189..e504fcd2243e 100644 --- a/Tasks/ContainerStructureTestV0/package.json +++ b/Tasks/ContainerStructureTestV0/package.json @@ -22,6 +22,7 @@ "azure-pipelines-task-lib": "2.8.0", "docker-common-v2": "file:../../_build/Tasks/Common/docker-common-v2-1.0.0.tgz", "utility-common": "file:../../_build/Tasks/Common/utility-common-1.0.2.tgz", + "utility-common-v2": "file:../../_build/Tasks/Common/utility-common-v2-2.0.0.tgz", "uuid": "^3.0.1" } } diff --git a/Tasks/ContainerStructureTestV0/task.json b/Tasks/ContainerStructureTestV0/task.json index 7993432aae25..8e9657a68e3c 100644 --- a/Tasks/ContainerStructureTestV0/task.json +++ b/Tasks/ContainerStructureTestV0/task.json @@ -62,6 +62,14 @@ "required": true, "helpMarkDown": "Config files path, that contains container structure tests. Either .yaml or .json files" }, + { + "name": "testRunTitle", + "type": "string", + "label": "Test run title", + "defaultValue": "", + "required": false, + "helpMarkDown": "Provide a name for the Test Run." + }, { "name": "failTaskOnFailedTests", "type": "boolean", diff --git a/Tasks/ContainerStructureTestV0/task.loc.json b/Tasks/ContainerStructureTestV0/task.loc.json index d35ebfdc0670..35a858100d84 100644 --- a/Tasks/ContainerStructureTestV0/task.loc.json +++ b/Tasks/ContainerStructureTestV0/task.loc.json @@ -62,6 +62,14 @@ "required": true, "helpMarkDown": "ms-resource:loc.input.help.configFile" }, + { + "name": "testRunTitle", + "type": "string", + "label": "ms-resource:loc.input.label.testRunTitle", + "defaultValue": "", + "required": false, + "helpMarkDown": "ms-resource:loc.input.help.testRunTitle" + }, { "name": "failTaskOnFailedTests", "type": "boolean", diff --git a/Tasks/ContainerStructureTestV0/testresultspublisher.ts b/Tasks/ContainerStructureTestV0/testresultspublisher.ts new file mode 100644 index 000000000000..b9cdb1757fa8 --- /dev/null +++ b/Tasks/ContainerStructureTestV0/testresultspublisher.ts @@ -0,0 +1,225 @@ +import { WebRequest, sendRequest } from 'utility-common-v2/restutilities'; +let uuid = require('uuid'); +import * as tl from 'azure-pipelines-task-lib/task'; +import * as dockerCommandUtils from "docker-common-v2/dockercommandutils"; +import { writeFileSync } from 'fs'; +import * as path from "path"; + + +export interface TestSummary { + "Total": number; + "Pass": number; + "Fail": number; + "Results": TestResult[]; + "Duration": number; +} + +export interface TestResult { + "Name": string; + "Pass": boolean; + "Errors": string[] | undefined; +} + +interface TestAttestation { + "testId": string; + "testTool": string; + "testResult": TestResultAttestation; + "testDurationSeconds": number; + "testPassPercentage": string; + "relatedUrls": RelatedUrls[]; +} + +interface TestResultAttestation { + "total": number; + "passed": number; + "failed": number; + "skipped": number; +} + +interface RelatedUrls { + "url": string; + "label": string; +} + +export class TestResultPublisher { + + public publishToTcm(testResults: TestSummary, testRunTitle: string) { + let resultsFile = this.createResultsFile(JSON.stringify(testResults)); + + if (!resultsFile) { + tl.warning("Unable to create the results file, hence not publishing the test results"); + return; + } + try { + var properties = <{ [key: string]: string }>{}; + properties['type'] = this.testRunType; + properties['mergeResults'] = "false"; + properties['runTitle'] = testRunTitle; + properties['resultFiles'] = resultsFile; + properties['testRunSystem'] = this.testRunSystem; + properties['publishRunAttachments'] = "true"; + + tl.command('results.publish', properties, ''); + tl.debug("Finished publishing the test results to TCM"); + } catch(error) { + tl.debug(`Unable to publish the test results because of ${error}`); + throw error; + } + } + + public async publishToMetaDataStore(testSummary: TestSummary, imageName: string): Promise { + return new Promise(async (resolve, reject) => { + try{ + const request = new WebRequest(); + const accessToken: string = tl.getEndpointAuthorizationParameter('SYSTEMVSSCONNECTION', 'ACCESSTOKEN', false); + const requestUrl = tl.getVariable("System.TeamFoundationCollectionUri") + tl.getVariable("System.TeamProject") + "/_apis/deployment/attestationdetails?api-version=5.2-preview.1"; + const requestBody = this.getMetadataStoreUploadPayload(testSummary, imageName) + + request.uri = requestUrl; + request.method = 'POST'; + request.body = JSON.stringify(requestBody); + request.headers = { + "Content-Type": "application/json", + "Authorization": "Bearer " + accessToken + }; + + tl.debug("requestUrl: " + requestUrl); + tl.debug("requestBody: " + JSON.stringify(requestBody)); + + try { + const response = await sendRequest(request); + tl.debug("Finished publishing the test results to MetaData Store"); + resolve(response); + } + catch (error) { + tl.debug(`Unable to push to attestation Details to MetaData Store, Error: ${error}`); + reject(error); + } + + } catch(error) { + tl.debug(`Unable to push the attestation details to MetaData Store: ${error}`) + reject(error); + } + }); + } + + private createResultsFile(fileContent: string): string { + let resultFilePath: string = null; + try { + const agentTempDirectory = tl.getVariable('Agent.TempDirectory'); + resultFilePath = path.join(agentTempDirectory, uuid.v1() + '.json'); + + writeFileSync(resultFilePath, fileContent); + } catch (ex) { + tl.warning(`Exception while creating results file: ${ex}`); + return null; + } + + return resultFilePath; + } + + private getAttestationName(): string { + return `projects/${tl.getVariable("System.TeamProject")}/notes/${uuid.v1()}` + } + + private getTestTabUrl(): string { + var pipeLineUrl = dockerCommandUtils.getPipelineLogsUrl(); + var testTabUrl = ""; + if (this.isBuild) { + testTabUrl = pipeLineUrl + `&view=${this.testTabViewIdInBuild}`; + } else { + pipeLineUrl = pipeLineUrl + `&environmentId=${tl.getVariable("Release.EnvironmentId")}`; + testTabUrl = pipeLineUrl + `&extensionId=${this.testTabViewIdInRelease}&_a=release-environment-extension` + } + + return testTabUrl; + } + + private getResourceUri(imageName: string): string { + let inspectOutput = tl.execSync("docker", ["image", "inspect", imageName]); + let imageDetails = JSON.parse(inspectOutput.stdout); + let repoDigest = imageDetails[0].RepoDigests[0] as string; + let digest = repoDigest.split(":")[1]; + let resourceName = this.getResourceName(imageName, digest); + return resourceName + } + + private getMetadataStoreUploadPayload(testSummary: TestSummary, imageName: string): any { + const testPassPercentage = (testSummary.Pass/testSummary.Total) * 100; + const resourceUri = this.getResourceUri(imageName); + tl.debug(`Resource URI: ${resourceUri}`); + + const testSummaryJson: TestAttestation = { + testId: "ContainerStructureTestV0", + testTool: "container-structure-test", + testResult: { + total: testSummary.Total, + passed: testSummary.Pass, + failed: testSummary.Fail, + skipped: 0 + } as TestResultAttestation, + testDurationSeconds: testSummary.Duration, + testPassPercentage: testPassPercentage.toString(), + relatedUrls: [ + { + url: this.getTestTabUrl(), + label: "test-results-url" + }, + { + url: dockerCommandUtils.getPipelineLogsUrl(), + label: "pipeline-run-url" + } + ] + }; + + return { + name: this.getAttestationName(), + description: "Test Results from Container structure test", + resourceUri:[resourceUri], + kind: "ATTESTATION", + relatedUrl: [ + { + url: dockerCommandUtils.getPipelineUrl(), + label: "pipeline-url" + } + ], + humanReadableName: "Container Structure test results", + serializedPayload: JSON.stringify(testSummaryJson) + }; + } + + private getResourceName(image: string, digest: string): string { + var match = image.match(/^(?:([^\/]+)\/)?(?:([^\/]+)\/)?([^@:\/]+)(?:[@:](.+))?$/); + if (!match) { + return null; + } + + var registry = match[1]; + var namespace = match[2]; + var repository = match[3]; + var tag = match[4]; + + if (!namespace && registry && !/[:.]/.test(registry)) { + namespace = registry + registry = 'docker.io' + } + + if (!namespace && !registry) { + registry = 'docker.io' + namespace = 'library' + } + + registry = registry ? registry + '/' : ''; + namespace = namespace ? namespace + '/' : ''; + + return "https://" + registry + namespace + repository + "@sha256:" + digest; + } + + private readonly testRunType:string = "ContainerStructure"; + private readonly testRunSystem:string = "VSTS-PTR"; + private readonly testTabViewIdInBuild = "ms.vss-test-web.build-test-results-tab"; + private readonly testTabViewIdInRelease = "ms.vss-test-web.test-result-in-release-environment-editor-tab"; + private readonly buildString = "build"; + private readonly hostType = tl.getVariable("System.HostType").toLowerCase(); + private readonly isBuild = this.hostType === this.buildString; +} \ No newline at end of file diff --git a/Tasks/ContainerStructureTestV0/testrunner.ts b/Tasks/ContainerStructureTestV0/testrunner.ts new file mode 100644 index 000000000000..d445c6baf8c0 --- /dev/null +++ b/Tasks/ContainerStructureTestV0/testrunner.ts @@ -0,0 +1,94 @@ +import { TestSummary } from "./testresultspublisher"; +import * as tl from 'azure-pipelines-task-lib/task'; +import { chmodSync, existsSync } from 'fs'; +import * as path from "path"; +import downloadutility = require("utility-common/downloadutility"); + +export class TestRunner { + constructor(testFilePath: string, imageName: string) { + this.testFilePath = testFilePath; + this.imageName = imageName; + } + + public async Run(): Promise { + return new Promise(async (resolve, reject) => { + try { + const runnerDownloadUrl = this.getContainerStructureTestRunnerDownloadPath(this.osType); + if (!runnerDownloadUrl) { + throw new Error("Unable to get runner download path"); + } + + const runnerPath = await this.downloadTestRunner(runnerDownloadUrl); + tl.debug(`Successfully downloaded : ${runnerDownloadUrl}`); + + var start = new Date().getTime(); + const output: string = this.runContainerStructureTest(runnerPath, this.testFilePath, this.imageName); + var end = new Date().getTime(); + + if (!output || output.length <= 0) { + throw new Error("No output from runner"); + } + + tl.debug(`Successfully finished testing`); + let resultObj: TestSummary = JSON.parse(output); + resultObj.Duration = end-start; + console.log(`Total Tests: ${resultObj.Total}, Pass: ${resultObj.Pass}, Fail: ${resultObj.Fail}`); + resolve(resultObj); + } catch (error) { + reject(error) + } + }); + } + + private getContainerStructureTestRunnerDownloadPath(osType: string): string { + switch (osType) { + case 'darwin': + return "https://storage.googleapis.com/container-structure-test/latest/container-structure-test-darwin-amd64"; + case 'linux': + return "https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-amd64"; + default: + return null; + } + } + + private async downloadTestRunner(downloadUrl: string): Promise { + const gcst = path.join(__dirname, "container-structure-test"); + return downloadutility.download(downloadUrl, gcst, false, true).then((res) => { + chmodSync(gcst, "777"); + if (!existsSync(gcst)) { + tl.error(tl.loc('FileNotFoundException', path)); + throw new Error(tl.loc('FileNotFoundException', path)); + } + return gcst; + }).catch((reason) => { + tl.error(tl.loc('DownloadException', reason)); + throw new Error(tl.loc('DownloadException', reason)); + }) + } + + private runContainerStructureTest(runnerPath: string, testFilePath: string, image: string): string { + let command = tl.tool(runnerPath); + command.arg(["test", "--image", image, "--config", testFilePath, "--json"]); + + const output = command.execSync(); + let jsonOutput: string; + + if (!output.error) { + jsonOutput = output.stdout; + } else { + tl.error(tl.loc('ErrorInExecutingCommand', output.error)); + throw new Error(tl.loc('ErrorInExecutingCommand', output.error)); + } + + tl.debug("Standard Output: " + output.stdout); + tl.debug("Standard Error: " + output.stderr); + tl.debug("Error from command executor: " + output.error); + tl.debug("Return code from command executor: " + output.code); + + return jsonOutput; + } + + private readonly testFilePath: string; + private readonly imageName: string; + private readonly osType = tl.osType().toLowerCase(); +} \ No newline at end of file