Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DownloadBuildArtifact: FileContainer #4521

Merged
merged 4 commits into from
Jun 16, 2017
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Tasks/DownloadBuildArtifact/ArtifactProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {BuildArtifact} from 'vso-node-api/interfaces/BuildInterfaces';

export interface ArtifactProvider {
supportsArtifactType(artifactType: string): boolean;
downloadArtifact(artifact: BuildArtifact, targetPath: string): Promise<void>;
}
84 changes: 84 additions & 0 deletions Tasks/DownloadBuildArtifact/Downloader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import * as path from 'path';
import * as tl from 'vsts-task-lib/task';
import * as fs from 'fs';

/**
* Represents an item to be downloaded
*/
export interface DownloadItem<T> {
/**
* The path to the item, relative to the target path
*/
relativePath: string;

/**
* Artifact-specific data
*/
data: T;
}

/**
* Downloads items
* @param items the items to download
* @param targetPath the folder that will hold the downloaded items
* @param maxConcurrency the maximum number of items to download simultaneously
* @param downloader a function that, given a DownloadItem, will return a content stream
*/
export async function download<T>(items: DownloadItem<T>[], targetPath: string, maxConcurrency: number, downloader: (item: DownloadItem<T>) => Promise<fs.ReadStream>): Promise<void> {
// keep track of folders we've touched so we don't call mkdirP for every single file
let createdFolders: { [key: string]: boolean } = {};
let downloaders: Promise<{}>[] = [];
let fileCount: number = items.length;

maxConcurrency = Math.min(maxConcurrency, items.length);
for (let i = 0; i < maxConcurrency; ++i) {
downloaders.push(new Promise(async (resolve, reject) => {
try {
while (items.length > 0) {
let item = items.pop();
let fileIndex = fileCount - items.length;

// the full path of the downloaded file
let outputFilename = path.join(targetPath, item.relativePath);

// create the folder if necessary
let folder = path.dirname(outputFilename);
if (!createdFolders.hasOwnProperty(folder)) {
if (!tl.exist(folder)) {
tl.mkdirP(folder);
}
createdFolders[folder] = true;
}

console.log(tl.loc("DownloadingFile", item.relativePath, outputFilename, fileIndex, fileCount));
await new Promise(async (downloadResolve, downloadReject) => {
try {
// get the content stream from the provider
let contentStream = await downloader(item);

// create the target stream
let outputStream = fs.createWriteStream(outputFilename);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens if the file exists?

what if the path exists as a directory, will this produce a weird non-intuitive error?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the file exists, it is overwritten. if it's a folder and we're trying to write a file to it, on windows we get EISDIR: illegal operation on a directory, open 'e:\test\downloadbuildartifact\drop\ConsoleApplication1\bin\Release\ConsoleApplication1.pdb'


// pipe the content to the target
contentStream.pipe(outputStream);
contentStream.on('end', () => {
tl.debug(`Downloaded '${item.relativePath}' to '${outputFilename}'`);
downloadResolve();
});
}
catch (err) {
console.log(tl.loc("DownloadError", item.relativePath));
downloadReject(err);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to know which file failed?

}
});
}
resolve();
}
catch (err) {
reject(err);
}
}));
}

await Promise.all(downloaders);
}
80 changes: 80 additions & 0 deletions Tasks/DownloadBuildArtifact/FileContainer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import * as tl from 'vsts-task-lib/task';
import * as fs from 'fs';
import * as path from 'path';

import {BuildArtifact} from 'vso-node-api/interfaces/BuildInterfaces';
import {FileContainerItem, ContainerItemType} from 'vso-node-api/interfaces/FileContainerInterfaces';
import {IFileContainerApi} from 'vso-node-api/FileContainerApi';
import {WebApi, getHandlerFromToken} from 'vso-node-api/WebApi';

import {DownloadItem, download} from './Downloader';
import {ArtifactProvider} from './ArtifactProvider';

