mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-10 04:22:12 +00:00
This PR fixes a handful of Go builder issues all related to the selected Golang version being used to build the function: - `go.mod` version ignored for `vc build` and `vc dev`, uses system `PATH` version only - `vc dev` fails if `go.mod` does not exist - If the analyze bin doesn’t exist, downloads golang into `.vercel/cache/golang` instead of a global shared dir - When running `vc dev`, doesn’t reuse go build code/common settings - go tidy fails when `go.mod` set to 1.19 or 1.20, but 1.18 or older is installed - `vc build` builds wrong arch on Apple Silicon/arm64 - `vc build` on Windows doesn't properly resolve "builds" in `vercel.json` due to posix separator issue - `vc build` on Windows fails with `package <pkg/name> is not in GOROOT` due to posix separator issue - Removed `actions/setup-go` from all test workflows I added a test that tests the `go tidy` issue.
374 lines
10 KiB
TypeScript
374 lines
10 KiB
TypeScript
import tar from 'tar';
|
|
import execa from 'execa';
|
|
import fetch from 'node-fetch';
|
|
import {
|
|
createWriteStream,
|
|
mkdirp,
|
|
pathExists,
|
|
readFile,
|
|
remove,
|
|
symlink,
|
|
} from 'fs-extra';
|
|
import { join, delimiter, dirname } from 'path';
|
|
import stringArgv from 'string-argv';
|
|
import { cloneEnv, debug } from '@vercel/build-utils';
|
|
import { pipeline } from 'stream';
|
|
import { promisify } from 'util';
|
|
import { tmpdir } from 'os';
|
|
import yauzl from 'yauzl-promise';
|
|
import XDGAppPaths from 'xdg-app-paths';
|
|
import type { Env } from '@vercel/build-utils';
|
|
|
|
const streamPipeline = promisify(pipeline);
|
|
|
|
const versionMap = new Map([
|
|
['1.19', '1.19.6'],
|
|
['1.18', '1.18.10'],
|
|
['1.17', '1.17.13'],
|
|
['1.16', '1.16.15'],
|
|
['1.15', '1.15.15'],
|
|
['1.14', '1.14.15'],
|
|
['1.13', '1.13.15'],
|
|
]);
|
|
const archMap = new Map([
|
|
['x64', 'amd64'],
|
|
['x86', '386'],
|
|
]);
|
|
const platformMap = new Map([['win32', 'windows']]);
|
|
export const cacheDir = join('.vercel', 'cache', 'golang');
|
|
const GO_FLAGS = process.platform === 'win32' ? [] : ['-ldflags', '-s -w'];
|
|
const GO_MIN_VERSION = 13;
|
|
const getPlatform = (p: string) => platformMap.get(p) || p;
|
|
const getArch = (a: string) => archMap.get(a) || a;
|
|
|
|
function getGoUrl(version: string) {
|
|
const { arch, platform } = process;
|
|
const goArch = getArch(arch);
|
|
const goPlatform = getPlatform(platform);
|
|
const ext = platform === 'win32' ? 'zip' : 'tar.gz';
|
|
const filename = `go${version}.${goPlatform}-${goArch}.${ext}`;
|
|
return {
|
|
filename,
|
|
url: `https://dl.google.com/go/${filename}`,
|
|
};
|
|
}
|
|
|
|
export const goGlobalCachePath = join(
|
|
XDGAppPaths('com.vercel.cli').cache(),
|
|
'golang'
|
|
);
|
|
|
|
export const OUT_EXTENSION = process.platform === 'win32' ? '.exe' : '';
|
|
|
|
export async function getAnalyzedEntrypoint(
|
|
workPath: string,
|
|
filePath: string,
|
|
modulePath: string
|
|
) {
|
|
const bin = join(__dirname, `analyze${OUT_EXTENSION}`);
|
|
|
|
const isAnalyzeExist = await pathExists(bin);
|
|
if (!isAnalyzeExist) {
|
|
debug(`Building analyze bin: ${bin}`);
|
|
const src = join(__dirname, 'util', 'analyze.go');
|
|
const go = await createGo({
|
|
modulePath,
|
|
workPath,
|
|
});
|
|
await go.build(src, bin);
|
|
}
|
|
|
|
debug(`Analyzing entrypoint ${filePath} with modulePath ${modulePath}`);
|
|
const args = [`-modpath=${modulePath}`, filePath];
|
|
const analyzed = await execa.stdout(bin, args);
|
|
debug(`Analyzed entrypoint ${analyzed}`);
|
|
return analyzed;
|
|
}
|
|
|
|
class GoWrapper {
|
|
private env: Env;
|
|
private opts: execa.Options;
|
|
|
|
constructor(env: Env, opts: execa.Options = {}) {
|
|
if (!opts.cwd) {
|
|
opts.cwd = process.cwd();
|
|
}
|
|
this.env = env;
|
|
this.opts = opts;
|
|
}
|
|
|
|
private execute(...args: string[]) {
|
|
const { opts, env } = this;
|
|
debug(
|
|
`Exec: go ${args
|
|
.map(a => (a.includes(' ') ? `"${a}"` : a))
|
|
.join(' ')} CWD=${opts.cwd}`
|
|
);
|
|
return execa('go', args, { stdio: 'inherit', ...opts, env });
|
|
}
|
|
|
|
mod() {
|
|
return this.execute('mod', 'tidy');
|
|
}
|
|
|
|
get(src?: string) {
|
|
const args = ['get'];
|
|
if (src) {
|
|
debug(`Fetching 'go' dependencies for file ${src}`);
|
|
args.push(src);
|
|
} else {
|
|
debug(`Fetching 'go' dependencies for cwd ${this.opts.cwd}`);
|
|
}
|
|
return this.execute(...args);
|
|
}
|
|
|
|
build(src: string | string[], dest: string) {
|
|
debug(`Building optimized 'go' binary ${src} -> ${dest}`);
|
|
const sources = Array.isArray(src) ? src : [src];
|
|
|
|
const flags = process.env.GO_BUILD_FLAGS
|
|
? stringArgv(process.env.GO_BUILD_FLAGS)
|
|
: GO_FLAGS;
|
|
|
|
return this.execute('build', ...flags, '-o', dest, ...sources);
|
|
}
|
|
}
|
|
|
|
type CreateGoOptions = {
|
|
modulePath?: string;
|
|
opts?: execa.Options;
|
|
workPath: string;
|
|
};
|
|
|
|
/**
|
|
* Initializes a `GoWrapper` instance.
|
|
*
|
|
* This function determines the Go version to use by first looking in the
|
|
* `go.mod`, if exists, otherwise uses the latest version from the version
|
|
* map.
|
|
*
|
|
* Next it will attempt to find the desired Go version by checking the
|
|
* following locations:
|
|
* 1. The "local" project cache directory (e.g. `.vercel/cache/golang`)
|
|
* 2. The "global" cache directory (e.g. `~/.cache/com.vercel.com/golang`)
|
|
* 3. The system PATH
|
|
*
|
|
* If the Go version is not found, it's downloaded and installed in the
|
|
* global cache directory so it can be shared across projects. When using
|
|
* Linux or macOS, it creates a symlink from the global cache to the local
|
|
* cache directory so that `prepareCache` will persist it.
|
|
*
|
|
* @param modulePath The path possibly containing a `go.mod` file
|
|
* @param opts `execa` options (`cwd`, `env`, `stdio`, etc)
|
|
* @param workPath The path to the project to be built
|
|
* @returns An initialized `GoWrapper` instance
|
|
*/
|
|
export async function createGo({
|
|
modulePath,
|
|
opts = {},
|
|
workPath,
|
|
}: CreateGoOptions): Promise<GoWrapper> {
|
|
// parse the `go.mod`, if exists
|
|
let goPreferredVersion: string | undefined;
|
|
if (modulePath) {
|
|
goPreferredVersion = await parseGoModVersion(modulePath);
|
|
}
|
|
|
|
// default to newest (first) supported go version
|
|
const goSelectedVersion =
|
|
goPreferredVersion || Array.from(versionMap.values())[0];
|
|
|
|
const env = cloneEnv(process.env, opts.env);
|
|
const { PATH } = env;
|
|
const { platform } = process;
|
|
const goGlobalCacheDir = join(
|
|
goGlobalCachePath,
|
|
`${goSelectedVersion}_${platform}_${process.arch}`
|
|
);
|
|
const goCacheDir = join(workPath, cacheDir);
|
|
|
|
if (goPreferredVersion) {
|
|
debug(`Preferred go version ${goPreferredVersion} (from go.mod)`);
|
|
env.GO111MODULE = 'on';
|
|
} else {
|
|
debug(
|
|
`Preferred go version ${goSelectedVersion} (latest from version map)`
|
|
);
|
|
}
|
|
|
|
const setGoEnv = async (goDir: string | null) => {
|
|
if (platform !== 'win32' && goDir === goGlobalCacheDir) {
|
|
debug(`Symlinking ${goDir} -> ${goCacheDir}`);
|
|
await remove(goCacheDir);
|
|
await mkdirp(dirname(goCacheDir));
|
|
await symlink(goDir, goCacheDir);
|
|
goDir = goCacheDir;
|
|
}
|
|
env.GOROOT = goDir || undefined;
|
|
env.PATH = goDir ? `${join(goDir, 'bin')}${delimiter}${PATH}` : PATH;
|
|
};
|
|
|
|
// try each of these Go directories looking for the version we need
|
|
const goDirs = {
|
|
'local cache': goCacheDir,
|
|
'global cache': goGlobalCacheDir,
|
|
'system PATH': null,
|
|
};
|
|
|
|
for (const [label, goDir] of Object.entries(goDirs)) {
|
|
try {
|
|
const goBinDir = goDir && join(goDir, 'bin');
|
|
if (goBinDir && !(await pathExists(goBinDir))) {
|
|
debug(`Go not found in ${label}`);
|
|
continue;
|
|
}
|
|
|
|
env.GOROOT = goDir || undefined;
|
|
env.PATH = goBinDir || PATH;
|
|
|
|
const { stdout } = await execa('go', ['version'], { env });
|
|
const { minor, short, version } = parseGoVersionString(stdout);
|
|
|
|
if (minor < GO_MIN_VERSION) {
|
|
debug(`Found go ${version} in ${label}, but version is unsupported`);
|
|
}
|
|
if (version === goSelectedVersion || short === goSelectedVersion) {
|
|
console.log(`Selected go ${version} (from ${label})`);
|
|
|
|
await setGoEnv(goDir);
|
|
return new GoWrapper(env, opts);
|
|
} else {
|
|
debug(`Found go ${version} in ${label}, but need ${goSelectedVersion}`);
|
|
}
|
|
} catch {
|
|
debug(`Go not found in ${label}`);
|
|
}
|
|
}
|
|
|
|
// we need to download and cache the desired `go` version
|
|
await download({
|
|
dest: goGlobalCacheDir,
|
|
version: goSelectedVersion,
|
|
});
|
|
|
|
await setGoEnv(goGlobalCacheDir);
|
|
return new GoWrapper(env, opts);
|
|
}
|
|
|
|
/**
|
|
* Download and installs the Go distribution.
|
|
*
|
|
* @param dest The directory to install Go into. If directory exists, it is
|
|
* first deleted before installing.
|
|
* @param version The Go version to download
|
|
*/
|
|
async function download({ dest, version }: { dest: string; version: string }) {
|
|
const { filename, url } = getGoUrl(version);
|
|
console.log(`Downloading go: ${url}`);
|
|
const res = await fetch(url);
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to download: ${url} (${res.status})`);
|
|
}
|
|
|
|
debug(`Installing go ${version} to ${dest}`);
|
|
|
|
await remove(dest);
|
|
await mkdirp(dest);
|
|
|
|
if (/\.zip$/.test(filename)) {
|
|
const zipFile = join(tmpdir(), filename);
|
|
try {
|
|
await streamPipeline(res.body, createWriteStream(zipFile));
|
|
const zip = await yauzl.open(zipFile);
|
|
let entry = await zip.readEntry();
|
|
while (entry) {
|
|
const fileName = entry.fileName.split('/').slice(1).join('/');
|
|
|
|
if (fileName) {
|
|
const destPath = join(dest, fileName);
|
|
|
|
if (/\/$/.test(fileName)) {
|
|
await mkdirp(destPath);
|
|
} else {
|
|
const [entryStream] = await Promise.all([
|
|
entry.openReadStream(),
|
|
mkdirp(dirname(destPath)),
|
|
]);
|
|
const out = createWriteStream(destPath);
|
|
await streamPipeline(entryStream, out);
|
|
}
|
|
}
|
|
|
|
entry = await zip.readEntry();
|
|
}
|
|
} finally {
|
|
await remove(zipFile);
|
|
}
|
|
return;
|
|
}
|
|
|
|
await new Promise((resolve, reject) => {
|
|
res.body
|
|
.on('error', reject)
|
|
.pipe(tar.extract({ cwd: dest, strip: 1 }))
|
|
.on('error', reject)
|
|
.on('finish', resolve);
|
|
});
|
|
}
|
|
|
|
const goVersionRegExp = /(\d+)\.(\d+)(?:\.(\d+))?/;
|
|
|
|
/**
|
|
* Parses the raw output from `go version` and returns the version parts.
|
|
*
|
|
* @param goVersionOutput The output from `go version`
|
|
*/
|
|
function parseGoVersionString(goVersionOutput: string) {
|
|
const matches = goVersionOutput.match(goVersionRegExp) || [];
|
|
const major = parseInt(matches[1], 10);
|
|
const minor = parseInt(matches[2], 10);
|
|
const patch = parseInt(matches[3] || '0', 10);
|
|
return {
|
|
version: `${major}.${minor}.${patch}`,
|
|
short: `${major}.${minor}`,
|
|
major,
|
|
minor,
|
|
patch,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Attempts to parse the preferred Go version from the `go.mod` file.
|
|
*
|
|
* @param modulePath The directory containing the `go.mod` file
|
|
* @returns
|
|
*/
|
|
async function parseGoModVersion(
|
|
modulePath: string
|
|
): Promise<string | undefined> {
|
|
let version;
|
|
const file = join(modulePath, 'go.mod');
|
|
|
|
try {
|
|
const content = await readFile(file, 'utf8');
|
|
const matches = /^go (\d+)\.(\d+)\.?$/gm.exec(content) || [];
|
|
const major = parseInt(matches[1], 10);
|
|
const minor = parseInt(matches[2], 10);
|
|
const full = versionMap.get(`${major}.${minor}`);
|
|
if (major === 1 && minor >= GO_MIN_VERSION && full) {
|
|
version = full;
|
|
} else {
|
|
console.log(`Warning: Unknown Go version in ${file}`);
|
|
}
|
|
} catch (err: any) {
|
|
if (err.code === 'ENOENT') {
|
|
debug(`File not found: ${file}`);
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
return version;
|
|
}
|