Skip to content

Commit d96d68e

Browse files
committed
feat: add timezone support for scheduled backups
1 parent 39ba3cd commit d96d68e

9 files changed

Lines changed: 378 additions & 29 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Glob patterns • tar.gz compression • Local + S3 storage • Docker volumes
2929
- 🔍 **Glob Patterns** — Include/exclude files using patterns (`**/*.ts`, `!**/node_modules/**`)
3030
- 🐳 **Docker Volumes** — Backup Docker volumes with Docker Compose integration
3131
- ☁️ **Dual Storage** — Store backups locally and/or in S3 (supports R2, MinIO, etc.)
32-
-**Scheduled Backups** — Cron-based scheduling with independent retention policies
32+
-**Scheduled Backups** — Cron-based scheduling with timezone support and independent retention policies
3333
- 🛡️ **Safe Cleanup** — Multi-layer validation before any deletion (checksums, path verification)
3434
-**Integrity Verification** — Verify backups exist and checksums match
3535

@@ -106,9 +106,14 @@ s3:
106106
# accessKeyId: "key" # Or use S3_ACCESS_KEY_ID env var
107107
# secretAccessKey: "secret" # Or use S3_SECRET_ACCESS_KEY env var
108108

109+
# Optional: Set default timezone for all schedules
110+
# scheduler:
111+
# timezone: "America/New_York"
112+
109113
schedules:
110114
daily:
111115
cron: "0 2 * * *" # Daily at 2 AM
116+
# timezone: "Europe/London" # Override global timezone
112117
retention:
113118
maxCount: 7 # Keep max 7 backups
114119
maxDays: 14 # Delete after 14 days

docs/configuration.md

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -221,19 +221,37 @@ S3-compatible storage (AWS S3, R2, MinIO, etc.).
221221
- `S3_REGION` (optional)
222222
- `S3_ENDPOINT` (optional)
223223

224+
### `scheduler`
225+
226+
Global scheduler settings.
227+
228+
| Field | Type | Required | Description |
229+
| ---------- | ------ | -------- | ---------------------------------------------- |
230+
| `timezone` | string | No | Default timezone for all schedules (IANA name) |
231+
232+
**Example:**
233+
234+
```yaml
235+
scheduler:
236+
timezone: "America/New_York"
237+
```
238+
224239
### `schedules`
225240

226241
Named schedules with cron expressions and retention policies.
227242

228-
| Field | Type | Required | Description |
229-
| -------------------- | -------- | -------- | -------------------------------- |
230-
| `cron` | string | Yes | Cron expression (5 fields) |
231-
| `retention.maxCount` | number | No | Maximum backups to keep |
232-
| `retention.maxDays` | number | No | Delete backups older than N days |
233-
| `sources` | string[] | No | Limit to specific sources |
243+
| Field | Type | Required | Description |
244+
| -------------------- | -------- | -------- | --------------------------------------------- |
245+
| `cron` | string | Yes | Cron expression (5 fields) |
246+
| `retention.maxCount` | number | No | Maximum backups to keep |
247+
| `retention.maxDays` | number | No | Delete backups older than N days |
248+
| `sources` | string[] | No | Limit to specific sources |
249+
| `timezone` | string | No | Timezone for this schedule (overrides global) |
234250

235251
**Cron Format:** `minute hour day-of-month month day-of-week`
236252

253+
**Timezone:** Use IANA timezone names (e.g., `America/New_York`, `Europe/London`, `Asia/Tokyo`). If not specified, the system timezone is used.
254+
237255
### `archive`
238256

239257
Archive creation settings.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@climactic/backitup",
3-
"version": "1.0.0",
3+
"version": "1.1.0",
44
"description": "Secure backup utility with glob patterns, tar.gz archives, local + S3 storage, and safe cleanup",
55
"author": {
66
"name": "Climactic",

src/config/validator.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,24 @@ function validateSchedule(name: string, schedule: unknown, sourceNames: string[]
215215
}
216216
}
217217
}
218+
219+
if (sched.timezone !== undefined && typeof sched.timezone !== "string") {
220+
throw new ConfigError(`schedules.${name}.timezone must be a string`);
221+
}
222+
}
223+
224+
/**
225+
* Validate the scheduler configuration
226+
*/
227+
function validateSchedulerConfig(config: unknown): void {
228+
if (!config) return;
229+
if (typeof config !== "object") {
230+
throw new ConfigError("scheduler must be an object");
231+
}
232+
const scheduler = config as Record<string, unknown>;
233+
if (scheduler.timezone !== undefined && typeof scheduler.timezone !== "string") {
234+
throw new ConfigError("scheduler.timezone must be a string");
235+
}
218236
}
219237

