Path: blob/main/build/azure-pipelines/common/publish.ts
5318 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.ts';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 StatusCode = Object.freeze({76Pass: 'pass',77Aborted: 'aborted',78Inprogress: 'inprogress',79FailCanRetry: 'failCanRetry',80FailDoNotRetry: 'failDoNotRetry',81PendingAnalysis: 'pendingAnalysis',82Cancelled: 'cancelled'83});84type StatusCode = typeof StatusCode[keyof typeof StatusCode];8586interface ReleaseResultMessage {87activities: ReleaseActivityInfo[];88childWorkflowType: string;89clientId: string;90customerCorrelationId: string;91errorInfo: InnerServiceError;92groupId: string;93lastModifiedAt: Date;94operationId: string;95releaseError: ReleaseError;96requestSubmittedAt: Date;97routedRegion: string;98status: StatusCode;99totalFileCount: number;100totalReleaseSize: number;101version: string;102}103104interface ReleaseFileInfo {105name?: string;106hash?: number[];107sourceLocation?: FileLocation;108sizeInBytes?: number;109hashType?: FileHashType;110fileId?: any;111distributionRelativePath?: string;112partNumber?: string;113friendlyFileName?: string;114tenantFileLocationType?: string;115tenantFileLocation?: string;116signedEngineeringCopyLocation?: string;117encryptedDistributionBlobLocation?: string;118preEncryptedDistributionBlobLocation?: string;119secondaryDistributionHashRequired?: boolean;120secondaryDistributionHashType?: FileHashType;121lastModifiedAt?: Date;122cultureCodes?: string[];123displayFileInDownloadCenter?: boolean;124isPrimaryFileInDownloadCenter?: boolean;125fileDownloadDetails?: FileDownloadDetails[];126}127128interface ReleaseDetailsFileInfo extends ReleaseFileInfo { }129130interface ReleaseDetailsMessage extends ReleaseResultMessage {131clusterRegion: string;132correlationVector: string;133releaseCompletedAt?: Date;134releaseInfo: ReleaseInfo;135productInfo: ProductInfo;136createdBy: UserInfo;137owners: OwnerInfo[];138accessPermissionsInfo: AccessPermissionsInfo;139files: ReleaseDetailsFileInfo[];140comments: string[];141cancellationReason: string;142downloadCenterInfo: DownloadCenterInfo;143}144145146interface ProductInfo {147name?: string;148version?: string;149description?: string;150}151152interface ReleaseInfo {153title?: string;154minimumNumberOfApprovers: number;155properties?: { [key: string]: string };156isRevision?: boolean;157revisionNumber?: string;158}159160type FileLocationType = 'azureBlob';161162interface FileLocation {163type: FileLocationType;164blobUrl: string;165uncPath?: string;166url?: string;167}168169type FileHashType = 'sha256' | 'sha1';170171interface FileDownloadDetails {172portalName: string;173downloadUrl: string;174}175176interface RoutingInfo {177intent?: string;178contentType?: string;179contentOrigin?: string;180productState?: string;181audience?: string;182}183184interface ReleaseFileInfo {185name?: string;186hash?: number[];187sourceLocation?: FileLocation;188sizeInBytes?: number;189hashType?: FileHashType;190fileId?: any;191distributionRelativePath?: string;192partNumber?: string;193friendlyFileName?: string;194tenantFileLocationType?: string;195tenantFileLocation?: string;196signedEngineeringCopyLocation?: string;197encryptedDistributionBlobLocation?: string;198preEncryptedDistributionBlobLocation?: string;199secondaryDistributionHashRequired?: boolean;200secondaryDistributionHashType?: FileHashType;201lastModifiedAt?: Date;202cultureCodes?: string[];203displayFileInDownloadCenter?: boolean;204isPrimaryFileInDownloadCenter?: boolean;205fileDownloadDetails?: FileDownloadDetails[];206}207208interface UserInfo {209userPrincipalName?: string;210}211212interface OwnerInfo {213owner: UserInfo;214}215216interface ApproverInfo {217approver: UserInfo;218isAutoApproved: boolean;219isMandatory: boolean;220}221222interface AccessPermissionsInfo {223mainPublisher?: string;224releasePublishers?: string[];225channelDownloadEntityDetails?: { [key: string]: string[] };226}227228interface DownloadCenterLocaleInfo {229cultureCode?: string;230downloadTitle?: string;231shortName?: string;232shortDescription?: string;233longDescription?: string;234instructions?: string;235additionalInfo?: string;236keywords?: string[];237version?: string;238relatedLinks?: { [key: string]: URL };239}240241interface DownloadCenterInfo {242downloadCenterId: number;243publishToDownloadCenter?: boolean;244publishingGroup?: string;245operatingSystems?: string[];246relatedReleases?: string[];247kbNumbers?: string[];248sbNumbers?: string[];249locales?: DownloadCenterLocaleInfo[];250additionalProperties?: { [key: string]: string };251}252253interface ReleaseRequestMessage {254driEmail: string[];255groupId?: string;256customerCorrelationId: string;257esrpCorrelationId: string;258contextData?: { [key: string]: string };259releaseInfo: ReleaseInfo;260productInfo: ProductInfo;261files: ReleaseFileInfo[];262routingInfo?: RoutingInfo;263createdBy: UserInfo;264owners: OwnerInfo[];265approvers: ApproverInfo[];266accessPermissionsInfo: AccessPermissionsInfo;267jwsToken?: string;268publisherId?: string;269downloadCenterInfo?: DownloadCenterInfo;270}271272function getCertificateBuffer(input: string) {273return Buffer.from(input.replace(/-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----|\n/g, ''), 'base64');274}275276function getThumbprint(input: string, algorithm: string): Buffer {277const buffer = getCertificateBuffer(input);278return crypto.createHash(algorithm).update(buffer).digest();279}280281function getKeyFromPFX(pfx: string): string {282const pfxCertificatePath = path.join(os.tmpdir(), 'cert.pfx');283const pemKeyPath = path.join(os.tmpdir(), 'key.pem');284285try {286const pfxCertificate = Buffer.from(pfx, 'base64');287fs.writeFileSync(pfxCertificatePath, pfxCertificate);288cp.execSync(`openssl pkcs12 -in "${pfxCertificatePath}" -nocerts -nodes -out "${pemKeyPath}" -passin pass:`);289const raw = fs.readFileSync(pemKeyPath, 'utf-8');290const result = raw.match(/-----BEGIN PRIVATE KEY-----[\s\S]+?-----END PRIVATE KEY-----/g)![0];291return result;292} finally {293fs.rmSync(pfxCertificatePath, { force: true });294fs.rmSync(pemKeyPath, { force: true });295}296}297298function getCertificatesFromPFX(pfx: string): string[] {299const pfxCertificatePath = path.join(os.tmpdir(), 'cert.pfx');300const pemCertificatePath = path.join(os.tmpdir(), 'cert.pem');301302try {303const pfxCertificate = Buffer.from(pfx, 'base64');304fs.writeFileSync(pfxCertificatePath, pfxCertificate);305cp.execSync(`openssl pkcs12 -in "${pfxCertificatePath}" -nokeys -out "${pemCertificatePath}" -passin pass:`);306const raw = fs.readFileSync(pemCertificatePath, 'utf-8');307const matches = raw.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g);308return matches ? matches.reverse() : [];309} finally {310fs.rmSync(pfxCertificatePath, { force: true });311fs.rmSync(pemCertificatePath, { force: true });312}313}314315class ESRPReleaseService {316317static async create(318log: (...args: unknown[]) => void,319tenantId: string,320clientId: string,321authCertificatePfx: string,322requestSigningCertificatePfx: string,323containerClient: ContainerClient,324stagingSasToken: string325) {326const authKey = getKeyFromPFX(authCertificatePfx);327const authCertificate = getCertificatesFromPFX(authCertificatePfx)[0];328const requestSigningKey = getKeyFromPFX(requestSigningCertificatePfx);329const requestSigningCertificates = getCertificatesFromPFX(requestSigningCertificatePfx);330331const app = new ConfidentialClientApplication({332auth: {333clientId,334authority: `https://login.microsoftonline.com/${tenantId}`,335clientCertificate: {336thumbprintSha256: getThumbprint(authCertificate, 'sha256').toString('hex'),337privateKey: authKey,338x5c: authCertificate339}340}341});342343const response = await app.acquireTokenByClientCredential({344scopes: ['https://api.esrp.microsoft.com/.default']345});346347return new ESRPReleaseService(log, clientId, response!.accessToken, requestSigningCertificates, requestSigningKey, containerClient, stagingSasToken);348}349350private static API_URL = 'https://api.esrp.microsoft.com/api/v3/releaseservices/clients/';351352private readonly log: (...args: unknown[]) => void;353private readonly clientId: string;354private readonly accessToken: string;355private readonly requestSigningCertificates: string[];356private readonly requestSigningKey: string;357private readonly containerClient: ContainerClient;358private readonly stagingSasToken: string;359360private constructor(361log: (...args: unknown[]) => void,362clientId: string,363accessToken: string,364requestSigningCertificates: string[],365requestSigningKey: string,366containerClient: ContainerClient,367stagingSasToken: string368) {369this.log = log;370this.clientId = clientId;371this.accessToken = accessToken;372this.requestSigningCertificates = requestSigningCertificates;373this.requestSigningKey = requestSigningKey;374this.containerClient = containerClient;375this.stagingSasToken = stagingSasToken;376}377378async createRelease(version: string, filePath: string, friendlyFileName: string) {379const correlationId = crypto.randomUUID();380const blobClient = this.containerClient.getBlockBlobClient(correlationId);381382this.log(`Uploading ${filePath} to ${blobClient.url}`);383await blobClient.uploadFile(filePath);384this.log('Uploaded blob successfully');385386try {387this.log(`Submitting release for ${version}: ${filePath}`);388const submitReleaseResult = await this.submitRelease(version, filePath, friendlyFileName, correlationId, blobClient);389390this.log(`Successfully submitted release ${submitReleaseResult.operationId}. Polling for completion...`);391392// Poll every 5 seconds, wait 60 minutes max -> poll 60/5*60=720 times393for (let i = 0; i < 720; i++) {394await new Promise(c => setTimeout(c, 5000));395const releaseStatus = await this.getReleaseStatus(submitReleaseResult.operationId);396397if (releaseStatus.status === 'pass') {398break;399} else if (releaseStatus.status === 'aborted') {400this.log(JSON.stringify(releaseStatus));401throw new Error(`Release was aborted`);402} else if (releaseStatus.status !== 'inprogress') {403this.log(JSON.stringify(releaseStatus));404throw new Error(`Unknown error when polling for release`);405}406}407408const releaseDetails = await this.getReleaseDetails(submitReleaseResult.operationId);409410if (releaseDetails.status !== 'pass') {411throw new Error(`Timed out waiting for release: ${JSON.stringify(releaseDetails)}`);412}413414this.log('Successfully created release:', releaseDetails.files[0].fileDownloadDetails![0].downloadUrl);415return releaseDetails.files[0].fileDownloadDetails![0].downloadUrl;416} finally {417this.log(`Deleting blob ${blobClient.url}`);418await blobClient.delete();419this.log('Deleted blob successfully');420}421}422423private async submitRelease(424version: string,425filePath: string,426friendlyFileName: string,427correlationId: string,428blobClient: BlobClient429): Promise<ReleaseSubmitResponse> {430const size = fs.statSync(filePath).size;431const hash = await hashStream('sha256', fs.createReadStream(filePath));432const blobUrl = `${blobClient.url}?${this.stagingSasToken}`;433434const message: ReleaseRequestMessage = {435customerCorrelationId: correlationId,436esrpCorrelationId: correlationId,437driEmail: ['[email protected]'],438createdBy: { userPrincipalName: '[email protected]' },439owners: [{ owner: { userPrincipalName: '[email protected]' } }],440approvers: [{ approver: { userPrincipalName: '[email protected]' }, isAutoApproved: true, isMandatory: false }],441releaseInfo: {442title: 'VS Code',443properties: {444'ReleaseContentType': 'InstallPackage'445},446minimumNumberOfApprovers: 1447},448productInfo: {449name: 'VS Code',450version,451description: 'VS Code'452},453accessPermissionsInfo: {454mainPublisher: 'VSCode',455channelDownloadEntityDetails: {456AllDownloadEntities: ['VSCode']457}458},459routingInfo: {460intent: 'filedownloadlinkgeneration'461},462files: [{463name: path.basename(filePath),464friendlyFileName,465tenantFileLocation: blobUrl,466tenantFileLocationType: 'AzureBlob',467sourceLocation: {468type: 'azureBlob',469blobUrl470},471hashType: 'sha256',472hash: Array.from(hash),473sizeInBytes: size474}]475};476477message.jwsToken = await this.generateJwsToken(message);478479const res = await fetch(`${ESRPReleaseService.API_URL}${this.clientId}/workflows/release/operations`, {480method: 'POST',481headers: {482'Content-Type': 'application/json',483'Authorization': `Bearer ${this.accessToken}`484},485body: JSON.stringify(message)486});487488if (!res.ok) {489const text = await res.text();490throw new Error(`Failed to submit release: ${res.statusText}\n${text}`);491}492493return await res.json() as ReleaseSubmitResponse;494}495496private async getReleaseStatus(releaseId: string): Promise<ReleaseResultMessage> {497const url = `${ESRPReleaseService.API_URL}${this.clientId}/workflows/release/operations/grs/${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 ReleaseResultMessage;511}512513private async getReleaseDetails(releaseId: string): Promise<ReleaseDetailsMessage> {514const url = `${ESRPReleaseService.API_URL}${this.clientId}/workflows/release/operations/grd/${releaseId}`;515516const res = await retry(() => fetch(url, {517headers: {518'Authorization': `Bearer ${this.accessToken}`519}520}));521522if (!res.ok) {523const text = await res.text();524throw new Error(`Failed to get release status: ${res.statusText}\n${text}`);525}526527return await res.json() as ReleaseDetailsMessage;528}529530private async generateJwsToken(message: ReleaseRequestMessage): Promise<string> {531// Create header with properly typed properties, then override x5c with the non-standard string format532const header: jws.Header = {533alg: 'RS256',534crit: ['exp', 'x5t'],535// Release service uses ticks, not seconds :roll_eyes: (https://stackoverflow.com/a/7968483)536exp: ((Date.now() + (6 * 60 * 1000)) * 10000) + 621355968000000000,537// Release service uses hex format, not base64url :roll_eyes:538x5t: getThumbprint(this.requestSigningCertificates[0], 'sha1').toString('hex'),539};540541// The Release service expects x5c as a '.' separated string, not the standard array format542(header as Record<string, unknown>)['x5c'] = this.requestSigningCertificates.map(c => getCertificateBuffer(c).toString('base64url')).join('.');543544return jws.sign({545header,546payload: message,547privateKey: this.requestSigningKey,548});549}550}551552class State {553554private statePath: string;555private set = new Set<string>();556557constructor() {558const pipelineWorkspacePath = e('PIPELINE_WORKSPACE');559const previousState = fs.readdirSync(pipelineWorkspacePath)560.map(name => /^artifacts_processed_(\d+)$/.exec(name))561.filter((match): match is RegExpExecArray => !!match)562.map(match => ({ name: match![0], attempt: Number(match![1]) }))563.sort((a, b) => b.attempt - a.attempt)[0];564565if (previousState) {566const previousStatePath = path.join(pipelineWorkspacePath, previousState.name, previousState.name + '.txt');567fs.readFileSync(previousStatePath, 'utf8').split(/\n/).filter(name => !!name).forEach(name => this.set.add(name));568}569570const stageAttempt = e('SYSTEM_STAGEATTEMPT');571this.statePath = path.join(pipelineWorkspacePath, `artifacts_processed_${stageAttempt}`, `artifacts_processed_${stageAttempt}.txt`);572fs.mkdirSync(path.dirname(this.statePath), { recursive: true });573fs.writeFileSync(this.statePath, [...this.set.values()].map(name => `${name}\n`).join(''));574}575576get size(): number {577return this.set.size;578}579580has(name: string): boolean {581return this.set.has(name);582}583584add(name: string): void {585this.set.add(name);586fs.appendFileSync(this.statePath, `${name}\n`);587}588589[Symbol.iterator](): IterableIterator<string> {590return this.set[Symbol.iterator]();591}592}593594const azdoFetchOptions = {595headers: {596// Pretend we're a web browser to avoid download rate limits597'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',598'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',599'Accept-Encoding': 'gzip, deflate, br',600'Accept-Language': 'en-US,en;q=0.9',601'Referer': 'https://dev.azure.com',602Authorization: `Bearer ${e('SYSTEM_ACCESSTOKEN')}`603}604};605606export async function requestAZDOAPI<T>(path: string): Promise<T> {607const abortController = new AbortController();608const timeout = setTimeout(() => abortController.abort(), 2 * 60 * 1000);609610try {611const res = await retry(() => fetch(`${e('BUILDS_API_URL')}${path}?api-version=6.0`, { ...azdoFetchOptions, signal: abortController.signal }));612613if (!res.ok) {614throw new Error(`Unexpected status code: ${res.status}`);615}616617return await res.json();618} finally {619clearTimeout(timeout);620}621}622623export interface Artifact {624readonly name: string;625readonly resource: {626readonly downloadUrl: string;627readonly properties: {628readonly artifactsize: number;629};630};631}632633async function getPipelineArtifacts(): Promise<Artifact[]> {634const result = await requestAZDOAPI<{ readonly value: Artifact[] }>('artifacts');635return result.value.filter(a => /^vscode_/.test(a.name) && !/sbom$/.test(a.name));636}637638interface Timeline {639readonly records: {640readonly name: string;641readonly type: string;642readonly state: string;643readonly result: string;644}[];645}646647async function getPipelineTimeline(): Promise<Timeline> {648return await requestAZDOAPI<Timeline>('timeline');649}650651async function downloadArtifact(artifact: Artifact, downloadPath: string): Promise<void> {652const abortController = new AbortController();653const timeout = setTimeout(() => abortController.abort(), 4 * 60 * 1000);654655try {656const res = await fetch(artifact.resource.downloadUrl, { ...azdoFetchOptions, signal: abortController.signal });657658if (!res.ok) {659throw new Error(`Unexpected status code: ${res.status}`);660}661662await pipeline(Readable.fromWeb(res.body as ReadableStream), fs.createWriteStream(downloadPath));663} finally {664clearTimeout(timeout);665}666}667668async function unzip(packagePath: string, outputPath: string): Promise<string[]> {669return new Promise((resolve, reject) => {670yauzl.open(packagePath, { lazyEntries: true, autoClose: true }, (err, zipfile) => {671if (err) {672return reject(err);673}674675const result: string[] = [];676zipfile!.on('entry', entry => {677if (/\/$/.test(entry.fileName)) {678zipfile!.readEntry();679} else {680zipfile!.openReadStream(entry, (err, istream) => {681if (err) {682return reject(err);683}684685const filePath = path.join(outputPath, entry.fileName);686fs.mkdirSync(path.dirname(filePath), { recursive: true });687688const ostream = fs.createWriteStream(filePath);689ostream.on('finish', () => {690result.push(filePath);691zipfile!.readEntry();692});693istream?.on('error', err => reject(err));694istream!.pipe(ostream);695});696}697});698699zipfile!.on('close', () => resolve(result));700zipfile!.readEntry();701});702});703}704705interface Asset {706platform: string;707type: string;708url: string;709mooncakeUrl?: string;710prssUrl?: string;711hash: string;712sha256hash: string;713size: number;714supportsFastUpdate?: boolean;715}716717// Contains all of the logic for mapping details to our actual product names in CosmosDB718function getPlatform(product: string, os: string, arch: string, type: string): string {719switch (os) {720case 'win32':721switch (product) {722case 'client': {723switch (type) {724case 'archive':725return `win32-${arch}-archive`;726case 'setup':727return `win32-${arch}`;728case 'user-setup':729return `win32-${arch}-user`;730default:731throw new Error(`Unrecognized: ${product} ${os} ${arch} ${type}`);732}733}734case 'server':735return `server-win32-${arch}`;736case 'web':737return `server-win32-${arch}-web`;738case 'cli':739return `cli-win32-${arch}`;740default:741throw new Error(`Unrecognized: ${product} ${os} ${arch} ${type}`);742}743case 'alpine':744switch (product) {745case 'server':746return `server-alpine-${arch}`;747case 'web':748return `server-alpine-${arch}-web`;749case 'cli':750return `cli-alpine-${arch}`;751default:752throw new Error(`Unrecognized: ${product} ${os} ${arch} ${type}`);753}754case 'linux':755switch (type) {756case 'snap':757return `linux-snap-${arch}`;758case 'archive-unsigned':759switch (product) {760case 'client':761return `linux-${arch}`;762case 'server':763return `server-linux-${arch}`;764case 'web':765if (arch === 'standalone') {766return 'web-standalone';767}768return `server-linux-${arch}-web`;769default:770throw new Error(`Unrecognized: ${product} ${os} ${arch} ${type}`);771}772case 'deb-package':773return `linux-deb-${arch}`;774case 'rpm-package':775return `linux-rpm-${arch}`;776case 'cli':777return `cli-linux-${arch}`;778default:779throw new Error(`Unrecognized: ${product} ${os} ${arch} ${type}`);780}781case 'darwin':782switch (product) {783case 'client':784switch (type) {785case 'dmg':786return `darwin-${arch}-dmg`;787case 'archive':788default:789if (arch === 'x64') {790return 'darwin';791}792return `darwin-${arch}`;793}794case 'server':795if (arch === 'x64') {796return 'server-darwin';797}798return `server-darwin-${arch}`;799case 'web':800if (arch === 'x64') {801return 'server-darwin-web';802}803return `server-darwin-${arch}-web`;804case 'cli':805return `cli-darwin-${arch}`;806default:807throw new Error(`Unrecognized: ${product} ${os} ${arch} ${type}`);808}809default:810throw new Error(`Unrecognized: ${product} ${os} ${arch} ${type}`);811}812}813814// Contains all of the logic for mapping types to our actual types in CosmosDB815function getRealType(type: string) {816switch (type) {817case 'user-setup':818return 'setup';819case 'deb-package':820case 'rpm-package':821return 'package';822default:823return type;824}825}826827async function withLease<T>(client: BlockBlobClient, fn: () => Promise<T>) {828const lease = client.getBlobLeaseClient();829830for (let i = 0; i < 360; i++) { // Try to get lease for 30 minutes831try {832await client.uploadData(new ArrayBuffer()); // blob needs to exist for lease to be acquired833await lease.acquireLease(60);834835try {836const abortController = new AbortController();837const refresher = new Promise<void>((c, e) => {838abortController.signal.onabort = () => {839clearInterval(interval);840c();841};842843const interval = setInterval(() => {844lease.renewLease().catch(err => {845clearInterval(interval);846e(new Error('Failed to renew lease ' + err));847});848}, 30_000);849});850851const result = await Promise.race([fn(), refresher]);852abortController.abort();853return result;854} finally {855await lease.releaseLease();856}857} catch (err) {858if (err.statusCode !== 409 && err.statusCode !== 412) {859throw err;860}861862await new Promise(c => setTimeout(c, 5000));863}864}865866throw new Error('Failed to acquire lease on blob after 30 minutes');867}868869async function processArtifact(870artifact: Artifact,871filePath: string872) {873const log = (...args: unknown[]) => console.log(`[${artifact.name}]`, ...args);874const match = /^vscode_(?<product>[^_]+)_(?<os>[^_]+)(?:_legacy)?_(?<arch>[^_]+)_(?<unprocessedType>[^_]+)$/.exec(artifact.name);875876if (!match) {877throw new Error(`Invalid artifact name: ${artifact.name}`);878}879880const { cosmosDBAccessToken, blobServiceAccessToken } = JSON.parse(e('PUBLISH_AUTH_TOKENS'));881const quality = e('VSCODE_QUALITY');882const version = e('BUILD_SOURCEVERSION');883const friendlyFileName = `${quality}/${version}/${path.basename(filePath)}`;884885const blobServiceClient = new BlobServiceClient(`https://${e('VSCODE_STAGING_BLOB_STORAGE_ACCOUNT_NAME')}.blob.core.windows.net/`, { getToken: async () => blobServiceAccessToken });886const leasesContainerClient = blobServiceClient.getContainerClient('leases');887await leasesContainerClient.createIfNotExists();888const leaseBlobClient = leasesContainerClient.getBlockBlobClient(friendlyFileName);889890log(`Acquiring lease for: ${friendlyFileName}`);891892await withLease(leaseBlobClient, async () => {893log(`Successfully acquired lease for: ${friendlyFileName}`);894895const url = `${e('PRSS_CDN_URL')}/${friendlyFileName}`;896const res = await retry(() => fetch(url));897898if (res.status === 200) {899log(`Already released and provisioned: ${url}`);900} else {901const stagingContainerClient = blobServiceClient.getContainerClient('staging');902await stagingContainerClient.createIfNotExists();903904const now = new Date().valueOf();905const oneHour = 60 * 60 * 1000;906const oneHourAgo = new Date(now - oneHour);907const oneHourFromNow = new Date(now + oneHour);908const userDelegationKey = await blobServiceClient.getUserDelegationKey(oneHourAgo, oneHourFromNow);909const sasOptions = { containerName: 'staging', permissions: ContainerSASPermissions.from({ read: true }), startsOn: oneHourAgo, expiresOn: oneHourFromNow };910const stagingSasToken = generateBlobSASQueryParameters(sasOptions, userDelegationKey, e('VSCODE_STAGING_BLOB_STORAGE_ACCOUNT_NAME')).toString();911912const releaseService = await ESRPReleaseService.create(913log,914e('RELEASE_TENANT_ID'),915e('RELEASE_CLIENT_ID'),916e('RELEASE_AUTH_CERT'),917e('RELEASE_REQUEST_SIGNING_CERT'),918stagingContainerClient,919stagingSasToken920);921922await releaseService.createRelease(version, filePath, friendlyFileName);923}924925const { product, os, arch, unprocessedType } = match.groups!;926const platform = getPlatform(product, os, arch, unprocessedType);927const type = getRealType(unprocessedType);928const size = fs.statSync(filePath).size;929const stream = fs.createReadStream(filePath);930const [hash, sha256hash] = await Promise.all([hashStream('sha1', stream), hashStream('sha256', stream)]); // CodeQL [SM04514] Using SHA1 only for legacy reasons, we are actually only respecting SHA256931const asset: Asset = { platform, type, url, hash: hash.toString('hex'), sha256hash: sha256hash.toString('hex'), size, supportsFastUpdate: true };932log('Creating asset...');933934const result = await retry(async (attempt) => {935log(`Creating asset in Cosmos DB (attempt ${attempt})...`);936const client = new CosmosClient({ endpoint: e('AZURE_DOCUMENTDB_ENDPOINT')!, tokenProvider: () => Promise.resolve(`type=aad&ver=1.0&sig=${cosmosDBAccessToken.token}`) });937const scripts = client.database('builds').container(quality).scripts;938const { resource: result } = await scripts.storedProcedure('createAsset').execute<'ok' | 'already exists'>('', [version, asset, true]);939return result;940});941942if (result === 'already exists') {943log('Asset already exists!');944} else {945log('Asset successfully created: ', JSON.stringify(asset, undefined, 2));946}947});948949log(`Successfully released lease for: ${friendlyFileName}`);950}951952// It is VERY important that we don't download artifacts too much too fast from AZDO.953// AZDO throttles us SEVERELY if we do. Not just that, but they also close open954// sockets, so the whole things turns to a grinding halt. So, downloading and extracting955// happens serially in the main thread, making the downloads are spaced out956// properly. For each extracted artifact, we spawn a worker thread to upload it to957// the CDN and finally update the build in Cosmos DB.958async function main() {959if (!isMainThread) {960const { artifact, artifactFilePath } = workerData;961await processArtifact(artifact, artifactFilePath);962return;963}964965const done = new State();966const processing = new Set<string>();967968for (const name of done) {969console.log(`\u2705 ${name}`);970}971972const stages = new Set<string>(['Compile']);973974if (975e('VSCODE_BUILD_STAGE_LINUX') === 'True' ||976e('VSCODE_BUILD_STAGE_MACOS') === 'True' ||977e('VSCODE_BUILD_STAGE_WINDOWS') === 'True'978) {979stages.add('CompileCLI');980}981982if (e('VSCODE_BUILD_STAGE_WINDOWS') === 'True') { stages.add('Windows'); }983if (e('VSCODE_BUILD_STAGE_LINUX') === 'True') { stages.add('Linux'); }984if (e('VSCODE_BUILD_STAGE_ALPINE') === 'True') { stages.add('Alpine'); }985if (e('VSCODE_BUILD_STAGE_MACOS') === 'True') { stages.add('macOS'); }986if (e('VSCODE_BUILD_STAGE_WEB') === 'True') { stages.add('Web'); }987988let timeline: Timeline;989let artifacts: Artifact[];990let resultPromise = Promise.resolve<PromiseSettledResult<void>[]>([]);991const operations: { name: string; operation: Promise<void> }[] = [];992993while (true) {994[timeline, artifacts] = await Promise.all([retry(() => getPipelineTimeline()), retry(() => getPipelineArtifacts())]);995const stagesCompleted = new Set<string>(timeline.records.filter(r => r.type === 'Stage' && r.state === 'completed' && stages.has(r.name)).map(r => r.name));996const stagesInProgress = [...stages].filter(s => !stagesCompleted.has(s));997const artifactsInProgress = artifacts.filter(a => processing.has(a.name));998999if (stagesInProgress.length === 0 && artifacts.length === done.size + processing.size) {1000break;1001} else if (stagesInProgress.length > 0) {1002console.log('Stages in progress:', stagesInProgress.join(', '));1003} else if (artifactsInProgress.length > 0) {1004console.log('Artifacts in progress:', artifactsInProgress.map(a => a.name).join(', '));1005} else {1006console.log(`Waiting for a total of ${artifacts.length}, ${done.size} done, ${processing.size} in progress...`);1007}10081009for (const artifact of artifacts) {1010if (done.has(artifact.name) || processing.has(artifact.name)) {1011continue;1012}10131014console.log(`[${artifact.name}] Found new artifact`);10151016const artifactZipPath = path.join(e('AGENT_TEMPDIRECTORY'), `${artifact.name}.zip`);10171018await retry(async (attempt) => {1019const start = Date.now();1020console.log(`[${artifact.name}] Downloading (attempt ${attempt})...`);1021await downloadArtifact(artifact, artifactZipPath);1022const archiveSize = fs.statSync(artifactZipPath).size;1023const downloadDurationS = (Date.now() - start) / 1000;1024const downloadSpeedKBS = Math.round((archiveSize / 1024) / downloadDurationS);1025console.log(`[${artifact.name}] Successfully downloaded after ${Math.floor(downloadDurationS)} seconds(${downloadSpeedKBS} KB/s).`);1026});10271028const artifactFilePaths = await unzip(artifactZipPath, e('AGENT_TEMPDIRECTORY'));1029const artifactFilePath = artifactFilePaths.filter(p => !/_manifest/.test(p))[0];10301031processing.add(artifact.name);1032const promise = new Promise<void>((resolve, reject) => {1033const worker = new Worker(import.meta.filename, { workerData: { artifact, artifactFilePath } });1034worker.on('error', reject);1035worker.on('exit', code => {1036if (code === 0) {1037resolve();1038} else {1039reject(new Error(`[${artifact.name}] Worker stopped with exit code ${code}`));1040}1041});1042});10431044const operation = promise.then(() => {1045processing.delete(artifact.name);1046done.add(artifact.name);1047console.log(`\u2705 ${artifact.name} `);1048});10491050operations.push({ name: artifact.name, operation });1051resultPromise = Promise.allSettled(operations.map(o => o.operation));1052}10531054await new Promise(c => setTimeout(c, 10_000));1055}10561057console.log(`Found all ${done.size + processing.size} artifacts, waiting for ${processing.size} artifacts to finish publishing...`);10581059const artifactsInProgress = operations.filter(o => processing.has(o.name));10601061if (artifactsInProgress.length > 0) {1062console.log('Artifacts in progress:', artifactsInProgress.map(a => a.name).join(', '));1063}10641065const results = await resultPromise;10661067for (let i = 0; i < operations.length; i++) {1068const result = results[i];10691070if (result.status === 'rejected') {1071console.error(`[${operations[i].name}]`, result.reason);1072}1073}10741075// Fail the job if any of the artifacts failed to publish1076if (results.some(r => r.status === 'rejected')) {1077throw new Error('Some artifacts failed to publish');1078}10791080// Also fail the job if any of the stages did not succeed1081let shouldFail = false;10821083for (const stage of stages) {1084const record = timeline.records.find(r => r.name === stage && r.type === 'Stage')!;10851086if (record.result !== 'succeeded' && record.result !== 'succeededWithIssues') {1087shouldFail = true;1088console.error(`Stage ${stage} did not succeed: ${record.result}`);1089}1090}10911092if (shouldFail) {1093throw new Error('Some stages did not succeed');1094}10951096console.log(`All ${done.size} artifacts published!`);1097}10981099if (import.meta.main) {1100main().then(() => {1101process.exit(0);1102}, err => {1103console.error(err);1104process.exit(1);1105});1106}110711081109