-
Notifications
You must be signed in to change notification settings - Fork 2.6k
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
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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>; | ||
} |
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); | ||
|
||
// 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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
} |
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}`); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. File is capitalized (intentional?) There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. forget about this todo? or will this land in a future PR? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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})" | ||
} |
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() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); |
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" | ||
} | ||
} |
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", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. preview: true |
||
"preview": true, | ||
"version": { | ||
"Major": 0, | ||
"Minor": 1, | ||
"Patch": 48 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should we start from 0? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes, but why patch level 48? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. RM extension in the agent doesn't set this variable There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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})" | ||
} | ||
} |
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" | ||
} | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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'