Skip to content

Commit 5510535

Browse files
committed
PoC download externally stored evaluator logs
1 parent f7caf01 commit 5510535

File tree

3 files changed

+317
-5
lines changed

3 files changed

+317
-5
lines changed

extensions/ql-vscode/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,10 @@
751751
"dark": "media/dark/github.svg"
752752
}
753753
},
754+
{
755+
"command": "codeQL.compare-performance.downloadExternalLogs",
756+
"title": "Download External Logs for Performance Comparison"
757+
},
754758
{
755759
"command": "codeQL.setCurrentDatabase",
756760
"title": "CodeQL: Set Current Database"

extensions/ql-vscode/src/compare-performance/compare-performance-view.ts

Lines changed: 312 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
1-
import { ViewColumn } from "vscode";
2-
1+
import { execFileSync } from "child_process";
2+
import {
3+
createWriteStream,
4+
ensureDir,
5+
existsSync,
6+
readdirSync,
7+
remove,
8+
} from "fs-extra";
9+
import path, { basename, join } from "path";
10+
import { Uri, ViewColumn } from "vscode";
311
import type { CodeQLCliServer } from "../codeql-cli/cli";
412
import type { App } from "../common/app";
513
import { redactableError } from "../common/errors";
14+
import { createTimeoutSignal } from "../common/fetch-stream";
615
import type {
716
FromComparePerformanceViewMessage,
817
ToComparePerformanceViewMessage,
@@ -12,16 +21,27 @@ import { showAndLogExceptionWithTelemetry } from "../common/logging";
1221
import { extLogger } from "../common/logging/vscode";
1322
import type { WebviewPanelConfig } from "../common/vscode/abstract-webview";
1423
import { AbstractWebview } from "../common/vscode/abstract-webview";
24+
import type { ProgressCallback } from "../common/vscode/progress";
25+
import { reportStreamProgress, withProgress } from "../common/vscode/progress";
1526
import { telemetryListener } from "../common/vscode/telemetry";
16-
import type { HistoryItemLabelProvider } from "../query-history/history-item-label-provider";
17-
import { PerformanceOverviewScanner } from "../log-insights/performance-comparison";
18-
import { scanLog } from "../log-insights/log-scanner";
27+
import { downloadTimeout } from "../config";
1928
import type { ResultsView } from "../local-queries";
29+
import { scanLog } from "../log-insights/log-scanner";
30+
import { PerformanceOverviewScanner } from "../log-insights/performance-comparison";
31+
import type { HistoryItemLabelProvider } from "../query-history/history-item-label-provider";
32+
import { tmpDir } from "../tmp-dir";
33+
34+
type ComparePerformanceCommands = {
35+
"codeQL.compare-performance.downloadExternalLogs": () => Promise<void>;
36+
};
2037

2138
export class ComparePerformanceView extends AbstractWebview<
2239
ToComparePerformanceViewMessage,
2340
FromComparePerformanceViewMessage
2441
> {
42+
private workingDirectory;
43+
private LOG_DOWNLOAD_PROGRESS_STEPS = 3;
44+
2545
constructor(
2646
app: App,
2747
public cliServer: CodeQLCliServer, // TODO: make private
@@ -30,6 +50,10 @@ export class ComparePerformanceView extends AbstractWebview<
3050
private resultsView: ResultsView,
3151
) {
3252
super(app);
53+
this.workingDirectory = path.join(
54+
app.globalStoragePath,
55+
"compare-performance",
56+
);
3357
}
3458

3559
async showResults(fromJsonLog: string, toJsonLog: string) {
@@ -94,4 +118,287 @@ export class ComparePerformanceView extends AbstractWebview<
94118
break;
95119
}
96120
}
121+
122+
async downloadExternalLogs(): Promise<void> {
123+
const client = await this.app.credentials.getOctokit();
124+
async function getArtifactDownloadUrl(
125+
url: string,
126+
): Promise<{ url: string; bytes: number; id: string }> {
127+
const pattern =
128+
/https:\/\/github.com\/([^/]+)\/([^/]+)\/actions\/runs\/([^/]+)\/artifacts\/([^/]+)/;
129+
const match = url.match(pattern);
130+
if (!match) {
131+
throw new Error(`Invalid artifact URL: ${url}`);
132+
}
133+
const [, owner, repo, , artifact_id] = match;
134+
const response = await client.request(
135+
"HEAD /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/{archive_format}",
136+
{
137+
owner,
138+
repo,
139+
artifact_id,
140+
archive_format: "zip",
141+
},
142+
);
143+
if (!response.headers["content-length"]) {
144+
throw new Error(
145+
`No content-length header found for artifact URL: ${url}`,
146+
);
147+
}
148+
return {
149+
url: response.url,
150+
bytes: response.headers["content-length"],
151+
id: `artifacts/${owner}/${repo}/${artifact_id}`,
152+
};
153+
}
154+
155+
const downloadLog = async (originalUrl: string) => {
156+
const {
157+
url,
158+
bytes,
159+
id: artifactDiskId,
160+
} = await getArtifactDownloadUrl(originalUrl);
161+
const logPath = path.join(
162+
this.workingDirectory,
163+
`logs-of/${artifactDiskId}`,
164+
);
165+
if (existsSync(logPath) && readdirSync(logPath).length > 0) {
166+
void extLogger.log(
167+
`Skipping log download and extraction to existing '${logPath}'...`,
168+
);
169+
}
170+
await withProgress(
171+
async (progress) => {
172+
const downloadPath = path.join(this.workingDirectory, artifactDiskId);
173+
if (
174+
existsSync(downloadPath) &&
175+
readdirSync(downloadPath).length > 0
176+
) {
177+
void extLogger.log(
178+
`Skipping download to existing '${artifactDiskId}'...`,
179+
);
180+
} else {
181+
await ensureDir(downloadPath);
182+
void extLogger.log(
183+
`Downloading from ${artifactDiskId} (bytes: ${bytes}) ${downloadPath}...`,
184+
);
185+
await this.fetchAndUnzip(url, downloadPath, progress);
186+
}
187+
if (existsSync(logPath) && readdirSync(logPath).length >= 0) {
188+
void extLogger.log(
189+
`Skipping log extraction to existing '${logPath}'...`,
190+
);
191+
} else {
192+
await ensureDir(logPath);
193+
// find the lone tar.gz file in the unzipped directory
194+
const unzippedFiles = readdirSync(downloadPath);
195+
const tarGzFiles = unzippedFiles.filter((f) =>
196+
f.endsWith(".tar.gz"),
197+
);
198+
if (tarGzFiles.length !== 1) {
199+
throw new Error(
200+
`Expected exactly one .tar.gz file in the unzipped directory, but found: ${tarGzFiles.join(
201+
", ",
202+
)}`,
203+
);
204+
}
205+
await this.untargz(
206+
path.join(downloadPath, tarGzFiles[0]),
207+
logPath,
208+
progress,
209+
);
210+
}
211+
},
212+
{
213+
title: `Downloading evaluator logs (${(bytes / 1024 / 1024).toFixed(1)} MB}`,
214+
},
215+
);
216+
};
217+
// hardcoded URLs from https://github.com/codeql-dca-runners/codeql-dca-worker_javascript/actions/runs/11816721194
218+
const url1 =
219+
"https://github.com/codeql-dca-runners/codeql-dca-worker_javascript/actions/runs/11816721194/artifacts/2181621080";
220+
const url2 =
221+
"https://github.com/codeql-dca-runners/codeql-dca-worker_javascript/actions/runs/11816721194/artifacts/2181601861";
222+
223+
await Promise.all([downloadLog(url1), downloadLog(url2)]);
224+
void extLogger.log(`Downloaded logs to ${this.workingDirectory}`);
225+
226+
return;
227+
}
228+
229+
private async fetchAndUnzip(
230+
contentUrl: string,
231+
// (see below) requestHeaders: { [key: string]: string },
232+
unzipPath: string,
233+
progress?: ProgressCallback,
234+
) {
235+
// Although it is possible to download and stream directly to an unzipped directory,
236+
// we need to avoid this for two reasons. The central directory is located at the
237+
// end of the zip file. It is the source of truth of the content locations. Individual
238+
// file headers may be incorrect. Additionally, saving to file first will reduce memory
239+
// pressure compared with unzipping while downloading the archive.
240+
241+
const archivePath = join(tmpDir.name, `archive-${Date.now()}.zip`);
242+
243+
progress?.({
244+
maxStep: this.LOG_DOWNLOAD_PROGRESS_STEPS,
245+
message: "Downloading content",
246+
step: 1,
247+
});
248+
249+
const {
250+
signal,
251+
onData,
252+
dispose: disposeTimeout,
253+
} = createTimeoutSignal(downloadTimeout());
254+
255+
let response: Response;
256+
try {
257+
response = await this.checkForFailingResponse(
258+
await fetch(contentUrl, {
259+
// XXX disabled header forwarding headers: requestHeaders,
260+
signal,
261+
}),
262+
"Error downloading content",
263+
);
264+
} catch (e) {
265+
disposeTimeout();
266+
267+
if (e instanceof DOMException && e.name === "AbortError") {
268+
const thrownError = new Error("The request timed out.");
269+
thrownError.stack = e.stack;
270+
throw thrownError;
271+
}
272+
273+
throw e;
274+
}
275+
276+
const body = response.body;
277+
if (!body) {
278+
throw new Error("No response body found");
279+
}
280+
281+
const archiveFileStream = createWriteStream(archivePath);
282+
283+
const contentLength = response.headers.get("content-length");
284+
const totalNumBytes = contentLength
285+
? parseInt(contentLength, 10)
286+
: undefined;
287+
288+
const reportProgress = reportStreamProgress(
289+
"Downloading log",
290+
totalNumBytes,
291+
progress,
292+
);
293+
294+
try {
295+
const reader = body.getReader();
296+
for (;;) {
297+
const { done, value } = await reader.read();
298+
if (done) {
299+
break;
300+
}
301+
302+
onData();
303+
reportProgress(value?.length ?? 0);
304+
305+
await new Promise((resolve, reject) => {
306+
archiveFileStream.write(value, (err) => {
307+
if (err) {
308+
reject(err);
309+
}
310+
resolve(undefined);
311+
});
312+
});
313+
}
314+
315+
await new Promise((resolve, reject) => {
316+
archiveFileStream.close((err) => {
317+
if (err) {
318+
reject(err);
319+
}
320+
resolve(undefined);
321+
});
322+
});
323+
} catch (e) {
324+
// Close and remove the file if an error occurs
325+
archiveFileStream.close(() => {
326+
void remove(archivePath);
327+
});
328+
329+
if (e instanceof DOMException && e.name === "AbortError") {
330+
const thrownError = new Error("The download timed out.");
331+
thrownError.stack = e.stack;
332+
throw thrownError;
333+
}
334+
335+
throw e;
336+
} finally {
337+
disposeTimeout();
338+
}
339+
340+
await this.readAndUnzip(
341+
Uri.file(archivePath).toString(true),
342+
unzipPath,
343+
progress,
344+
);
345+
346+
// remove archivePath eagerly since these archives can be large.
347+
await remove(archivePath);
348+
}
349+
350+
private async checkForFailingResponse(
351+
response: Response,
352+
errorMessage: string,
353+
): Promise<Response | never> {
354+
if (response.ok) {
355+
return response;
356+
}
357+
358+
// An error downloading the content. Attempt to extract the reason behind it.
359+
const text = await response.text();
360+
let msg: string;
361+
try {
362+
const obj = JSON.parse(text);
363+
msg =
364+
obj.error || obj.message || obj.reason || JSON.stringify(obj, null, 2);
365+
} catch {
366+
msg = text;
367+
}
368+
throw new Error(`${errorMessage}.\n\nReason: ${msg}`);
369+
}
370+
371+
private async readAndUnzip(
372+
zipUrl: string,
373+
unzipPath: string,
374+
progress?: ProgressCallback,
375+
) {
376+
const zipFile = Uri.parse(zipUrl).fsPath;
377+
progress?.({
378+
maxStep: this.LOG_DOWNLOAD_PROGRESS_STEPS,
379+
step: 2,
380+
message: `Unzipping into ${basename(unzipPath)}`,
381+
});
382+
execFileSync("unzip", ["-q", "-d", unzipPath, zipFile]);
383+
}
384+
private async untargz(
385+
tarballPath: string,
386+
untarPath: string,
387+
progress?: ProgressCallback,
388+
) {
389+
progress?.({
390+
maxStep: this.LOG_DOWNLOAD_PROGRESS_STEPS,
391+
step: 3,
392+
message: `Untarring into ${basename(untarPath)}`,
393+
});
394+
void extLogger.log(`Untarring ${tarballPath} into ${untarPath}`);
395+
execFileSync("tar", ["-xzf", tarballPath, "-C", untarPath]);
396+
}
397+
398+
public getCommands(): ComparePerformanceCommands {
399+
return {
400+
"codeQL.compare-performance.downloadExternalLogs":
401+
this.downloadExternalLogs.bind(this),
402+
};
403+
}
97404
}

extensions/ql-vscode/src/extension.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,6 +1076,7 @@ async function activateWithInstalledDistribution(
10761076
...testUiCommands,
10771077
...mockServer.getCommands(),
10781078
...debuggerUI.getCommands(),
1079+
...comparePerformanceView.getCommands(),
10791080
};
10801081

10811082
for (const [commandName, command] of Object.entries(allCommands)) {

0 commit comments

Comments
 (0)