diff --git a/.gitignore b/.gitignore index 83cc53ad9..bc0d5c88c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ /lerna-debug.log /lerna-debug.log.* +/@xen-orchestra/*/dist/ +/@xen-orchestra/*/node_modules/ /packages/*/dist/ /packages/*/node_modules/ diff --git a/@xen-orchestra/cron/.babelrc.js b/@xen-orchestra/cron/.babelrc.js new file mode 100644 index 000000000..4d6b191e5 --- /dev/null +++ b/@xen-orchestra/cron/.babelrc.js @@ -0,0 +1,41 @@ +'use strict' + +const NODE_ENV = process.env.NODE_ENV || 'development' +const __PROD__ = NODE_ENV === 'production' +const __TEST__ = NODE_ENV === 'test' + +const pkg = require('./package') + +let nodeCompat = (pkg.engines || {}).node +if (nodeCompat === undefined) { + nodeCompat = '6' +} else { + const trimChars = '^=>~' + while (trimChars.includes(nodeCompat[0])) { + nodeCompat = nodeCompat.slice(1) + } +} + +module.exports = { + comments: !__PROD__, + ignore: __TEST__ ? undefined : [/\.spec\.js$/], + plugins: ['lodash'], + presets: [ + [ + '@babel/env', + { + debug: !__TEST__, + loose: true, + shippedProposals: true, + targets: __PROD__ + ? { + browsers: '>2%', + node: nodeCompat, + } + : { node: 'current' }, + useBuiltIns: '@babel/polyfill' in (pkg.dependencies || {}) && 'usage', + }, + ], + '@babel/flow', + ], +} diff --git a/@xen-orchestra/cron/.npmignore b/@xen-orchestra/cron/.npmignore new file mode 100644 index 000000000..e058b6bc1 --- /dev/null +++ b/@xen-orchestra/cron/.npmignore @@ -0,0 +1,24 @@ +/benchmark/ +/benchmarks/ +*.bench.js +*.bench.js.map + +/examples/ +example.js +example.js.map +*.example.js +*.example.js.map + +/fixture/ +/fixtures/ +*.fixture.js +*.fixture.js.map +*.fixtures.js +*.fixtures.js.map + +/test/ +/tests/ +*.spec.js +*.spec.js.map + +__snapshots__/ diff --git a/@xen-orchestra/cron/README.md b/@xen-orchestra/cron/README.md new file mode 100644 index 000000000..663209c0e --- /dev/null +++ b/@xen-orchestra/cron/README.md @@ -0,0 +1,92 @@ +# @xen-orchestra/cron [![Build Status](https://travis-ci.org/vatesfr/xen-orchestra.png?branch=master)](https://travis-ci.org/vatesfr/xen-orchestra) + +> Focused, well maintained, cron parser/scheduler + +## Install + +Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/cron): + +``` +> npm install --save @xen-orchestra/cron +``` + +## Usage + +```js +import * as Cron from '@xen-orchestra/cron' + +Cron.parse('* * * jan,mar *') +// → { month: [ 1, 3 ] } + +Cron.next('* * * jan,mar *', 2, 'America/New_York') +// → [ 2018-01-19T22:15:00.000Z, 2018-01-19T22:16:00.000Z ] + +const stop = Cron.schedule('@hourly', () => { + console.log(new Date()) +}, 'UTC+05:30') +``` + +### Pattern syntax + +``` + +``` + + +Each entry can be: + +- a single value +- a range (`0-23` or `*/2`) +- a list of values/ranges (`1,8-12`) + +A wildcard (`*`) can be used as a shortcut for the whole range +(`first-last`). + +Step values can be used in conjunctions with ranges. For instance, +`1-7/2` is the same as `1,3,5,7`. + +| Field | Allowed values | +|------------------|----------------| +| minute | 0-59 | +| hour | 0-23 | +| day of the month | 1-31 or 3-letter names (`jan`, `feb`, …) | +| month | 0-11 | +| day of week | 0-7 (0 and 7 both mean Sunday) or 3-letter names (`mon`, `tue`, …) | + +> Note: the month range is 0-11 to be compatible with +> [cron](https://github.com/kelektiv/node-cron), it does not appear to +> be very standard though. + +## Development + +``` +# Install dependencies +> yarn + +# Run the tests +> yarn test + +# Continuously compile +> yarn dev + +# Continuously run the tests +> yarn dev-test + +# Build for production (automatically called by npm install) +> yarn build +``` + +## Contributions + +Contributions are *very* welcomed, either on the documentation or on +the code. + +You may: + +- report any [issue](https://github.com/vatesfr/xo-web/issues) + you've encountered; +- fork and create a pull request. + +## License + +ISC © [Vates SAS](https://vates.fr) diff --git a/@xen-orchestra/cron/package.json b/@xen-orchestra/cron/package.json new file mode 100644 index 000000000..048a655e6 --- /dev/null +++ b/@xen-orchestra/cron/package.json @@ -0,0 +1,57 @@ +{ + "private": true, + "name": "@xen-orchestra/cron", + "version": "0.0.0", + "license": "ISC", + "description": "Focused, well maintained, cron parser/scheduler", + "keywords": [ + "cron", + "cronjob", + "crontab", + "job", + "parser", + "pattern", + "schedule", + "scheduling", + "task" + ], + "homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/@xen-orchestra/cron", + "bugs": "https://github.com/vatesfr/xo-web/issues", + "repository": { + "type": "git", + "url": "https://github.com/vatesfr/xen-orchestra.git" + }, + "author": { + "name": "Julien Fontanet", + "email": "julien.fontanet@isonoe.net" + }, + "preferGlobal": false, + "main": "dist/", + "bin": {}, + "files": [ + "dist/" + ], + "engines": { + "node": ">=6" + }, + "dependencies": { + "lodash": "^4.17.4", + "luxon": "^0.4.0" + }, + "devDependencies": { + "@babel/cli": "7.0.0-beta.38", + "@babel/core": "7.0.0-beta.38", + "@babel/preset-env": "7.0.0-beta.38", + "@babel/preset-flow": "7.0.0-beta.38", + "cross-env": "^5.1.3", + "rimraf": "^2.6.2" + }, + "scripts": { + "build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/", + "clean": "rimraf dist/", + "dev": "cross-env NODE_ENV=development babel --watch --source-maps --out-dir=dist/ src/", + "prebuild": "yarn run clean", + "predev": "yarn run clean", + "prepublishOnly": "yarn run build" + } +} diff --git a/@xen-orchestra/cron/src/index.js b/@xen-orchestra/cron/src/index.js new file mode 100644 index 000000000..ac8154df5 --- /dev/null +++ b/@xen-orchestra/cron/src/index.js @@ -0,0 +1,40 @@ +import { DateTime } from 'luxon' + +import nextDate from './next' +import parse from './parse' + +const autoParse = pattern => + typeof pattern === 'string' ? parse(pattern) : schedule + +export const next = (cronPattern, n = 1, zone = 'utc') => { + const schedule = autoParse(cronPattern) + + let date = DateTime.fromObject({ zone }) + const dates = [] + for (let i = 0; i < n; ++i) { + dates.push((date = nextDate(schedule, date)).toJSDate()) + } + return dates +} + +export { parse } + +export const schedule = (cronPattern, fn, zone = 'utc') => { + const wrapper = () => { + fn() + scheduleNextRun() + } + + let handle + const schedule = autoParse(cronPattern) + const scheduleNextRun = () => { + const now = DateTime.fromObject({ zone }) + const nextRun = next(schedule, now) + handle = setTimeout(wrapper, nextRun - now) + } + + scheduleNextRun() + return () => { + clearTimeout(handle) + } +} diff --git a/@xen-orchestra/cron/src/next.js b/@xen-orchestra/cron/src/next.js new file mode 100644 index 000000000..88e859418 --- /dev/null +++ b/@xen-orchestra/cron/src/next.js @@ -0,0 +1,90 @@ +import sortedIndex from 'lodash/sortedIndex' +import { DateTime } from 'luxon' + +const NEXT_MAPPING = { + month: { year: 1 }, + day: { month: 1 }, + weekday: { week: 1 }, + hour: { day: 1 }, + minute: { hour: 1 }, +} + +const getFirst = values => values !== undefined ? values[0] : 0 + +const setFirstAvailable = (date, unit, values) => { + if (values === undefined) { + return date + } + + const curr = date.get(unit) + const next = values[sortedIndex(values, curr) % values.length] + if (curr === next) { + return date + } + + const newDate = date.set({ [unit]: next }) + return newDate > date ? newDate : newDate.plus(NEXT_MAPPING[unit]) +} + +// returns the next run, after the passed date +export default (schedule, fromDate) => { + let date = fromDate + .set({ + second: 0, + millisecond: 0, + }) + .plus({ minute: 1 }) + + const { minute, hour, dayOfMonth, month, dayOfWeek } = schedule + date = setFirstAvailable(date, 'minute', minute) + + let tmp + + tmp = setFirstAvailable(date, 'hour', hour) + if (tmp !== date) { + date = tmp.set({ + minute: getFirst(minute), + }) + } + + let loop + let i = 0 + do { + loop = false + + tmp = setFirstAvailable(date, 'month', month) + if (tmp !== date) { + date = tmp.set({ + day: 1, + hour: getFirst(hour), + minute: getFirst(minute), + }) + } + + if (dayOfMonth === undefined) { + if (dayOfWeek !== undefined) { + tmp = setFirstAvailable(date, 'weekday', dayOfWeek) + } + } else if (dayOfWeek === undefined) { + tmp = setFirstAvailable(date, 'day', dayOfMonth) + } else { + tmp = DateTime.min( + setFirstAvailable(date, 'day', dayOfMonth), + setFirstAvailable(date, 'weekday', dayOfWeek) + ) + } + if (tmp !== date) { + loop = tmp.month !== date.month + date = tmp.set({ + hour: getFirst(hour), + minute: getFirst(minute), + }) + } + } while (loop && ++i < 5) + + if (loop) { + throw new Error('no solutions found for this schedule') + } + + return date +} diff --git a/@xen-orchestra/cron/src/next.spec.js b/@xen-orchestra/cron/src/next.spec.js new file mode 100644 index 000000000..842ce880e --- /dev/null +++ b/@xen-orchestra/cron/src/next.spec.js @@ -0,0 +1,46 @@ +/* eslint-env jest */ + +import mapValues from 'lodash/mapValues' +import { DateTime } from 'luxon' + +import next from './next' +import parse from './parse' + +const N = (pattern, fromDate = '2018-04-09T06:25') => + next(parse(pattern), DateTime.fromISO(fromDate, { zone: 'utc' })).toISO({ + includeOffset: false, + suppressMilliseconds: true, + suppressSeconds: true, + }) + +describe('next()', () => { + mapValues( + { + minutely: ['* * * * *', '2018-04-09T06:26'], + hourly: ['@hourly', '2018-04-09T07:00'], + daily: ['@daily', '2018-04-10T00:00'], + monthly: ['@monthly', '2018-05-01T00:00'], + yearly: ['@yearly', '2019-01-01T00:00'], + weekly: ['@weekly', '2018-04-15T00:00'], + }, + ([pattern, result], title) => + it(title, () => { + expect(N(pattern)).toBe(result) + }) + ) + + it('select first between month-day and week-day', () => { + expect(N('* * 10 * wen')).toBe('2018-04-10T00:00') + expect(N('* * 12 * wen')).toBe('2018-04-11T00:00') + }) + + it('select the last available day of a month', () => { + expect(N('* * 29 feb *')).toBe('2020-02-29T00:00') + }) + + it('fails when no solutions has been found', () => { + expect(() => N('0 0 30 feb *')).toThrow( + 'no solutions found for this schedule' + ) + }) +}) diff --git a/@xen-orchestra/cron/src/parse.js b/@xen-orchestra/cron/src/parse.js new file mode 100644 index 000000000..a5d7714b2 --- /dev/null +++ b/@xen-orchestra/cron/src/parse.js @@ -0,0 +1,198 @@ +const compareNumbers = (a, b) => a - b + +const createParser = ({ fields: [...fields], presets: { ...presets } }) => { + const m = fields.length + + for (let j = 0; j < m; ++j) { + const field = fields[j] + let { aliases } = field + if (aliases !== undefined) { + let symbols = aliases + + if (Array.isArray(aliases)) { + aliases = {} + const [start] = field.range + symbols.forEach((alias, i) => { + aliases[alias] = start + i + }) + } else { + symbols = Object.keys(aliases) + } + + fields[j] = { + ...field, + aliases, + aliasesRegExp: new RegExp(symbols.join('|'), 'y'), + } + } + } + + let field, i, n, pattern, schedule, values + + const isDigit = c => c >= '0' && c <= '9' + const match = c => pattern[i] === c && (++i, true) + + const consumeWhitespaces = () => { + let c + while ((c = pattern[i]) === ' ' || c === '\t') { + ++i + } + } + + const parseInteger = () => { + let c + const digits = [] + while (isDigit((c = pattern[i]))) { + ++i + digits.push(c) + } + if (digits.length === 0) { + throw new SyntaxError(`${field.name}: missing integer at character ${i}`) + } + return Number.parseInt(digits.join(''), 10) + } + + const parseValue = () => { + let value + + const { aliasesRegExp } = field + if (aliasesRegExp === undefined || isDigit(pattern[i])) { + value = parseInteger() + const { post } = field + if (post !== undefined) { + value = post(value) + } + } else { + aliasesRegExp.lastIndex = i + const matches = aliasesRegExp.exec(pattern) + if (matches === null) { + throw new SyntaxError( + `${field.name}: missing alias or integer at character ${i}` + ) + } + const [alias] = matches + i += alias.length + value = field.aliases[alias] + } + + const { range } = field + if (value < range[0] || value > range[1]) { + throw new SyntaxError( + `${field.name}: ${value} is not between ${range[0]} and ${range[1]}` + ) + } + return value + } + + const parseRange = () => { + let end, start, step + if (match('*')) { + if (!match('/')) { + return + } + [start, end] = field.range + step = parseInteger() + } else { + start = parseValue() + if (!match('-')) { + values.add(start) + return + } + end = parseValue() + step = match('/') ? parseInteger() : 1 + } + + for (let i = start; i <= end; i += step) { + values.add(i) + } + } + + const parseSequence = () => { + do { + parseRange() + } while (match(',')) + } + + const parse = p => { + { + const schedule = presets[p] + if (schedule !== undefined) { + return typeof schedule === 'string' + ? (presets[p] = parse(schedule)) + : schedule + } + } + + try { + i = 0 + n = p.length + pattern = p + schedule = {} + + for (let j = 0; j < m; ++j) { + consumeWhitespaces() + + field = fields[j] + values = new Set() + parseSequence() + if (values.size !== 0) { + schedule[field.name] = Array.from(values).sort(compareNumbers) + } + } + + consumeWhitespaces() + if (i !== n) { + throw new SyntaxError( + `unexpected character at offset ${i}, expected end` + ) + } + + return schedule + } finally { + field = pattern = schedule = values = undefined + } + } + + return parse +} + +export default createParser({ + fields: [ + { + name: 'minute', + range: [0, 59], + }, + { + name: 'hour', + range: [0, 23], + }, + { + name: 'dayOfMonth', + range: [1, 31], + }, + { + aliases: 'jan feb mar apr may jun jul aug sep oct nov dec'.split(' '), + name: 'month', + range: [1, 12], + + // this function is applied to numeric entries (not steps) + // + // currently parse month 0-11 + post: value => value + 1, + }, + { + aliases: 'mon tue wen thu fri sat sun'.split(' '), + name: 'dayOfWeek', + post: value => (value === 0 ? 7 : value), + range: [1, 7], + }, + ], + presets: { + '@annually': '0 0 1 jan *', + '@daily': '0 0 * * *', + '@hourly': '0 * * * *', + '@monthly': '0 0 1 * *', + '@weekly': '0 0 * * sun', + '@yearly': '0 0 1 jan *', + }, +}) diff --git a/@xen-orchestra/cron/src/parse.spec.js b/@xen-orchestra/cron/src/parse.spec.js new file mode 100644 index 000000000..f06e660b6 --- /dev/null +++ b/@xen-orchestra/cron/src/parse.spec.js @@ -0,0 +1,51 @@ +/* eslint-env jest */ + +import parse from './parse' + +describe('parse()', () => { + it('works', () => { + expect(parse('0 0-10 */10 jan,2,4-11/3 *')).toEqual({ + minute: [0], + hour: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + dayOfMonth: [1, 11, 21, 31], + month: [1, 3, 5, 8, 11], + }) + }) + + it('correctly parse months', () => { + expect(parse('* * * 0,11 *')).toEqual({ + month: [1, 12], + }) + expect(parse('* * * jan,dec *')).toEqual({ + month: [1, 12], + }) + }) + + it('correctly parse days', () => { + expect(parse('* * * * mon,sun')).toEqual({ + dayOfWeek: [1, 7], + }) + }) + + it('reports missing integer', () => { + expect(() => parse('*/a')).toThrow( + 'minute: missing integer at character 2' + ) + expect(() => parse('*')).toThrow('hour: missing integer at character 1') + }) + + it('reports invalid aliases', () => { + expect(() => parse('* * * jan-foo *')).toThrow( + 'month: missing alias or integer at character 10' + ) + }) + + it('dayOfWeek: 0 and 7 bind to sunday', () => { + expect(parse('* * * * 0')).toEqual({ + dayOfWeek: [7], + }) + expect(parse('* * * * 7')).toEqual({ + dayOfWeek: [7], + }) + }) +}) diff --git a/package.json b/package.json index 6076d5177..f0f9394c1 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,10 @@ ], "testRegex": "\\.spec\\.js$", "transform": { - "complex-matcher/.+\\.jsx?$": "babel-7-jest", - "value-matcher/.+\\.jsx?$": "babel-7-jest", - "xo-cli/.+\\.jsx?$": "babel-7-jest", + "/@xen-orchestra/cron/.+\\.jsx?$": "babel-7-jest", + "/packages/complex-matcher/.+\\.jsx?$": "babel-7-jest", + "/packages/value-matcher/.+\\.jsx?$": "babel-7-jest", + "/packages/xo-cli/.+\\.jsx?$": "babel-7-jest", "\\.jsx?$": "babel-jest" } }, @@ -57,6 +58,7 @@ "test": "jest && flow status" }, "workspaces": [ + "@xen-orchestra/*", "packages/*" ] } diff --git a/scripts/utils.js b/scripts/utils.js index 29a5ee844..be37a98a2 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -1,16 +1,25 @@ const { forEach, fromCallback } = require('promise-toolbox') const fs = require('fs') -const PKGS_DIR = `${__dirname}/../packages` +const ROOT_DIR = `${__dirname}/..` + +const _getPackages = scope => { + const inScope = scope !== undefined + const dir = `${ROOT_DIR}/${inScope ? scope : 'packages'}` + return fromCallback(cb => fs.readdir(dir, cb)).then(names => + names.map(name => ({ + dir: `${dir}/${name}`, + name: inScope ? `${scope}/${name}` : name, + })) + ) +} exports.getPackages = (readPackageJson = false) => { - const p = fromCallback(cb => - fs.readdir(PKGS_DIR, cb) - ).then(names => { - const pkgs = names.map(name => ({ - dir: `${PKGS_DIR}/${name}`, - name, - })) + const p = Promise.all([ + _getPackages(), + _getPackages('@xen-orchestra'), + ]).then(pkgs => { + pkgs = [].concat(...pkgs) // flatten return readPackageJson ? Promise.all(pkgs.map(pkg => readFile(`${pkg.dir}/package.json`).then(data => { diff --git a/yarn.lock b/yarn.lock index 07d241faf..c49372f9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2681,6 +2681,10 @@ fstream@^1.0.0, fstream@^1.0.10, fstream@^1.0.2: mkdirp ">=0.5 0" rimraf "2" +full-icu@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/full-icu/-/full-icu-1.2.0.tgz#6bd8bf565f696aab30df503de2d47b92d9f1046f" + function-bind@^1.0.2, function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -4182,6 +4186,12 @@ ltx@^2.5.0, ltx@^2.6.2: dependencies: inherits "^2.0.1" +luxon@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-0.4.0.tgz#e7146ce52a6db7e82448438827860092d75bb8d6" + dependencies: + full-icu "^1.2.0" + make-error@^1.0.2, make-error@^1.0.4, make-error@^1.2.1, make-error@^1.2.3, make-error@^1.3.0: version "1.3.2" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.2.tgz#8762ffad2444dd8ff1f7c819629fa28e24fea1c4"