Skip to content

Commit

Permalink
feat(cli): new version notifications (#454)
Browse files Browse the repository at this point in the history
The CLI will print a notification if there is a new version of the CDK8s CLI available in npm.

The result is cached under ~/.cdk8s-cli.version with a TTL of 30min.

Resolves #452

---

<img width="963" alt="Screen Shot 2020-12-15 at 23 06 38" src="https://user-images.githubusercontent.com/598796/102272933-3a0c6800-3f2a-11eb-9fc0-60a10cb341bb.png">


*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
Elad Ben-Israel authored Dec 24, 2020
1 parent 48ca970 commit 065756e
Show file tree
Hide file tree
Showing 7 changed files with 295 additions and 20 deletions.
2 changes: 2 additions & 0 deletions packages/cdk8s-cli/.projenrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const project = new TypeScriptLibraryProject({
'yaml',
'yargs',
'json2jsii',
'colors',

// add @types/node as a regular dependency since it's needed to during "import"
// to compile the generated jsii code.
'@types/node',
Expand Down
3 changes: 2 additions & 1 deletion packages/cdk8s-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@types/node": "^10.17.50",
"cdk8s": "^0.0.0",
"codemaker": "^1.16.0",
"colors": "^1.4.0",
"constructs": "^3.2.34",
"fs-extra": "^8.1.0",
"jsii-pacmak": "^1.16.0",
Expand Down Expand Up @@ -108,4 +109,4 @@
},
"types": "lib/index.d.ts",
"//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"."
}
}
42 changes: 30 additions & 12 deletions packages/cdk8s-cli/src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,33 @@
import * as colors from 'colors';
import * as yargs from 'yargs';
import { upgradeAvailable } from '../upgrades';

const args = yargs
.commandDir('cmds')
.recommendCommands()
.wrap(yargs.terminalWidth())
.showHelpOnFail(false)
.env('CDK8S')
.epilogue('Options can be specified via environment variables with the "CDK8S_" prefix (e.g. "CDK8S_OUTPUT")')
.help()
.argv;

