From 42051f1620c86f0d8ca6e15ef656944a1459272d Mon Sep 17 00:00:00 2001 From: Andrew Eisenberg Date: Sun, 24 Oct 2021 16:37:23 -0700 Subject: [PATCH] Remote Queries: Create packs for remote queries This is still a bit rough, but handles two cases: 1. There is a qlpack.yml or codeql-pack.yml file in the same directory as the query to run remotely. In this case, run `codeql pack packlist` to determine what files to include (and also always include the lock file and the query itself. Copy to a temp folder and run `pack install`, then `pack bundle`. Finally upload. 2. There is no qlpack in the current directory. Just copy the single file to the temp folder and generate a synthetic qlpack before installing, bundling and uploading. Two cases that are not handled: 1. The query file is part of a workspace. Peer dependencies will not be found. 2. The query file and its qlpack file are not in the same directory. These should be possible to handle later. Also, need to create some unit and integration tests for this. --- extensions/ql-vscode/src/cli.ts | 51 ++- extensions/ql-vscode/src/config.ts | 2 +- extensions/ql-vscode/src/extension.ts | 16 +- extensions/ql-vscode/src/helpers.ts | 16 +- extensions/ql-vscode/src/run-remote-query.ts | 332 +++++++++++++----- .../data-remote-no-qlpack/in-pack.ql | 2 + .../data-remote-no-qlpack/lib.qll | 3 + .../data-remote-no-qlpack/not-in-pack.ql | 4 + .../data-remote-qlpack/in-pack.ql | 4 + .../data-remote-qlpack/lib.qll | 3 + .../data-remote-qlpack/not-in-pack.ql | 2 + .../data-remote-qlpack/qlpack.yml | 5 + .../src/vscode-tests/cli-integration/index.ts | 8 +- .../cli-integration/run-remote-query.test.ts | 164 +++++++++ .../ql-vscode/src/vscode-tests/ensureCli.ts | 2 +- 15 files changed, 517 insertions(+), 97 deletions(-) create mode 100644 extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/in-pack.ql create mode 100644 extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/lib.qll create mode 100644 extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/not-in-pack.ql create mode 100644 extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/in-pack.ql create mode 100644 extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/lib.qll create mode 100644 extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/not-in-pack.ql create mode 100644 extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/qlpack.yml create mode 100644 extensions/ql-vscode/src/vscode-tests/cli-integration/run-remote-query.test.ts diff --git a/extensions/ql-vscode/src/cli.ts b/extensions/ql-vscode/src/cli.ts index dc6f49de2..32b424b0e 100644 --- a/extensions/ql-vscode/src/cli.ts +++ b/extensions/ql-vscode/src/cli.ts @@ -605,7 +605,7 @@ export class CodeQLCliServer implements Disposable { if (target) subcommandArgs.push('--target', target); if (name) subcommandArgs.push('--name', name); subcommandArgs.push(archivePath); - + return await this.runCodeQlCliCommand(['database', 'unbundle'], subcommandArgs, `Extracting ${archivePath} to directory ${target}`); } @@ -805,8 +805,30 @@ export class CodeQLCliServer implements Disposable { return this.runJsonCodeQlCliCommand(['pack', 'install'], [dir], 'Installing pack dependencies'); } - async packBundle(dir: string, outputPath: string): Promise { - return this.runJsonCodeQlCliCommand(['pack', 'bundle'], ['-o', outputPath, dir], 'Bundling pack'); + async packBundle(dir: string, workspaceFolders: string[], outputPath: string, precompile = true): Promise { + const args = [ + '-o', + outputPath, + dir, + '--additional-packs', + workspaceFolders.join(path.delimiter) + ]; + if (!precompile && await this.cliConstraints.supportsNoPrecompile()) { + args.push('--no-precompile'); + } + + return this.runJsonCodeQlCliCommand(['pack', 'bundle'], args, 'Bundling pack'); + } + + async packPacklist(dir: string, includeQueries: boolean): Promise { + const args = includeQueries ? [dir] : ['--no-include-queries', dir]; + const results = await this.runJsonCodeQlCliCommand(['pack', 'packlist'], args, 'Generating the pack list'); + + if (await this.cliConstraints.usesNewPackPacklistLayout()) { + return (results as { paths: string[] }).paths; + } else { + return results as string[]; + } } async generateDil(qloFile: string, outFile: string): Promise { @@ -1057,6 +1079,12 @@ export function shouldDebugQueryServer() { && process.env.QUERY_SERVER_JAVA_DEBUG?.toLocaleLowerCase() !== 'false'; } +export function shouldDebugCliServer() { + return 'CLI_SERVER_JAVA_DEBUG' in process.env + && process.env.CLI_SERVER_JAVA_DEBUG !== '0' + && process.env.CLI_SERVER_JAVA_DEBUG?.toLocaleLowerCase() !== 'false'; +} + export class CliVersionConstraint { /** @@ -1096,6 +1124,16 @@ export class CliVersionConstraint { */ public static CLI_VERSION_WITH_DATABASE_UNBUNDLE = new SemVer('2.6.0'); + /** + * CLI version where the `--no-precompile` option for pack creation was introduced. + */ + public static CLI_VERSION_WITH_NO_PRECOMPILE = new SemVer('2.7.1'); + + /** + * CLI version where `pack packlist` layout changed from array to object + */ + public static CLI_VERSION_PACK_PACKLIST_LAYOUT_CHANGE = new SemVer('2.7.1'); + constructor(private readonly cli: CodeQLCliServer) { /**/ } @@ -1132,4 +1170,11 @@ export class CliVersionConstraint { return this.isVersionAtLeast(CliVersionConstraint.CLI_VERSION_WITH_DATABASE_UNBUNDLE); } + async supportsNoPrecompile() { + return this.isVersionAtLeast(CliVersionConstraint.CLI_VERSION_WITH_NO_PRECOMPILE); + } + + async usesNewPackPacklistLayout() { + return this.isVersionAtLeast(CliVersionConstraint.CLI_VERSION_PACK_PACKLIST_LAYOUT_CHANGE); + } } diff --git a/extensions/ql-vscode/src/config.ts b/extensions/ql-vscode/src/config.ts index d8d1a8b06..67e7f4721 100644 --- a/extensions/ql-vscode/src/config.ts +++ b/extensions/ql-vscode/src/config.ts @@ -304,7 +304,7 @@ const REMOTE_QUERIES_SETTING = new Setting('remoteQueries', ROOT_SETTING); /** * Lists of GitHub repositories that you want to query remotely via the "Run Remote query" command. * Note: This command is only available for internal users. - * + * * This setting should be a JSON object where each key is a user-specified name (string), * and the value is an array of GitHub repositories (of the form `/`). */ diff --git a/extensions/ql-vscode/src/extension.ts b/extensions/ql-vscode/src/extension.ts index 81dc308b8..54b80975a 100644 --- a/extensions/ql-vscode/src/extension.ts +++ b/extensions/ql-vscode/src/extension.ts @@ -716,13 +716,25 @@ async function activateWithInstalledDistribution( ); // The "runRemoteQuery" command is internal-only. ctx.subscriptions.push( - commandRunner('codeQL.runRemoteQuery', async ( + commandRunnerWithProgress('codeQL.runRemoteQuery', async ( + progress: ProgressCallback, + token: CancellationToken, uri: Uri | undefined ) => { if (isCanary()) { + progress({ + maxStep: 5, + step: 0, + message: 'Getting credentials' + }); const credentials = await Credentials.initialize(ctx); - await runRemoteQuery(cliServer, credentials, uri || window.activeTextEditor?.document.uri); + await runRemoteQuery(cliServer, credentials, uri || window.activeTextEditor?.document.uri, false, progress, token); + } else { + throw new Error('Remote queries require the CodeQL Canary version to run.'); } + }, { + title: 'Run Remote Query', + cancellable: true }) ); ctx.subscriptions.push( diff --git a/extensions/ql-vscode/src/helpers.ts b/extensions/ql-vscode/src/helpers.ts index 9caf7991b..d5156dd17 100644 --- a/extensions/ql-vscode/src/helpers.ts +++ b/extensions/ql-vscode/src/helpers.ts @@ -10,6 +10,7 @@ import { env } from 'vscode'; import { CodeQLCliServer, QlpacksInfo } from './cli'; +import { UserCancellationException } from './commandRunner'; import { logger } from './logging'; /** @@ -494,14 +495,25 @@ export async function findLanguage( void logger.log('Could not autodetect query language. Select language manually.'); } } - const availableLanguages = Object.keys(await cliServer.resolveLanguages()); + + // will be undefined if user cancels the quick pick. + return await askForLanguage(cliServer, false); +} + + +export async function askForLanguage(cliServer: CodeQLCliServer, throwOnEmpty = true): Promise { + const availableLanguages = Object.keys(await cliServer.resolveLanguages()).sort(); const language = await Window.showQuickPick( availableLanguages, { placeHolder: 'Select target language for your query', ignoreFocusOut: true } ); if (!language) { // This only happens if the user cancels the quick pick. - void showAndLogErrorMessage('Language not found. Language must be specified manually.'); + if (throwOnEmpty) { + throw new UserCancellationException('Cancelled.'); + } else { + void showAndLogErrorMessage('Language not found. Language must be specified manually.'); + } } return language; } diff --git a/extensions/ql-vscode/src/run-remote-query.ts b/extensions/ql-vscode/src/run-remote-query.ts index a3960795a..a142b550f 100644 --- a/extensions/ql-vscode/src/run-remote-query.ts +++ b/extensions/ql-vscode/src/run-remote-query.ts @@ -1,13 +1,15 @@ -import { QuickPickItem, Uri, window } from 'vscode'; +import { CancellationToken, QuickPickItem, Uri, window } from 'vscode'; import * as path from 'path'; import * as yaml from 'js-yaml'; import * as fs from 'fs-extra'; import * as tmp from 'tmp-promise'; -import { findLanguage, showAndLogErrorMessage, showAndLogInformationMessage, showInformationMessageWithAction } from './helpers'; +import { askForLanguage, findLanguage, getOnDiskWorkspaceFolders, showAndLogErrorMessage, showAndLogInformationMessage, showInformationMessageWithAction } from './helpers'; import { Credentials } from './authentication'; import * as cli from './cli'; import { logger } from './logging'; import { getRemoteControllerRepo, getRemoteRepositoryLists, setRemoteControllerRepo } from './config'; +import { tmpDir } from './run-queries'; +import { ProgressCallback, UserCancellationException } from './commandRunner'; interface Config { repositories: string[]; ref?: string; @@ -70,93 +72,244 @@ export async function getRepositories(): Promise { } } -async function generateQueryPack(cliServer: cli.CodeQLCliServer, queryFile: string): Promise { +/** + * Two possibilities: + * 1. There is no qlpack.yml in this directory. Assume this is a lone query and generate a synthetic qlpack for it. + * 2. There is a qlpack.yml in this directory. Assume this is a query pack and use the yml to pack the query before uploading it. + * + * @returns the entire qlpack as a base64 string. + */ +async function generateQueryPack(cliServer: cli.CodeQLCliServer, queryFile: string, queryPackDir: string, fallbackLanguage?: string): Promise<{ + base64Pack: string, + language: string +}> { + const originalPackRoot = path.dirname(queryFile); + // TODO this assumes that the qlpack.yml is in the same directory as the query file, but in reality, + // the file could be in a parent directory. + const targetQueryFileName = path.join(queryPackDir, path.basename(queryFile)); - const packRoot = path.dirname(queryFile); + // the server is expecting the query file to be named `query.ql`. Rename it here. + const renamedQueryFile = path.join(queryPackDir, 'query.ql'); - const bundlePath = await tmp.tmpName({ - postfix: '.tgz', - prefix: 'qlpack-', - }); + let language: string | undefined; + if (await fs.pathExists(path.join(originalPackRoot, 'qlpack.yml'))) { + // don't include ql files. We only want the queryFile to be copied. + const toCopy = await cliServer.packPacklist(originalPackRoot, false); - await cliServer.packBundle(packRoot, bundlePath); + // also copy the lock file (either new name or old name) and the query file itself. These are not included in the packlist. + [path.join(originalPackRoot, 'qlpack.lock.yml'), path.join(originalPackRoot, 'codeql-pack.lock.yml'), queryFile] + .forEach(aboslutePath => { + if (aboslutePath) { + toCopy.push(aboslutePath); + } + }); + void logger.log(`Copying ${toCopy.length} files to ${queryPackDir}`); + await fs.copy(originalPackRoot, queryPackDir, { + filter: (file: string) => + // copy file if it is in the packlist, or it is a parent directory of a file in the packlist + !!toCopy.find(f => f === file || f.startsWith(file + path.sep) + ) + }); + language = await findLanguage(cliServer, Uri.file(targetQueryFileName)); + + } else { + // open popup to ask for language if not already hardcoded + language = fallbackLanguage || await askForLanguage(cliServer); + + // copy only the query file to the query pack directory + // and generate a synthetic query pack + // TODO this has a limitation that query packs inside of a workspace will not resolve it's peer dependencies. + // Something to work on later. For now, we will only support query packs that are not in a workspace. + void logger.log(`Copying ${queryFile} to ${queryPackDir}`); + await fs.copy(queryFile, targetQueryFileName); + void logger.log('Generating synthetic query pack'); + const syntheticQueryPack = { + name: 'codeql-remote/query', + version: '1.0.0', + dependencies: { + [`codeql/${language}-all`]: '*', + } + }; + await fs.writeFile(path.join(queryPackDir, 'qlpack.yml'), yaml.safeDump(syntheticQueryPack)); + } + if (!language) { + throw new UserCancellationException('Could not determine language.'); + } + + await fs.rename(targetQueryFileName, renamedQueryFile); + + const bundlePath = await getPackedBundlePath(queryPackDir); + void logger.log(`Compiling and bundling query pack from ${queryPackDir} to ${bundlePath}. (This may take a while.)`); + await cliServer.packInstall(queryPackDir); + const workspaceFolders = getOnDiskWorkspaceFolders(); + await cliServer.packBundle(queryPackDir, workspaceFolders, bundlePath, false); const base64Pack = (await fs.readFile(bundlePath)).toString('base64'); - - await fs.unlink(bundlePath); - return base64Pack; + return { + base64Pack, + language + }; } -export async function runRemoteQuery(cliServer: cli.CodeQLCliServer, credentials: Credentials, uri?: Uri) { - if (!uri?.fsPath.endsWith('.ql')) { +async function createRemoteQueriesTempDirectory() { + const remoteQueryDir = await tmp.dir({ dir: tmpDir.name, unsafeCleanup: true }); + const queryPackDir = path.join(remoteQueryDir.path, 'query-pack'); + await fs.mkdirp(queryPackDir); + return { remoteQueryDir, queryPackDir }; +} + +async function getPackedBundlePath(queryPackDir: string) { + return tmp.tmpName({ + dir: path.dirname(queryPackDir), + postfix: 'generated.tgz', + prefix: 'qlpack', + }); +} + +export async function runRemoteQuery( + cliServer: cli.CodeQLCliServer, + credentials: Credentials, + uri: Uri | undefined, + dryRun: boolean, + progress: ProgressCallback, + token: CancellationToken +): Promise { + const { remoteQueryDir, queryPackDir } = await createRemoteQueriesTempDirectory(); + try { + if (!uri?.fsPath.endsWith('.ql')) { + throw new UserCancellationException('Not a CodeQL query file.'); + } + + progress({ + maxStep: 5, + step: 1, + message: 'Determining project list' + }); + + const queryFile = uri.fsPath; + const repositoriesFile = queryFile.substring(0, queryFile.length - '.ql'.length) + '.repositories'; + let ref: string | undefined; + // For the case of single file remote queries, use the language from the config in order to avoid the user having to select it. + let fallbackLanguage: string | undefined; + let repositories: string[] | undefined; + + progress({ + maxStep: 5, + step: 2, + message: 'Determining query target language' + }); + + // If the user has an explicit `.repositories` file, use that. + // Otherwise, prompt user to select repositories from the `codeQL.remoteQueries.repositoryLists` setting. + if (await fs.pathExists(repositoriesFile)) { + void logger.log(`Found '${repositoriesFile}'. Using information from that file to run ${queryFile}.`); + + const config = yaml.safeLoad(await fs.readFile(repositoriesFile, 'utf8')) as Config; + + ref = config.ref || 'main'; + fallbackLanguage = config.language; + repositories = config.repositories; + } else { + ref = 'main'; + repositories = await getRepositories(); + } + + if (!repositories || repositories.length === 0) { + // No error message needed, since `getRepositories` already displays one. + throw new UserCancellationException('No repositories to query.'); + } + + progress({ + maxStep: 5, + step: 3, + message: 'Determining controller repo' + }); + + // Get the controller repo from the config, if it exists. + // If it doesn't exist, prompt the user to enter it, and save that value to the config. + let controllerRepo: string | undefined; + controllerRepo = getRemoteControllerRepo(); + if (!controllerRepo || !REPO_REGEX.test(controllerRepo)) { + void logger.log(controllerRepo ? 'Invalid controller repository name.' : 'No controller repository defined.'); + controllerRepo = await window.showInputBox({ + title: 'Controller repository in which to display progress and results of remote queries', + placeHolder: '/', + prompt: 'Enter the name of a GitHub repository in the format /', + ignoreFocusOut: true, + }); + if (!controllerRepo) { + void showAndLogErrorMessage('No controller repository entered.'); + return; + } else if (!REPO_REGEX.test(controllerRepo)) { // Check if user entered invalid input + void showAndLogErrorMessage('Invalid repository format. Must be a valid GitHub repository in the format /.'); + return; + } + void logger.log(`Setting the controller repository as: ${controllerRepo}`); + await setRemoteControllerRepo(controllerRepo); + } + + void logger.log(`Using controller repository: ${controllerRepo}`); + const [owner, repo] = controllerRepo.split('/'); + + progress({ + maxStep: 5, + step: 4, + message: 'Bundling the query pack' + }); + + if (token.isCancellationRequested) { + throw new UserCancellationException('Cancelled'); + } + + const { base64Pack, language } = await generateQueryPack(cliServer, queryFile, queryPackDir, fallbackLanguage); + + if (token.isCancellationRequested) { + throw new UserCancellationException('Cancelled'); + } + + progress({ + maxStep: 5, + step: 5, + message: 'Sending request' + }); + + await runRemoteQueriesApiRequest(credentials, ref, language, repositories, owner, repo, base64Pack, dryRun); + + if (dryRun) { + return remoteQueryDir.path; + } else { + // don't return the path because it has been deleted + return; + } + + } finally { + if (dryRun) { + // If we are in a dry run keep the data around for debugging purposes. + void logger.log(`[DRY RUN] Not deleting ${queryPackDir}.`); + } else { + await remoteQueryDir.cleanup(); + } + } +} + +async function runRemoteQueriesApiRequest( + credentials: Credentials, + ref: string, + language: string, + repositories: string[], + owner: string, + repo: string, + queryPackBase64: string, + dryRun = false +): Promise { + + if (dryRun) { + void showAndLogInformationMessage('[DRY RUN] Would have sent request. See extension log for the payload.'); + void logger.log(JSON.stringify({ ref, language, repositories, owner, repo, queryPackBase64: queryPackBase64.substring(0, 100) + '... ' + queryPackBase64.length + ' bytes' })); return; } - const queryFile = uri.fsPath; - const query = await fs.readFile(queryFile, 'utf8'); - - const repositoriesFile = queryFile.substring(0, queryFile.length - '.ql'.length) + '.repositories'; - let ref: string | undefined; - let language: string | undefined; - let repositories: string[] | undefined; - - await cliServer.packInstall(path.dirname(queryFile)); - // If the user has an explicit `.repositories` file, use that. - // Otherwise, prompt user to select repositories from the `codeQL.remoteQueries.repositoryLists` setting. - if (await fs.pathExists(repositoriesFile)) { - void logger.log(`Found '${repositoriesFile}'. Using information from that file to run ${queryFile}.`); - - const config = yaml.safeLoad(await fs.readFile(repositoriesFile, 'utf8')) as Config; - - ref = config.ref || 'main'; - language = config.language || await findLanguage(cliServer, uri); - repositories = config.repositories; - } else { - ref = 'main'; - [language, repositories] = await Promise.all([findLanguage(cliServer, uri), getRepositories()]); - } - - if (!language) { - return; // No error message needed, since `findLanguage` already displays one. - } - - if (!repositories || repositories.length === 0) { - return; // No error message needed, since `getRepositories` already displays one. - } - - // Get the controller repo from the config, if it exists. - // If it doesn't exist, prompt the user to enter it, and save that value to the config. - let controllerRepo: string | undefined; - controllerRepo = getRemoteControllerRepo(); - if (!controllerRepo || !REPO_REGEX.test(controllerRepo)) { - void logger.log(controllerRepo ? 'Invalid controller repository name.' : 'No controller repository defined.'); - controllerRepo = await window.showInputBox({ - title: 'Controller repository in which to display progress and results of remote queries', - placeHolder: '/', - prompt: 'Enter the name of a GitHub repository in the format /', - ignoreFocusOut: true, - }); - if (!controllerRepo) { - void showAndLogErrorMessage('No controller repository entered.'); - return; - } else if (!REPO_REGEX.test(controllerRepo)) { // Check if user entered invalid input - void showAndLogErrorMessage('Invalid repository format. Must be a valid GitHub repository in the format /.'); - return; - } - void logger.log(`Setting the controller repository as: ${controllerRepo}`); - await setRemoteControllerRepo(controllerRepo); - } - - void logger.log(`Using controller repository: ${controllerRepo}`); - const [owner, repo] = controllerRepo.split('/'); - - const queryPackBase64 = await generateQueryPack(cliServer, queryFile); - await runRemoteQueriesApiRequest(credentials, ref, language, repositories, query, owner, repo, queryPackBase64); -} - -async function runRemoteQueriesApiRequest(credentials: Credentials, ref: string, language: string, repositories: string[], query: string, owner: string, repo: string, queryPackBase64: string): Promise { - const octokit = await credentials.getOctokit(); - try { + const octokit = await credentials.getOctokit(); await octokit.request( 'POST /repos/:owner/:repo/code-scanning/codeql/queries', { @@ -166,20 +319,29 @@ async function runRemoteQueriesApiRequest(credentials: Credentials, ref: string, ref, language, repositories, - query, - queryPack: queryPackBase64, + query_pack: queryPackBase64, } } ); void showAndLogInformationMessage(`Successfully scheduled runs. [Click here to see the progress](https://github.com/${owner}/${repo}/actions).`); } catch (error) { - await attemptRerun(error, credentials, ref, language, repositories, query, owner, repo, queryPackBase64); + await attemptRerun(error, credentials, ref, language, repositories, owner, repo, queryPackBase64, dryRun); } } /** Attempts to rerun the query on only the valid repositories */ -export async function attemptRerun(error: any, credentials: Credentials, ref: string, language: string, repositories: string[], query: string, owner: string, repo: string, queryPackBase64: string) { +export async function attemptRerun( + error: any, + credentials: Credentials, + ref: string, + language: string, + repositories: string[], + owner: string, + repo: string, + queryPackBase64: string, + dryRun = false +) { if (typeof error.message === 'string' && error.message.includes('Some repositories were invalid')) { const invalidRepos = error?.response?.data?.invalid_repos || []; const reposWithoutDbUploads = error?.response?.data?.repos_without_db_uploads || []; @@ -202,11 +364,9 @@ export async function attemptRerun(error: any, credentials: Credentials, ref: st if (rerunQuery) { const validRepositories = repositories.filter(r => !invalidRepos.includes(r) && !reposWithoutDbUploads.includes(r)); void logger.log(`Rerunning query on set of valid repositories: ${JSON.stringify(validRepositories)}`); - await runRemoteQueriesApiRequest(credentials, ref, language, validRepositories, query, owner, repo, queryPackBase64); + await runRemoteQueriesApiRequest(credentials, ref, language, validRepositories, owner, repo, queryPackBase64, dryRun); } - } else { void showAndLogErrorMessage(error); } - } diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/in-pack.ql b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/in-pack.ql new file mode 100644 index 000000000..7ca7d397b --- /dev/null +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/in-pack.ql @@ -0,0 +1,2 @@ +// This file should not be included the remote query pack. +select 1 diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/lib.qll b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/lib.qll new file mode 100644 index 000000000..08a900299 --- /dev/null +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/lib.qll @@ -0,0 +1,3 @@ +int number() { + result = 1 +} diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/not-in-pack.ql b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/not-in-pack.ql new file mode 100644 index 000000000..a0f39af72 --- /dev/null +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/not-in-pack.ql @@ -0,0 +1,4 @@ +import javascript +import lib + +select number() diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/in-pack.ql b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/in-pack.ql new file mode 100644 index 000000000..a0f39af72 --- /dev/null +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/in-pack.ql @@ -0,0 +1,4 @@ +import javascript +import lib + +select number() diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/lib.qll b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/lib.qll new file mode 100644 index 000000000..08a900299 --- /dev/null +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/lib.qll @@ -0,0 +1,3 @@ +int number() { + result = 1 +} diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/not-in-pack.ql b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/not-in-pack.ql new file mode 100644 index 000000000..7ca7d397b --- /dev/null +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/not-in-pack.ql @@ -0,0 +1,2 @@ +// This file should not be included the remote query pack. +select 1 diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/qlpack.yml b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/qlpack.yml new file mode 100644 index 000000000..d715c36c3 --- /dev/null +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/qlpack.yml @@ -0,0 +1,5 @@ +name: github/remote-query-pack +version: 0.0.0 +extractor: javascript +dependencies: + codeql/javascript-all: '*' diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/index.ts b/extensions/ql-vscode/src/vscode-tests/cli-integration/index.ts index 681137289..d6fa49763 100644 --- a/extensions/ql-vscode/src/vscode-tests/cli-integration/index.ts +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/index.ts @@ -1,6 +1,10 @@ -import 'mocha'; -import 'sinon-chai'; import { runTestsInDirectory } from '../index-template'; +import 'mocha'; +import * as sinonChai from 'sinon-chai'; +import * as chai from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +chai.use(chaiAsPromised); +chai.use(sinonChai); // The simple database used throughout the tests export function run(): Promise { diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/run-remote-query.test.ts b/extensions/ql-vscode/src/vscode-tests/cli-integration/run-remote-query.test.ts new file mode 100644 index 000000000..bd2e81be2 --- /dev/null +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/run-remote-query.test.ts @@ -0,0 +1,164 @@ +import { assert, expect } from 'chai'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { CancellationToken, extensions, QuickPickItem, Uri, window } from 'vscode'; +import 'mocha'; +import * as fs from 'fs-extra'; +import * as yaml from 'js-yaml'; + +import { runRemoteQuery } from '../../run-remote-query'; +import { Credentials } from '../../authentication'; +import { CodeQLCliServer } from '../../cli'; +import { CodeQLExtensionInterface } from '../../extension'; +import { setRemoteControllerRepo } from '../../config'; +import { UserCancellationException } from '../../commandRunner'; + +describe('Remote queries', function() { + const baseDir = path.join(__dirname, '../../../src/vscode-tests/cli-integration'); + + let sandbox: sinon.SinonSandbox; + + // up to 3 minutes per test + this.timeout(3 * 60 * 1000); + + let cli: CodeQLCliServer; + let credentials: Credentials = {} as unknown as Credentials; + let token: CancellationToken; + let progress: sinon.SinonSpy; + let showQuickPickSpy: sinon.SinonStub; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + + const extension = await extensions.getExtension>('GitHub.vscode-codeql')!.activate(); + if ('cliServer' in extension) { + cli = extension.cliServer; + } else { + throw new Error('Extension not initialized. Make sure cli is downloaded and installed properly.'); + } + credentials = {} as unknown as Credentials; + token = { + isCancellationRequested: false + } as unknown as CancellationToken; + + progress = sandbox.spy(); + // Should not have asked for a language + showQuickPickSpy = sandbox.stub(window, 'showQuickPick') + .onFirstCall().resolves({ repoList: ['github/vscode-codeql'] } as unknown as QuickPickItem) + .onSecondCall().resolves('javascript' as unknown as QuickPickItem); + + // always run in the vscode-codeql repo + void setRemoteControllerRepo('github/vscode-codeql'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should run a remote query that is part of a qlpack', async () => { + const fileUri = getFile('data-remote-qlpack/in-pack.ql'); + + const queryPackRootDir = (await runRemoteQuery(cli, credentials, fileUri, true, progress, token))!; + + // to retrieve the list of repositories + expect(showQuickPickSpy).to.have.been.calledOnce; + + // check a few files that we know should exist and others that we know should not + + // the tarball to deliver to the server + expect(fs.readdirSync(queryPackRootDir).find(f => f.startsWith('qlpack-') && f.endsWith('-generated.tgz'))).not.to.be.undefined; + + const queryPackDir = path.join(queryPackRootDir, 'query-pack'); + // in-pack.ql renamed to query.ql + expect(fs.existsSync(path.join(queryPackDir, 'query.ql'))).to.be.true; + expect(fs.existsSync(path.join(queryPackDir, 'lib.qll'))).to.be.true; + expect(fs.existsSync(path.join(queryPackDir, 'qlpack.yml'))).to.be.true; + expect(fs.existsSync( + // depending on the cli version, we should have one of these files + path.join(queryPackDir, 'qlpack.lock.yml') || path.join(queryPackDir, 'codeql-pack.lock.yml') + )).to.be.true; + expect(fs.existsSync(path.join(queryPackDir, 'not-in-pack.ql'))).to.be.false; + + // the compiled pack + const compiledPackDir = path.join(queryPackDir, '.codeql/pack/github/remote-query-pack/0.0.0/'); + expect(fs.existsSync(path.join(compiledPackDir, 'query.ql'))).to.be.true; + expect(fs.existsSync(path.join(compiledPackDir, 'lib.qll'))).to.be.true; + expect(fs.existsSync(path.join(compiledPackDir, 'qlpack.yml'))).to.be.true; + expect(fs.existsSync( + // depending on the cli version, we should have one of these files + path.join(compiledPackDir, 'qlpack.lock.yml') || path.join(queryPackDir, 'codeql-pack.lock.yml') + )).to.be.true; + expect(fs.existsSync(path.join(compiledPackDir, 'not-in-pack.ql'))).to.be.false; + + // dependencies + const libraryDir = path.join(compiledPackDir, '.codeql/libraries/codeql'); + const packNames = fs.readdirSync(libraryDir).sort(); + expect(packNames).to.deep.equal(['javascript-all', 'javascript-upgrades']); + }); + + it('should run a remote query that is not part of a qlpack', async () => { + const fileUri = getFile('data-remote-no-qlpack/in-pack.ql'); + + const queryPackRootDir = (await runRemoteQuery(cli, credentials, fileUri, true, progress, token))!; + + // to retrieve the list of repositories + // and a second time to ask for the language + expect(showQuickPickSpy).to.have.been.calledTwice; + + // check a few files that we know should exist and others that we know should not + + // the tarball to deliver to the server + expect(fs.readdirSync(queryPackRootDir).find(f => f.startsWith('qlpack-') && f.endsWith('-generated.tgz'))).not.to.be.undefined; + + const queryPackDir = path.join(queryPackRootDir, 'query-pack'); + // in-pack.ql renamed to query.ql + expect(fs.existsSync(path.join(queryPackDir, 'query.ql'))).to.be.true; + expect(fs.existsSync(path.join(queryPackDir, 'qlpack.yml'))).to.be.true; + expect(fs.existsSync( + // depending on the cli version, we should have one of these files + path.join(queryPackDir, 'qlpack.lock.yml') || path.join(queryPackDir, 'codeql-pack.lock.yml') + )).to.be.true; + expect(fs.existsSync(path.join(queryPackDir, 'lib.qll'))).to.be.false; + expect(fs.existsSync(path.join(queryPackDir, 'not-in-pack.ql'))).to.be.false; + + // the compiled pack + const compiledPackDir = path.join(queryPackDir, '.codeql/pack/codeql-remote/query/1.0.0/'); + expect(fs.existsSync(path.join(compiledPackDir, 'query.ql'))).to.be.true; + expect(fs.existsSync(path.join(compiledPackDir, 'qlpack.yml'))).to.be.true; + expect(fs.existsSync( + // depending on the cli version, we should have one of these files + path.join(compiledPackDir, 'qlpack.lock.yml') || path.join(queryPackDir, 'codeql-pack.lock.yml') + )).to.be.true; + expect(fs.existsSync(path.join(compiledPackDir, 'lib.qll'))).to.be.false; + expect(fs.existsSync(path.join(compiledPackDir, 'not-in-pack.ql'))).to.be.false; + // should have generated a correct qlpack file + const qlpackContents: any = yaml.safeLoad(fs.readFileSync(path.join(compiledPackDir, 'qlpack.yml'), 'utf8')); + expect(qlpackContents.name).to.equal('codeql-remote/query'); + expect(qlpackContents.version).to.equal('1.0.0'); + expect(qlpackContents.dependencies?.['codeql/javascript-all']).to.equal('*'); + + // dependencies + const libraryDir = path.join(compiledPackDir, '.codeql/libraries/codeql'); + const packNames = fs.readdirSync(libraryDir).sort(); + expect(packNames).to.deep.equal(['javascript-all', 'javascript-upgrades']); + }); + + it('should cancel a run before uploading', async () => { + const fileUri = getFile('data-remote-no-qlpack/in-pack.ql'); + + const promise = runRemoteQuery(cli, credentials, fileUri, true, progress, token); + + token.isCancellationRequested = true; + + try { + await promise; + assert.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceof(UserCancellationException); + } + }); + + function getFile(file: string): Uri { + return Uri.file(path.join(baseDir, file)); + } +}); diff --git a/extensions/ql-vscode/src/vscode-tests/ensureCli.ts b/extensions/ql-vscode/src/vscode-tests/ensureCli.ts index cbe8d25f9..a6bebae5c 100644 --- a/extensions/ql-vscode/src/vscode-tests/ensureCli.ts +++ b/extensions/ql-vscode/src/vscode-tests/ensureCli.ts @@ -44,7 +44,7 @@ const _10MB = _1MB * 10; // CLI version to test. Hard code the latest as default. And be sure // to update the env if it is not otherwise set. -const CLI_VERSION = process.env.CLI_VERSION || 'v2.6.3'; +const CLI_VERSION = process.env.CLI_VERSION || 'v2.7.0'; process.env.CLI_VERSION = CLI_VERSION; // Base dir where CLIs will be downloaded into