"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
    if (mod && mod.__esModule) return mod;
    var result = {};
    if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
    __setModuleDefault(result, mod);
    return result;
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.UploadHttpClient = void 0;
const fs = __importStar(require("fs"));
const core = __importStar(require("@actions/core"));
const tmp = __importStar(require("tmp-promise"));
const stream = __importStar(require("stream"));
const utils_1 = require("./utils");
const config_variables_1 = require("./config-variables");
const util_1 = require("util");
const url_1 = require("url");
const perf_hooks_1 = require("perf_hooks");
const status_reporter_1 = require("./status-reporter");
const http_client_1 = require("@actions/http-client");
const http_manager_1 = require("./http-manager");
const upload_gzip_1 = require("./upload-gzip");
const requestUtils_1 = require("./requestUtils");
const stat = util_1.promisify(fs.stat);
class UploadHttpClient {
    constructor() {
        this.uploadHttpManager = new http_manager_1.HttpManager(config_variables_1.getUploadFileConcurrency(), '@actions/artifact-upload');
        this.statusReporter = new status_reporter_1.StatusReporter(10000);
    }
    /**
     * Creates a file container for the new artifact in the remote blob storage/file service
     * @param {string} artifactName Name of the artifact being created
     * @returns The response from the Artifact Service if the file container was successfully created
     */
    createArtifactInFileContainer(artifactName, options) {
        return __awaiter(this, void 0, void 0, function* () {
            const parameters = {
                Type: 'actions_storage',
                Name: artifactName
            };
            // calculate retention period
            if (options && options.retentionDays) {
                const maxRetentionStr = config_variables_1.getRetentionDays();
                parameters.RetentionDays = utils_1.getProperRetention(options.retentionDays, maxRetentionStr);
            }
            const data = JSON.stringify(parameters, null, 2);
            const artifactUrl = utils_1.getArtifactUrl();
            // use the first client from the httpManager, `keep-alive` is not used so the connection will close immediately
            const client = this.uploadHttpManager.getClient(0);
            const headers = utils_1.getUploadHeaders('application/json', false);
            // Extra information to display when a particular HTTP code is returned
            // If a 403 is returned when trying to create a file container, the customer has exceeded
            // their storage quota so no new artifact containers can be created
            const customErrorMessages = new Map([
                [
                    http_client_1.HttpCodes.Forbidden,
                    'Artifact storage quota has been hit. Unable to upload any new artifacts'
                ],
                [
                    http_client_1.HttpCodes.BadRequest,
                    `The artifact name ${artifactName} is not valid. Request URL ${artifactUrl}`
                ]
            ]);
            const response = yield requestUtils_1.retryHttpClientRequest('Create Artifact Container', () => __awaiter(this, void 0, void 0, function* () { return client.post(artifactUrl, data, headers); }), customErrorMessages);
            const body = yield response.readBody();
            return JSON.parse(body);
        });
    }
    /**
     * Concurrently upload all of the files in chunks
     * @param {string} uploadUrl Base Url for the artifact that was created
     * @param {SearchResult[]} filesToUpload A list of information about the files being uploaded
     * @returns The size of all the files uploaded in bytes
     */
    uploadArtifactToFileContainer(uploadUrl, filesToUpload, options) {
        return __awaiter(this, void 0, void 0, function* () {
            const FILE_CONCURRENCY = config_variables_1.getUploadFileConcurrency();
            const MAX_CHUNK_SIZE = config_variables_1.getUploadChunkSize();
            core.debug(`File Concurrency: ${FILE_CONCURRENCY}, and Chunk Size: ${MAX_CHUNK_SIZE}`);
            const parameters = [];
            // by default, file uploads will continue if there is an error unless specified differently in the options
            let continueOnError = true;
            if (options) {
                if (options.continueOnError === false) {
                    continueOnError = false;
                }
            }
            // prepare the necessary parameters to upload all the files
            for (const file of filesToUpload) {
                const resourceUrl = new url_1.URL(uploadUrl);
                resourceUrl.searchParams.append('itemPath', file.uploadFilePath);
                parameters.push({
                    file: file.absoluteFilePath,
                    resourceUrl: resourceUrl.toString(),
                    maxChunkSize: MAX_CHUNK_SIZE,
                    continueOnError
                });
            }
            const parallelUploads = [...new Array(FILE_CONCURRENCY).keys()];
            const failedItemsToReport = [];
            let currentFile = 0;
            let completedFiles = 0;
            let uploadFileSize = 0;
            let totalFileSize = 0;
            let abortPendingFileUploads = false;
            this.statusReporter.setTotalNumberOfFilesToProcess(filesToUpload.length);
            this.statusReporter.start();
            // only allow a certain amount of files to be uploaded at once, this is done to reduce potential errors
            yield Promise.all(parallelUploads.map((index) => __awaiter(this, void 0, void 0, function* () {
                while (currentFile < filesToUpload.length) {
                    const currentFileParameters = parameters[currentFile];
                    currentFile += 1;
                    if (abortPendingFileUploads) {
                        failedItemsToReport.push(currentFileParameters.file);
                        continue;
                    }
                    const startTime = perf_hooks_1.performance.now();
                    const uploadFileResult = yield this.uploadFileAsync(index, currentFileParameters);
                    if (core.isDebug()) {
                        core.debug(`File: ${++completedFiles}/${filesToUpload.length}. ${currentFileParameters.file} took ${(perf_hooks_1.performance.now() - startTime).toFixed(3)} milliseconds to finish upload`);
                    }
                    uploadFileSize += uploadFileResult.successfulUploadSize;
                    totalFileSize += uploadFileResult.totalSize;
                    if (uploadFileResult.isSuccess === false) {
                        failedItemsToReport.push(currentFileParameters.file);
                        if (!continueOnError) {
                            // fail fast
                            core.error(`aborting artifact upload`);
                            abortPendingFileUploads = true;
                        }
                    }
                    this.statusReporter.incrementProcessedCount();
                }
            })));
            this.statusReporter.stop();
            // done uploading, safety dispose all connections
            this.uploadHttpManager.disposeAndReplaceAllClients();
            core.info(`Total size of all the files uploaded is ${uploadFileSize} bytes`);
            return {
                uploadSize: uploadFileSize,
                totalSize: totalFileSize,
                failedItems: failedItemsToReport
            };
        });
    }
    /**
     * Asynchronously uploads a file. The file is compressed and uploaded using GZip if it is determined to save space.
     * If the upload file is bigger than the max chunk size it will be uploaded via multiple calls
     * @param {number} httpClientIndex The index of the httpClient that is being used to make all of the calls
     * @param {UploadFileParameters} parameters Information about the file that needs to be uploaded
     * @returns The size of the file that was uploaded in bytes along with any failed uploads
     */
    uploadFileAsync(httpClientIndex, parameters) {
        return __awaiter(this, void 0, void 0, function* () {
            const fileStat = yield stat(parameters.file);
            const totalFileSize = fileStat.size;
            const isFIFO = fileStat.isFIFO();
            let offset = 0;
            let isUploadSuccessful = true;
            let failedChunkSizes = 0;
            let uploadFileSize = 0;
            let isGzip = true;
            // the file that is being uploaded is less than 64k in size to increase throughput and to minimize disk I/O
            // for creating a new GZip file, an in-memory buffer is used for compression
            // with named pipes the file size is reported as zero in that case don't read the file in memory
            if (!isFIFO && totalFileSize < 65536) {
                core.debug(`${parameters.file} is less than 64k in size. Creating a gzip file in-memory to potentially reduce the upload size`);
                const buffer = yield upload_gzip_1.createGZipFileInBuffer(parameters.file);
                // An open stream is needed in the event of a failure and we need to retry. If a NodeJS.ReadableStream is directly passed in,
                // it will not properly get reset to the start of the stream if a chunk upload needs to be retried
                let openUploadStream;
                if (totalFileSize < buffer.byteLength) {
                    // compression did not help with reducing the size, use a readable stream from the original file for upload
                    core.debug(`The gzip file created for ${parameters.file} did not help with reducing the size of the file. The original file will be uploaded as-is`);
                    openUploadStream = () => fs.createReadStream(parameters.file);
                    isGzip = false;
                    uploadFileSize = totalFileSize;
                }
                else {
                    // create a readable stream using a PassThrough stream that is both readable and writable
                    core.debug(`A gzip file created for ${parameters.file} helped with reducing the size of the original file. The file will be uploaded using gzip.`);
                    openUploadStream = () => {
                        const passThrough = new stream.PassThrough();
                        passThrough.end(buffer);
                        return passThrough;
                    };
                    uploadFileSize = buffer.byteLength;
                }
                const result = yield this.uploadChunk(httpClientIndex, parameters.resourceUrl, openUploadStream, 0, uploadFileSize - 1, uploadFileSize, isGzip, totalFileSize);
                if (!result) {
                    // chunk failed to upload
                    isUploadSuccessful = false;
                    failedChunkSizes += uploadFileSize;
                    core.warning(`Aborting upload for ${parameters.file} due to failure`);
                }
                return {
                    isSuccess: isUploadSuccessful,
                    successfulUploadSize: uploadFileSize - failedChunkSizes,
                    totalSize: totalFileSize
                };
            }
            else {
                // the file that is being uploaded is greater than 64k in size, a temporary file gets created on disk using the
                // npm tmp-promise package and this file gets used to create a GZipped file
                const tempFile = yield tmp.file();
                core.debug(`${parameters.file} is greater than 64k in size. Creating a gzip file on-disk ${tempFile.path} to potentially reduce the upload size`);
                // create a GZip file of the original file being uploaded, the original file should not be modified in any way
                uploadFileSize = yield upload_gzip_1.createGZipFileOnDisk(parameters.file, tempFile.path);
                let uploadFilePath = tempFile.path;
                // compression did not help with size reduction, use the original file for upload and delete the temp GZip file
                // for named pipes totalFileSize is zero, this assumes compression did help
                if (!isFIFO && totalFileSize < uploadFileSize) {
                    core.debug(`The gzip file created for ${parameters.file} did not help with reducing the size of the file. The original file will be uploaded as-is`);
                    uploadFileSize = totalFileSize;
                    uploadFilePath = parameters.file;
                    isGzip = false;
                }
                else {
                    core.debug(`The gzip file created for ${parameters.file} is smaller than the original file. The file will be uploaded using gzip.`);
                }
                let abortFileUpload = false;
                // upload only a single chunk at a time
                while (offset < uploadFileSize) {
                    const chunkSize = Math.min(uploadFileSize - offset, parameters.maxChunkSize);
                    const startChunkIndex = offset;
                    const endChunkIndex = offset + chunkSize - 1;
                    offset += parameters.maxChunkSize;
                    if (abortFileUpload) {
                        // if we don't want to continue in the event of an error, any pending upload chunks will be marked as failed
                        failedChunkSizes += chunkSize;
                        continue;
                    }
                    const result = yield this.uploadChunk(httpClientIndex, parameters.resourceUrl, () => fs.createReadStream(uploadFilePath, {
                        start: startChunkIndex,
                        end: endChunkIndex,
                        autoClose: false
                    }), startChunkIndex, endChunkIndex, uploadFileSize, isGzip, totalFileSize);
                    if (!result) {
                        // Chunk failed to upload, report as failed and do not continue uploading any more chunks for the file. It is possible that part of a chunk was
                        // successfully uploaded so the server may report a different size for what was uploaded
                        isUploadSuccessful = false;
                        failedChunkSizes += chunkSize;
                        core.warning(`Aborting upload for ${parameters.file} due to failure`);
                        abortFileUpload = true;
                    }
                    else {
                        // if an individual file is greater than 8MB (1024*1024*8) in size, display extra information about the upload status
                        if (uploadFileSize > 8388608) {
                            this.statusReporter.updateLargeFileStatus(parameters.file, startChunkIndex, endChunkIndex, uploadFileSize);
                        }
                    }
                }
                // Delete the temporary file that was created as part of the upload. If the temp file does not get manually deleted by
                // calling cleanup, it gets removed when the node process exits. For more info see: https://www.npmjs.com/package/tmp-promise#about
                core.debug(`deleting temporary gzip file ${tempFile.path}`);
                yield tempFile.cleanup();
                return {
                    isSuccess: isUploadSuccessful,
                    successfulUploadSize: uploadFileSize - failedChunkSizes,
                    totalSize: totalFileSize
                };
            }
        });
    }
    /**
     * Uploads a chunk of an individual file to the specified resourceUrl. If the upload fails and the status code
     * indicates a retryable status, we try to upload the chunk as well
     * @param {number} httpClientIndex The index of the httpClient being used to make all the necessary calls
     * @param {string} resourceUrl Url of the resource that the chunk will be uploaded to
     * @param {NodeJS.ReadableStream} openStream Stream of the file that will be uploaded
     * @param {number} start Starting byte index of file that the chunk belongs to
     * @param {number} end Ending byte index of file that the chunk belongs to
     * @param {number} uploadFileSize Total size of the file in bytes that is being uploaded
     * @param {boolean} isGzip Denotes if we are uploading a Gzip compressed stream
     * @param {number} totalFileSize Original total size of the file that is being uploaded
     * @returns if the chunk was successfully uploaded
     */
    uploadChunk(httpClientIndex, resourceUrl, openStream, start, end, uploadFileSize, isGzip, totalFileSize) {
        return __awaiter(this, void 0, void 0, function* () {
            // open a new stream and read it to compute the digest
            const digest = yield utils_1.digestForStream(openStream());
            // prepare all the necessary headers before making any http call
            const headers = utils_1.getUploadHeaders('application/octet-stream', true, isGzip, totalFileSize, end - start + 1, utils_1.getContentRange(start, end, uploadFileSize), digest);
            const uploadChunkRequest = () => __awaiter(this, void 0, void 0, function* () {
                const client = this.uploadHttpManager.getClient(httpClientIndex);
                return yield client.sendStream('PUT', resourceUrl, openStream(), headers);
            });
            let retryCount = 0;
            const retryLimit = config_variables_1.getRetryLimit();
            // Increments the current retry count and then checks if the retry limit has been reached
            // If there have been too many retries, fail so the download stops
            const incrementAndCheckRetryLimit = (response) => {
                retryCount++;
                if (retryCount > retryLimit) {
                    if (response) {
                        utils_1.displayHttpDiagnostics(response);
                    }
                    core.info(`Retry limit has been reached for chunk at offset ${start} to ${resourceUrl}`);
                    return true;
                }
                return false;
            };
            const backOff = (retryAfterValue) => __awaiter(this, void 0, void 0, function* () {
                this.uploadHttpManager.disposeAndReplaceClient(httpClientIndex);
                if (retryAfterValue) {
                    core.info(`Backoff due to too many requests, retry #${retryCount}. Waiting for ${retryAfterValue} milliseconds before continuing the upload`);
                    yield utils_1.sleep(retryAfterValue);
                }
                else {
                    const backoffTime = utils_1.getExponentialRetryTimeInMilliseconds(retryCount);
                    core.info(`Exponential backoff for retry #${retryCount}. Waiting for ${backoffTime} milliseconds before continuing the upload at offset ${start}`);
                    yield utils_1.sleep(backoffTime);
                }
                core.info(`Finished backoff for retry #${retryCount}, continuing with upload`);
                return;
            });
            // allow for failed chunks to be retried multiple times
            while (retryCount <= retryLimit) {
                let response;
                try {
                    response = yield uploadChunkRequest();
                }
                catch (error) {
                    // if an error is caught, it is usually indicative of a timeout so retry the upload
                    core.info(`An error has been caught http-client index ${httpClientIndex}, retrying the upload`);
                    // eslint-disable-next-line no-console
                    console.log(error);
                    if (incrementAndCheckRetryLimit()) {
                        return false;
                    }
                    yield backOff();
                    continue;
                }
                // Always read the body of the response. There is potential for a resource leak if the body is not read which will
                // result in the connection remaining open along with unintended consequences when trying to dispose of the client
                yield response.readBody();
                if (utils_1.isSuccessStatusCode(response.message.statusCode)) {
                    return true;
                }
                else if (utils_1.isRetryableStatusCode(response.message.statusCode)) {
                    core.info(`A ${response.message.statusCode} status code has been received, will attempt to retry the upload`);
                    if (incrementAndCheckRetryLimit(response)) {
                        return false;
                    }
                    utils_1.isThrottledStatusCode(response.message.statusCode)
                        ? yield backOff(utils_1.tryGetRetryAfterValueTimeInMilliseconds(response.message.headers))
                        : yield backOff();
                }
                else {
                    core.error(`Unexpected response. Unable to upload chunk to ${resourceUrl}`);
                    utils_1.displayHttpDiagnostics(response);
                    return false;
                }
            }
            return false;
        });
    }
    /**
     * Updates the size of the artifact from -1 which was initially set when the container was first created for the artifact.
     * Updating the size indicates that we are done uploading all the contents of the artifact
     */
    patchArtifactSize(size, artifactName) {
        return __awaiter(this, void 0, void 0, function* () {
            const resourceUrl = new url_1.URL(utils_1.getArtifactUrl());
            resourceUrl.searchParams.append('artifactName', artifactName);
            const parameters = { Size: size };
            const data = JSON.stringify(parameters, null, 2);
            core.debug(`URL is ${resourceUrl.toString()}`);
            // use the first client from the httpManager, `keep-alive` is not used so the connection will close immediately
            const client = this.uploadHttpManager.getClient(0);
            const headers = utils_1.getUploadHeaders('application/json', false);
            // Extra information to display when a particular HTTP code is returned
            const customErrorMessages = new Map([
                [
                    http_client_1.HttpCodes.NotFound,
                    `An Artifact with the name ${artifactName} was not found`
                ]
            ]);
            // TODO retry for all possible response codes, the artifact upload is pretty much complete so it at all costs we should try to finish this
            const response = yield requestUtils_1.retryHttpClientRequest('Finalize artifact upload', () => __awaiter(this, void 0, void 0, function* () { return client.patch(resourceUrl.toString(), data, headers); }), customErrorMessages);
            yield response.readBody();
            core.debug(`Artifact ${artifactName} has been successfully uploaded, total size in bytes: ${size}`);
        });
    }
}
exports.UploadHttpClient = UploadHttpClient;
//# sourceMappingURL=upload-http-client.js.map