Skip to content

Commit

Permalink
CLI Improvements:
Browse files Browse the repository at this point in the history
- New command to run job
- Refactor CLI and error handling
  • Loading branch information
nicomt committed Mar 3, 2024
1 parent ee1f482 commit f528a7e
Show file tree
Hide file tree
Showing 5 changed files with 101 additions and 37 deletions.
62 changes: 36 additions & 26 deletions bin/cli.js
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,57 @@

import fs from 'fs';
import { Command } from 'commander';
import betterAjvErrors from 'better-ajv-errors';
import Ckron from '../lib/ckron.js';
import CkronError from '../lib/error.js';

const { version } = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url)));
const program = new Command();

function handleError(err) {
if (err instanceof CkronError) {
program.error(err.message, { exit: err.exitCode });
}
throw err;
}

program
.name('ckron')
.option('--config [path]', 'configuration file', '/etc/ckron/config.yml')
.description('Cron-like job scheduler for docker')
.version(version);

program
.version(version)
.command('daemon')
.option('--config <path>', 'configuration file', '/etc/ckron/config.yml')
.action(async (cmd) => {
.description('Run the scheduler daemon')
.action(async (_, cmd) => {
const { config } = cmd.optsWithGlobals();
try {
const scheduler = new Ckron();
await scheduler.loadConfig(cmd.config);
await scheduler.loadConfig(config);
scheduler.start();
} catch (err) {
if (err.code === 'CONFIG_SYNTAX') {
const output = betterAjvErrors(
err.validationSchema,
err.validationData,
[err.validationErrors[0]],
{ indent: 2 }
);
process.stderr.write(output, () => {
process.exit(1);
});
} else {
process.stderr.write(`${err.message || err.toString()}\n`, () => {
process.exit(1);
});
}
handleError(err);
}
});

program.parse(process.argv);

process.on('SIGINT', () => {
process.stdout.write('Interrupted\n', () => {
process.exit(0);
program
.command('run <job>')
.description('Run a job')
.option('--notify', 'send notification on error', false)
.action(async (_, cmd) => {
const { config, job, notify } = cmd.optsWithGlobals();
try {
const scheduler = new Ckron();
await scheduler.loadConfig(config);
await scheduler.runJob(job, notify);
} catch (err) {
handleError(err);
}
});
});


program.parse();




Expand Down
38 changes: 28 additions & 10 deletions lib/ckron.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { promises as fs } from 'fs';
import yaml from 'js-yaml';
import betterAjvErrors from 'better-ajv-errors';
import taskTypes from './tasks/index.js';
import notifierTypes from './notifiers/index.js';
import Job from './job.js';
import loadValidator from './load-validator.js';
import CkronError from './error.js';
import { readConfigFile, parseYaml } from './util.js';


class Ckron {

Expand All @@ -30,12 +32,19 @@ class Ckron {
this.jobs = {};
this.notifiers = {};

const config = yaml.load(await fs.readFile(configFile));
const content = await readConfigFile(configFile);
const config = parseYaml(content);

if (!this.configValidator(config)){
if (!this.configValidator(config)) {
const { errors } = this.configValidator;
const err = new Error(`Config errors:\n${errors.map(e => `${e.dataPath} ${e.message}`).join('\n')}`);
err.code = 'CONFIG_SYNTAX';
const message = betterAjvErrors(
this.configValidator.schema,
config,
[errors[0]],
{ indent: 2 }
);

const err = new CkronError(message, 'SYNTAX_CONFIG');
err.validationErrors = errors;
err.validationSchema = this.configValidator.schema;
err.validationData = config;
Expand Down Expand Up @@ -64,7 +73,7 @@ class Ckron {
static _validateName(name) {
const valid = /^[a-zA-Z0-9._-]+$/.test(name);
if (!valid) {
throw new Error(`Invalid name ${name}`);
throw new CkronError(`Invalid name ${name}`, 'INVALID_CONF_NAME');
}
}

Expand All @@ -73,7 +82,7 @@ class Ckron {
Ckron._validateName(name);
if (!this.taskValidator(task)) {
const [err] = this.taskValidator.errors;
throw new Error(`Task error: ${err.dataPath} ${err.message}`);
throw new CkronError(`Task error: ${err.dataPath} ${err.message}`, 'SYNTAX_TASK');
}
}

Expand All @@ -82,7 +91,7 @@ class Ckron {
Ckron._validateName(name);
if (!this.jobValidator(job)) {
const [err] = this.jobValidator.errors;
throw new Error(`Job error: ${err.dataPath} ${err.message}`);
throw new CkronError(`Job error: ${err.dataPath} ${err.message}`, 'SYNTAX_JOB');
}
}

Expand All @@ -91,8 +100,17 @@ class Ckron {
Ckron._validateName(name);
if (!this.notifierValidator(notifier)) {
const [err] = this.notifierValidator.errors;
throw new Error(`Notifier error: ${err.dataPath} ${err.message}`);
throw new CkronError(`Notifier error: ${err.dataPath} ${err.message}`, 'SYNTAX_NOTIFIER');
}
}

async runJob(name, notifyError = false) {
await this.init();
const job = this.jobs[name];
if (!job) {
throw new CkronError(`Job ${name} not found`, 'JOB_NOT_FOUND');
}
return job.run(notifyError);
}

start() {
Expand Down
10 changes: 10 additions & 0 deletions lib/error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@

class CkronError extends Error {
constructor(message, code, exitCode = 1) {
super(message);
this.code = code;
this.exitCode = exitCode;
}
}

export default CkronError;
3 changes: 2 additions & 1 deletion lib/job.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class Job {
this.job = new CronJob(opt);
}

async run() {
async run(notifyError = true) {
const log = new Log();
log.pushNamespace(`${this.name} ${hashids.encode(Date.now())}`);

Expand All @@ -58,6 +58,7 @@ class Job {
return true;
} catch (err) {
log.error('Job failed', err);
if (!notifyError) return false;
await this.notifyError(formatError(err));
return false;
}
Expand Down
25 changes: 25 additions & 0 deletions lib/util.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import yaml from 'js-yaml';
import { resolve } from 'path';
import { split } from 'shlex';
import { parseRepositoryTag as _parseRepositoryTag } from 'dockerode/lib/util.js';
import { promises as fs } from 'fs';
import { Writable } from 'stream';
import { StringDecoder } from 'string_decoder';
import CkronError from './error.js';

export const MAX_OUTPUT_BUFFER_SIZE = 200000; // 200KB

Expand Down Expand Up @@ -78,3 +80,26 @@ export function formatError(err) {
}
return err.toString();
}

export async function readConfigFile(configFile) {
try {
return await fs.readFile(configFile, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') {
throw new CkronError(`Config file not found: ${configFile}`, 'CONFIG_ENOENT');
} else if (err.code === 'EACCES') {
throw new CkronError(`Config file not readable: ${configFile}`, 'CONFIG_EACCES');
} else if (err.code === 'EISDIR') {
throw new CkronError(`Config file is a directory: ${configFile}`, 'CONFIG_EISDIR');
}
throw err;
}
}

export async function parseYaml(content) {
try {
return yaml.safeLoad(content);
} catch (err) {
throw new CkronError(`Invalid YAML: ${err.message}`, 'CONFIG_SYNTAX');
}
}

0 comments on commit f528a7e

Please sign in to comment.