Path: blob/main/build/azure-pipelines/common/publish.ts
3520 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import fs from 'fs';6import path from 'path';7import { Readable } from 'stream';8import type { ReadableStream } from 'stream/web';9import { pipeline } from 'node:stream/promises';10import yauzl from 'yauzl';11import crypto from 'crypto';12import { retry } from './retry';13import { CosmosClient } from '@azure/cosmos';14import cp from 'child_process';15import os from 'os';16import { Worker, isMainThread, workerData } from 'node:worker_threads';17import { ConfidentialClientApplication } from '@azure/msal-node';18import { BlobClient, BlobServiceClient, BlockBlobClient, ContainerClient, ContainerSASPermissions, generateBlobSASQueryParameters } from '@azure/storage-blob';19import jws from 'jws';20import { clearInterval, setInterval } from 'node:timers';2122export function e(name: string): string {23const result = process.env[name];2425if (typeof result !== 'string') {26throw new Error(`Missing env: ${name}`);27}2829return result;30}3132function hashStream(hashName: string, stream: Readable): Promise<Buffer> {33return new Promise<Buffer>((c, e) => {34const shasum = crypto.createHash(hashName);3536stream37.on('data', shasum.update.bind(shasum))38.on('error', e)39.on('close', () => c(shasum.digest()));40});41}4243interface ReleaseSubmitResponse {44operationId: string;45esrpCorrelationId: string;46code?: string;47message?: string;48target?: string;49innerError?: any;50}5152interface ReleaseActivityInfo {53activityId: string;54activityType: string;55name: string;56status: string;57errorCode: number;58errorMessages: string[];59beginTime?: Date;60endTime?: Date;61lastModifiedAt?: Date;62}6364interface InnerServiceError {65code: string;66details: { [key: string]: string };67innerError?: InnerServiceError;68}6970interface ReleaseError {71errorCode: number;72errorMessages: string[];73}7475const enum StatusCode {76Pass = 'pass',77Aborted = 'aborted',78Inprogress = 'inprogress',79FailCanRetry = 'failCanRetry',80FailDoNotRetry = 'failDoNotRetry',81PendingAnalysis = 'pendingAnalysis',82Cancelled = 'cancelled'83}8485interface ReleaseResultMessage {86activities: ReleaseActivityInfo[];87childWorkflowType: string;88clientId: string;89customerCorrelationId: string;90errorInfo: InnerServiceError;91groupId: string;92lastModifiedAt: Date;93operationId: string;94releaseError: ReleaseError;95requestSubmittedAt: Date;96routedRegion: string;97status: StatusCode;98totalFileCount: number;99totalReleaseSize: number;100version: string;101}102103interface ReleaseFileInfo {104name?: string;105hash?: number[];106sourceLocation?: FileLocation;107sizeInBytes?: number;108hashType?: FileHashType;109fileId?: any;110distributionRelativePath?: string;111partNumber?: string;112friendlyFileName?: string;113tenantFileLocationType?: string;114tenantFileLocation?: string;115signedEngineeringCopyLocation?: string;116encryptedDistributionBlobLocation?: string;117preEncryptedDistributionBlobLocation?: string;118secondaryDistributionHashRequired?: boolean;119secondaryDistributionHashType?: FileHashType;120lastModifiedAt?: Date;121cultureCodes?: string[];122displayFileInDownloadCenter?: boolean;123isPrimaryFileInDownloadCenter?: boolean;124fileDownloadDetails?: FileDownloadDetails[];125}126127interface ReleaseDetailsFileInfo extends ReleaseFileInfo { }128129interface ReleaseDetailsMessage extends ReleaseResultMessage {130clusterRegion: string;131correlationVector: string;132releaseCompletedAt?: Date;133releaseInfo: ReleaseInfo;134productInfo: ProductInfo;135createdBy: UserInfo;136owners: OwnerInfo[];137accessPermissionsInfo: AccessPermissionsInfo;138files: ReleaseDetailsFileInfo[];139comments: string[];140cancellationReason: string;141downloadCenterInfo: DownloadCenterInfo;142}143144145interface ProductInfo {146name?: string;147version?: string;148description?: string;149}150151interface ReleaseInfo {152title?: string;153minimumNumberOfApprovers: number;154properties?: { [key: string]: string };155isRevision?: boolean;156revisionNumber?: string;157}158159type FileLocationType = 'azureBlob';160161interface FileLocation {162type: FileLocationType;163blobUrl: string;164uncPath?: string;165url?: string;166}167168type FileHashType = 'sha256' | 'sha1';169170interface FileDownloadDetails {171portalName: string;172downloadUrl: string;173}174175interface RoutingInfo {176intent?: string;177contentType?: string;178contentOrigin?: string;179productState?: string;180audience?: string;181}182183interface ReleaseFileInfo {184name?: string;185hash?: number[];186sourceLocation?: FileLocation;187sizeInBytes?: number;188hashType?: FileHashType;189fileId?: any;190distributionRelativePath?: string;191partNumber?: string;192friendlyFileName?: string;193tenantFileLocationType?: string;194tenantFileLocation?: string;195signedEngineeringCopyLocation?: string;196encryptedDistributionBlobLocation?: string;197preEncryptedDistributionBlobLocation?: string;198secondaryDistributionHashRequired?: boolean;199secondaryDistributionHashType?: FileHashType;200lastModifiedAt?: Date;201cultureCodes?: string[];202displayFileInDownloadCenter?: boolean;203isPrimaryFileInDownloadCenter?: boolean;204fileDownloadDetails?: FileDownloadDetails[];205}206207interface UserInfo {208userPrincipalName?: string;209}210211interface OwnerInfo {212owner: UserInfo;213}214215interface ApproverInfo {216approver: UserInfo;217isAutoApproved: boolean;218isMandatory: boolean;219}220221interface AccessPermissionsInfo {222mainPublisher?: string;223releasePublishers?: string[];224channelDownloadEntityDetails?: { [key: string]: string[] };225}226227interface DownloadCenterLocaleInfo {228cultureCode?: string;229downloadTitle?: string;230shortName?: string;231shortDescription?: string;232longDescription?: string;233instructions?: string;234additionalInfo?: string;235keywords?: string[];236version?: string;237relatedLinks?: { [key: string]: URL };238}239240interface DownloadCenterInfo {241downloadCenterId: number;242publishToDownloadCenter?: boolean;243publishingGroup?: string;244operatingSystems?: string[];245relatedReleases?: string[];246kbNumbers?: string[];247sbNumbers?: string[];248locales?: DownloadCenterLocaleInfo[];249additionalProperties?: { [key: string]: string };250}251252interface ReleaseRequestMessage {253driEmail: string[];254groupId?: string;255customerCorrelationId: string;256esrpCorrelationId: string;257contextData?: { [key: string]: string };258releaseInfo: ReleaseInfo;259productInfo: ProductInfo;260files: ReleaseFileInfo[];261routingInfo?: RoutingInfo;262createdBy: UserInfo;263owners: OwnerInfo[];264approvers: ApproverInfo[];265accessPermissionsInfo: AccessPermissionsInfo;266jwsToken?: string;267publisherId?: string;268downloadCenterInfo?: DownloadCenterInfo;269}270271function getCertificateBuffer(input: string) {272return Buffer.from(input.replace(/-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----|\n/g, ''), 'base64');273}274275function getThumbprint(input: string, algorithm: string): Buffer {276const buffer = getCertificateBuffer(input);277return crypto.createHash(algorithm).update(buffer).digest();278}279280function getKeyFromPFX(pfx: string): string {281const pfxCertificatePath = path.join(os.tmpdir(), 'cert.pfx');282const pemKeyPath = path.join(os.tmpdir(), 'key.pem');283284try {285const pfxCertificate = Buffer.from(pfx, 'base64');286fs.writeFileSync(pfxCertificatePath, pfxCertificate);287cp.execSync(`openssl pkcs12 -in "${pfxCertificatePath}" -nocerts -nodes -out "${pemKeyPath}" -passin pass:`);288const raw = fs.readFileSync(pemKeyPath, 'utf-8');289const result = raw.match(/-----BEGIN PRIVATE KEY-----[\s\S]+?-----END PRIVATE KEY-----/g)![0];290return result;291} finally {292fs.rmSync(pfxCertificatePath, { force: true });293fs.rmSync(pemKeyPath, { force: true });294}295}296297function getCertificatesFromPFX(pfx: string): string[] {298const pfxCertificatePath = path.join(os.tmpdir(), 'cert.pfx');299const pemCertificatePath = path.join(os.tmpdir(), 'cert.pem');300301try {302const pfxCertificate = Buffer.from(pfx, 'base64');303fs.writeFileSync(pfxCertificatePath, pfxCertificate);304cp.execSync(`openssl pkcs12 -in "${pfxCertificatePath}" -nokeys -out "${pemCertificatePath}" -passin pass:`);305const raw = fs.readFileSync(pemCertificatePath, 'utf-8');306const matches = raw.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g);307return matches ? matches.reverse() : [];308} finally {309fs.rmSync(pfxCertificatePath, { force: true });310fs.rmSync(pemCertificatePath, { force: true });311}312}313314class ESRPReleaseService {315316static async create(317log: (...args: any[]) => void,318tenantId: string,319clientId: string,320authCertificatePfx: string,321requestSigningCertificatePfx: string,322containerClient: ContainerClient,323stagingSasToken: string324) {325const authKey = getKeyFromPFX(authCertificatePfx);326const authCertificate = getCertificatesFromPFX(authCertificatePfx)[0];327const requestSigningKey = getKeyFromPFX(requestSigningCertificatePfx);328const requestSigningCertificates = getCertificatesFromPFX(requestSigningCertificatePfx);329330const app = new ConfidentialClientApplication({331auth: {332clientId,333authority: `https://login.microsoftonline.com/${tenantId}`,334clientCertificate: {335thumbprintSha256: getThumbprint(authCertificate, 'sha256').toString('hex'),336privateKey: authKey,337x5c: authCertificate338}339}340});341342const response = await app.acquireTokenByClientCredential({343scopes: ['https://api.esrp.microsoft.com/.default']344});345346return new ESRPReleaseService(log, clientId, response!.accessToken, requestSigningCertificates, requestSigningKey, containerClient, stagingSasToken);347}348349private static API_URL = 'https://api.esrp.microsoft.com/api/v3/releaseservices/clients/';350351private constructor(352private readonly log: (...args: any[]) => void,353private readonly clientId: string,354private readonly accessToken: string,355private readonly requestSigningCertificates: string[],356private readonly requestSigningKey: string,357private readonly containerClient: ContainerClient,358private readonly stagingSasToken: string359) { }360361async createRelease(version: string, filePath: string, friendlyFileName: string) {362const correlationId = crypto.randomUUID();363const blobClient = this.containerClient.getBlockBlobClient(correlationId);364365this.log(`Uploading ${filePath} to ${blobClient.url}`);366await blobClient.uploadFile(filePath);367this.log('Uploaded blob successfully');368369try {370this.log(`Submitting release for ${version}: ${filePath}`);371const submitReleaseResult = await this.submitRelease(version, filePath, friendlyFileName, correlationId, blobClient);372373this.log(`Successfully submitted release ${submitReleaseResult.operationId}. Polling for completion...`);374375// Poll every 5 seconds, wait 60 minutes max -> poll 60/5*60=720 times376for (let i = 0; i < 720; i++) {377await new Promise(c => setTimeout(c, 5000));378const releaseStatus = await this.getReleaseStatus(submitReleaseResult.operationId);379380if (releaseStatus.status === 'pass') {381break;382} else if (releaseStatus.status === 'aborted') {383this.log(JSON.stringify(releaseStatus));384throw new Error(`Release was aborted`);385} else if (releaseStatus.status !== 'inprogress') {386this.log(JSON.stringify(releaseStatus));387throw new Error(`Unknown error when polling for release`);388}389}390391const releaseDetails = await this.getReleaseDetails(submitReleaseResult.operationId);392393if (releaseDetails.status !== 'pass') {394throw new Error(`Timed out waiting for release: ${JSON.stringify(releaseDetails)}`);395}396397this.log('Successfully created release:', releaseDetails.files[0].fileDownloadDetails![0].downloadUrl);398return releaseDetails.files[0].fileDownloadDetails![0].downloadUrl;399} finally {400this.log(`Deleting blob ${blobClient.url}`);401await blobClient.delete();402this.log('Deleted blob successfully');403}404}405406private async submitRelease(407version: string,408filePath: string,409friendlyFileName: string,410correlationId: string,411blobClient: BlobClient412): Promise<ReleaseSubmitResponse> {413const size = fs.statSync(filePath).size;414const hash = await hashStream('sha256', fs.createReadStream(filePath));415const blobUrl = `${blobClient.url}?${this.stagingSasToken}`;416417const message: ReleaseRequestMessage = {418customerCorrelationId: correlationId,419esrpCorrelationId: correlationId,420driEmail: ['[email protected]'],421createdBy: { userPrincipalName: '[email protected]' },422owners: [{ owner: { userPrincipalName: '[email protected]' } }],423approvers: [{ approver: { userPrincipalName: '[email protected]' }, isAutoApproved: true, isMandatory: false }],424releaseInfo: {425title: 'VS Code',426properties: {427'ReleaseContentType': 'InstallPackage'428},429minimumNumberOfApprovers: 1430},431productInfo: {432name: 'VS Code',433version,434description: 'VS Code'435},436accessPermissionsInfo: {437mainPublisher: 'VSCode',438channelDownloadEntityDetails: {439AllDownloadEntities: ['VSCode']440}441},442routingInfo: {443intent: 'filedownloadlinkgeneration'444},445files: [{446name: path.basename(filePath),447friendlyFileName,448tenantFileLocation: blobUrl,449tenantFileLocationType: 'AzureBlob',450sourceLocation: {451type: 'azureBlob',452blobUrl453},454hashType: 'sha256',455hash: Array.from(hash),456sizeInBytes: size457}]458};459460message.jwsToken = await this.generateJwsToken(message);461462const res = await fetch(`${ESRPReleaseService.API_URL}${this.clientId}/workflows/release/operations`, {463method: 'POST',464headers: {465'Content-Type': 'application/json',466'Authorization': `Bearer ${this.accessToken}`467},468body: JSON.stringify(message)469});470471if (!res.ok) {472const text = await res.text();473throw new Error(`Failed to submit release: ${res.statusText}\n${text}`);474}475476return await res.json() as ReleaseSubmitResponse;477}478479private async getReleaseStatus(releaseId: string): Promise<ReleaseResultMessage> {480const url = `${ESRPReleaseService.API_URL}${this.clientId}/workflows/release/operations/grs/${releaseId}`;481482const res = await retry(() => fetch(url, {483headers: {484'Authorization': `Bearer ${this.accessToken}`485}486}));487488if (!res.ok) {489const text = await res.text();490throw new Error(`Failed to get release status: ${res.statusText}\n${text}`);491}492493return await res.json() as ReleaseResultMessage;494}495496private async getReleaseDetails(releaseId: string): Promise<ReleaseDetailsMessage> {497const url = `${ESRPReleaseService.API_URL}${this.clientId}/workflows/release/operations/grd/${releaseId}`;498499const res = await retry(() => fetch(url, {500headers: {501'Authorization': `Bearer ${this.accessToken}`502}503}));504505if (!res.ok) {506const text = await res.text();507throw new Error(`Failed to get release status: ${res.statusText}\n${text}`);508}509510return await res.json() as ReleaseDetailsMessage;511}512513private async generateJwsToken(message: ReleaseRequestMessage): Promise<string> {514return jws.sign({515header: {516alg: 'RS256',517crit: ['exp', 'x5t'],518// Release service uses ticks, not seconds :roll_eyes: (https://stackoverflow.com/a/7968483)519exp: ((Date.now() + (6 * 60 * 1000)) * 10000) + 621355968000000000,520// Release service uses hex format, not base64url :roll_eyes:521x5t: getThumbprint(this.requestSigningCertificates[0], 'sha1').toString('hex'),522// Release service uses a '.' separated string, not an array of strings :roll_eyes:523x5c: this.requestSigningCertificates.map(c => getCertificateBuffer(c).toString('base64url')).join('.') as any,524},525payload: message,526privateKey: this.requestSigningKey,527});528}529}530531class State {532533private statePath: string;534private set = new Set<string>();535536constructor() {537const pipelineWorkspacePath = e('PIPELINE_WORKSPACE');538const previousState = fs.readdirSync(pipelineWorkspacePath)539.map(name => /^artifacts_processed_(\d+)$/.exec(name))540.filter((match): match is RegExpExecArray => !!match)541.map(match => ({ name: match![0], attempt: Number(match![1]) }))542.sort((a, b) => b.attempt - a.attempt)[0];543544if (previousState) {545const previousStatePath = path.join(pipelineWorkspacePath, previousState.name, previousState.name + '.txt');546fs.readFileSync(previousStatePath, 'utf8').split(/\n/).filter(name => !!name).forEach(name => this.set.add(name));547}548549const stageAttempt = e('SYSTEM_STAGEATTEMPT');550this.statePath = path.join(pipelineWorkspacePath, `artifacts_processed_${stageAttempt}`, `artifacts_processed_${stageAttempt}.txt`);551fs.mkdirSync(path.dirname(this.statePath), { recursive: true });552fs.writeFileSync(this.statePath, [...this.set.values()].map(name => `${name}\n`).join(''));553}554555get size(): number {556return this.set.size;557}558559has(name: string): boolean {560return this.set.has(name);561}562563add(name: string): void {564this.set.add(name);565fs.appendFileSync(this.statePath, `${name}\n`);566}567568[Symbol.iterator](): IterableIterator<string> {569return this.set[Symbol.iterator]();570}571}572573const azdoFetchOptions = {574headers: {575// Pretend we're a web browser to avoid download rate limits576'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0',577'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',578'Accept-Encoding': 'gzip, deflate, br',579'Accept-Language': 'en-US,en;q=0.9',580'Referer': 'https://dev.azure.com',581Authorization: `Bearer ${e('SYSTEM_ACCESSTOKEN')}`582}583};584585export async function requestAZDOAPI<T>(path: string): Promise<T> {586const abortController = new AbortController();587const timeout = setTimeout(() => abortController.abort(), 2 * 60 * 1000);588589try {590const res = await retry(() => fetch(`${e('BUILDS_API_URL')}${path}?api-version=6.0`, { ...azdoFetchOptions, signal: abortController.signal }));591592if (!res.ok) {593throw new Error(`Unexpected status code: ${res.status}`);594}595596return await res.json();597} finally {598clearTimeout(timeout);599}600}601602export interface Artifact {603readonly name: string;604readonly resource: {605readonly downloadUrl: string;606readonly properties: {607readonly artifactsize: number;608};609};610}611612async function getPipelineArtifacts(): Promise<Artifact[]> {613const result = await requestAZDOAPI<{ readonly value: Artifact[] }>('artifacts');614return result.value.filter(a => /^vscode_/.test(a.name) && !/sbom$/.test(a.name));615}616617interface Timeline {618readonly records: {619readonly name: string;620readonly type: string;621readonly state: string;622readonly result: string;623}[];624}625626async function getPipelineTimeline(): Promise<Timeline> {627return await requestAZDOAPI<Timeline>('timeline');628}629630async function downloadArtifact(artifact: Artifact, downloadPath: string): Promise<void> {631const abortController = new AbortController();632const timeout = setTimeout(() => abortController.abort(), 4 * 60 * 1000);633634try {635const res = await fetch(artifact.resource.downloadUrl, { ...azdoFetchOptions, signal: abortController.signal });636637if (!res.ok) {638throw new Error(`Unexpected status code: ${res.status}`);639}640641await pipeline(Readable.fromWeb(res.body as ReadableStream), fs.createWriteStream(downloadPath));642} finally {643clearTimeout(timeout);644}645}646647async function unzip(packagePath: string, outputPath: string): Promise<string[]> {648return new Promise((resolve, reject) => {649yauzl.open(packagePath, { lazyEntries: true, autoClose: true }, (err, zipfile) => {650if (err) {651return reject(err);652}653654const result: string[] = [];655zipfile!.on('entry', entry => {656if (/\/$/.test(entry.fileName)) {657zipfile!.readEntry();658} else {659zipfile!.openReadStream(entry, (err, istream) => {660if (err) {661return reject(err);662}663664const filePath = path.join(outputPath, entry.fileName);665fs.mkdirSync(path.dirname(filePath), { recursive: true });666667const ostream = fs.createWriteStream(filePath);668ostream.on('finish', () => {669result.push(filePath);670zipfile!.readEntry();671});672istream?.on('error', err => reject(err));673istream!.pipe(ostream);674});675}676});677678zipfile!.on('close', () => resolve(result));679zipfile!.readEntry();680});681});682}683684interface Asset {685platform: string;686type: string;687url: string;688mooncakeUrl?: string;689prssUrl?: string;690hash: string;691sha256hash: string;692size: number;693supportsFastUpdate?: boolean;694}695696// Contains all of the logic for mapping details to our actual product names in CosmosDB697function getPlatform(product: string, os: string, arch: string, type: string): string {698switch (os) {699case 'win32':700switch (product) {701case 'client': {702switch (type) {703case 'archive':704return `win32-${arch}-archive`;705case 'setup':706return `win32-${arch}`;707case 'user-setup':708return `win32-${arch}-user`;709default:710throw new Error(`Unrecognized: ${product} ${os} ${arch} ${type}`);711}712}713case 'server':714return `server-win32-${arch}`;715case 'web':716return `server-win32-${arch}-web`;717case 'cli':718return `cli-win32-${arch}`;719default:720throw new Error(`Unrecognized: ${product} ${os} ${arch} ${type}`);721}722case 'alpine':723switch (product) {724case 'server':725return `server-alpine-${arch}`;726case 'web':727return `server-alpine-${arch}-web`;728case 'cli':729return `cli-alpine-${arch}`;730default:731throw new Error(`Unrecognized: ${product} ${os} ${arch} ${type}`);732}733case 'linux':734switch (type) {735case 'snap':736return `linux-snap-${arch}`;737case 'archive-unsigned':738switch (product) {739case 'client':740return `linux-${arch}`;741case 'server':742return `server-linux-${arch}`;743case 'web':744if (arch === 'standalone') {745return 'web-standalone';746}747return `server-linux-${arch}-web`;748default:749throw new Error(`Unrecognized: ${product} ${os} ${arch} ${type}`);750}751case 'deb-package':752return `linux-deb-${arch}`;753case 'rpm-package':754return `linux-rpm-${arch}`;755case 'cli':756return `cli-linux-${arch}`;757default:758throw new Error(`Unrecognized: ${product} ${os} ${arch} ${type}`);759}760case 'darwin':761switch (product) {762case 'client':763if (arch === 'x64') {764return 'darwin';765}766return `darwin-${arch}`;767case 'server':768if (arch === 'x64') {769return 'server-darwin';770}771return `server-darwin-${arch}`;772case 'web':773if (arch === 'x64') {774return 'server-darwin-web';775}776return `server-darwin-${arch}-web`;777case 'cli':778return `cli-darwin-${arch}`;779default:780throw new Error(`Unrecognized: ${product} ${os} ${arch} ${type}`);781}782default:783throw new Error(`Unrecognized: ${product} ${os} ${arch} ${type}`);784}785}786787// Contains all of the logic for mapping types to our actual types in CosmosDB788function getRealType(type: string) {789switch (type) {790case 'user-setup':791return 'setup';792case 'deb-package':793case 'rpm-package':794return 'package';795default:796return type;797}798}799800async function withLease<T>(client: BlockBlobClient, fn: () => Promise<T>) {801const lease = client.getBlobLeaseClient();802803for (let i = 0; i < 360; i++) { // Try to get lease for 30 minutes804try {805await client.uploadData(new ArrayBuffer()); // blob needs to exist for lease to be acquired806await lease.acquireLease(60);807808try {809const abortController = new AbortController();810const refresher = new Promise<void>((c, e) => {811abortController.signal.onabort = () => {812clearInterval(interval);813c();814};815816const interval = setInterval(() => {817lease.renewLease().catch(err => {818clearInterval(interval);819e(new Error('Failed to renew lease ' + err));820});821}, 30_000);822});823824const result = await Promise.race([fn(), refresher]);825abortController.abort();826return result;827} finally {828await lease.releaseLease();829}830} catch (err) {831if (err.statusCode !== 409 && err.statusCode !== 412) {832throw err;833}834835await new Promise(c => setTimeout(c, 5000));836}837}838839throw new Error('Failed to acquire lease on blob after 30 minutes');840}841842async function processArtifact(843artifact: Artifact,844filePath: string845) {846const log = (...args: any[]) => console.log(`[${artifact.name}]`, ...args);847const match = /^vscode_(?<product>[^_]+)_(?<os>[^_]+)(?:_legacy)?_(?<arch>[^_]+)_(?<unprocessedType>[^_]+)$/.exec(artifact.name);848849if (!match) {850throw new Error(`Invalid artifact name: ${artifact.name}`);851}852853const { cosmosDBAccessToken, blobServiceAccessToken } = JSON.parse(e('PUBLISH_AUTH_TOKENS'));854const quality = e('VSCODE_QUALITY');855const version = e('BUILD_SOURCEVERSION');856const friendlyFileName = `${quality}/${version}/${path.basename(filePath)}`;857858const blobServiceClient = new BlobServiceClient(`https://${e('VSCODE_STAGING_BLOB_STORAGE_ACCOUNT_NAME')}.blob.core.windows.net/`, { getToken: async () => blobServiceAccessToken });859const leasesContainerClient = blobServiceClient.getContainerClient('leases');860await leasesContainerClient.createIfNotExists();861const leaseBlobClient = leasesContainerClient.getBlockBlobClient(friendlyFileName);862863log(`Acquiring lease for: ${friendlyFileName}`);864865await withLease(leaseBlobClient, async () => {866log(`Successfully acquired lease for: ${friendlyFileName}`);867868const url = `${e('PRSS_CDN_URL')}/${friendlyFileName}`;869const res = await retry(() => fetch(url));870871if (res.status === 200) {872log(`Already released and provisioned: ${url}`);873} else {874const stagingContainerClient = blobServiceClient.getContainerClient('staging');875await stagingContainerClient.createIfNotExists();876877const now = new Date().valueOf();878const oneHour = 60 * 60 * 1000;879const oneHourAgo = new Date(now - oneHour);880const oneHourFromNow = new Date(now + oneHour);881const userDelegationKey = await blobServiceClient.getUserDelegationKey(oneHourAgo, oneHourFromNow);882const sasOptions = { containerName: 'staging', permissions: ContainerSASPermissions.from({ read: true }), startsOn: oneHourAgo, expiresOn: oneHourFromNow };883const stagingSasToken = generateBlobSASQueryParameters(sasOptions, userDelegationKey, e('VSCODE_STAGING_BLOB_STORAGE_ACCOUNT_NAME')).toString();884885const releaseService = await ESRPReleaseService.create(886log,887e('RELEASE_TENANT_ID'),888e('RELEASE_CLIENT_ID'),889e('RELEASE_AUTH_CERT'),890e('RELEASE_REQUEST_SIGNING_CERT'),891stagingContainerClient,892stagingSasToken893);894895await releaseService.createRelease(version, filePath, friendlyFileName);896}897898const { product, os, arch, unprocessedType } = match.groups!;899const platform = getPlatform(product, os, arch, unprocessedType);900const type = getRealType(unprocessedType);901const size = fs.statSync(filePath).size;902const stream = fs.createReadStream(filePath);903const [hash, sha256hash] = await Promise.all([hashStream('sha1', stream), hashStream('sha256', stream)]); // CodeQL [SM04514] Using SHA1 only for legacy reasons, we are actually only respecting SHA256904const asset: Asset = { platform, type, url, hash: hash.toString('hex'), sha256hash: sha256hash.toString('hex'), size, supportsFastUpdate: true };905log('Creating asset...');906907const result = await retry(async (attempt) => {908log(`Creating asset in Cosmos DB (attempt ${attempt})...`);909const client = new CosmosClient({ endpoint: e('AZURE_DOCUMENTDB_ENDPOINT')!, tokenProvider: () => Promise.resolve(`type=aad&ver=1.0&sig=${cosmosDBAccessToken.token}`) });910const scripts = client.database('builds').container(quality).scripts;911const { resource: result } = await scripts.storedProcedure('createAsset').execute<'ok' | 'already exists'>('', [version, asset, true]);912return result;913});914915if (result === 'already exists') {916log('Asset already exists!');917} else {918log('Asset successfully created: ', JSON.stringify(asset, undefined, 2));919}920});921922log(`Successfully released lease for: ${friendlyFileName}`);923}924925// It is VERY important that we don't download artifacts too much too fast from AZDO.926// AZDO throttles us SEVERELY if we do. Not just that, but they also close open927// sockets, so the whole things turns to a grinding halt. So, downloading and extracting928// happens serially in the main thread, making the downloads are spaced out929// properly. For each extracted artifact, we spawn a worker thread to upload it to930// the CDN and finally update the build in Cosmos DB.931async function main() {932if (!isMainThread) {933const { artifact, artifactFilePath } = workerData;934await processArtifact(artifact, artifactFilePath);935return;936}937938const done = new State();939const processing = new Set<string>();940941for (const name of done) {942console.log(`\u2705 ${name}`);943}944945const stages = new Set<string>(['Compile']);946947if (948e('VSCODE_BUILD_STAGE_LINUX') === 'True' ||949e('VSCODE_BUILD_STAGE_ALPINE') === 'True' ||950e('VSCODE_BUILD_STAGE_MACOS') === 'True' ||951e('VSCODE_BUILD_STAGE_WINDOWS') === 'True'952) {953stages.add('CompileCLI');954}955956if (e('VSCODE_BUILD_STAGE_WINDOWS') === 'True') { stages.add('Windows'); }957if (e('VSCODE_BUILD_STAGE_LINUX') === 'True') { stages.add('Linux'); }958if (e('VSCODE_BUILD_STAGE_ALPINE') === 'True') { stages.add('Alpine'); }959if (e('VSCODE_BUILD_STAGE_MACOS') === 'True') { stages.add('macOS'); }960if (e('VSCODE_BUILD_STAGE_WEB') === 'True') { stages.add('Web'); }961962let timeline: Timeline;963let artifacts: Artifact[];964let resultPromise = Promise.resolve<PromiseSettledResult<void>[]>([]);965const operations: { name: string; operation: Promise<void> }[] = [];966967while (true) {968[timeline, artifacts] = await Promise.all([retry(() => getPipelineTimeline()), retry(() => getPipelineArtifacts())]);969const stagesCompleted = new Set<string>(timeline.records.filter(r => r.type === 'Stage' && r.state === 'completed' && stages.has(r.name)).map(r => r.name));970const stagesInProgress = [...stages].filter(s => !stagesCompleted.has(s));971const artifactsInProgress = artifacts.filter(a => processing.has(a.name));972973if (stagesInProgress.length === 0 && artifacts.length === done.size + processing.size) {974break;975} else if (stagesInProgress.length > 0) {976console.log('Stages in progress:', stagesInProgress.join(', '));977} else if (artifactsInProgress.length > 0) {978console.log('Artifacts in progress:', artifactsInProgress.map(a => a.name).join(', '));979} else {980console.log(`Waiting for a total of ${artifacts.length}, ${done.size} done, ${processing.size} in progress...`);981}982983for (const artifact of artifacts) {984if (done.has(artifact.name) || processing.has(artifact.name)) {985continue;986}987988console.log(`[${artifact.name}] Found new artifact`);989990const artifactZipPath = path.join(e('AGENT_TEMPDIRECTORY'), `${artifact.name}.zip`);991992await retry(async (attempt) => {993const start = Date.now();994console.log(`[${artifact.name}] Downloading (attempt ${attempt})...`);995await downloadArtifact(artifact, artifactZipPath);996const archiveSize = fs.statSync(artifactZipPath).size;997const downloadDurationS = (Date.now() - start) / 1000;998const downloadSpeedKBS = Math.round((archiveSize / 1024) / downloadDurationS);999console.log(`[${artifact.name}] Successfully downloaded after ${Math.floor(downloadDurationS)} seconds(${downloadSpeedKBS} KB/s).`);1000});10011002const artifactFilePaths = await unzip(artifactZipPath, e('AGENT_TEMPDIRECTORY'));1003const artifactFilePath = artifactFilePaths.filter(p => !/_manifest/.test(p))[0];10041005processing.add(artifact.name);1006const promise = new Promise<void>((resolve, reject) => {1007const worker = new Worker(__filename, { workerData: { artifact, artifactFilePath } });1008worker.on('error', reject);1009worker.on('exit', code => {1010if (code === 0) {1011resolve();1012} else {1013reject(new Error(`[${artifact.name}] Worker stopped with exit code ${code}`));1014}1015});1016});10171018const operation = promise.then(() => {1019processing.delete(artifact.name);1020done.add(artifact.name);1021console.log(`\u2705 ${artifact.name} `);1022});10231024operations.push({ name: artifact.name, operation });1025resultPromise = Promise.allSettled(operations.map(o => o.operation));1026}10271028await new Promise(c => setTimeout(c, 10_000));1029}10301031console.log(`Found all ${done.size + processing.size} artifacts, waiting for ${processing.size} artifacts to finish publishing...`);10321033const artifactsInProgress = operations.filter(o => processing.has(o.name));10341035if (artifactsInProgress.length > 0) {1036console.log('Artifacts in progress:', artifactsInProgress.map(a => a.name).join(', '));1037}10381039const results = await resultPromise;10401041for (let i = 0; i < operations.length; i++) {1042const result = results[i];10431044if (result.status === 'rejected') {1045console.error(`[${operations[i].name}]`, result.reason);1046}1047}10481049// Fail the job if any of the artifacts failed to publish1050if (results.some(r => r.status === 'rejected')) {1051throw new Error('Some artifacts failed to publish');1052}10531054// Also fail the job if any of the stages did not succeed1055let shouldFail = false;10561057for (const stage of stages) {1058const record = timeline.records.find(r => r.name === stage && r.type === 'Stage')!;10591060if (record.result !== 'succeeded' && record.result !== 'succeededWithIssues') {1061shouldFail = true;1062console.error(`Stage ${stage} did not succeed: ${record.result}`);1063}1064}10651066if (shouldFail) {1067throw new Error('Some stages did not succeed');1068}10691070console.log(`All ${done.size} artifacts published!`);1071}10721073if (require.main === module) {1074main().then(() => {1075process.exit(0);1076}, err => {1077console.error(err);1078process.exit(1);1079});1080}108110821083