if (args._.length === 0) {
yargs.showHelp();
async function main() {
const versions = upgradeAvailable();
if (versions) {
console.error('------------------------------------------------------------------------------------------------');
console.error(colors.yellow(`A new version ${versions.latest} of cdk8s-cli is available (current ${versions.current}).`));
console.error(colors.yellow('Run "npm install -g cdk8s-cli" to install the latest version on your system.'));
console.error(colors.yellow('For additional installation methods, see https://cdk8s.io/docs/latest/getting-started'));
console.error('------------------------------------------------------------------------------------------------');
}

const ya = yargs
.commandDir('cmds')
.recommendCommands()
.wrap(yargs.terminalWidth())
.showHelpOnFail(false)
.env('CDK8S')
.epilogue('Options can be specified via environment variables with the "CDK8S_" prefix (e.g. "CDK8S_OUTPUT")')
.help();

const args = ya.argv;
if (args._.length === 0) {
yargs.showHelp();
}
}

main().catch(e => {
console.error(e.stack);
process.exit(1);
});
84 changes: 84 additions & 0 deletions packages/cdk8s-cli/src/upgrades.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// import { shell } from './util';
import { homedir } from 'os';
import { join } from 'path';
import * as cdk8s from 'cdk8s';
import { readFileSync, statSync, writeFileSync } from 'fs-extra';

// eslint-disable-next-line @typescript-eslint/no-require-imports
const pkg = require('../package.json');

const CACHE = {
cacheTtlSec: 60 * 30, // 30min
cacheFile: join(homedir(), '.cdk8s-cli.version'),
};

/**
* Checks if there is a new version of the CLI available on npm.
*
* @returns `undefined` if there is no upgrade available. If there is a new
* version, returns an object with `latest` (the latest version) and `current`
* (the current version).
*/
export function upgradeAvailable() {
const latest = getLatestVersion(pkg.name, CACHE);
if (latest !== pkg.version) {
return { latest, current: pkg.version };
} else {
return undefined;
}
}

/**
* Returns the latest version of an npm module. The version is cached for 30min
* to ~/.cdk8s-cli.version.
*
* Never throws.
*
* @param module The module name
* @param options cache options
*/
export function getLatestVersion(module: string, options: { cacheFile: string; cacheTtlSec: number }) {
let latest = readCache();
if (!latest) {
try {
const info = cdk8s.Yaml.load(`http://registry.npmjs.org/${module}/latest`);
if (!info || info.length < 1) {
return undefined;
}
latest = info[0].version;
} catch (e) {
return undefined;
}

// cannot determine version, return undefined.
if (!latest) {
return undefined;
}

writeCache(latest);
}

return latest;

function readCache(): string | undefined {
try {
const lastModified = statSync(options.cacheFile).mtime;
const diff = (new Date().getTime()) - lastModified.getTime();
if (diff > options.cacheTtlSec * 1000) {
return undefined;
}

return readFileSync(options.cacheFile, 'utf-8').trim();
} catch (e) {
return undefined;
}
}

function writeCache(version: string) {
try {
writeFileSync(options.cacheFile, version);
} catch (e) {
return;
}
}
}
13 changes: 7 additions & 6 deletions packages/cdk8s-cli/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,17 @@ import * as path from 'path';
import { parse } from 'url';
import * as fs from 'fs-extra';

export async function shell(program: string, args: string[] = [], options: SpawnOptions = { }) {
export async function shell(program: string, args: string[] = [], options: SpawnOptions = { }): Promise<string> {
const command = `"${program} ${args.join(' ')}" at ${path.resolve(options.cwd ?? '.')}`;
return new Promise((ok, ko) => {
const child = spawn(program, args, { stdio: 'inherit', ...options });
child.once('error', err => {
throw new Error(`command ${command} failed: ${err}`);
});
const child = spawn(program, args, { stdio: ['inherit', 'pipe', 'inherit'], ...options });
const data = new Array<Buffer>();
child.stdout.on('data', chunk => data.push(chunk));

child.once('error', err => ko(new Error(`command ${command} failed: ${err}`)));
child.once('exit', code => {
if (code === 0) {
return ok();
return ok(Buffer.concat(data).toString('utf-8'));
} else {
return ko(new Error(`command ${command} returned a non-zero exit code ${code}`));
}
Expand Down
164 changes: 164 additions & 0 deletions packages/cdk8s-cli/test/upgrades.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import * as fs from 'fs-extra';
import { tmpdir } from 'os';
import { join } from 'path';
import { Yaml } from 'cdk8s';
import { getLatestVersion } from '../src/upgrades';

describe('getLatestVersion', () => {
const yamlLoad = jest.spyOn(Yaml, 'load');
let cacheFile: string;

beforeEach(() => {
const workdir = fs.mkdtempSync(join(tmpdir(), 'test'));
cacheFile = join(workdir, 'version-cache.txt');
});

afterEach(() => {
jest.clearAllMocks();
});

test('http get successful with no local cache', async () => {
// GIVEN
yamlLoad.mockReturnValue([{ version: '1.2.3' }]);

// WHEN
const result = getLatestVersion('dummy-module', {
cacheFile,
cacheTtlSec: 30,
});

// THEN

// getLatestVersion returns the version from npm
expect(result).toBe('1.2.3');

// Yaml.load() is called with the correct URL
expect(yamlLoad).toBeCalledWith('http://registry.npmjs.org/dummy-module/latest');

// local cache now includes the version
expect(fs.readFileSync(cacheFile, 'utf-8')).toStrictEqual('1.2.3');
});

test('local cache exists and valid', () => {
// GIVEN
fs.writeFileSync(cacheFile, '7.7.7');

// WHEN
const result = getLatestVersion('dummy-module', {
cacheFile,
cacheTtlSec: 30,
});

// THEN

// getLatestVersion returns the version from npm
expect(result).toBe('7.7.7');

// Yaml.load() should not be called
expect(yamlLoad).not.toBeCalled();

// local cache now includes the version
expect(fs.readFileSync(cacheFile, 'utf-8')).toStrictEqual('7.7.7');
});

test('local cache exists and invalid', () => {
// GIVEN
yamlLoad.mockReturnValue([{ version: '43.12.13' }]);
fs.writeFileSync(cacheFile, '88.88.88');

// WHEN
const result = getLatestVersion('dummy-module', {
cacheFile,
cacheTtlSec: -1,
});

// THEN

// getLatestVersion returns the version from npm
expect(result).toBe('43.12.13');

// Yaml.load() should be called
expect(yamlLoad).toBeCalled();

// local cache now includes the version
expect(fs.readFileSync(cacheFile, 'utf-8')).toStrictEqual('43.12.13');
});

test('no local cache, http get failed => undefined', () => {
// GIVEN
yamlLoad.mockImplementation(() => {
throw new Error('unable to download');
});

// WHEN
const result = getLatestVersion('dummy-module', {
cacheFile,
cacheTtlSec: 30,
});

// THEN

// Yaml.load() is called with the correct URL
expect(yamlLoad).toBeCalledWith('http://registry.npmjs.org/dummy-module/latest');

// getLatestVersion returns the version from npm
expect(result).toBe(undefined);

// local cache now includes the version
expect(fs.existsSync(cacheFile)).toBeFalsy();
});

test('npm returns a malformed result', () => {
// GIVEN
yamlLoad.mockReturnValue([{ bboom: 123 }]);

// WHEN
const result = getLatestVersion('dummy-module', {
cacheFile,
cacheTtlSec: 30,
});

// THEN
expect(result).toBeUndefined();
expect(yamlLoad).toBeCalledWith('http://registry.npmjs.org/dummy-module/latest');
expect(fs.existsSync(cacheFile)).toBeFalsy();
});

test('fail to write to local cache', () => {
// GIVEN
yamlLoad.mockReturnValue([{ version: '43.12.13' }]);
jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {
throw new Error('unable to write file');
});

// WHEN
const result = getLatestVersion('dummy-module', {
cacheFile, cacheTtlSec: 30
});

// THEN
expect(fs.existsSync(cacheFile)).toBeFalsy();
expect(result).toBe('43.12.13');
});

test('fails to download & to write to local cache', () => {
// GIVEN
yamlLoad.mockImplementation(() => {
throw new Error('unable to download');
});

jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {
throw new Error('unable to write file');
});

// WHEN
const result = getLatestVersion('dummy-module', {
cacheFile, cacheTtlSec: 30
});

// THEN
expect(fs.existsSync(cacheFile)).toBeFalsy();
expect(result).toBeUndefined();
})
});

7 changes: 6 additions & 1 deletion packages/cdk8s/src/yaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import * as path from 'path';
import * as YAML from 'yaml';
import { Type } from 'yaml/util';

const MAX_DOWNLOAD_BUFFER = 10 * 1024 * 1024;

// Ensure that all strings are quoted when written to yaml to avoid unquoted
// primitive types in the output yaml in fields that require strings.
YAML.scalarOptions.str.defaultType = Type.QUOTE_DOUBLE;
Expand Down Expand Up @@ -85,5 +87,8 @@ export class Yaml {
*/
function loadurl(url: string): string {
const script = path.join(__dirname, '_loadurl.js');
return execFileSync(process.execPath, [script, url], { encoding: 'utf-8' }).toString();
return execFileSync(process.execPath, [script, url], {
encoding: 'utf-8',
maxBuffer: MAX_DOWNLOAD_BUFFER,
}).toString();
}

0 comments on commit 065756e

Please sign in to comment.