Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions lib/entry-points.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

129 changes: 127 additions & 2 deletions src/tools-download.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { once } from "events";
import * as fs from "fs";
import { ClientRequest, IncomingMessage } from "http";
import * as path from "path";

import * as core from "@actions/core";
import * as toolcache from "@actions/tool-cache";
import test from "ava";
import { https } from "follow-redirects";
import nock from "nock";
import * as sinon from "sinon";

import { getRunnerLogger } from "./logging";
import * as tar from "./tar";
import { setupTests } from "./testing-utils";
import { downloadAndExtract } from "./tools-download";
import { withTmpDir } from "./util";
import { HTTPError, withTmpDir } from "./util";

setupTests(test);

Expand Down Expand Up @@ -49,7 +53,10 @@ test.serial(
const destination = path.join(tmpDir, "codeql");
const downloadTool = sinon
.stub(toolcache, "downloadTool")
.resolves(archivePath);
.callsFake(async () => {
t.false(fs.existsSync(destination));
return archivePath;
});
const extract = sinon.stub(tar, "extract").resolves(destination);
const extractTarZst = sinon.stub(tar, "extractTarZst").resolves();
const request = nock("https://example.com")
Expand Down Expand Up @@ -78,6 +85,124 @@ test.serial(
},
);

test.serial(
"downloadAndExtract rethrows a 404 rather than retrying the download",
async (t) => {
await withTmpDir(async (tmpDir) => {
sinon.stub(process, "platform").value("linux");
const destination = path.join(tmpDir, "codeql");
const downloadTool = sinon.stub(toolcache, "downloadTool");
const extractTarZst = sinon.stub(tar, "extractTarZst").resolves();
const request = nock("https://example.com")
.get("/codeql-bundle.tar.zst")
.reply(404, "Not found");

const error = await t.throwsAsync(
downloadAndExtract(
"https://example.com/codeql-bundle.tar.zst",
"zstd",
destination,
undefined,
{},
{ type: "gnu", version: "1.34" },
getRunnerLogger(true),
),
{
instanceOf: HTTPError,
message:
"Failed to download CodeQL bundle from https://example.com/codeql-bundle.tar.zst. HTTP status code: 404.",
},
);

t.is(error?.status, 404);
t.true(request.isDone());
t.false(extractTarZst.called);
t.false(downloadTool.called);
t.false(fs.existsSync(destination));
});
},
);

test.serial(
"downloadAndExtract falls back to downloading before extracting on a server error",
async (t) => {
await withTmpDir(async (tmpDir) => {
sinon.stub(process, "platform").value("linux");
const archivePath = path.join(tmpDir, "codeql-bundle.tar.zst");
const destination = path.join(tmpDir, "codeql");
const downloadTool = sinon
.stub(toolcache, "downloadTool")
.callsFake(async () => {
t.false(fs.existsSync(destination));
return archivePath;
});
const extract = sinon.stub(tar, "extract").resolves(destination);
const extractTarZst = sinon.stub(tar, "extractTarZst").resolves();
const request = nock("https://example.com")
.get("/codeql-bundle.tar.zst")
.reply(500);

const statusReport = await downloadAndExtract(
"https://example.com/codeql-bundle.tar.zst",
"zstd",
destination,
undefined,
{},
{ type: "gnu", version: "1.34" },
getRunnerLogger(true),
);

t.assert(Number.isInteger(statusReport.downloadDurationMs));
t.true(request.isDone());
t.false(extractTarZst.called);
t.true(downloadTool.calledOnce);
t.true(extract.calledOnce);
});
},
);

