diff --git a/bin/cli.js b/bin/cli.js old mode 100644 new mode 100755 index a332bd0..9c7e74e --- a/bin/cli.js +++ b/bin/cli.js @@ -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 ', '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 ') + .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(); + diff --git a/lib/ckron.js b/lib/ckron.js index c2b3497..2301cf3 100644 --- a/lib/ckron.js +++ b/lib/ckron.js @@ -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 { @@ -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; @@ -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'); } } @@ -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'); } } @@ -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'); } } @@ -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() { diff --git a/lib/error.js b/lib/error.js new file mode 100644 index 0000000..bb3b8e4 --- /dev/null +++ b/lib/error.js @@ -0,0 +1,10 @@ + +class CkronError extends Error { + constructor(message, code, exitCode = 1) { + super(message); + this.code = code; + this.exitCode = exitCode; + } +} + +export default CkronError; diff --git a/lib/job.js b/lib/job.js index a613523..9d6741e 100644 --- a/lib/job.js +++ b/lib/job.js @@ -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())}`); @@ -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; } diff --git a/lib/util.js b/lib/util.js index d75dca4..e3d6fc4 100644 --- a/lib/util.js +++ b/lib/util.js @@ -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 @@ -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'); + } +}