Skip to content

Commit

Permalink
feat: install core tools automatically when needed (closes #353) (#391)
Browse files Browse the repository at this point in the history
* feat: check for func binary and suggest core tools install (#353)

* chore: fix incorrect lockfile merge

* chore: add types for which

* feat: automatically install core tools when needed (closes #353)

* fix: permission, warnings and display

* refactor: move interfaces to swa.d.ts

* refactor: rename core tools folder constant

* feat: add sha2 check after core tools download

* feat: allow using any compatible system version

* refactor: use function instead of constant for Node version

* test: add unit tests

* chore: exclude coverage

* fix: fallback to rmdirSync on older Node.js versions

Closes #353
  • Loading branch information
sinedied authored Mar 4, 2022
1 parent 6d79159 commit 5e98cba
Show file tree
Hide file tree
Showing 8 changed files with 9,325 additions and 6,952 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,4 @@ dist
*.orig
cypress/videos/
cypress/screenshots/
/coverage
15,715 changes: 8,771 additions & 6,944 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@types/globalyzer": "^0.1.1",
"@types/globrex": "^0.1.1",
"chalk": "^4.1.2",
"cli-progress": "^3.10.0",
"commander": "^7.2.0",
"concurrently": "^6.4.0",
"cookie": "^0.4.1",
Expand All @@ -40,14 +41,15 @@
"ora": "^5.4.1",
"rimraf": "^3.0.2",
"serve-static": "^1.14.2",
"unzipper": "^0.10.11",
"update-notifier": "^5.1.0",
"wait-on": "^5.3.0",
"which": "^1.3.1",
"yaml": "^1.10.2"
},
"devDependencies": {
"@commitlint/cli": "^11.0.0",
"@commitlint/config-angular": "^11.0.0",
"@types/cli-progress": "^3.9.2",
"@types/concurrently": "^6.0.1",
"@types/cookie": "^0.4.0",
"@types/finalhandler": "^1.1.0",
Expand All @@ -57,6 +59,7 @@
"@types/node-fetch": "^2.5.10",
"@types/serve-static": "^1.13.9",
"@types/shelljs": "^0.8.8",
"@types/unzipper": "^0.10.5",
"@types/update-notifier": "^5.0.0",
"@types/wait-on": "^5.3.0",
"@types/which": "^2.0.1",
Expand Down
14 changes: 7 additions & 7 deletions src/cli/commands/start.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import which from "which";
import concurrently from "concurrently";
import fs from "fs";
import path from "path";
import { DEFAULT_CONFIG } from "../../config";
import { createStartupScriptCommand, isAcceptingTcpConnections, isHttpUrl, logger, parseUrl, readWorkflowFile } from "../../core";
import { createStartupScriptCommand, isAcceptingTcpConnections, isHttpUrl, logger, parseUrl, readWorkflowFile, getCoreToolsBinary, detectTargetCoreToolsVersion, getNodeMajorVersion } from "../../core";
import builder from "../../core/builder";
let packageInfo = require("../../../package.json");

Expand Down Expand Up @@ -81,14 +80,15 @@ export async function start(startContext: string, options: SWACLIConfig) {
apiPort = parseUrl(useApiDevServer)?.port;
} else {
if (options.apiLocation && userWorkflowConfig?.apiLocation) {
const funcBinary = "func";
// check if the func binary is globally available
if (!which.sync(funcBinary, { nothrow: true })) {
// check if the func binary is globally available and if not, download it
const funcBinary = await getCoreToolsBinary();
if (!funcBinary) {
const targetVersion = detectTargetCoreToolsVersion(getNodeMajorVersion());
// prettier-ignore
logger.error(
`Could not find the "${funcBinary}" binary.\n` +
`\nCould not find or install Azure Functions Core Tools.\n` +
`Install Azure Functions Core Tools with:\n\n` +
` npm i -g azure-functions-core-tools@4 --unsafe-perm true\n\n` +
` npm i -g azure-functions-core-tools@${targetVersion} --unsafe-perm true\n\n` +
`See https://aka.ms/functions-core-tools for more information.`,
true
);
Expand Down
275 changes: 275 additions & 0 deletions src/core/func-core-tools.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import { Buffer } from 'buffer';
import { Readable, PassThrough } from 'stream';
import mockFs from "mock-fs";
import { detectTargetCoreToolsVersion, downloadCoreTools, getCoreToolsBinary, getLatestCoreToolsRelease, isCoreToolsVersionCompatible } from "./func-core-tools";

jest.mock('process', () => ({ versions: { node: '16.0.0' } }));
jest.mock('os', () => ({ platform: () => 'linux', homedir: () => '/home/user' }));
jest.mock('child_process', () => ({ exec: jest.fn() }));
jest.mock('node-fetch', () => jest.fn());
jest.mock('unzipper', () => ({
Extract: () => {
const fakeStream = new PassThrough() as any;
fakeStream.promise = () => Promise.resolve();
mockFs({
'/home/user/.swa/core-tools/v4/func': '',
'/home/user/.swa/core-tools/v4/gozip': ''
}, { createTmp: false, createCwd: false })
return fakeStream;
}
}));

describe('funcCoreTools', () => {
afterEach(() => {
mockFs.restore();
});

describe('isCoreToolsVersionCompatible()', () => {
it('should return true for compatible versions', () => {
expect(isCoreToolsVersionCompatible(4, 10)).toBe(false);
expect(isCoreToolsVersionCompatible(3, 10)).toBe(true);
expect(isCoreToolsVersionCompatible(2, 10)).toBe(true);
expect(isCoreToolsVersionCompatible(3, 11)).toBe(true);
expect(isCoreToolsVersionCompatible(2, 11)).toBe(false);
expect(isCoreToolsVersionCompatible(4, 12)).toBe(false);
expect(isCoreToolsVersionCompatible(3, 12)).toBe(true);
expect(isCoreToolsVersionCompatible(2, 12)).toBe(false);
expect(isCoreToolsVersionCompatible(3, 13)).toBe(true);
expect(isCoreToolsVersionCompatible(4, 14)).toBe(true);
expect(isCoreToolsVersionCompatible(3, 14)).toBe(true);
expect(isCoreToolsVersionCompatible(2, 14)).toBe(false);
expect(isCoreToolsVersionCompatible(4, 15)).toBe(true);
expect(isCoreToolsVersionCompatible(3, 15)).toBe(false);
expect(isCoreToolsVersionCompatible(4, 16)).toBe(true);
expect(isCoreToolsVersionCompatible(3, 16)).toBe(false);
expect(isCoreToolsVersionCompatible(2, 16)).toBe(false);
expect(isCoreToolsVersionCompatible(4, 17)).toBe(false);
expect(isCoreToolsVersionCompatible(3, 17)).toBe(false);
expect(isCoreToolsVersionCompatible(2, 17)).toBe(false);
});
});

describe('detectTargetCoreToolsVersion()', () => {
it('should return the latest valid version for each Node version', () => {
expect(detectTargetCoreToolsVersion(8)).toBe(2);
expect(detectTargetCoreToolsVersion(9)).toBe(2);
expect(detectTargetCoreToolsVersion(10)).toBe(3);
expect(detectTargetCoreToolsVersion(11)).toBe(3);
expect(detectTargetCoreToolsVersion(12)).toBe(3);
expect(detectTargetCoreToolsVersion(13)).toBe(3);
expect(detectTargetCoreToolsVersion(14)).toBe(4);
expect(detectTargetCoreToolsVersion(15)).toBe(4);
expect(detectTargetCoreToolsVersion(16)).toBe(4);
// Unsupported Node versions should always return the latest version
expect(detectTargetCoreToolsVersion(7)).toBe(4);
expect(detectTargetCoreToolsVersion(17)).toBe(4);
});
});

describe('getLatestCoreToolsRelease()', () => {
it('should return the latest release for the specified version', async () => {
const fetchMock = jest.requireMock('node-fetch');
fetchMock.mockImplementationOnce(() => Promise.resolve({
json: () => Promise.resolve({
tags: {
'v4': {
release: '4.0.0'
},
},
releases: {
'4.0.0': {
coreTools: [
{
OS: "Linux",
downloadLink: "https://abc.com/d.zip",
sha2: "123",
size: "full"
},
]
}
}
})
}));

const release = await getLatestCoreToolsRelease(4);
expect(release.version).toBe("4.0.0");
expect(release.sha2).toBe("123");
});

it('should throw an error if tags match the specified version', async () => {
const fetchMock = jest.requireMock('node-fetch');
fetchMock.mockImplementationOnce(() => Promise.resolve({
json: () => Promise.resolve({
tags: {
'v3': {},
'v4': { hidden: true }
}
})
}));

await expect(async () => await getLatestCoreToolsRelease(4)).rejects.toThrowError('Cannot find the latest version for v4');
});

it('should throw an error if no release match the specified version', async () => {
const fetchMock = jest.requireMock('node-fetch');
fetchMock.mockImplementationOnce(() => Promise.resolve({
json: () => Promise.resolve({
tags: {
'v4': { release: '4.0.0'},
},
releases: {}
})
}));

await expect(async () => await getLatestCoreToolsRelease(4)).rejects.toThrowError('Cannot find release for 4.0.0');
});

it('should throw an error if there\'s no compatible package', async () => {
const fetchMock = jest.requireMock('node-fetch');
fetchMock.mockImplementationOnce(() => Promise.resolve({
json: () => Promise.resolve({
tags: {
'v4': { release: '4.0.0'},
},
releases: {
'4.0.0': {
coreTools: [
{
OS: "Windows",
downloadLink: "https://abc.com/d.zip",
sha2: "123",
size: "full"
},
]
}
}
})
}));

await expect(async () => await getLatestCoreToolsRelease(4)).rejects.toThrowError('Cannot find download package for Linux');
});

it('should throw an error if no release match the specified version', async () => {
const fetchMock = jest.requireMock('node-fetch');
fetchMock.mockImplementationOnce(() => Promise.reject(new Error('bad network')));

await expect(async () => await getLatestCoreToolsRelease(4)).rejects.toThrowError(/bad network/);
});
});

describe('getCoreToolsBinary', () => {
it ('should return the system binary if it\'s compatible', async () => {
const execMock = jest.requireMock('child_process').exec;
execMock.mockImplementationOnce((_cmd: string, cb: Function) => cb(null, { stdout: '4.0.0' }));

const binary = await getCoreToolsBinary();
expect(binary).toBe('func');
});

it ('should return the downloaded binary if there\'s no system binary', async () => {
const execMock = jest.requireMock('child_process').exec;
execMock.mockImplementationOnce((_cmd: string, cb: Function) => cb({ stderr: 'func does not exist' }));
mockFs({
['/home/user/.swa/core-tools/v4']: { ".release-version": '4.3.2' },
}, { createTmp: false, createCwd: false });

const binary = await getCoreToolsBinary();
expect(binary).toBe('/home/user/.swa/core-tools/v4/func');
});

it ('should return the downloaded binary if the system binary is incompatible', async () => {
const execMock = jest.requireMock('child_process').exec;
execMock.mockImplementationOnce((_cmd: string, cb: Function) => cb(null, { stdout: '3.0.0' }));
mockFs({
['/home/user/.swa/core-tools/v4']: { ".release-version": '4.3.2' },
}, { createTmp: false, createCwd: false });

const binary = await getCoreToolsBinary();
expect(binary).toBe('/home/user/.swa/core-tools/v4/func');
});

it ('should download core tools and return downloaded binary', async () => {
const execMock = jest.requireMock('child_process').exec;
execMock.mockImplementationOnce((_cmd: string, cb: Function) => cb({ stderr: 'func does not exist' }));

const fetchMock = jest.requireMock('node-fetch');
fetchMock.mockImplementationOnce(() => Promise.resolve({
json: () => Promise.resolve({
tags: {
'v4': { release: '4.0.0'},
},
releases: {
'4.0.0': {
coreTools: [
{
OS: "Linux",
downloadLink: "https://abc.com/d.zip",
// Real sha2 for "package" string
sha2: "bc4a71180870f7945155fbb02f4b0a2e3faa2a62d6d31b7039013055ed19869a",
size: "full"
},
]
}
}
})
}));
const packageZip = Buffer.from('package');
fetchMock.mockImplementationOnce(() => Promise.resolve({
body: Readable.from(packageZip),
headers: new Headers({ 'content-length': packageZip.length.toString() })
}));
mockFs({ ['/home/user/.swa/core-tools/']: {} }, { createTmp: false, createCwd: false });

const binary = await getCoreToolsBinary();
expect(binary).toBe('/home/user/.swa/core-tools/v4/func');
});

it ('should return undefined if an error occured', async () => {
const execMock = jest.requireMock('child_process').exec;
execMock.mockImplementationOnce((_cmd: string, cb: Function) => cb({ stderr: 'func does not exist' }));

const fetchMock = jest.requireMock('node-fetch');
fetchMock.mockImplementationOnce(() => Promise.reject({}));
mockFs({}, { createTmp: false, createCwd: false });

const binary = await getCoreToolsBinary();
expect(binary).toBe(undefined);
});
});

describe('downloadCoreTools', () => {
it ('should throw an error if the download is corrupted', async () => {
const execMock = jest.requireMock('child_process').exec;
execMock.mockImplementationOnce((_cmd: string, cb: Function) => cb({ stderr: 'func does not exist' }));

const fetchMock = jest.requireMock('node-fetch');
fetchMock.mockImplementationOnce(() => Promise.resolve({
json: () => Promise.resolve({
tags: {
'v4': { release: '4.0.0'},
},
releases: {
'4.0.0': {
coreTools: [
{
OS: "Linux",
downloadLink: "https://abc.com/d.zip",
sha2: "123",
size: "full"
},
]
}
}
})
}));
const packageZip = Buffer.from('package');
fetchMock.mockImplementationOnce(() => Promise.resolve({
body: Readable.from(packageZip),
headers: new Headers({ 'content-length': packageZip.length.toString() })
}));
mockFs({ ['/home/user/.swa/core-tools/']: {} }, { createTmp: false, createCwd: false });

await expect(async () => await downloadCoreTools(4)).rejects.toThrowError(/SHA2 mismatch/);
});
});
});
Loading

0 comments on commit 5e98cba

Please sign in to comment.