220238
/**
@@ -242,4 +260,5 @@ export function validateConfig(config: unknown): asserts config is BackitupConfi
242260
validators.schedules!(c, sourceNames);
243261
validators.storage!(c);
244262
validators.docker!(c);
263+
validateSchedulerConfig(c.scheduler);
245264
}

src/core/scheduler/cron-parser.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,15 @@ import { CronExpressionParser } from "cron-parser";
1414

1515
export interface ParsedCron {
1616
expression: string;
17+
timezone?: string;
1718
interval: ReturnType<typeof CronExpressionParser.parse>;
1819
}
1920

20-
export function parseCron(expression: string): ParsedCron {
21+
export interface ParseCronOptions {
22+
timezone?: string;
23+
}
24+
25+
export function parseCron(expression: string, options?: ParseCronOptions): ParsedCron {
2126
// Validate that we have exactly 5 fields (standard cron format)
2227
const fields = expression.trim().split(/\s+/);
2328
if (fields.length !== 5) {
@@ -26,8 +31,9 @@ export function parseCron(expression: string): ParsedCron {
2631
);
2732
}
2833

29-
const interval = CronExpressionParser.parse(expression);
30-
return { expression, interval };
34+
const parserOptions = options?.timezone ? { tz: options.timezone } : undefined;
35+
const interval = CronExpressionParser.parse(expression, parserOptions);
36+
return { expression, timezone: options?.timezone, interval };
3137
}
3238

3339
export function matchesCron(cron: ParsedCron, date: Date): boolean {
@@ -38,9 +44,13 @@ export function matchesCron(cron: ParsedCron, date: Date): boolean {
3844

3945
// Get the next scheduled time from a minute before
4046
const checkDate = new Date(testDate.getTime() - 60000);
41-
const interval = CronExpressionParser.parse(cron.expression, {
47+
const parserOptions: { currentDate: Date; tz?: string } = {
4248
currentDate: checkDate,
43-
});
49+
};
50+
if (cron.timezone) {
51+
parserOptions.tz = cron.timezone;
52+
}
53+
const interval = CronExpressionParser.parse(cron.expression, parserOptions);
4454

4555
const nextDate = interval.next().toDate();
4656
nextDate.setSeconds(0);
@@ -50,9 +60,17 @@ export function matchesCron(cron: ParsedCron, date: Date): boolean {
5060
}
5161

5262
export function getNextRun(cron: ParsedCron, fromDate?: Date): Date {
53-
const interval = CronExpressionParser.parse(cron.expression, {
54-
currentDate: fromDate,
55-
});
63+
const parserOptions: { currentDate?: Date; tz?: string } = {};
64+
if (fromDate) {
65+
parserOptions.currentDate = fromDate;
66+
}
67+
if (cron.timezone) {
68+
parserOptions.tz = cron.timezone;
69+
}
70+
const interval = CronExpressionParser.parse(
71+
cron.expression,
72+
Object.keys(parserOptions).length > 0 ? parserOptions : undefined,
73+
);
5674
return interval.next().toDate();
5775
}
5876

src/core/scheduler/daemon.ts

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,22 @@ export class Scheduler {
2525
constructor(config: BackitupConfig) {
2626
this.config = config;
2727

28+
// Get global timezone from scheduler config
29+
const globalTimezone = config.scheduler?.timezone;
30+
2831
for (const [name, scheduleConfig] of Object.entries(config.schedules)) {
2932
try {
30-
const cron = parseCron(scheduleConfig.cron);
33+
// Use schedule-specific timezone, falling back to global timezone
34+
const timezone = scheduleConfig.timezone ?? globalTimezone;
35+
const cron = parseCron(scheduleConfig.cron, { timezone });
3136
this.schedules.set(name, {
3237
name,
3338
cron,
3439
lastRun: null,
3540
retention: scheduleConfig.retention,
3641
});
37-
logger.debug(`Parsed schedule "${name}": ${scheduleConfig.cron}`);
42+
const tzInfo = timezone ? ` (${timezone})` : "";
43+
logger.debug(`Parsed schedule "${name}": ${scheduleConfig.cron}${tzInfo}`);
3844
} catch (error) {
3945
logger.error(`Failed to parse schedule "${name}": ${(error as Error).message}`);
4046
}
@@ -125,31 +131,40 @@ export class Scheduler {
125131
return null;
126132
}
127133

128-
const now = new Date();
129-
const maxIterations = 60 * 24 * 366;
134+
try {
135+
const now = new Date();
136+
const maxIterations = 60 * 24 * 366;
130137

131-
for (let i = 0; i < maxIterations; i++) {
132-
const checkTime = new Date(now.getTime() + i * 60 * 1000);
133-
checkTime.setSeconds(0);
134-
checkTime.setMilliseconds(0);
138+
for (let i = 0; i < maxIterations; i++) {
139+
const checkTime = new Date(now.getTime() + i * 60 * 1000);
140+
checkTime.setSeconds(0);
141+
checkTime.setMilliseconds(0);
135142

136-
if (matchesCron(state.cron, checkTime)) {
137-
return checkTime;
143+
if (matchesCron(state.cron, checkTime)) {
144+
return checkTime;
145+
}
138146
}
139-
}
140147

141-
return null;
148+
return null;
149+
} catch (error) {
150+
logger.error(
151+
`Failed to calculate next run for "${scheduleName}": ${(error as Error).message}`,
152+
);
153+
return null;
154+
}
142155
}
143156

144157
getStatus(): {
145158
name: string;
146159
cron: string;
160+
timezone?: string;
147161
lastRun: Date | null;
148162
nextRun: Date | null;
149163
}[] {
150164
const status: {
151165
name: string;
152166
cron: string;
167+
timezone?: string;
153168
lastRun: Date | null;
154169
nextRun: Date | null;
155170
}[] = [];
@@ -160,6 +175,7 @@ export class Scheduler {
160175
status.push({
161176
name,
162177
cron: scheduleConfig.cron,
178+
timezone: state.cron.timezone,
163179
lastRun: state.lastRun,
164180
nextRun: this.getNextRun(name),
165181
});

src/types/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ export interface ScheduleConfig {
3232
cron: string;
3333
retention: RetentionConfig;
3434
sources?: string[];
35+
/** Timezone for this schedule (overrides global timezone) */
36+
timezone?: string;
37+
}
38+
39+
export interface SchedulerConfig {
40+
/** Global timezone for all schedules (default: system timezone) */
41+
timezone?: string;
3542
}
3643

