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 all commits
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>;
}
97 changes: 97 additions & 0 deletions Tasks/DownloadBuildArtifact/Downloader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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;
let logProgress = fileCount < 100 ? logProgressFilename : logProgressPercentage;

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;
}

logProgress(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);
}
});
}
resolve();
}
catch (err) {
reject(err);
}
}));
}

await Promise.all(downloaders);
}

function logProgressFilename(relativePath: string, outputFilename: string, fileIndex: number, fileCount: number): void {
console.log(tl.loc("DownloadingFile", relativePath, outputFilename, fileIndex, fileCount));
}

function logProgressPercentage(relativePath: string, outputFilename: string, fileIndex: number, fileCount: number): void {
let percentage = (fileIndex / fileCount) * 100;
if (Math.floor(percentage) > Math.floor(((fileIndex - 1) / fileCount) * 100)) {
console.log(tl.loc("DownloadingPercentage", percentage.toFixed(2), fileIndex, fileCount));
}
}
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"))
}
}
15 changes: 15 additions & 0 deletions Tasks/DownloadBuildArtifact/FilePath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as tl from 'vsts-task-lib/task';

import {BuildArtifact} from 'vso-node-api/interfaces/BuildInterfaces';

import {ArtifactProvider} from './ArtifactProvider';

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

public async downloadArtifact(artifact: BuildArtifact, targetPath: string): Promise<void> {
throw new Error(tl.loc("FilePathNotSupported"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"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.FilePathNotSupported": "File share artifacts are not yet supported in the early preview. Coming soon.",
"loc.messages.ArtifactProviderNotFound": "Could not determine a provider to download artifact of type %s",
"loc.messages.DownloadError": "Error downloading file %s",
"loc.messages.DownloadingFile": "Downloading '%s' to '%s' (file %d of %d)",
"loc.messages.DownloadingPercentage": "Downloading... %d%% (%d of %d)",
"loc.messages.InvalidBuildId": "Invalid build id specified (%s)"
}
Binary file added Tasks/DownloadBuildArtifact/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions Tasks/DownloadBuildArtifact/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
53 changes: 53 additions & 0 deletions Tasks/DownloadBuildArtifact/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import path = require('path');

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';
import {FilePathProvider} from './FilePath';

async function main(): Promise<void> {
try {
tl.setResourcePath(path.join(__dirname, 'task.json'));

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(),
new FilePathProvider()
];

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"
}
}
65 changes: 65 additions & 0 deletions Tasks/DownloadBuildArtifact/task.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
{
"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": 57
},
"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",
"FilePathNotSupported": "File share artifacts are not yet supported in the early preview. Coming soon.",
"ArtifactProviderNotFound": "Could not determine a provider to download artifact of type %s",
"DownloadError": "Error downloading file %s",
"DownloadingFile": "Downloading '%s' to '%s' (file %d of %d)",
"DownloadingPercentage": "Downloading... %d%% (%d of %d)",
"InvalidBuildId": "Invalid build id specified (%s)"
}
}
Loading