test.serial(
"downloadAndExtract omits an unknown HTTP status from the error message",
async (t) => {
await withTmpDir(async (tmpDir) => {
sinon.stub(process, "platform").value("linux");
const archivePath = path.join(tmpDir, "codeql-bundle.tar.zst");
const destination = path.join(tmpDir, "codeql");
const response = sinon.createStubInstance(IncomingMessage);
response.statusCode = undefined;
sinon
.stub(https, "get")
.callsArgWith(2, response)
.returns(sinon.createStubInstance(ClientRequest));
const warning = sinon.stub(core, "warning");
const downloadTool = sinon
.stub(toolcache, "downloadTool")
.resolves(archivePath);
const extract = sinon.stub(tar, "extract").resolves(destination);
const extractTarZst = sinon.stub(tar, "extractTarZst").resolves();

await downloadAndExtract(
"https://example.com/codeql-bundle.tar.zst",
"zstd",
destination,
undefined,
{},
{ type: "gnu", version: "1.34" },
getRunnerLogger(true),
);

t.is(
warning.firstCall.args[0],
"Failed to download and extract CodeQL bundle using streaming with error: Failed to download CodeQL bundle from https://example.com/codeql-bundle.tar.zst.",
);
t.true(response.resume.calledOnce);
t.false(extractTarZst.called);
t.true(downloadTool.calledOnce);
t.true(extract.calledOnce);
});
},
);

test.serial(
"downloadAndExtract reports only the total duration when streaming extraction",
async (t) => {
Expand Down
33 changes: 24 additions & 9 deletions src/tools-download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ import { ActionState } from "./action-common";
import { ActionsEnvVars, getEnv, ReadOnlyEnv } from "./environment";
import { formatDuration, Logger } from "./logging";
import * as tar from "./tar";
import { cleanUpPath, getErrorMessage, getRequiredEnvParam } from "./util";
import {
asHTTPError,
cleanUpPath,
getErrorMessage,
getRequiredEnvParam,
HTTPError,
} from "./util";

/**
* High watermark to use when streaming the download and extraction of the CodeQL tools.
Expand Down Expand Up @@ -88,14 +94,20 @@ export async function downloadAndExtract(
return { totalDurationMs };
}
} catch (e) {
// If we failed during processing, we want to clean up the destination directory
// before we either try again or give up.
await cleanUpPath(dest, "CodeQL bundle", logger);

// Retrying a 404 is pointless: the asset does not exist, so downloading it a different way
// will fail in the same way.
if (asHTTPError(e)?.status === 404) {
throw e;
}

core.warning(
`Failed to download and extract CodeQL bundle using streaming with error: ${getErrorMessage(e)}`,
);
core.warning(`Falling back to downloading the bundle before extracting.`);

// If we failed during processing, we want to clean up the destination directory
// before we try again.
await cleanUpPath(dest, "CodeQL bundle", logger);
}

const toolsDownloadStart = performance.now();
Expand Down Expand Up @@ -188,12 +200,15 @@ async function downloadAndExtractZstdWithStreaming(
});
});

if (response.statusCode !== 200) {
const statusCode = response.statusCode ?? 0;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I have seen the previous review comment from Copilot.)

Why default to 0 here? Based on the review comment from Copilot, it seems that the justification is so that we don't end up with undefined in the HTTPError below, but why even throw a HTTPError at all in that case? Could we throw a non-HTTPError if we don't have a status code instead?

If the HTTPError is needed, e.g. because some upstream handler uses it to distinguish between different scenarios, then it would be worth documenting that here (e.g. "We throw a HTTPError even if we don't have a status code, because ...") or possibly refactoring so that we can throw a different error type here and still get the desired upstream effect.

if (statusCode !== 200) {
// Discard the response body so that the connection can be released.
response.resume();
throw new Error(
`Failed to download CodeQL bundle from ${codeqlURL}. HTTP status code: ${response.statusCode}.`,
);
let message = `Failed to download CodeQL bundle from ${codeqlURL}.`;
if (statusCode !== 0) {
message += ` HTTP status code: ${statusCode}.`;
}
throw new HTTPError(message, statusCode);
Comment on lines +207 to +211

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The message is only used once, so it might be nicer (especially if we throw a different kind of error in the absence of a statusCode) to have e.g.

Suggested change
let message = `Failed to download CodeQL bundle from ${codeqlURL}.`;
if (statusCode !== 0) {
message += ` HTTP status code: ${statusCode}.`;
}
throw new HTTPError(message, statusCode);
const baseMessage = `Failed to download CodeQL bundle from ${codeqlURL}.`;
if (statusCode !== 0) {
throw new HTTPError(`${baseMessage} HTTP status code: ${statusCode}.`;
}
throw new SomeOtherError(baseMessage);

}

await tar.extractTarZst(response, dest, tarVersion, logger);
Expand Down
Loading