3744
export interface SafetyConfig {
@@ -97,6 +104,7 @@ export interface BackitupConfig {
97104
local: LocalStorageConfig;
98105
s3: S3StorageConfig;
99106
schedules: Record<string, ScheduleConfig>;
107+
scheduler?: SchedulerConfig;
100108
archive?: ArchiveConfig;
101109
safety?: SafetyConfig;
102110
docker?: DockerConfig;

tests/config/loader.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,92 @@ schedules:
422422
expect(loaded.schedules.daily!.sources).toEqual(["app"]);
423423
});
424424

425+
// Timezone validation tests
426+
describe("timezone validation", () => {
427+
test("accepts valid schedule timezone", async () => {
428+
const config = {
429+
...validConfig,
430+
schedules: {
431+
daily: {
432+
cron: "0 2 * * *",
433+
retention: { maxCount: 7, maxDays: 30 },
434+
timezone: "America/New_York",
435+
},
436+
},
437+
};
438+
const loaded = await writeAndLoad(config);
439+
expect(loaded.schedules.daily!.timezone).toBe("America/New_York");
440+
});
441+
442+
test("accepts schedule without timezone", async () => {
443+
const loaded = await writeAndLoad(validConfig);
444+
expect(loaded.schedules.daily!.timezone).toBeUndefined();
445+
});
446+
447+
test("throws for non-string schedule timezone", async () => {
448+
const config = {
449+
...validConfig,
450+
schedules: {
451+
daily: {
452+
cron: "0 2 * * *",
453+
retention: { maxCount: 7, maxDays: 30 },
454+
timezone: 123,
455+
},
456+
},
457+
};
458+
await expect(writeAndLoad(config)).rejects.toThrow(
459+
"schedules.daily.timezone must be a string",
460+
);
461+
});
462+
463+
test("accepts valid global scheduler timezone", async () => {
464+
const config = {
465+
...validConfig,
466+
scheduler: {
467+
timezone: "Europe/London",
468+
},
469+
};
470+
const loaded = await writeAndLoad(config);
471+
expect(loaded.scheduler?.timezone).toBe("Europe/London");
472+
});
473+
474+
test("accepts config without scheduler section", async () => {
475+
const loaded = await writeAndLoad(validConfig);
476+
expect(loaded.scheduler).toBeUndefined();
477+
});
478+
479+
test("throws for non-object scheduler", async () => {
480+
const config = {
481+
...validConfig,
482+
scheduler: "invalid",
483+
};
484+
await expect(writeAndLoad(config)).rejects.toThrow("scheduler must be an object");
485+
});
486+
487+
test("throws for non-string scheduler.timezone", async () => {
488+
const config = {
489+
...validConfig,
490+
scheduler: {
491+
timezone: 123,
492+
},
493+
};
494+
await expect(writeAndLoad(config)).rejects.toThrow("scheduler.timezone must be a string");
495+
});
496+
497+
test("accepts common IANA timezones", async () => {
498+
const timezones = ["UTC", "America/Los_Angeles", "Europe/Paris", "Asia/Tokyo"];
499+
500+
for (const tz of timezones) {
501+
const config = {
502+
...validConfig,
503+
scheduler: { timezone: tz },
504+
};
505+
const loaded = await writeAndLoad(config);
506+
expect(loaded.scheduler?.timezone).toBe(tz);
507+
}
508+
});
509+
});
510+
425511
// Docker containerStop validation tests
426512
describe("docker containerStop validation", () => {
427513
const dockerBaseConfig = {

0 commit comments

Comments
 (0)