export class FileContainerProvider implements ArtifactProvider {
public supportsArtifactType(artifactType: string): boolean {
return !!artifactType && artifactType.toLowerCase() === "container";
}

public async downloadArtifact(artifact: BuildArtifact, targetPath: string): Promise<void> {
if (!artifact || !artifact.resource || !artifact.resource.data) {
throw new Error(tl.loc("FileContainerInvalidArtifact"));
}

let containerParts: string[] = artifact.resource.data.split('/', 3);
if (containerParts.length !== 3) {
throw new Error(tl.loc("FileContainerInvalidArtifactData"));
}

let containerId: number = parseInt(containerParts[1]);
let containerPath: string = containerParts[2];

let accessToken = tl.getEndpointAuthorizationParameter('SYSTEMVSSCONNECTION', 'AccessToken', false);
let credentialHandler = getHandlerFromToken(accessToken);
let collectionUrl = tl.getEndpointUrl('SYSTEMVSSCONNECTION', false);
let vssConnection = new WebApi(collectionUrl, credentialHandler);

let fileContainerApi = vssConnection.getFileContainerApi();

// get all items
let items: FileContainerItem[] = await fileContainerApi.getItems(containerId, null, containerPath, false, null, null, false, false);

// ignore folders
items = items.filter(item => item.itemType === ContainerItemType.File);
tl.debug(`Found ${items.length} File items in #/${containerId}/${containerPath}`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File is capitalized (intentional?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, intentional, as I'm referring to the itemType value. Items are "File" or "Folder", IIRC. Although I suppose that value isn't relevant to your average build watcher...


let downloadItems: DownloadItem<FileContainerItem>[] = items.map((item) => {
return {
relativePath: item.path,
data: item
};
})

// download the items
await download(downloadItems, targetPath, 8, (item: DownloadItem<FileContainerItem>) => new Promise(async (resolve, reject) => {
try {
let downloadFilename = item.data.path.substring(item.data.path.lastIndexOf("/") + 1);
let itemResponse = await fileContainerApi.getItem(containerId, null, item.data.path, downloadFilename);
if (itemResponse.statusCode === 200) {
resolve(itemResponse.result);
}
else {
// TODO: decide whether to retry or bail
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

forget about this todo? or will this land in a future PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Future PR. I need to evaluate the different ways things might fail, and a retry strategy.

reject(itemResponse);
}
}
catch (err) {
reject(err);
}
}));
}
}

function getAuthToken() {
let auth = tl.getEndpointAuthorization('SYSTEMVSSCONNECTION', false);
if (auth.scheme.toLowerCase() === 'oauth') {
return auth.parameters['AccessToken'];
}
else {
throw new Error(tl.loc("CredentialsNotFound"))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"loc.friendlyName": "Download Build Artifact",
"loc.helpMarkDown": "",
"loc.description": "Download a build artifact.",
"loc.instanceNameFormat": "Download build artifact $(artifactName)",
"loc.input.label.buildId": "Build",
"loc.input.help.buildId": "The build from which to download the artifact",
"loc.input.label.artifactName": "Artifact name",
"loc.input.help.artifactName": "The name of the artifact to download",
"loc.input.label.downloadPath": "Destination directory",
"loc.input.help.downloadPath": "Path on the agent machine where the artifact will be downloaded",
"loc.messages.FileContainerCredentialsNotFound": "Could not determine credentials to connect to file container service.",
"loc.messages.FileContainerInvalidArtifact": "Invalid file container artifact",
"loc.messages.FileContainerInvalidArtifactData": "Invalid file container artifact. Resource data must be in the format #/{container id}/path",
"loc.messages.ArtifactProviderNotFound": "Could not determine a provider to download artifact of type {0}",
"loc.messages.DownloadError": "Error downloading file {0}",
"loc.messages.DownloadingFile": "Downloading '{0}' to '{1}' (file {2}/{3})",
"loc.messages.InvalidBuildId": "Invalid build id specified ({0})"
}
47 changes: 47 additions & 0 deletions Tasks/DownloadBuildArtifact/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {BuildArtifact, ArtifactResource} from 'vso-node-api/interfaces/BuildInterfaces';
import {WebApi, getHandlerFromToken} from 'vso-node-api/WebApi';
import * as tl from 'vsts-task-lib/task';

import {ArtifactProvider} from './ArtifactProvider';
import {FileContainerProvider} from './FileContainer';

async function main(): Promise<void> {
try {
let projectId = tl.getVariable('System.TeamProjectId');
let artifactName = tl.getInput("artifactName");
let downloadPath = tl.getPathInput("downloadPath");
let buildId = parseInt(tl.getInput("buildId"));

if (isNaN(buildId)) {
throw new Error(tl.loc("InvalidBuildId", tl.getInput("buildId")));
}

let accessToken = tl.getEndpointAuthorizationParameter('SYSTEMVSSCONNECTION', 'AccessToken', false);
let credentialHandler = getHandlerFromToken(accessToken);
let collectionUrl = tl.getEndpointUrl('SYSTEMVSSCONNECTION', false);
let vssConnection = new WebApi(collectionUrl, credentialHandler);

// get the artifact metadata
let buildApi = vssConnection.getBuildApi();
let artifact = await buildApi.getArtifact(buildId, artifactName, projectId);

let providers: ArtifactProvider[] = [
new FileContainerProvider()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want a fileshare provider that throws in the downloadArtifact method. I want an clear message that "File share artifacts are not yet supported in the early preview. Coming soon.". I don't want it to fall through and tell the customer file shares aren't supported (confusing and mixed message.

];

let provider = providers.filter((provider) => provider.supportsArtifactType(artifact.resource.type))[0];
if (provider) {
await provider.downloadArtifact(artifact, downloadPath);
}
else {
throw new Error(tl.loc("ArtifactProviderNotFound", artifact.resource.type));
}

tl.setResult(tl.TaskResult.Succeeded, "");
}
catch (err) {
tl.setResult(tl.TaskResult.Failed, err);
}
}

main();
24 changes: 24 additions & 0 deletions Tasks/DownloadBuildArtifact/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "downloadbuildartifact",
"version": "0.1.0",
"description": "Download Build Artifact Task",
"main": "download.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Microsoft/vsts-tasks.git"
},
"author": "Microsoft Corporation",
"license": "MIT",
"bugs": {
"url": "https://github.com/Microsoft/vsts-tasks/issues"
},
"homepage": "https://github.com/Microsoft/vsts-tasks#readme",
"dependencies": {
"@types/node": "^6.0.78",
"vso-node-api": "^6.2.5-preview",
"vsts-task-lib": "^2.0.3-preview"
}
}
63 changes: 63 additions & 0 deletions Tasks/DownloadBuildArtifact/task.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"id": "a433f589-fce1-4460-9ee6-44a624aeb1fb",
"name": "DownloadBuildArtifact",
"friendlyName": "Download Build Artifact",
"description": "Download a build artifact.",
"helpMarkDown": "",
"category": "Utility",
"author": "Microsoft Corporation",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

preview: true

"preview": true,
"version": {
"Major": 0,
"Minor": 1,
"Patch": 48
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we start from 0?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, but why patch level 48?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was iterating. Does it matter where we start it? I can bump it down to 0.0.1 if it's really important.

},
"demands": [],
"inputs": [
{
"name": "buildId",
"type": "pickList",
"label": "Build",
"required": false,
"helpMarkDown": "The build from which to download the artifact",
"defaultValue": "$(Build.BuildId)",
"options": {
"$(Build.BuildId)": "The current build"
}
},
{
"name": "artifactName",
"type": "string",
"label": "Artifact name",
"defaultValue": "drop",
"required": true,
"helpMarkDown": "The name of the artifact to download"
},
{
"name": "downloadPath",
"type": "string",
"label": "Destination directory",
"defaultValue": "$(System.ArtifactsDirectory)",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RM extension in the agent doesn't set this variable

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems like a bug. It is a System.* variable. It also seems problematic to hope all extensions set something like that. Shouldn't the agent just set it since it's a system level thing? The agent should define the a dir then RM extension should respect and use it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should definately default to this though

"required": true,
"helpMarkDown": "Path on the agent machine where the artifact will be downloaded"
}
],
"dataSourceBindings": [
],
"instanceNameFormat": "Download build artifact $(artifactName)",
"execution": {
"Node": {
"target": "main.js",
"argumentFormat": ""
}
},
"messages": {
"FileContainerCredentialsNotFound": "Could not determine credentials to connect to file container service.",
"FileContainerInvalidArtifact": "Invalid file container artifact",
"FileContainerInvalidArtifactData": "Invalid file container artifact. Resource data must be in the format #/{container id}/path",
"ArtifactProviderNotFound": "Could not determine a provider to download artifact of type {0}",
"DownloadError": "Error downloading file {0}",
"DownloadingFile": "Downloading '{0}' to '{1}' (file {2}/{3})",
"InvalidBuildId": "Invalid build id specified ({0})"
}
}
62 changes: 62 additions & 0 deletions Tasks/DownloadBuildArtifact/task.loc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"id": "a433f589-fce1-4460-9ee6-44a624aeb1fb",
"name": "DownloadBuildArtifact",
"friendlyName": "ms-resource:loc.friendlyName",
"description": "ms-resource:loc.description",
"helpMarkDown": "ms-resource:loc.helpMarkDown",
"category": "Utility",
"author": "Microsoft Corporation",
"preview": true,
"version": {
"Major": 0,
"Minor": 1,
"Patch": 48
},
"demands": [],
"inputs": [
{
"name": "buildId",
"type": "pickList",
"label": "ms-resource:loc.input.label.buildId",
"required": false,
"helpMarkDown": "ms-resource:loc.input.help.buildId",
"defaultValue": "$(Build.BuildId)",
"options": {
"$(Build.BuildId)": "The current build"
}
},
{
"name": "artifactName",
"type": "string",
"label": "ms-resource:loc.input.label.artifactName",
"defaultValue": "drop",
"required": true,
"helpMarkDown": "ms-resource:loc.input.help.artifactName"
},
{
"name": "downloadPath",
"type": "string",
"label": "ms-resource:loc.input.label.downloadPath",
"defaultValue": "$(System.ArtifactsDirectory)",
"required": true,
"helpMarkDown": "ms-resource:loc.input.help.downloadPath"
}
],
"dataSourceBindings": [],
"instanceNameFormat": "ms-resource:loc.instanceNameFormat",
"execution": {
"Node": {
"target": "main.js",
"argumentFormat": ""
}
},
"messages": {
"FileContainerCredentialsNotFound": "ms-resource:loc.messages.FileContainerCredentialsNotFound",
"FileContainerInvalidArtifact": "ms-resource:loc.messages.FileContainerInvalidArtifact",
"FileContainerInvalidArtifactData": "ms-resource:loc.messages.FileContainerInvalidArtifactData",
"ArtifactProviderNotFound": "ms-resource:loc.messages.ArtifactProviderNotFound",
"DownloadError": "ms-resource:loc.messages.DownloadError",
"DownloadingFile": "ms-resource:loc.messages.DownloadingFile",
"InvalidBuildId": "ms-resource:loc.messages.InvalidBuildId"
}
}
Loading