Skip to content

extension/src/goInstallTools: strengthing minimum go version requirement #3371

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 15, 2024
Merged
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
15 changes: 11 additions & 4 deletions extension/src/commands/startLanguageServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ export const startLanguageServer: CommandFactory = (ctx, goCtx) => {
return async (reason: RestartReason = RestartReason.MANUAL) => {
const goConfig = getGoConfig();
const cfg = await buildLanguageServerConfig(goConfig);

if (typeof reason === 'string') {
updateRestartHistory(goCtx, reason, cfg.enabled);
}
Expand All @@ -47,9 +46,14 @@ export const startLanguageServer: CommandFactory = (ctx, goCtx) => {
errorKind.manualRestart
);
}
outputChannel.info(`Try to start language server - ${reason} (enabled: ${cfg.enabled})`);

// If the client has already been started, make sure to clear existing
// diagnostics and stop it.
if (goCtx.languageClient) {
goCtx.serverOutputChannel?.append(
`Request to stop language server - ${reason} (enabled: ${cfg.enabled})`
);
await stopLanguageClient(goCtx);
}
updateStatus(goCtx, goConfig, false);
Expand All @@ -62,6 +66,7 @@ export const startLanguageServer: CommandFactory = (ctx, goCtx) => {
}

if (!shouldActivateLanguageFeatures()) {
outputChannel.warn('Cannot activate language features - unsupported environment');
return;
}

Expand All @@ -72,6 +77,7 @@ export const startLanguageServer: CommandFactory = (ctx, goCtx) => {
}

if (!cfg.enabled) {
outputChannel.warn('Language server is disabled');
const legacyService = new LegacyLanguageService();
goCtx.legacyLanguageService = legacyService;
ctx.subscriptions.push(legacyService);
Expand All @@ -87,11 +93,12 @@ export const startLanguageServer: CommandFactory = (ctx, goCtx) => {
ctx.globalState,
goCtx.serverInfo?.Commands
);

console.log(`Server: ${JSON.stringify(goCtx.serverInfo, null, 2)}`);
outputChannel.info(
`Running language server ${goCtx.serverInfo?.Name}(${goCtx.serverInfo?.Version}/${goCtx.serverInfo?.GoVersion})`
);
} catch (e) {
const msg = `Error starting language server: ${e}`;
console.log(msg);
outputChannel.error(msg);
goCtx.serverOutputChannel?.append(msg);
} finally {
updateStatus(goCtx, goConfig, true);
Expand Down
72 changes: 53 additions & 19 deletions extension/src/goInstallTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export interface IToolsManager {
installTool(tool: Tool, goVersion: GoVersion, env: NodeJS.Dict<string>): Promise<string | undefined>;
}

const defaultToolsManager: IToolsManager = {
export const defaultToolsManager: IToolsManager = {
getMissingTools,
installTool: installToolWithGo
};
Expand Down Expand Up @@ -105,23 +105,22 @@ export async function installAllTools(updateExistingToolsOnly = false) {
);
}

async function getGoForInstall(goVersion: GoVersion, silent?: boolean): Promise<GoVersion> {
const configured = getGoConfig().get<string>('toolsManagement.go');
export const getGoForInstall = _getGoForInstall;
async function _getGoForInstall(goVersion: GoVersion): Promise<GoVersion | undefined> {
let configured = getGoConfig().get<string>('toolsManagement.go');
if (!configured) {
return goVersion; // use the default.
configured = goVersion.binaryPath;
}

try {
const go = await getGoVersion(configured);
// goVersion may be the version picked based on the the minimum
// toolchain version requirement specified in go.mod or go.work.
// Compute the local toolchain version. (GOTOOLCHAIN=local go version)
const go = await getGoVersion(configured, 'local');
if (go) return go;
} catch (e) {
if (!silent) {
outputChannel.error(
`failed to run "go version" with "${configured}". Provide a valid path to the Go binary`
);
}
outputChannel.error(`failed to run "go version" with "${configured}". Provide a valid path to the Go binary`);
}
return goVersion;
return;
}

interface installToolsOptions {
Expand All @@ -135,7 +134,8 @@ interface installToolsOptions {
*
* @param missing array of tool names and optionally, their versions to be installed.
* If a tool's version is not specified, it will install the latest.
* @param goVersion version of Go used in the project. If go used for tools installation
* @param goVersion version of Go used in the project. (e.g. result of 'go version' from
* workspace root). If go used for tools installation
* is not configured or misconfigured, this is used as a fallback.
* @returns a list of tools that failed to install.
*/
Expand All @@ -144,34 +144,65 @@ export async function installTools(
goVersion: GoVersion,
options?: installToolsOptions
): Promise<{ tool: ToolAtVersion; reason: string }[]> {
// TODO(hyangah): the return value (tool, reason) is not used anywhere
// other than in tests. Check if we are giving users enough information
// about failed tools installation.
if (!missing) {
return [];
}
const { silent, skipRestartGopls } = options || {};
if (!silent) {
outputChannel.show();
}
outputChannel.clear();

const goForInstall = await getGoForInstall(goVersion);
if (!goForInstall || !goForInstall.isValid()) {
vscode.window.showErrorMessage('Failed to find a go command needed to install tools.');
outputChannel.show(); // show error.
return missing.map((tool) => {
return { tool: tool, reason: 'failed to find go' };
});
}

if (goForInstall.lt('1.16')) {
const minVersion = goForInstall.lt('1.21') ? (goVersion.lt('1.18') ? '1.18' : goVersion.format()) : '1.21';
if (goForInstall.lt(minVersion)) {
vscode.window.showErrorMessage(
'Go 1.16 or newer is needed to install tools. ' +
'If your project requires a Go version older than go1.16, either manually install the tools or, use the "go.toolsManagement.go" setting ' +
`Failed to find a go command (go${minVersion} or newer) needed to install tools. ` +
`The go command (${goForInstall.binaryPath}) is too old (go${goForInstall.svString}). ` +
'If your project requires a Go version older than go1.18, either manually install the tools or, use the "go.toolsManagement.go" setting ' +
'to configure the Go version used for tools installation. See https://github.com/golang/vscode-go/issues/2898.'
);
return missing.map((tool) => {
return { tool: tool, reason: 'require go1.16 or newer' };
return { tool: tool, reason: `failed to find go (requires go${minVersion} or newer)` };
});
}

const envForTools = toolInstallationEnvironment();
if (
!goVersion.isDevel &&
goVersion.sv &&
!goForInstall.isDevel &&
goForInstall.sv &&
semver.gt(goVersion.sv, goForInstall.sv)
) {
// If goVersion.isDevel === true, for example,
// go version go1.23-20240317-RC00 cl/616607620 +0a6f05e30f X:fieldtrack,boringcrypto linux/amd64
// go version devel go1.23-cd294f55ca Mon Apr 1 20:28:41 2024 +0000 darwin/amd64
// we optimisitically assume the go command chosen for install (goForInstall)
// is new enough (possibly newer than the officially released go version),
// and don't set GOTOOLCHAIN.
const version = goVersion.format(true);
envForTools['GOTOOLCHAIN'] = `go${version}+auto`;
}

const toolsGopath = envForTools['GOPATH'];
let envMsg = `Tools environment: GOPATH=${toolsGopath}`;
if (envForTools['GOBIN']) {
envMsg += `, GOBIN=${envForTools['GOBIN']}`;
}
if (envForTools['GOTOOLCHAIN']) {
envMsg += `, GOTOOLCHAIN=${envForTools['GOTOOLCHAIN']}`;
}
outputChannel.appendLine(envMsg);

let installingMsg = `Installing ${missing.length} ${missing.length > 1 ? 'tools' : 'tool'} at `;
Expand Down Expand Up @@ -245,6 +276,9 @@ async function tmpDirForToolInstallation() {
// installTool installs the specified tool.
export async function installTool(tool: ToolAtVersion): Promise<string | undefined> {
const goVersion = await getGoForInstall(await getGoVersion());
if (!goVersion) {
return 'failed to find "go" for install';
}
const envForTools = toolInstallationEnvironment();

return await installToolWithGo(tool, goVersion, envForTools);
Expand Down Expand Up @@ -292,7 +326,7 @@ async function installToolWithGoInstall(goVersion: GoVersion, env: NodeJS.Dict<s
};

const execFile = util.promisify(cp.execFile);
outputChannel.trace(`$ ${goBinary} install -v ${importPath}} (cwd: ${opts.cwd})`);
outputChannel.trace(`${goBinary} install -v ${importPath} (cwd: ${opts.cwd})`);
await execFile(goBinary, ['install', '-v', importPath], opts);
}

Expand Down
1 change: 0 additions & 1 deletion extension/src/goPlayground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ export const playgroundCommand: CommandFactory = () => () => {
return promptForMissingTool(TOOL_CMD_NAME);
}

outputChannel.clear();
outputChannel.show();
outputChannel.appendLine('Upload to the Go Playground in progress...\n');

Expand Down
13 changes: 10 additions & 3 deletions extension/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,12 @@ export function parseFilePrelude(text: string): Prelude {
* Gets version of Go based on the output of the command `go version`.
* Throws if go version can't be determined because go is not available
* or `go version` fails.
* If GOTOOLCHAIN is provided, it will be used to set GOTOOLCHAIN env var.
* For example, getGoVersion(binPath, 'local') can be used to query
* the local toolchain's go version regardless of the go version specified
* in the workspace go.mod or go.work.
*/
export async function getGoVersion(goBinPath?: string): Promise<GoVersion> {
export async function getGoVersion(goBinPath?: string, GOTOOLCHAIN?: string): Promise<GoVersion> {
// TODO(hyangah): limit the number of concurrent getGoVersion call.
// When the extension starts, at least 4 concurrent calls race
// and end up calling `go version`.
Expand Down Expand Up @@ -189,6 +193,9 @@ export async function getGoVersion(goBinPath?: string): Promise<GoVersion> {
let goVersion: GoVersion | undefined;
try {
const env = toolExecutionEnvironment();
if (GOTOOLCHAIN !== undefined) {
env['GOTOOLCHAIN'] = GOTOOLCHAIN;
}
const execFile = util.promisify(cp.execFile);
const { stdout, stderr } = await execFile(goRuntimePath, ['version'], { env, cwd });
if (stderr) {
Expand All @@ -198,8 +205,8 @@ export async function getGoVersion(goBinPath?: string): Promise<GoVersion> {
} catch (err) {
throw error(`failed to run "${goRuntimePath} version": ${err} cwd: ${cwd}`);
}
if (!goBinPath) {
// if getGoVersion was called with a given goBinPath, don't cache the result.
if (!goBinPath && !GOTOOLCHAIN) {
// if getGoVersion was called with a given goBinPath or an explicit GOTOOLCHAIN env var, don't cache the result.
cachedGoBinPath = goRuntimePath;
cachedGoVersion = goVersion;
if (!cachedGoVersion.isValid()) {
Expand Down
55 changes: 49 additions & 6 deletions extension/test/integration/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
import AdmZip = require('adm-zip');
import assert from 'assert';
import * as config from '../../src/config';
import { inspectGoToolVersion, installTools, maybeInstallImportantTools } from '../../src/goInstallTools';
import {
IToolsManager,
defaultToolsManager,
inspectGoToolVersion,
installTools,
maybeInstallImportantTools
} from '../../src/goInstallTools';
import { Tool, getConfiguredTools, getTool, getToolAtVersion } from '../../src/goTools';
import { getBinPath, getGoVersion, GoVersion, rmdirRecursive } from '../../src/util';
import { correctBinname } from '../../src/utils/pathUtils';
Expand Down Expand Up @@ -78,11 +84,16 @@ suite('Installation Tests', function () {
// runTest actually executes the logic of the test.
// If withLocalProxy is true, the test does not require internet.
// If withGOBIN is true, the test will set GOBIN env var.
// If withGoVersion is set, the test will run assuming the project requires the version.
// If goForInstall is set, the test will use the go version for 'go install'.
// If toolsManager is set, goInstallTools will use it instead of the default tools Manager.
async function runTest(
testCases: installationTestCase[],
withLocalProxy?: boolean,
withGOBIN?: boolean,
withGoVersion?: string
withGoVersion?: string,
goForInstall?: GoVersion,
toolsManager?: goInstallTools.IToolsManager
) {
const gobin = withLocalProxy && withGOBIN ? path.join(tmpToolsGopath, 'gobin') : undefined;

Expand Down Expand Up @@ -115,14 +126,17 @@ suite('Installation Tests', function () {
}

const missingTools = testCases.map((tc) => getToolAtVersion(tc.name));
const goBinary = getBinPath('go');
const goVersion = withGoVersion
? /* we want a fake go version, but need the real 'go' binary to run `go install` */
new GoVersion(getBinPath('go'), `go version ${withGoVersion} amd64/linux`)
new GoVersion(goBinary, `go version ${withGoVersion} linux/amd64`)
: await getGoVersion();

sandbox.stub(vscode.commands, 'executeCommand').withArgs('go.languageserver.restart');
sandbox.stub(goInstallTools, 'getGoForInstall').returns(Promise.resolve(goForInstall ?? goVersion));

const failures = await installTools(missingTools, goVersion);
const opts = toolsManager ? { toolsManager } : undefined;
const failures = await installTools(missingTools, goVersion, opts);
assert(!failures || failures.length === 0, `installTools failed: ${JSON.stringify(failures)}`);

// Confirm that each expected tool has been installed.
Expand Down Expand Up @@ -197,7 +211,8 @@ suite('Installation Tests', function () {
});

test('Try to install with old go', async () => {
const oldGo = new GoVersion(getBinPath('go'), 'go version go1.15 amd64/linux');
const oldGo = new GoVersion(getBinPath('go'), 'go version go1.17 amd64/linux');
sandbox.stub(goInstallTools, 'getGoForInstall').returns(Promise.resolve(oldGo));
const failures = await installTools([getToolAtVersion('gopls')], oldGo);
assert(failures?.length === 1 && failures[0].tool.name === 'gopls' && failures[0].reason.includes('or newer'));
});
Expand All @@ -216,9 +231,37 @@ suite('Installation Tests', function () {
[{ name: 'gofumpt', versions: ['v0.4.0', 'v0.5.0', gofumptDefault], wantVersion: gofumptDefault }],
true, // LOCAL PROXY
true, // GOBIN
'go1.22' // Go Version
'go1.22.0' // Go Version
);
});

test('Install a tool, with go1.21.0', async () => {
const systemGoVersion = await getGoVersion();
const oldGo = new GoVersion(systemGoVersion.binaryPath, 'go version go1.21.0 linux/amd64');
const tm: IToolsManager = {
getMissingTools: () => {
assert.fail('must not be called');
},
installTool: (tool, goVersion, env) => {
// Assert the go install command is what we expect.
assert.strictEqual(tool.name, 'gopls');
assert.strictEqual(goVersion, oldGo);
assert(env['GOTOOLCHAIN'], `go${systemGoVersion.format()}+auto`);
// runTest checks if the tool build succeeds. So, delegate the remaining
// build task to the default tools manager's installTool function.
return defaultToolsManager.installTool(tool, goVersion, env);
}
};
await runTest(
[{ name: 'gopls', versions: ['v0.1.0', 'v1.0.0'], wantVersion: 'v1.0.0' }],
true, // LOCAL PROXY
true, // GOBIN
'go' + systemGoVersion.format(true), // Go Version
oldGo, // Go for install
tm // stub installTool to
);
});

test('Install all tools via GOPROXY', async () => {
// Only run this test if we are in CI before a Nightly release.
if (!shouldRunSlowTests()) {
Expand Down
Loading