@mtakla/cronops 0.1.1-rc2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ import { JobRunner } from "./JobRunner.js";
2
+ import { JobModel } from "../models/JobModel.js";
3
+ import { JobRunnerSetup } from "../models/JobRunnerSetup.js";
4
+ import { AbstractTask } from "./AbstractTask.js";
5
+ import { JobError } from "../errors/JobError.js";
6
+ export class JobScheduler extends AbstractTask {
7
+ runnerSetup;
8
+ runnerMap;
9
+ changed = false;
10
+ isReload = false;
11
+ constructor(options = {}) {
12
+ super("*/5 * * * * *");
13
+ this.runnerSetup = new JobRunnerSetup(options);
14
+ this.runnerMap = new Map();
15
+ }
16
+ get scheduledJobs() {
17
+ return this.runnerMap.size;
18
+ }
19
+ get tempDir() {
20
+ return this.runnerSetup.tempDir;
21
+ }
22
+ async run() {
23
+ if (this.changed) {
24
+ this.changed = false;
25
+ this.events.emit("schedule-changed", this.isReload);
26
+ this.isReload = true;
27
+ }
28
+ }
29
+ unscheduleAll() {
30
+ for (const runner of this.runnerMap.values())
31
+ runner.unschedule();
32
+ this.runnerMap = new Map();
33
+ this.changed = true;
34
+ }
35
+ scheduleJobs(jobs, cb) {
36
+ this.unscheduleAll();
37
+ for (const jobEntry of jobs)
38
+ this.scheduleJob(jobEntry);
39
+ if (cb)
40
+ cb(this.runnerMap.size);
41
+ }
42
+ scheduleJob(job, defaults = {}) {
43
+ let rescheduled = false;
44
+ if (job.enabled === false)
45
+ JobError.throw(job.id, `Cannot schedule disabled job [${job.id}]!`);
46
+ this.runnerSetup.validateJob(job);
47
+ if (this.runnerMap.has(job.id)) {
48
+ this.runnerMap.get(job.id)?.unschedule();
49
+ rescheduled = true;
50
+ }
51
+ const task = new JobRunner(new JobModel(job, defaults), this.runnerSetup);
52
+ this.runnerMap.set(job.id, task);
53
+ task.onScheduled(() => this.events.emit("job-scheduled", job, rescheduled));
54
+ task.onStarted(() => this.events.emit("job-started", job));
55
+ task.onFinished((stat) => this.events.emit("job-finished", job, stat));
56
+ task.onActivity((activity, path, count) => this.events.emit("job-activity", job, activity, path, count));
57
+ task.onError((err) => this.events.emit("job-error", job, err));
58
+ task.schedule();
59
+ this.changed = true;
60
+ }
61
+ unscheduleJob(jobId) {
62
+ const task = this.runnerMap.get(jobId);
63
+ if (task) {
64
+ this.runnerMap.delete(jobId);
65
+ this.changed = true;
66
+ task.unschedule();
67
+ }
68
+ }
69
+ isJobScheduled(jobId) {
70
+ return this.runnerMap.has(jobId);
71
+ }
72
+ executeJob(jobId) {
73
+ if (!this.runnerMap.has(jobId))
74
+ throw new Error(`Unknown job [${jobId}]!`);
75
+ this.runnerMap.get(jobId)?.execute();
76
+ }
77
+ validateJob(job) {
78
+ this.runnerSetup.validateJob(job);
79
+ }
80
+ getScheduledJobs() {
81
+ return Array.from(this.runnerMap.values()).map((runner) => runner.job);
82
+ }
83
+ async gracefulTerminate(timeout = 1000) {
84
+ const jobRunnerTasks = [...this.runnerMap.values()];
85
+ this.unscheduleAll();
86
+ await super.gracefulTerminate(timeout);
87
+ for (const task of jobRunnerTasks)
88
+ await task.gracefulTerminate(timeout);
89
+ }
90
+ onChanged(cb) {
91
+ this.events.on("schedule-changed", cb);
92
+ }
93
+ onJobScheduled(cb) {
94
+ this.events.on("job-scheduled", cb);
95
+ }
96
+ onJobStarted(cb) {
97
+ this.events.on("job-started", cb);
98
+ }
99
+ onJobFinished(cb) {
100
+ this.events.on("job-finished", cb);
101
+ }
102
+ onJobActivity(cb) {
103
+ this.events.on("job-activity", cb);
104
+ }
105
+ onJobError(cb) {
106
+ this.events.on("job-error", cb);
107
+ }
108
+ }
@@ -0,0 +1,102 @@
1
+ import fsx from "fs-extra";
2
+ import { createJobScheduler } from "../index.js";
3
+ let time = Date.now();
4
+ await fsx.emptyDir("./build/loadtest");
5
+ await fsx.ensureDir("./build/loadtest/copied");
6
+ await fsx.ensureDir("./build/loadtest/moved");
7
+ const jobs = [
8
+ {
9
+ id: "copy",
10
+ action: "copy",
11
+ cron: "0 0 */5 * *",
12
+ source: {
13
+ dir: "/node_modules",
14
+ includes: ["**/**"],
15
+ },
16
+ target: {
17
+ dir: "/build/loadtest/copied",
18
+ },
19
+ },
20
+ {
21
+ id: "move",
22
+ action: "move",
23
+ cron: "0 0 */5 * *",
24
+ source: {
25
+ dir: "/build/loadtest/copied",
26
+ includes: ["**/**"],
27
+ },
28
+ target: {
29
+ dir: "/build/loadtest/moved",
30
+ },
31
+ },
32
+ {
33
+ id: "archive",
34
+ action: "archive",
35
+ cron: "0 0 */5 * *",
36
+ source: {
37
+ dir: "/build/loadtest/moved",
38
+ includes: ["**/**"],
39
+ },
40
+ target: {
41
+ dir: "/build/loadtest",
42
+ archive_name: "loadtest.tgz",
43
+ },
44
+ },
45
+ {
46
+ id: "execute",
47
+ action: "exec",
48
+ command: "chmod",
49
+ args: ["ugo+rwx", "{file}"],
50
+ cron: "0 0 */5 * *",
51
+ source: {
52
+ dir: "/build/loadtest/moved",
53
+ includes: ["**/*.ts"],
54
+ },
55
+ target: {
56
+ dir: "/build/loadtest/moved",
57
+ },
58
+ },
59
+ {
60
+ id: "delete",
61
+ action: "delete",
62
+ cron: "0 0 */5 * *",
63
+ source: {
64
+ dir: "/build/loadtest/moved",
65
+ includes: ["**/**"],
66
+ },
67
+ },
68
+ ];
69
+ const scheduler = createJobScheduler({
70
+ logDir: "./build/loadtest",
71
+ });
72
+ scheduler.onJobStarted((job) => {
73
+ console.log(`Starting job [${job.id}] ...`);
74
+ time = Date.now();
75
+ });
76
+ scheduler.onJobFinished((job, { copied, deleted, archived, executed }) => {
77
+ console.log(`✔ Done in ${Date.now() - time}ms (${copied} copied, ${deleted} deleted, ${archived} archived, ${executed} executed)`);
78
+ if (job.id === "copy")
79
+ scheduler.executeJob("move");
80
+ else if (job.id === "move")
81
+ scheduler.executeJob("execute");
82
+ else if (job.id === "execute")
83
+ scheduler.executeJob("archive");
84
+ else if (job.id === "archive")
85
+ scheduler.executeJob("delete");
86
+ else
87
+ done();
88
+ });
89
+ scheduler.onJobError((job, err) => {
90
+ console.log(`${job.id} failed. ${String(err)}`);
91
+ });
92
+ scheduler.scheduleJobs(jobs, () => {
93
+ scheduler.executeJob("copy");
94
+ });
95
+ async function done() {
96
+ fsx.rmdirSync("./build/loadtest/copied");
97
+ fsx.rmdirSync("./build/loadtest/moved");
98
+ if (!fsx.pathExistsSync("./build/loadtest/loadtest.tgz"))
99
+ throw Error("Archive should exist!");
100
+ await scheduler.gracefulTerminate();
101
+ process.exit(0);
102
+ }
@@ -0,0 +1,34 @@
1
+ import { z } from "zod";
2
+ export const JobSchema = z.strictObject({
3
+ id: z.string().optional(),
4
+ cron: z.string().min(1).optional(),
5
+ action: z.literal(["exec", "call", "copy", "move", "delete", "archive"]),
6
+ command: z.string().optional(),
7
+ shell: z.boolean().or(z.string().min(1)).optional(),
8
+ args: z.array(z.string().min(1)).min(1).optional(),
9
+ env: z.record(z.string().regex(/^[A-Z_][A-Z0-9_]*$/), z.string()).optional(),
10
+ source: z
11
+ .strictObject({
12
+ dir: z.string().min(1).optional(),
13
+ includes: z.array(z.string().min(1)).min(1).optional(),
14
+ excludes: z.array(z.string().min(1)).min(1).optional(),
15
+ })
16
+ .optional(),
17
+ target: z
18
+ .strictObject({
19
+ dir: z.string().min(1).optional(),
20
+ archive_name: z.string().min(1).optional(),
21
+ permissions: z
22
+ .strictObject({
23
+ owner: z.string().min(1).optional(),
24
+ file_mode: z.string().min(1).optional(),
25
+ dir_mode: z.string().min(1).optional(),
26
+ })
27
+ .optional(),
28
+ retention: z.string().min(1).optional(),
29
+ })
30
+ .optional(),
31
+ dry_run: z.boolean().optional(),
32
+ verbose: z.boolean().optional(),
33
+ enabled: z.boolean().optional(),
34
+ });
@@ -0,0 +1,19 @@
1
+ export const ENV = {
2
+ CONFIG_DIR: "CROPS_CONFIG_DIR",
3
+ TEMP_DIR: "CROPS_TEMP_DIR",
4
+ LOG_DIR: "CROPS_LOG_DIR",
5
+ SOURCE_ROOT: "CROPS_SOURCE_ROOT",
6
+ TARGET_ROOT: "CROPS_TARGET_ROOT",
7
+ SOURCE_2_ROOT: "CROPS_SOURCE_2_ROOT",
8
+ TARGET_2_ROOT: "CROPS_TARGET_2_ROOT",
9
+ SOURCE_3_ROOT: "CROPS_SOURCE_3_ROOT",
10
+ TARGET_3_ROOT: "CROPS_TARGET_3_ROOT",
11
+ EXEC_SHELL: "CROPS_EXEC_SHELL",
12
+ PLIMIT_SPAWN: "CROPS_PLIMIT_SPAWN",
13
+ PLIMIT_FS: "CROPS_PLIMIT_FS",
14
+ API_KEY: "CROPS_API_KEY",
15
+ BASE_URL: "CROPS_BASE_URL",
16
+ HOST: "CROPS_HOST",
17
+ PORT: "CROPS_PORT",
18
+ TZ: "TZ",
19
+ };
@@ -0,0 +1 @@
1
+ export {};
package/dist/webapi.js ADDED
@@ -0,0 +1,52 @@
1
+ import Fastify from "fastify";
2
+ import chalk from "chalk";
3
+ import { ENV } from "./types/Options.types.js";
4
+ const app = Fastify();
5
+ const port = Number(process.env[ENV.PORT] ?? 8778);
6
+ const host = process.env[ENV.HOST] ?? "127.0.0.1";
7
+ const apiKey = process.env[ENV.API_KEY];
8
+ app.addHook("preHandler", async (request, reply) => {
9
+ if (request.url === "/health")
10
+ return;
11
+ const key = request.headers["x-api-key"];
12
+ if (!apiKey || key !== apiKey) {
13
+ reply.code(401).send("Unauthorized");
14
+ }
15
+ });
16
+ app.get("/health", async (request, reply) => {
17
+ const jobScheduler = request.server.scheduler;
18
+ const jobs = jobScheduler.getScheduledJobs();
19
+ reply.code(200).send({ status: "ok", active_jobs: jobs.length });
20
+ });
21
+ app.post("/trigger/:jobId", async (request) => {
22
+ const { jobId } = request.params;
23
+ return { triggered: true, jobId };
24
+ });
25
+ app.post("/terminate", async () => {
26
+ return { terminating: true };
27
+ });
28
+ export default function (scheduler) {
29
+ if (!apiKey || !/^[0-9a-f]{64}$/i.test(apiKey)) {
30
+ console.log(chalk.red(`Web API is disabled. No valid API key configured!`));
31
+ console.log(`\nTo use the CronOps admin Web API:`);
32
+ console.log(` - Generate a hex‑encoded 256‑bit secret (e.g. 'openssl rand -hex 32')`);
33
+ console.log(` - Configure api-key via environment variable CROPS_API_KEY`);
34
+ console.log(` - Use 'x-api-key' header on each web admin HTTP request`);
35
+ }
36
+ else {
37
+ app.decorate("scheduler", scheduler);
38
+ app.listen({ port, host }, (err, address) => {
39
+ if (err) {
40
+ console.log(chalk.red(`Web API is disabled. ${err?.message}`));
41
+ }
42
+ else {
43
+ console.log(`\nWeb API listening on port ${port} ...`);
44
+ console.log(`⎆ to get server health status, type curl -X GET ${address}/health`);
45
+ console.log(`⎆ to list scheduled jobs status, type curl -X GET ${address}/status¹`);
46
+ console.log(`⎆ to trigger a job manually*, type curl -X POST ${address}/trigger/{job-id}¹`);
47
+ console.log(`⎆ to gracefully terminate server,type curl -X POST ${address}/terminate¹"`);
48
+ console.log(`¹for authentication use curl option -H "x-api-key: YOUR_API_KEY" `);
49
+ }
50
+ });
51
+ }
52
+ }
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "@mtakla/cronops",
3
+ "version": "0.1.1-rc2.4",
4
+ "description": "Cron-based cross-container server lifecycle management",
5
+ "type": "module",
6
+ "author": "nevereven",
7
+ "license": "ISC",
8
+ "bin": {
9
+ "cronops": "dist/server.js"
10
+ },
11
+ "main": "./dist/server.js",
12
+ "types": "./dist/index.d.ts",
13
+ "engines": {
14
+ "node": ">=24"
15
+ },
16
+ "keywords": [
17
+ "cron",
18
+ "file",
19
+ "glob",
20
+ "exec",
21
+ "copy",
22
+ "move",
23
+ "archive",
24
+ "docker",
25
+ "scheduler",
26
+ "lifecycle",
27
+ "cross-container"
28
+ ],
29
+ "export": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "config",
38
+ "LICENSE",
39
+ "README.md"
40
+ ],
41
+ "dependencies": {
42
+ "chalk": "5.6.2",
43
+ "date-fns": "4.1.0",
44
+ "fast-glob": "3.3.3",
45
+ "figlet": "1.10.0",
46
+ "fs-extra": "11.3.3",
47
+ "p-limit": "7.3.0",
48
+ "node-cron": "4.2.1",
49
+ "parse-duration": "2.1.5",
50
+ "tar-fs": "3.1.1",
51
+ "yaml": "2.8.2",
52
+ "zod": "4.3.6",
53
+ "fastify": "5.7.4"
54
+ },
55
+ "devDependencies": {
56
+ "@biomejs/biome": "^2.3.6",
57
+ "@types/fs-extra": "^11.0.4",
58
+ "@types/node": "^25.0.3",
59
+ "@types/shell-quote": "^1.7.5",
60
+ "@types/tar-fs": "2.0.4",
61
+ "@vitest/coverage-v8": "^4.0.14",
62
+ "typescript": "^5.9.3",
63
+ "typedoc": "^0.28.16",
64
+ "tsx": "^4.21.0",
65
+ "vitest": "^4.0.14"
66
+ },
67
+ "scripts": {
68
+ "check": "biome check",
69
+ "check:fix": "biome check --write",
70
+ "test": "vitest run",
71
+ "docs": "typedoc",
72
+ "coverage": "vitest run --coverage",
73
+ "loadtest": "tsc && node ./dist/tests/loadtest.js",
74
+ "clean": "rm -rf dist",
75
+ "build": "tsc",
76
+ "dev": "tsc && node ./dist/server.js",
77
+ "start": "node ./dist/server.js",
78
+ "docker:build": "docker build -t ghcr.io/mtakla/cronops:latest -t ghcr.io/mtakla/cronops:0.1 .",
79
+ "docker:push": "docker push ghcr.io/mtakla/cronops:latest"
80
+ }
81
+ }