diff --git a/docs/resources/(resources)/cron.mdx b/docs/resources/(resources)/cron.mdx new file mode 100644 index 00000000..e1f11f5a --- /dev/null +++ b/docs/resources/(resources)/cron.mdx @@ -0,0 +1,62 @@ +--- +title: cron +description: A reference page for the cron resource +--- + +The cron resource manages scheduled jobs in the current user's crontab using the built-in `crontab` command. It works the same way on macOS and Linux. + +Each managed job is written to the crontab as a comment marker followed by the schedule and command: + +```sh title="crontab -l" +# Codify managed: + +``` + +The marker comment is how Codify identifies, updates, and removes jobs without disturbing any other entries already in your crontab. + +## Parameters + +- **jobs**: *(array[object], optional)* An array of cron job definitions. Each job object contains: + - **name**: *(string, required)* A unique name identifying this cron job. Used to track the job across runs. + - **schedule**: *(string, required)* A cron schedule expression (e.g. `"0 5 * * *"`) or a special schedule string (`@reboot`, `@yearly`, `@monthly`, `@weekly`, `@daily`, `@hourly`). + - **command**: *(string, required)* The command to run on the configured schedule. + +- **declarationsOnly**: *(boolean, optional)* Controls whether the resource operates in declarative or stateful mode. Defaults to `true`. + - `true` (default): Declarative mode — only manages the jobs explicitly listed in the configuration. Other Codify-managed jobs in the crontab (e.g. ones declared elsewhere) are left untouched. + - `false`: Stateful mode — Codify tracks and manages all Codify-managed jobs found in the crontab. + +## Example usage + +### Nightly backup job + +```json title="codify.jsonc" +[ + { + "type": "cron", + "jobs": [ + { "name": "nightly-backup", "schedule": "30 2 * * *", "command": "/usr/local/bin/backup.sh" } + ] + } +] +``` + +### Multiple maintenance jobs + +```json title="codify.jsonc" +[ + { + "type": "cron", + "jobs": [ + { "name": "health-check", "schedule": "*/5 * * * *", "command": "curl -fsS https://example.com/health" }, + { "name": "weekly-cleanup", "schedule": "0 3 * * 0", "command": "rm -rf /tmp/myapp-cache/*" } + ] + } +] +``` + +## Notes + +- No installation is required — `cron`/`crontab` ships with both macOS and Linux. +- This resource manages the **current user's** crontab (`crontab -l` / `crontab `), not system-wide crontabs (e.g. `/etc/crontab` or `/etc/cron.d`). +- Only entries with the `# Codify managed: ` marker are considered — manually added crontab entries are never modified or removed. +- When a job's `schedule` or `command` changes, Codify removes the old entry and writes a new one in its place. diff --git a/src/index.ts b/src/index.ts index b0145bc8..f233e673 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { AsdfPluginResource } from './resources/asdf/asdf-plugin.js'; import { AwsCliResource } from './resources/aws-cli/cli/aws-cli.js'; import { AwsProfileResource } from './resources/aws-cli/profile/aws-profile.js'; import { DnfResource } from './resources/dnf/dnf.js'; +import { CronResource } from './resources/cron/cron-resource.js'; import { GoenvResource } from './resources/go/goenv/goenv.js'; import { DockerResource } from './resources/docker/docker.js'; import { EnvFileResource } from './resources/file/env-file/env-file-resource.js'; @@ -67,6 +68,7 @@ runPlugin(Plugin.create( 'default', [ new GitResource(), + new CronResource(), new XcodeToolsResource(), new PathResource(), new AliasResource(), diff --git a/src/resources/cron/cron-resource.test.ts b/src/resources/cron/cron-resource.test.ts new file mode 100644 index 00000000..1c5e2c59 --- /dev/null +++ b/src/resources/cron/cron-resource.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { parseManagedJobs, removeJobBlock } from './cron-resource.js'; + +describe('CronResource unit tests', () => { + it('parses a single managed job', () => { + const result = parseManagedJobs('# Codify managed: my-job\n*/5 * * * * echo hello\n'); + expect(result).toMatchObject([{ name: 'my-job', schedule: '*/5 * * * *', command: 'echo hello' }]); + }); + + it('parses a job with an @special schedule', () => { + const result = parseManagedJobs('# Codify managed: daily-job\n@daily echo hello\n'); + expect(result).toMatchObject([{ name: 'daily-job', schedule: '@daily', command: 'echo hello' }]); + }); + + it('parses multiple managed jobs', () => { + const result = parseManagedJobs(` +# Codify managed: job-one +0 5 * * * /usr/local/bin/backup.sh + +# Codify managed: job-two +*/10 * * * * /usr/local/bin/healthcheck.sh +`); + expect(result).toMatchObject([ + { name: 'job-one', schedule: '0 5 * * *', command: '/usr/local/bin/backup.sh' }, + { name: 'job-two', schedule: '*/10 * * * *', command: '/usr/local/bin/healthcheck.sh' }, + ]); + }); + + it('ignores unmanaged crontab entries', () => { + const result = parseManagedJobs(` +# A manual job, not managed by Codify +0 0 * * * /usr/local/bin/manual.sh + +# Codify managed: managed-job +0 1 * * * /usr/local/bin/managed.sh +`); + expect(result).toMatchObject([{ name: 'managed-job', schedule: '0 1 * * *', command: '/usr/local/bin/managed.sh' }]); + }); + + it('removes a managed job block by name', () => { + const content = `0 0 * * * /usr/local/bin/manual.sh +# Codify managed: job-one +0 5 * * * /usr/local/bin/backup.sh +# Codify managed: job-two +*/10 * * * * /usr/local/bin/healthcheck.sh`; + + const result = removeJobBlock(content, 'job-one'); + + expect(result).to.not.include('job-one'); + expect(result).to.not.include('/usr/local/bin/backup.sh'); + expect(result).to.include('# Codify managed: job-two'); + expect(result).to.include('/usr/local/bin/healthcheck.sh'); + expect(result).to.include('/usr/local/bin/manual.sh'); + }); + + it('removeJobBlock is a no-op when the job is not present', () => { + const content = '# Codify managed: job-two\n*/10 * * * * /usr/local/bin/healthcheck.sh'; + const result = removeJobBlock(content, 'job-one'); + expect(result).toBe(content); + }); +}); diff --git a/src/resources/cron/cron-resource.ts b/src/resources/cron/cron-resource.ts new file mode 100644 index 00000000..d587b20e --- /dev/null +++ b/src/resources/cron/cron-resource.ts @@ -0,0 +1,261 @@ +import { + CreatePlan, + DestroyPlan, + ExampleConfig, + ModifyPlan, + ParameterChange, + RefreshContext, + Resource, + ResourceSettings, + SpawnStatus, + getPty, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +const MARKER_PREFIX = '# Codify managed:'; +const JOB_LINE_REGEX = /^(@\w+|(?:\S+\s+){4}\S+)\s+(.+)$/; + +export const schema = z.object({ + jobs: z + .array(z.object({ + name: z.string().describe('A unique name identifying this cron job'), + schedule: z.string().describe('Cron schedule expression (e.g. "0 5 * * *") or a special schedule string (@reboot, @yearly, @monthly, @weekly, @daily, @hourly)'), + command: z.string().describe('The command to run on the configured schedule'), + })) + .describe('Cron jobs to manage in the current user\'s crontab') + .optional(), + declarationsOnly: z + .boolean() + .optional() + .describe('Only manage explicitly declared cron jobs found in the crontab. Defaults to true.'), +}) + .describe('Manages scheduled jobs in the current user\'s crontab.'); + +export type CronConfig = z.infer; + +interface CronJob { + name: string; + schedule: string; + command: string; +} + +const defaultConfig: Partial = { + jobs: [], +} + +const exampleBackup: ExampleConfig = { + title: 'Nightly backup job', + description: 'Run a backup script every night at 2:30am.', + configs: [{ + type: 'cron', + jobs: [ + { name: 'nightly-backup', schedule: '30 2 * * *', command: '/usr/local/bin/backup.sh' }, + ], + }] +} + +const exampleMultiple: ExampleConfig = { + title: 'Multiple maintenance jobs', + description: 'Run a health check every 5 minutes and clean up a temp directory once a week.', + configs: [{ + type: 'cron', + jobs: [ + { name: 'health-check', schedule: '*/5 * * * *', command: 'curl -fsS https://example.com/health' }, + { name: 'weekly-cleanup', schedule: '0 3 * * 0', command: 'rm -rf /tmp/myapp-cache/*' }, + ], + }] +} + +export class CronResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'cron', + defaultConfig, + exampleConfigs: { + example1: exampleBackup, + example2: exampleMultiple, + }, + operatingSystems: [OS.Darwin, OS.Linux], + schema, + parameterSettings: { + jobs: { + type: 'array', + itemType: 'object', + isElementEqual: (a, b) => a.name === b.name && a.schedule === b.schedule && a.command === b.command, + filterInStatelessMode: (desired, current) => + current.filter((c) => desired.some((d) => d.name === c.name)), + canModify: true, + }, + declarationsOnly: { default: true, setting: true }, + }, + importAndDestroy: { + refreshMapper(input) { + if ((input.jobs?.length === 0 || !input?.jobs) && input?.jobs === undefined) { + return { jobs: [], declarationsOnly: true }; + } + + return input; + } + } + } + } + + override async refresh(parameters: CronConfig, context: RefreshContext): Promise | null> { + let jobs = await this.getManagedJobs(); + + if (parameters.declarationsOnly) { + jobs = jobs.filter((j) => parameters.jobs?.some((d) => d.name === j.name)); + } + + if (context.commandType === 'validationPlan' + && jobs.filter((j) => context.originalDesiredConfig?.jobs?.some((d) => d.name === j.name)).length === 0 + ) { + return null; + } + + if (jobs.length === 0) { + return null; + } + + return { jobs }; + } + + override async create(plan: CreatePlan): Promise { + await this.addJobs(plan.desiredConfig.jobs ?? []); + } + + async modify(pc: ParameterChange, plan: ModifyPlan): Promise { + const { isStateful } = plan; + + let jobsToRemove: CronJob[]; + let jobsToAdd: CronJob[]; + + if (isStateful) { + jobsToRemove = (pc.previousValue ?? []).filter((j: CronJob) => + !pc.newValue?.some((n: CronJob) => n.name === j.name) + || pc.newValue?.some((n: CronJob) => n.name === j.name && (n.schedule !== j.schedule || n.command !== j.command))); + jobsToAdd = (pc.newValue ?? []).filter((j: CronJob) => + !pc.previousValue?.some((p: CronJob) => p.name === j.name) + || pc.previousValue?.some((p: CronJob) => p.name === j.name && (p.schedule !== j.schedule || p.command !== j.command))); + } else { + jobsToRemove = (pc.previousValue ?? []).filter((j: CronJob) => + pc.newValue?.some((n: CronJob) => n.name === j.name && (n.schedule !== j.schedule || n.command !== j.command))); + jobsToAdd = (pc.newValue ?? []).filter((j: CronJob) => + !pc.previousValue?.some((p: CronJob) => p.name === j.name) + || pc.previousValue?.some((p: CronJob) => p.name === j.name && (p.schedule !== j.schedule || p.command !== j.command))); + } + + await this.removeJobs(jobsToRemove.map((j) => j.name)); + await this.addJobs(jobsToAdd); + } + + async destroy(plan: DestroyPlan): Promise { + await this.removeJobs((plan.currentConfig.jobs ?? []).map((j) => j.name)); + } + + private async getCrontab(): Promise { + const $ = getPty(); + const { data, status } = await $.spawnSafe('crontab -l', { interactive: true }); + + if (status === SpawnStatus.ERROR) { + return ''; + } + + return data; + } + + private async setCrontab(content: string): Promise { + const $ = getPty(); + const tmpPath = path.join(os.tmpdir(), `codify-crontab-${process.pid}-${Date.now()}.txt`); + + const fileContent = content.length > 0 && !content.endsWith('\n') ? `${content}\n` : content; + await fs.writeFile(tmpPath, fileContent, 'utf8'); + try { + await $.spawn(`crontab "${tmpPath}"`, { interactive: true }); + } finally { + await fs.rm(tmpPath, { force: true }); + } + } + + private async getManagedJobs(): Promise { + const content = await this.getCrontab(); + return parseManagedJobs(content); + } + + private async addJobs(jobsToAdd: CronJob[]): Promise { + if (jobsToAdd.length === 0) { + return; + } + + let content = await this.getCrontab(); + for (const job of jobsToAdd) { + content = removeJobBlock(content, job.name); + + const block = `${MARKER_PREFIX} ${job.name}\n${job.schedule} ${job.command}\n`; + content = content.length > 0 && !content.endsWith('\n') ? `${content}\n${block}` : `${content}${block}`; + } + + await this.setCrontab(content); + } + + private async removeJobs(namesToRemove: string[]): Promise { + if (namesToRemove.length === 0) { + return; + } + + let content = await this.getCrontab(); + for (const name of namesToRemove) { + content = removeJobBlock(content, name); + } + + await this.setCrontab(content); + } +} + +export function parseManagedJobs(content: string): CronJob[] { + const lines = content.split('\n'); + const jobs: CronJob[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line.startsWith(MARKER_PREFIX)) { + continue; + } + + const name = line.slice(MARKER_PREFIX.length).trim(); + const nextLine = (lines[i + 1] ?? '').trim(); + const match = nextLine.match(JOB_LINE_REGEX); + + if (name && match) { + jobs.push({ name, schedule: match[1], command: match[2] }); + } + } + + return jobs; +} + +export function removeJobBlock(content: string, name: string): string { + const lines = content.split('\n'); + const result: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + if (line === `${MARKER_PREFIX} ${name}`) { + const nextLine = (lines[i + 1] ?? '').trim(); + if (JOB_LINE_REGEX.test(nextLine)) { + i++; // Skip the schedule/command line that belongs to this marker + } + + continue; + } + + result.push(lines[i]); + } + + return result.join('\n'); +} diff --git a/test/cron/cron.test.ts b/test/cron/cron.test.ts new file mode 100644 index 00000000..b58fa9bd --- /dev/null +++ b/test/cron/cron.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { PluginTester, testSpawn } from '@codifycli/plugin-test'; +import * as path from 'node:path'; +import { ResourceOperation } from '@codifycli/schemas'; + +describe('Cron resource integration tests', async () => { + const pluginPath = path.resolve('./src/index.ts'); + + it('Can add cron jobs to the crontab', { timeout: 300000 }, async () => { + await PluginTester.fullTest(pluginPath, [ + { + type: 'cron', + jobs: [ + { name: 'codify-test-job-1', schedule: '*/5 * * * *', command: 'echo job-one' }, + { name: 'codify-test-job-2', schedule: '0 3 * * *', command: 'echo job-two' }, + ], + } + ], { + validatePlan: (plans) => { + console.log(JSON.stringify(plans, null, 2)) + }, + validateApply: async () => { + const { data: crontab } = await testSpawn('crontab -l'); + + expect(crontab).to.include('# Codify managed: codify-test-job-1'); + expect(crontab).to.include('*/5 * * * * echo job-one'); + expect(crontab).to.include('# Codify managed: codify-test-job-2'); + expect(crontab).to.include('0 3 * * * echo job-two'); + }, + testModify: { + modifiedConfigs: [{ + type: 'cron', + jobs: [ + { name: 'codify-test-job-1', schedule: '*/10 * * * *', command: 'echo job-one-updated' }, + { name: 'codify-test-job-2', schedule: '0 3 * * *', command: 'echo job-two' }, + { name: 'codify-test-job-3', schedule: '@daily', command: 'echo job-three' }, + ], + }], + validateModify: async (plans) => { + console.log('Modify plans', JSON.stringify(plans, null, 2)); + + expect(plans[0]).toMatchObject({ + operation: ResourceOperation.MODIFY, + }) + + const { data: crontab } = await testSpawn('crontab -l'); + + expect(crontab).to.include('# Codify managed: codify-test-job-1'); + expect(crontab).to.include('*/10 * * * * echo job-one-updated'); + expect(crontab).to.not.include('*/5 * * * * echo job-one\n'); + expect(crontab).to.include('# Codify managed: codify-test-job-2'); + expect(crontab).to.include('0 3 * * * echo job-two'); + expect(crontab).to.include('# Codify managed: codify-test-job-3'); + expect(crontab).to.include('@daily echo job-three'); + } + }, + validateDestroy: async () => { + const { data: crontab } = await testSpawn('crontab -l'); + + expect(crontab).to.not.include('codify-test-job-1'); + expect(crontab).to.not.include('codify-test-job-2'); + expect(crontab).to.not.include('codify-test-job-3'); + }, + }); + }) +})