detached b1de2a8

fix(backup): local-time schedules, parallel volume runs, and safe disabled routes

- Backups run on an installation schedule with an optional time of day:
  backups.{databases,volumes}.schedule (hourly|daily|weekly|monthly) and
  .time ("HH:MM", server local time). The next run is computed from the
  current time, so restarts no longer shift it and missed runs are skipped.
  backups.volumes.interval is removed and fails boot as config-retired.
- Scheduled volume backups run up to max_concurrency at once; retention
  still runs once every archive of the pass has finished.
- A change limited to a service's backup declaration keeps the running
  container: deploy compares runtime-relevant spec fields only, while
  apps diff still reports the backup change and ACTIVE records it.
- Disabled database or volume backups no longer reach the admin handler
  as typed nil services: backup status routes answer 503 instead of
  panicking with a 500.
- Volume archive job IDs are a short random suffix: object keys already
  carry the start timestamp. Existing keys still list and age out.
- Docs: schedule section, reference and defaults list current backup keys.
commit
b1de2a823d7abb17bf00d7b8d31551cc208f0071
author
brice · · 2026-10-17 05:47 UTC
parent
9697f9d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
25 files · +643 -96
M docs/cli/backup.md +6 -4
@@ -99,10 +99,12 @@ gordon backups volume status
9999
100100 ## Scheduling
101101
102-The installation backup schedule runs every declared database whose own
103-`schedule` matches the firing tier, and applies retention under the app name.
104-Volume declarations carry no schedule of their own: the installation volume
105-backup interval runs the declared volume targets.
102+Backups run on the installation schedule (`backups.databases.schedule` and
103+`backups.volumes.schedule`, each with an optional `time` in server local time;
104+see [Backups](../config/backups.md#schedule)). The database schedule runs every
105+declared database whose own `schedule` matches the firing tier. Volume
106+declarations carry no schedule of their own: every declared volume target runs,
107+up to `max_concurrency` at once. Retention is applied per app after the run.
106108
107109 ## JSON Output
108110
@@ -99,10 +99,12 @@ gordon backups volume status
99 99
100 ## Scheduling100 ## Scheduling
101 101
102-The installation backup schedule runs every declared database whose own102+Backups run on the installation schedule (`backups.databases.schedule` and
103-`schedule` matches the firing tier, and applies retention under the app name.103+`backups.volumes.schedule`, each with an optional `time` in server local time;
104-Volume declarations carry no schedule of their own: the installation volume104+see [Backups](../config/backups.md#schedule)). The database schedule runs every
105-backup interval runs the declared volume targets.105+declared database whose own `schedule` matches the firing tier. Volume
106+declarations carry no schedule of their own: every declared volume target runs,
107+up to `max_concurrency` at once. Retention is applied per app after the run.
106 108
107 ## JSON Output109 ## JSON Output
108 110
M docs/config/backups.md +24 -4
@@ -9,12 +9,29 @@ Backups are identified by app, service, and the declared database or volume
99 (see [Apps](../config/apps.md)). Domains are routing addresses and are never a
1010 backup identity.
1111
12+## Schedule
13+
14+Each backup flow runs on one installation-wide schedule. Apps only declare
15+*what* to back up; the installation decides *when*.
16+
17+| `schedule` | Runs | Default `time` |
18+|---|---|---|
19+| `hourly` | every hour at the `time` minutes | `00:00` (on the hour) |
20+| `daily` | every day | `02:00` |
21+| `weekly` | every Sunday | `03:00` |
22+| `monthly` | the first day of each month | `04:00` |
23+
24+- `time` is `HH:MM` (24-hour) in the **server's local timezone** (`TZ`, or `/etc/localtime`). The run keeps the same wall-clock time across daylight-saving changes.
25+- The next run is computed from the current time when Gordon starts. A run that falls while Gordon is down is skipped, not caught up: no workload runs then, so there is nothing new to back up.
26+- Gordon logs the schedule and `next_run` at startup (`backup scheduler enabled`, `volume backup scheduler enabled`).
27+
1228 ## Database backups
1329
1430 ```toml
1531 [backups.databases]
1632 enabled = true
1733 schedule = "daily"
34+time = "03:00"
1835 storage_dir = "~/.gordon/backups"
1936
2037 [backups.databases.retention]
@@ -27,7 +44,8 @@ monthly = 12
2744 | Key | Default | Description |
2845 |---|---:|---|
2946 | `backups.databases.enabled` | `false` | Enables PostgreSQL backup service wiring |
30-| `backups.databases.schedule` | `"daily"` | Scheduler preset: `hourly`, `daily`, `weekly`, `monthly` |
47+| `backups.databases.schedule` | `"daily"` | Run frequency: `hourly`, `daily`, `weekly`, `monthly`; databases run when their declared `schedule` matches |
48+| `backups.databases.time` | `""` | `HH:MM` server local time; empty uses the preset default |
3149 | `backups.databases.storage_dir` | `""` | Root backup directory; defaults to `{server.data_dir}/backups` |
3250 | `backups.databases.retention.*` | `0` | Number of backups to keep per DB and schedule tier |
3351
@@ -36,7 +54,8 @@ monthly = 12
3654 ```toml
3755 [backups.volumes]
3856 enabled = true
39-interval = "24h"
57+schedule = "daily"
58+time = "03:30"
4059 compression = "gzip"
4160 timeout = "2h"
4261 max_concurrency = 2
@@ -59,10 +78,11 @@ keep = 14
5978 | Key | Default | Description |
6079 |---|---:|---|
6180 | `backups.volumes.enabled` | `false` | Enables automatic volume archives |
62-| `backups.volumes.interval` | `"24h"` | Interval between scheduled runs |
81+| `backups.volumes.schedule` | `"daily"` | Run frequency: `hourly`, `daily`, `weekly`, `monthly` |
82+| `backups.volumes.time` | `""` | `HH:MM` server local time; empty uses the preset default |
6383 | `backups.volumes.compression` | `"gzip"` | `gzip` or `zstd`; helper image must provide the tool |
6484 | `backups.volumes.timeout` | `"2h"` | Per-volume archive/upload timeout |
65-| `backups.volumes.max_concurrency` | `2` | Maximum concurrent volume backups |
85+| `backups.volumes.max_concurrency` | `2` | Volumes archived in parallel during a scheduled run |
6686 | `backups.volumes.helper_image` | `"alpine:3.20"` | Helper container image used for tar/compression |
6787 | `backups.volumes.s3.bucket` | `""` | Required when volume backups are enabled |
6888 | `backups.volumes.s3.region` | `""` | Required when volume backups are enabled |
@@ -9,12 +9,29 @@ Backups are identified by app, service, and the declared database or volume
9 (see [Apps](../config/apps.md)). Domains are routing addresses and are never a9 (see [Apps](../config/apps.md)). Domains are routing addresses and are never a
10 backup identity.10 backup identity.
11 11
12+## Schedule
13+
14+Each backup flow runs on one installation-wide schedule. Apps only declare
15+*what* to back up; the installation decides *when*.
16+
17+| `schedule` | Runs | Default `time` |
18+|---|---|---|
19+| `hourly` | every hour at the `time` minutes | `00:00` (on the hour) |
20+| `daily` | every day | `02:00` |
21+| `weekly` | every Sunday | `03:00` |
22+| `monthly` | the first day of each month | `04:00` |
23+
24+- `time` is `HH:MM` (24-hour) in the **server's local timezone** (`TZ`, or `/etc/localtime`). The run keeps the same wall-clock time across daylight-saving changes.
25+- The next run is computed from the current time when Gordon starts. A run that falls while Gordon is down is skipped, not caught up: no workload runs then, so there is nothing new to back up.
26+- Gordon logs the schedule and `next_run` at startup (`backup scheduler enabled`, `volume backup scheduler enabled`).
27+
12 ## Database backups28 ## Database backups
13 29
14 ```toml30 ```toml
15 [backups.databases]31 [backups.databases]
16 enabled = true32 enabled = true
17 schedule = "daily"33 schedule = "daily"
34+time = "03:00"
18 storage_dir = "~/.gordon/backups"35 storage_dir = "~/.gordon/backups"
19 36
20 [backups.databases.retention]37 [backups.databases.retention]
@@ -27,7 +44,8 @@ monthly = 12
27 | Key | Default | Description |44 | Key | Default | Description |
28 |---|---:|---|45 |---|---:|---|
29 | `backups.databases.enabled` | `false` | Enables PostgreSQL backup service wiring |46 | `backups.databases.enabled` | `false` | Enables PostgreSQL backup service wiring |
30-| `backups.databases.schedule` | `"daily"` | Scheduler preset: `hourly`, `daily`, `weekly`, `monthly` |47+| `backups.databases.schedule` | `"daily"` | Run frequency: `hourly`, `daily`, `weekly`, `monthly`; databases run when their declared `schedule` matches |
48+| `backups.databases.time` | `""` | `HH:MM` server local time; empty uses the preset default |
31 | `backups.databases.storage_dir` | `""` | Root backup directory; defaults to `{server.data_dir}/backups` |49 | `backups.databases.storage_dir` | `""` | Root backup directory; defaults to `{server.data_dir}/backups` |
32 | `backups.databases.retention.*` | `0` | Number of backups to keep per DB and schedule tier |50 | `backups.databases.retention.*` | `0` | Number of backups to keep per DB and schedule tier |
33 51
@@ -36,7 +54,8 @@ monthly = 12
36 ```toml54 ```toml
37 [backups.volumes]55 [backups.volumes]
38 enabled = true56 enabled = true
39-interval = "24h"57+schedule = "daily"
58+time = "03:30"
40 compression = "gzip"59 compression = "gzip"
41 timeout = "2h"60 timeout = "2h"
42 max_concurrency = 261 max_concurrency = 2
@@ -59,10 +78,11 @@ keep = 14
59 | Key | Default | Description |78 | Key | Default | Description |
60 |---|---:|---|79 |---|---:|---|
61 | `backups.volumes.enabled` | `false` | Enables automatic volume archives |80 | `backups.volumes.enabled` | `false` | Enables automatic volume archives |
62-| `backups.volumes.interval` | `"24h"` | Interval between scheduled runs |81+| `backups.volumes.schedule` | `"daily"` | Run frequency: `hourly`, `daily`, `weekly`, `monthly` |
82+| `backups.volumes.time` | `""` | `HH:MM` server local time; empty uses the preset default |
63 | `backups.volumes.compression` | `"gzip"` | `gzip` or `zstd`; helper image must provide the tool |83 | `backups.volumes.compression` | `"gzip"` | `gzip` or `zstd`; helper image must provide the tool |
64 | `backups.volumes.timeout` | `"2h"` | Per-volume archive/upload timeout |84 | `backups.volumes.timeout` | `"2h"` | Per-volume archive/upload timeout |
65-| `backups.volumes.max_concurrency` | `2` | Maximum concurrent volume backups |85+| `backups.volumes.max_concurrency` | `2` | Volumes archived in parallel during a scheduled run |
66 | `backups.volumes.helper_image` | `"alpine:3.20"` | Helper container image used for tar/compression |86 | `backups.volumes.helper_image` | `"alpine:3.20"` | Helper container image used for tar/compression |
67 | `backups.volumes.s3.bucket` | `""` | Required when volume backups are enabled |87 | `backups.volumes.s3.bucket` | `""` | Required when volume backups are enabled |
68 | `backups.volumes.s3.region` | `""` | Required when volume backups are enabled |88 | `backups.volumes.s3.region` | `""` | Required when volume backups are enabled |
M docs/config/index.md +6 -2
@@ -183,8 +183,12 @@ Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and G
183183 | `volumes.preserve` | `true` |
184184 | `network_isolation.enabled` | `true` |
185185 | `network_isolation.internal` | `false` |
186-| `backups.enabled` | `false` |
187-| `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |
186+| `backups.databases.enabled` | `false` |
187+| `backups.databases.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |
188+| `backups.databases.time` | `""` (preset default, server local time) |
189+| `backups.volumes.enabled` | `false` |
190+| `backups.volumes.schedule` | `"daily"` |
191+| `backups.volumes.time` | `""` (preset default, server local time) |
188192 | `images.allowed_registries` | `[]` |
189193 | `images.require_digest` | `false` |
190194 | `images.prune.enabled` | `false` |
@@ -183,8 +183,12 @@ Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and G
183 | `volumes.preserve` | `true` |183 | `volumes.preserve` | `true` |
184 | `network_isolation.enabled` | `true` |184 | `network_isolation.enabled` | `true` |
185 | `network_isolation.internal` | `false` |185 | `network_isolation.internal` | `false` |
186-| `backups.enabled` | `false` |186+| `backups.databases.enabled` | `false` |
187-| `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |187+| `backups.databases.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |
188+| `backups.databases.time` | `""` (preset default, server local time) |
189+| `backups.volumes.enabled` | `false` |
190+| `backups.volumes.schedule` | `"daily"` |
191+| `backups.volumes.time` | `""` (preset default, server local time) |
188 | `images.allowed_registries` | `[]` |192 | `images.allowed_registries` | `[]` |
189 | `images.require_digest` | `false` |193 | `images.require_digest` | `false` |
190 | `images.prune.enabled` | `false` |194 | `images.prune.enabled` | `false` |
M docs/config/reference.md +25 -12
@@ -208,17 +208,24 @@ preserve = true # Keep volumes when containers are
208208 # =============================================================================
209209 # BACKUPS
210210 # =============================================================================
211-[backups]
212-enabled = false # Enable backup service
211+[backups.databases]
212+enabled = false # Enable PostgreSQL backups
213213 schedule = "daily" # "hourly", "daily", "weekly", "monthly"
214+time = "" # "HH:MM" server local time (empty: preset default)
214215 storage_dir = "" # Backup root (default: {data_dir}/backups)
215216
216-[backups.retention]
217+[backups.databases.retention]
217218 hourly = 0 # Keep N hourly backups per DB
218219 daily = 0 # Keep N daily backups per DB
219220 weekly = 0 # Keep N weekly backups per DB
220221 monthly = 0 # Keep N monthly backups per DB
221222
223+[backups.volumes]
224+enabled = false # Enable S3 volume archives (see backups.md)
225+schedule = "daily" # "hourly", "daily", "weekly", "monthly"
226+time = "" # "HH:MM" server local time (empty: preset default)
227+max_concurrency = 2 # Volumes archived in parallel
228+
222229 # =============================================================================
223230 # IMAGES
224231 # =============================================================================
@@ -338,22 +345,28 @@ keep_last = 3 # Keep N newest tags per repository
338345 | `services[].readiness.timeout` | default wait | Positive readiness timeout when set |
339346 | `services[].cleanup.preserve_volumes` | `true` | Preserve managed image-discovered volumes on cleanup |
340347 | `services[].cleanup.remove_container` | `true` | Remove old, disabled, or removed service containers |
341-| `backups.enabled` | `false` | Backup service disabled |
342-| `backups.schedule` | `"daily"` | Backup scheduler preset |
343-| `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty |
344-| `backups.retention.hourly` | `0` | Keep no hourly backups by default |
345-| `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |
346-| `backups.retention.weekly` | `0` | Keep no weekly backups by default |
347-| `backups.retention.monthly` | `0` | Keep no monthly backups by default |
348+| `backups.databases.enabled` | `false` | PostgreSQL backups disabled |
349+| `backups.databases.schedule` | `"daily"` | `hourly`, `daily`, `weekly`, or `monthly` |
350+| `backups.databases.time` | `""` | `HH:MM` server local time; empty uses the preset default |
351+| `backups.databases.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty |
352+| `backups.databases.retention.hourly` | `0` | Keep no hourly backups by default |
353+| `backups.databases.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |
354+| `backups.databases.retention.weekly` | `0` | Keep no weekly backups by default |
355+| `backups.databases.retention.monthly` | `0` | Keep no monthly backups by default |
356+| `backups.volumes.enabled` | `false` | S3 volume archives disabled |
357+| `backups.volumes.schedule` | `"daily"` | `hourly`, `daily`, `weekly`, or `monthly` |
358+| `backups.volumes.time` | `""` | `HH:MM` server local time; empty uses the preset default |
359+| `backups.volumes.max_concurrency` | `2` | Volumes archived in parallel |
360+| `backups.volumes.retention.keep` | `14` | Archives kept per app + volume |
348361 | `images.allowed_registries` | `[]` | Additional exact hostname+port entries. Defaults allow Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and Gordon's registry. This allowlist does not configure registry credentials and is not DNS/IP or runtime egress enforcement. |
349362 | `images.require_digest` | `false` | Require valid SHA-256 digest-pinned references for every registry, including Gordon |
350363 | `images.prune.enabled` | `false` | Scheduled image cleanup disabled |
351364 | `images.prune.schedule` | `"daily"` | Cleanup schedule preset |
352365 | `images.prune.keep_last` | `3` | Number of recent tags kept per repository |
353366
354-Note: for all `backups.retention.*` keys, `0` means keep no backups for that retention tier.
367+Note: for all `backups.databases.retention.*` keys, `0` means keep no backups for that retention tier. The top-level `backups.enabled`, `backups.schedule`, `backups.storage_dir`, and `backups.retention.*` keys are aliases of the `backups.databases.*` keys.
355368
356-Database (`backups.databases.*`) and S3 volume backup (`backups.volumes.*`) keys, including `backups.volumes.s3.storage_class`, are listed in [Backups](./backups.md).
369+Every volume backup key, including compression, the helper image, and the `backups.volumes.s3.*` settings, is listed in [Backups](./backups.md).
357370
358371 ## App Manifest HTTP Interfaces
359372
@@ -208,17 +208,24 @@ preserve = true # Keep volumes when containers are
208 # =============================================================================208 # =============================================================================
209 # BACKUPS209 # BACKUPS
210 # =============================================================================210 # =============================================================================
211-[backups]211+[backups.databases]
212-enabled = false # Enable backup service212+enabled = false # Enable PostgreSQL backups
213 schedule = "daily" # "hourly", "daily", "weekly", "monthly"213 schedule = "daily" # "hourly", "daily", "weekly", "monthly"
214+time = "" # "HH:MM" server local time (empty: preset default)
214 storage_dir = "" # Backup root (default: {data_dir}/backups)215 storage_dir = "" # Backup root (default: {data_dir}/backups)
215 216
216-[backups.retention]217+[backups.databases.retention]
217 hourly = 0 # Keep N hourly backups per DB218 hourly = 0 # Keep N hourly backups per DB
218 daily = 0 # Keep N daily backups per DB219 daily = 0 # Keep N daily backups per DB
219 weekly = 0 # Keep N weekly backups per DB220 weekly = 0 # Keep N weekly backups per DB
220 monthly = 0 # Keep N monthly backups per DB221 monthly = 0 # Keep N monthly backups per DB
221 222
223+[backups.volumes]
224+enabled = false # Enable S3 volume archives (see backups.md)
225+schedule = "daily" # "hourly", "daily", "weekly", "monthly"
226+time = "" # "HH:MM" server local time (empty: preset default)
227+max_concurrency = 2 # Volumes archived in parallel
228+
222 # =============================================================================229 # =============================================================================
223 # IMAGES230 # IMAGES
224 # =============================================================================231 # =============================================================================
@@ -338,22 +345,28 @@ keep_last = 3 # Keep N newest tags per repository
338 | `services[].readiness.timeout` | default wait | Positive readiness timeout when set |345 | `services[].readiness.timeout` | default wait | Positive readiness timeout when set |
339 | `services[].cleanup.preserve_volumes` | `true` | Preserve managed image-discovered volumes on cleanup |346 | `services[].cleanup.preserve_volumes` | `true` | Preserve managed image-discovered volumes on cleanup |
340 | `services[].cleanup.remove_container` | `true` | Remove old, disabled, or removed service containers |347 | `services[].cleanup.remove_container` | `true` | Remove old, disabled, or removed service containers |
341-| `backups.enabled` | `false` | Backup service disabled |348+| `backups.databases.enabled` | `false` | PostgreSQL backups disabled |
342-| `backups.schedule` | `"daily"` | Backup scheduler preset |349+| `backups.databases.schedule` | `"daily"` | `hourly`, `daily`, `weekly`, or `monthly` |
343-| `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty |350+| `backups.databases.time` | `""` | `HH:MM` server local time; empty uses the preset default |
344-| `backups.retention.hourly` | `0` | Keep no hourly backups by default |351+| `backups.databases.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty |
345-| `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |352+| `backups.databases.retention.hourly` | `0` | Keep no hourly backups by default |
346-| `backups.retention.weekly` | `0` | Keep no weekly backups by default |353+| `backups.databases.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |
347-| `backups.retention.monthly` | `0` | Keep no monthly backups by default |354+| `backups.databases.retention.weekly` | `0` | Keep no weekly backups by default |
355+| `backups.databases.retention.monthly` | `0` | Keep no monthly backups by default |
356+| `backups.volumes.enabled` | `false` | S3 volume archives disabled |
357+| `backups.volumes.schedule` | `"daily"` | `hourly`, `daily`, `weekly`, or `monthly` |
358+| `backups.volumes.time` | `""` | `HH:MM` server local time; empty uses the preset default |
359+| `backups.volumes.max_concurrency` | `2` | Volumes archived in parallel |
360+| `backups.volumes.retention.keep` | `14` | Archives kept per app + volume |
348 | `images.allowed_registries` | `[]` | Additional exact hostname+port entries. Defaults allow Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and Gordon's registry. This allowlist does not configure registry credentials and is not DNS/IP or runtime egress enforcement. |361 | `images.allowed_registries` | `[]` | Additional exact hostname+port entries. Defaults allow Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and Gordon's registry. This allowlist does not configure registry credentials and is not DNS/IP or runtime egress enforcement. |
349 | `images.require_digest` | `false` | Require valid SHA-256 digest-pinned references for every registry, including Gordon |362 | `images.require_digest` | `false` | Require valid SHA-256 digest-pinned references for every registry, including Gordon |
350 | `images.prune.enabled` | `false` | Scheduled image cleanup disabled |363 | `images.prune.enabled` | `false` | Scheduled image cleanup disabled |
351 | `images.prune.schedule` | `"daily"` | Cleanup schedule preset |364 | `images.prune.schedule` | `"daily"` | Cleanup schedule preset |
352 | `images.prune.keep_last` | `3` | Number of recent tags kept per repository |365 | `images.prune.keep_last` | `3` | Number of recent tags kept per repository |
353 366
354-Note: for all `backups.retention.*` keys, `0` means keep no backups for that retention tier.367+Note: for all `backups.databases.retention.*` keys, `0` means keep no backups for that retention tier. The top-level `backups.enabled`, `backups.schedule`, `backups.storage_dir`, and `backups.retention.*` keys are aliases of the `backups.databases.*` keys.
355 368
356-Database (`backups.databases.*`) and S3 volume backup (`backups.volumes.*`) keys, including `backups.volumes.s3.storage_class`, are listed in [Backups](./backups.md).369+Every volume backup key, including compression, the helper image, and the `backups.volumes.s3.*` settings, is listed in [Backups](./backups.md).
357 370
358 ## App Manifest HTTP Interfaces371 ## App Manifest HTTP Interfaces
359 372
M gordon.toml.example +8 -1
@@ -151,9 +151,15 @@ metrics = true
151151 logs = true
152152 trace_sample_rate = 1.0
153153
154+# Backup schedules: hourly | daily | weekly (Sunday) | monthly (day 1).
155+# time is HH:MM in the server's local timezone (TZ or /etc/localtime);
156+# hourly uses only the minutes. Empty time keeps the preset default
157+# (daily 02:00, weekly 03:00, monthly 04:00). A run missed while Gordon
158+# is down is skipped, not caught up.
154159 [backups.databases]
155160 enabled = false
156161 schedule = "daily"
162+# time = "03:00"
157163 storage_dir = ""
158164
159165 [backups.databases.retention]
@@ -164,7 +170,8 @@ monthly = 0
164170
165171 [backups.volumes]
166172 enabled = false
167-interval = "24h"
173+schedule = "daily"
174+# time = "03:30"
168175 compression = "gzip"
169176 timeout = "2h"
170177 max_concurrency = 2
@@ -151,9 +151,15 @@ metrics = true
151 logs = true151 logs = true
152 trace_sample_rate = 1.0152 trace_sample_rate = 1.0
153 153
154+# Backup schedules: hourly | daily | weekly (Sunday) | monthly (day 1).
155+# time is HH:MM in the server's local timezone (TZ or /etc/localtime);
156+# hourly uses only the minutes. Empty time keeps the preset default
157+# (daily 02:00, weekly 03:00, monthly 04:00). A run missed while Gordon
158+# is down is skipped, not caught up.
154 [backups.databases]159 [backups.databases]
155 enabled = false160 enabled = false
156 schedule = "daily"161 schedule = "daily"
162+# time = "03:00"
157 storage_dir = ""163 storage_dir = ""
158 164
159 [backups.databases.retention]165 [backups.databases.retention]
@@ -164,7 +170,8 @@ monthly = 0
164 170
165 [backups.volumes]171 [backups.volumes]
166 enabled = false172 enabled = false
167-interval = "24h"173+schedule = "daily"
174+# time = "03:30"
168 compression = "gzip"175 compression = "gzip"
169 timeout = "2h"176 timeout = "2h"
170 max_concurrency = 2177 max_concurrency = 2
M internal/adapters/out/s3/volume_backup_storage_test.go +25 -0
@@ -141,6 +141,31 @@ func TestVolumeBackupStorageSendsConfiguredStorageClass(t *testing.T) {
141141 assert.Equal(t, "ONEZONE_IA", string(uploader.last.StorageClass))
142142 }
143143
144+// TestVolumeBackupStorageRetentionMixesLongAndShortIDs proves archives
145+// written with the former timestamped job IDs stay listed and age out
146+// under retention next to archives with short IDs.
147+func TestVolumeBackupStorageRetentionMixesLongAndShortIDs(t *testing.T) {
148+ client := newFakeVolumeS3Client()
149+ storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket", S3Prefix: "gordon"}, client, &fakeVolumeUploader{client: client})
150+ longID := "gordon/apps/forgejo/volumes/forgejo-data/20261017T052537Z-2026-10-17T05_25_37.725487298Z-7cddcd8f.tar.gz"
151+ short1 := "gordon/apps/forgejo/volumes/forgejo-data/20261018T013000Z-1a2b3c4d.tar.gz"
152+ short2 := "gordon/apps/forgejo/volumes/forgejo-data/20261019T013000Z-5e6f7a8b.tar.gz"
153+ for _, key := range []string{longID, short1, short2} {
154+ client.objects[key] = []byte("x")
155+ }
156+
157+ jobs, err := storage.ListVolumeArchives(context.Background(), "forgejo")
158+ require.NoError(t, err)
159+ require.Len(t, jobs, 3)
160+ assert.Equal(t, "5e6f7a8b", jobs[0].ID)
161+ assert.Equal(t, "2026-10-17T05_25_37.725487298Z-7cddcd8f", jobs[2].ID)
162+
163+ deleted, err := storage.ApplyVolumeRetention(context.Background(), "forgejo", domain.VolumeBackupRetentionPolicy{Keep: 2})
164+ require.NoError(t, err)
165+ assert.Equal(t, 1, deleted)
166+ assert.Equal(t, []string{longID}, client.deleted)
167+}
168+
144169 func TestVolumeBackupStorageRetentionUsesParsedKeyTimestamp(t *testing.T) {
145170 client := newFakeVolumeS3Client()
146171 storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket"}, client, &fakeVolumeUploader{client: client})
@@ -141,6 +141,31 @@ func TestVolumeBackupStorageSendsConfiguredStorageClass(t *testing.T) {
141 assert.Equal(t, "ONEZONE_IA", string(uploader.last.StorageClass))141 assert.Equal(t, "ONEZONE_IA", string(uploader.last.StorageClass))
142 }142 }
143 143
144+// TestVolumeBackupStorageRetentionMixesLongAndShortIDs proves archives
145+// written with the former timestamped job IDs stay listed and age out
146+// under retention next to archives with short IDs.
147+func TestVolumeBackupStorageRetentionMixesLongAndShortIDs(t *testing.T) {
148+ client := newFakeVolumeS3Client()
149+ storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket", S3Prefix: "gordon"}, client, &fakeVolumeUploader{client: client})
150+ longID := "gordon/apps/forgejo/volumes/forgejo-data/20261017T052537Z-2026-10-17T05_25_37.725487298Z-7cddcd8f.tar.gz"
151+ short1 := "gordon/apps/forgejo/volumes/forgejo-data/20261018T013000Z-1a2b3c4d.tar.gz"
152+ short2 := "gordon/apps/forgejo/volumes/forgejo-data/20261019T013000Z-5e6f7a8b.tar.gz"
153+ for _, key := range []string{longID, short1, short2} {
154+ client.objects[key] = []byte("x")
155+ }
156+
157+ jobs, err := storage.ListVolumeArchives(context.Background(), "forgejo")
158+ require.NoError(t, err)
159+ require.Len(t, jobs, 3)
160+ assert.Equal(t, "5e6f7a8b", jobs[0].ID)
161+ assert.Equal(t, "2026-10-17T05_25_37.725487298Z-7cddcd8f", jobs[2].ID)
162+
163+ deleted, err := storage.ApplyVolumeRetention(context.Background(), "forgejo", domain.VolumeBackupRetentionPolicy{Keep: 2})
164+ require.NoError(t, err)
165+ assert.Equal(t, 1, deleted)
166+ assert.Equal(t, []string{longID}, client.deleted)
167+}
168+
144 func TestVolumeBackupStorageRetentionUsesParsedKeyTimestamp(t *testing.T) {169 func TestVolumeBackupStorageRetentionUsesParsedKeyTimestamp(t *testing.T) {
145 client := newFakeVolumeS3Client()170 client := newFakeVolumeS3Client()
146 storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket"}, client, &fakeVolumeUploader{client: client})171 storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket"}, client, &fakeVolumeUploader{client: client})
M internal/app/backup_wiring.go +4 -2
@@ -19,6 +19,7 @@ import (
1919 type databaseBackupSettingsConfig struct {
2020 Enabled bool
2121 Schedule string
22+ Time string
2223 StorageDir string
2324 Retention struct {
2425 Hourly int
@@ -32,6 +33,7 @@ func databaseBackupSettings(cfg Config) databaseBackupSettingsConfig {
3233 out := databaseBackupSettingsConfig{
3334 Enabled: cfg.Backups.Databases.Enabled,
3435 Schedule: cfg.Backups.Databases.Schedule,
36+ Time: cfg.Backups.Databases.Time,
3537 StorageDir: cfg.Backups.Databases.StorageDir,
3638 }
3739 out.Retention.Hourly = cfg.Backups.Databases.Retention.Hourly
@@ -157,7 +159,7 @@ func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) {
157159
158160 func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) {
159161 volumeCfg := cfg.Backups.Volumes
160- interval, err := parsePositiveDurationDefault(volumeCfg.Interval, "24h", "backups.volumes.interval")
162+ schedule, err := resolveCronSchedule(volumeCfg.Schedule, volumeCfg.Time, "backups.volumes")
161163 if err != nil {
162164 return domain.VolumeBackupConfig{}, err
163165 }
@@ -193,7 +195,7 @@ func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) {
193195 }
194196 return domain.VolumeBackupConfig{
195197 Enabled: volumeCfg.Enabled,
196- Interval: interval,
198+ Schedule: schedule,
197199 Compression: compression,
198200 Retention: domain.VolumeBackupRetentionPolicy{Keep: volumeCfg.Retention.Keep},
199201 Timeout: timeout,
@@ -19,6 +19,7 @@ import (
19 type databaseBackupSettingsConfig struct {19 type databaseBackupSettingsConfig struct {
20 Enabled bool20 Enabled bool
21 Schedule string21 Schedule string
22+ Time string
22 StorageDir string23 StorageDir string
23 Retention struct {24 Retention struct {
24 Hourly int25 Hourly int
@@ -32,6 +33,7 @@ func databaseBackupSettings(cfg Config) databaseBackupSettingsConfig {
32 out := databaseBackupSettingsConfig{33 out := databaseBackupSettingsConfig{
33 Enabled: cfg.Backups.Databases.Enabled,34 Enabled: cfg.Backups.Databases.Enabled,
34 Schedule: cfg.Backups.Databases.Schedule,35 Schedule: cfg.Backups.Databases.Schedule,
36+ Time: cfg.Backups.Databases.Time,
35 StorageDir: cfg.Backups.Databases.StorageDir,37 StorageDir: cfg.Backups.Databases.StorageDir,
36 }38 }
37 out.Retention.Hourly = cfg.Backups.Databases.Retention.Hourly39 out.Retention.Hourly = cfg.Backups.Databases.Retention.Hourly
@@ -157,7 +159,7 @@ func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) {
157 159
158 func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) {160 func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) {
159 volumeCfg := cfg.Backups.Volumes161 volumeCfg := cfg.Backups.Volumes
160- interval, err := parsePositiveDurationDefault(volumeCfg.Interval, "24h", "backups.volumes.interval")162+ schedule, err := resolveCronSchedule(volumeCfg.Schedule, volumeCfg.Time, "backups.volumes")
161 if err != nil {163 if err != nil {
162 return domain.VolumeBackupConfig{}, err164 return domain.VolumeBackupConfig{}, err
163 }165 }
@@ -193,7 +195,7 @@ func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) {
193 }195 }
194 return domain.VolumeBackupConfig{196 return domain.VolumeBackupConfig{
195 Enabled: volumeCfg.Enabled,197 Enabled: volumeCfg.Enabled,
196- Interval: interval,198+ Schedule: schedule,
197 Compression: compression,199 Compression: compression,
198 Retention: domain.VolumeBackupRetentionPolicy{Keep: volumeCfg.Retention.Keep},200 Retention: domain.VolumeBackupRetentionPolicy{Keep: volumeCfg.Retention.Keep},
199 Timeout: timeout,201 Timeout: timeout,
M internal/app/config.go +7 -2
@@ -138,6 +138,7 @@ type Config struct {
138138 Databases struct {
139139 Enabled bool `mapstructure:"enabled"`
140140 Schedule string `mapstructure:"schedule"`
141+ Time string `mapstructure:"time"`
141142 StorageDir string `mapstructure:"storage_dir"`
142143 Retention struct {
143144 Hourly int `mapstructure:"hourly"`
@@ -148,7 +149,8 @@ type Config struct {
148149 } `mapstructure:"databases"`
149150 Volumes struct {
150151 Enabled bool `mapstructure:"enabled"`
151- Interval string `mapstructure:"interval"`
152+ Schedule string `mapstructure:"schedule"`
153+ Time string `mapstructure:"time"`
152154 Compression string `mapstructure:"compression"`
153155 Timeout string `mapstructure:"timeout"`
154156 MaxConcurrency int `mapstructure:"max_concurrency"`
@@ -267,6 +269,7 @@ var retiredAppConfigKeys = []struct {
267269 {"auto_route_allowed_domains", "feature removed; declare explicit interfaces"},
268270 {"previews", "staging is an ordinary app file"},
269271 {"env", "remove the [env] section; declare [env] and [services.<name>.secrets] in app files"},
272+ {"backups.volumes.interval", "use backups.volumes.schedule and backups.volumes.time (server local time)"},
270273 }
271274
272275 // validateRetiredAppConfig rejects obsolete application configuration
@@ -414,13 +417,15 @@ func loadConfig(v *viper.Viper, configPath string) error {
414417 v.SetDefault("volumes.preserve", true)
415418 v.SetDefault("backups.databases.enabled", false)
416419 v.SetDefault("backups.databases.schedule", string(domain.ScheduleDaily))
420+ v.SetDefault("backups.databases.time", "")
417421 v.SetDefault("backups.databases.storage_dir", "")
418422 v.SetDefault("backups.databases.retention.hourly", 0)
419423 v.SetDefault("backups.databases.retention.daily", 0)
420424 v.SetDefault("backups.databases.retention.weekly", 0)
421425 v.SetDefault("backups.databases.retention.monthly", 0)
422426 v.SetDefault("backups.volumes.enabled", false)
423- v.SetDefault("backups.volumes.interval", "24h")
427+ v.SetDefault("backups.volumes.schedule", string(domain.ScheduleDaily))
428+ v.SetDefault("backups.volumes.time", "")
424429 v.SetDefault("backups.volumes.compression", string(domain.VolumeBackupCompressionGzip))
425430 v.SetDefault("backups.volumes.timeout", "2h")
426431 v.SetDefault("backups.volumes.max_concurrency", 2)
@@ -138,6 +138,7 @@ type Config struct {
138 Databases struct {138 Databases struct {
139 Enabled bool `mapstructure:"enabled"`139 Enabled bool `mapstructure:"enabled"`
140 Schedule string `mapstructure:"schedule"`140 Schedule string `mapstructure:"schedule"`
141+ Time string `mapstructure:"time"`
141 StorageDir string `mapstructure:"storage_dir"`142 StorageDir string `mapstructure:"storage_dir"`
142 Retention struct {143 Retention struct {
143 Hourly int `mapstructure:"hourly"`144 Hourly int `mapstructure:"hourly"`
@@ -148,7 +149,8 @@ type Config struct {
148 } `mapstructure:"databases"`149 } `mapstructure:"databases"`
149 Volumes struct {150 Volumes struct {
150 Enabled bool `mapstructure:"enabled"`151 Enabled bool `mapstructure:"enabled"`
151- Interval string `mapstructure:"interval"`152+ Schedule string `mapstructure:"schedule"`
153+ Time string `mapstructure:"time"`
152 Compression string `mapstructure:"compression"`154 Compression string `mapstructure:"compression"`
153 Timeout string `mapstructure:"timeout"`155 Timeout string `mapstructure:"timeout"`
154 MaxConcurrency int `mapstructure:"max_concurrency"`156 MaxConcurrency int `mapstructure:"max_concurrency"`
@@ -267,6 +269,7 @@ var retiredAppConfigKeys = []struct {
267 {"auto_route_allowed_domains", "feature removed; declare explicit interfaces"},269 {"auto_route_allowed_domains", "feature removed; declare explicit interfaces"},
268 {"previews", "staging is an ordinary app file"},270 {"previews", "staging is an ordinary app file"},
269 {"env", "remove the [env] section; declare [env] and [services.<name>.secrets] in app files"},271 {"env", "remove the [env] section; declare [env] and [services.<name>.secrets] in app files"},
272+ {"backups.volumes.interval", "use backups.volumes.schedule and backups.volumes.time (server local time)"},
270 }273 }
271 274
272 // validateRetiredAppConfig rejects obsolete application configuration275 // validateRetiredAppConfig rejects obsolete application configuration
@@ -414,13 +417,15 @@ func loadConfig(v *viper.Viper, configPath string) error {
414 v.SetDefault("volumes.preserve", true)417 v.SetDefault("volumes.preserve", true)
415 v.SetDefault("backups.databases.enabled", false)418 v.SetDefault("backups.databases.enabled", false)
416 v.SetDefault("backups.databases.schedule", string(domain.ScheduleDaily))419 v.SetDefault("backups.databases.schedule", string(domain.ScheduleDaily))
420+ v.SetDefault("backups.databases.time", "")
417 v.SetDefault("backups.databases.storage_dir", "")421 v.SetDefault("backups.databases.storage_dir", "")
418 v.SetDefault("backups.databases.retention.hourly", 0)422 v.SetDefault("backups.databases.retention.hourly", 0)
419 v.SetDefault("backups.databases.retention.daily", 0)423 v.SetDefault("backups.databases.retention.daily", 0)
420 v.SetDefault("backups.databases.retention.weekly", 0)424 v.SetDefault("backups.databases.retention.weekly", 0)
421 v.SetDefault("backups.databases.retention.monthly", 0)425 v.SetDefault("backups.databases.retention.monthly", 0)
422 v.SetDefault("backups.volumes.enabled", false)426 v.SetDefault("backups.volumes.enabled", false)
423- v.SetDefault("backups.volumes.interval", "24h")427+ v.SetDefault("backups.volumes.schedule", string(domain.ScheduleDaily))
428+ v.SetDefault("backups.volumes.time", "")
424 v.SetDefault("backups.volumes.compression", string(domain.VolumeBackupCompressionGzip))429 v.SetDefault("backups.volumes.compression", string(domain.VolumeBackupCompressionGzip))
425 v.SetDefault("backups.volumes.timeout", "2h")430 v.SetDefault("backups.volumes.timeout", "2h")
426 v.SetDefault("backups.volumes.max_concurrency", 2)431 v.SetDefault("backups.volumes.max_concurrency", 2)
M internal/app/kernel.go +2 -2
@@ -86,8 +86,8 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
8686 authEnabled: cfg.Auth.Enabled,
8787 configSvc: svc.configSvc,
8888 containerSvc: svc.containerSvc,
89- backupSvc: svc.backupSvc,
90- volumeBackupSvc: svc.volumeBackupSvc,
89+ backupSvc: backupServicePort(svc.backupSvc),
90+ volumeBackupSvc: volumeBackupServicePort(svc.volumeBackupSvc),
9191 registrySvc: svc.registrySvc,
9292 healthSvc: svc.healthSvc,
9393 logSvc: svc.logSvc,
@@ -86,8 +86,8 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
86 authEnabled: cfg.Auth.Enabled,86 authEnabled: cfg.Auth.Enabled,
87 configSvc: svc.configSvc,87 configSvc: svc.configSvc,
88 containerSvc: svc.containerSvc,88 containerSvc: svc.containerSvc,
89- backupSvc: svc.backupSvc,89+ backupSvc: backupServicePort(svc.backupSvc),
90- volumeBackupSvc: svc.volumeBackupSvc,90+ volumeBackupSvc: volumeBackupServicePort(svc.volumeBackupSvc),
91 registrySvc: svc.registrySvc,91 registrySvc: svc.registrySvc,
92 healthSvc: svc.healthSvc,92 healthSvc: svc.healthSvc,
93 logSvc: svc.logSvc,93 logSvc: svc.logSvc,
M internal/app/run_backup_test.go +56 -5
@@ -2,6 +2,7 @@ package app
22
33 import (
44 "testing"
5+ "time"
56
67 "github.com/spf13/viper"
78 "github.com/stretchr/testify/assert"
@@ -144,7 +145,8 @@ func TestValidateVolumeBackupConfig(t *testing.T) {
144145 t.Run("enabled accepts s3 config", func(t *testing.T) {
145146 var cfg Config
146147 cfg.Backups.Volumes.Enabled = true
147- cfg.Backups.Volumes.Interval = "6h"
148+ cfg.Backups.Volumes.Schedule = "weekly"
149+ cfg.Backups.Volumes.Time = "03:30"
148150 cfg.Backups.Volumes.Timeout = "30m"
149151 cfg.Backups.Volumes.Compression = "gzip"
150152 cfg.Backups.Volumes.MaxConcurrency = 3
@@ -158,6 +160,10 @@ func TestValidateVolumeBackupConfig(t *testing.T) {
158160 volumeCfg, err := validateVolumeBackupConfig(cfg)
159161 require.NoError(t, err)
160162 assert.True(t, volumeCfg.Enabled)
163+ assert.Equal(t, domain.ScheduleWeekly, volumeCfg.Schedule.Preset)
164+ require.NotNil(t, volumeCfg.Schedule.At)
165+ assert.Equal(t, domain.TimeOfDay{Hour: 3, Minute: 30}, *volumeCfg.Schedule.At)
166+ assert.Equal(t, time.Local, volumeCfg.Schedule.Location)
161167 assert.Equal(t, domain.VolumeBackupCompressionGzip, volumeCfg.Compression)
162168 assert.Equal(t, 7, volumeCfg.Retention.Keep)
163169 assert.Equal(t, 3, volumeCfg.MaxConcurrency)
@@ -165,6 +171,35 @@ func TestValidateVolumeBackupConfig(t *testing.T) {
165171 assert.Equal(t, "prod/gordon", volumeCfg.S3Prefix)
166172 })
167173
174+ t.Run("schedule defaults to daily at the preset time", func(t *testing.T) {
175+ var cfg Config
176+
177+ volumeCfg, err := validateVolumeBackupConfig(cfg)
178+ require.NoError(t, err)
179+ assert.Equal(t, domain.ScheduleDaily, volumeCfg.Schedule.Preset)
180+ assert.Nil(t, volumeCfg.Schedule.At)
181+ })
182+
183+ t.Run("schedule rejects invalid time", func(t *testing.T) {
184+ for _, raw := range []string{"3h30", "24:00", "03:60", "3:5", "night"} {
185+ var cfg Config
186+ cfg.Backups.Volumes.Time = raw
187+
188+ _, err := validateVolumeBackupConfig(cfg)
189+ require.Error(t, err, raw)
190+ assert.Contains(t, err.Error(), "backups.volumes.time")
191+ }
192+ })
193+
194+ t.Run("schedule rejects unknown preset", func(t *testing.T) {
195+ var cfg Config
196+ cfg.Backups.Volumes.Schedule = "nightly"
197+
198+ _, err := validateVolumeBackupConfig(cfg)
199+ require.Error(t, err)
200+ assert.Contains(t, err.Error(), "backups.volumes.schedule")
201+ })
202+
168203 t.Run("storage class is normalized and passed through", func(t *testing.T) {
169204 var cfg Config
170205 cfg.Backups.Volumes.S3.StorageClass = " onezone_ia "
@@ -220,7 +255,9 @@ func TestLoadConfigSetsBackupDefaults(t *testing.T) {
220255 require.NoError(t, loadConfig(v, ""))
221256
222257 assert.Equal(t, string(domain.ScheduleDaily), v.GetString("backups.databases.schedule"))
223- assert.Equal(t, "24h", v.GetString("backups.volumes.interval"))
258+ assert.Equal(t, string(domain.ScheduleDaily), v.GetString("backups.volumes.schedule"))
259+ assert.Empty(t, v.GetString("backups.volumes.time"))
260+ assert.Empty(t, v.GetString("backups.databases.time"))
224261 assert.Equal(t, string(domain.VolumeBackupCompressionGzip), v.GetString("backups.volumes.compression"))
225262 assert.Equal(t, 14, v.GetInt("backups.volumes.retention.keep"))
226263 }
@@ -240,16 +277,30 @@ func TestResolveBackupSchedule(t *testing.T) {
240277
241278 for _, tt := range tests {
242279 t.Run(tt.input, func(t *testing.T) {
243- got, err := resolveBackupSchedule(tt.input)
280+ got, err := resolveCronSchedule(tt.input, "", "backups.databases")
244281 require.NoError(t, err)
245- assert.Equal(t, tt.want, got)
282+ assert.Equal(t, tt.want, got.Preset)
246283 })
247284 }
248285 })
249286
250287 t.Run("invalid value", func(t *testing.T) {
251- _, err := resolveBackupSchedule("every-minute")
288+ _, err := resolveCronSchedule("every-minute", "", "backups.databases")
252289 require.Error(t, err)
253290 assert.Contains(t, err.Error(), "backups.databases.schedule")
254291 })
292+
293+ t.Run("time is read in server local time", func(t *testing.T) {
294+ got, err := resolveCronSchedule("daily", "04:15", "backups.databases")
295+ require.NoError(t, err)
296+ require.NotNil(t, got.At)
297+ assert.Equal(t, domain.TimeOfDay{Hour: 4, Minute: 15}, *got.At)
298+ assert.Equal(t, time.Local, got.Location)
299+ })
300+
301+ t.Run("invalid time names the database key", func(t *testing.T) {
302+ _, err := resolveCronSchedule("daily", "25:00", "backups.databases")
303+ require.Error(t, err)
304+ assert.Contains(t, err.Error(), "backups.databases.time")
305+ })
255306 }
@@ -2,6 +2,7 @@ package app
2 2
3 import (3 import (
4 "testing"4 "testing"
5+ "time"
5 6
6 "github.com/spf13/viper"7 "github.com/spf13/viper"
7 "github.com/stretchr/testify/assert"8 "github.com/stretchr/testify/assert"
@@ -144,7 +145,8 @@ func TestValidateVolumeBackupConfig(t *testing.T) {
144 t.Run("enabled accepts s3 config", func(t *testing.T) {145 t.Run("enabled accepts s3 config", func(t *testing.T) {
145 var cfg Config146 var cfg Config
146 cfg.Backups.Volumes.Enabled = true147 cfg.Backups.Volumes.Enabled = true
147- cfg.Backups.Volumes.Interval = "6h"148+ cfg.Backups.Volumes.Schedule = "weekly"
149+ cfg.Backups.Volumes.Time = "03:30"
148 cfg.Backups.Volumes.Timeout = "30m"150 cfg.Backups.Volumes.Timeout = "30m"
149 cfg.Backups.Volumes.Compression = "gzip"151 cfg.Backups.Volumes.Compression = "gzip"
150 cfg.Backups.Volumes.MaxConcurrency = 3152 cfg.Backups.Volumes.MaxConcurrency = 3
@@ -158,6 +160,10 @@ func TestValidateVolumeBackupConfig(t *testing.T) {
158 volumeCfg, err := validateVolumeBackupConfig(cfg)160 volumeCfg, err := validateVolumeBackupConfig(cfg)
159 require.NoError(t, err)161 require.NoError(t, err)
160 assert.True(t, volumeCfg.Enabled)162 assert.True(t, volumeCfg.Enabled)
163+ assert.Equal(t, domain.ScheduleWeekly, volumeCfg.Schedule.Preset)
164+ require.NotNil(t, volumeCfg.Schedule.At)
165+ assert.Equal(t, domain.TimeOfDay{Hour: 3, Minute: 30}, *volumeCfg.Schedule.At)
166+ assert.Equal(t, time.Local, volumeCfg.Schedule.Location)
161 assert.Equal(t, domain.VolumeBackupCompressionGzip, volumeCfg.Compression)167 assert.Equal(t, domain.VolumeBackupCompressionGzip, volumeCfg.Compression)
162 assert.Equal(t, 7, volumeCfg.Retention.Keep)168 assert.Equal(t, 7, volumeCfg.Retention.Keep)
163 assert.Equal(t, 3, volumeCfg.MaxConcurrency)169 assert.Equal(t, 3, volumeCfg.MaxConcurrency)
@@ -165,6 +171,35 @@ func TestValidateVolumeBackupConfig(t *testing.T) {
165 assert.Equal(t, "prod/gordon", volumeCfg.S3Prefix)171 assert.Equal(t, "prod/gordon", volumeCfg.S3Prefix)
166 })172 })
167 173
174+ t.Run("schedule defaults to daily at the preset time", func(t *testing.T) {
175+ var cfg Config
176+
177+ volumeCfg, err := validateVolumeBackupConfig(cfg)
178+ require.NoError(t, err)
179+ assert.Equal(t, domain.ScheduleDaily, volumeCfg.Schedule.Preset)
180+ assert.Nil(t, volumeCfg.Schedule.At)
181+ })
182+
183+ t.Run("schedule rejects invalid time", func(t *testing.T) {
184+ for _, raw := range []string{"3h30", "24:00", "03:60", "3:5", "night"} {
185+ var cfg Config
186+ cfg.Backups.Volumes.Time = raw
187+
188+ _, err := validateVolumeBackupConfig(cfg)
189+ require.Error(t, err, raw)
190+ assert.Contains(t, err.Error(), "backups.volumes.time")
191+ }
192+ })
193+
194+ t.Run("schedule rejects unknown preset", func(t *testing.T) {
195+ var cfg Config
196+ cfg.Backups.Volumes.Schedule = "nightly"
197+
198+ _, err := validateVolumeBackupConfig(cfg)
199+ require.Error(t, err)
200+ assert.Contains(t, err.Error(), "backups.volumes.schedule")
201+ })
202+
168 t.Run("storage class is normalized and passed through", func(t *testing.T) {203 t.Run("storage class is normalized and passed through", func(t *testing.T) {
169 var cfg Config204 var cfg Config
170 cfg.Backups.Volumes.S3.StorageClass = " onezone_ia "205 cfg.Backups.Volumes.S3.StorageClass = " onezone_ia "
@@ -220,7 +255,9 @@ func TestLoadConfigSetsBackupDefaults(t *testing.T) {
220 require.NoError(t, loadConfig(v, ""))255 require.NoError(t, loadConfig(v, ""))
221 256
222 assert.Equal(t, string(domain.ScheduleDaily), v.GetString("backups.databases.schedule"))257 assert.Equal(t, string(domain.ScheduleDaily), v.GetString("backups.databases.schedule"))
223- assert.Equal(t, "24h", v.GetString("backups.volumes.interval"))258+ assert.Equal(t, string(domain.ScheduleDaily), v.GetString("backups.volumes.schedule"))
259+ assert.Empty(t, v.GetString("backups.volumes.time"))
260+ assert.Empty(t, v.GetString("backups.databases.time"))
224 assert.Equal(t, string(domain.VolumeBackupCompressionGzip), v.GetString("backups.volumes.compression"))261 assert.Equal(t, string(domain.VolumeBackupCompressionGzip), v.GetString("backups.volumes.compression"))
225 assert.Equal(t, 14, v.GetInt("backups.volumes.retention.keep"))262 assert.Equal(t, 14, v.GetInt("backups.volumes.retention.keep"))
226 }263 }
@@ -240,16 +277,30 @@ func TestResolveBackupSchedule(t *testing.T) {
240 277
241 for _, tt := range tests {278 for _, tt := range tests {
242 t.Run(tt.input, func(t *testing.T) {279 t.Run(tt.input, func(t *testing.T) {
243- got, err := resolveBackupSchedule(tt.input)280+ got, err := resolveCronSchedule(tt.input, "", "backups.databases")
244 require.NoError(t, err)281 require.NoError(t, err)
245- assert.Equal(t, tt.want, got)282+ assert.Equal(t, tt.want, got.Preset)
246 })283 })
247 }284 }
248 })285 })
249 286
250 t.Run("invalid value", func(t *testing.T) {287 t.Run("invalid value", func(t *testing.T) {
251- _, err := resolveBackupSchedule("every-minute")288+ _, err := resolveCronSchedule("every-minute", "", "backups.databases")
252 require.Error(t, err)289 require.Error(t, err)
253 assert.Contains(t, err.Error(), "backups.databases.schedule")290 assert.Contains(t, err.Error(), "backups.databases.schedule")
254 })291 })
292+
293+ t.Run("time is read in server local time", func(t *testing.T) {
294+ got, err := resolveCronSchedule("daily", "04:15", "backups.databases")
295+ require.NoError(t, err)
296+ require.NotNil(t, got.At)
297+ assert.Equal(t, domain.TimeOfDay{Hour: 4, Minute: 15}, *got.At)
298+ assert.Equal(t, time.Local, got.Location)
299+ })
300+
301+ t.Run("invalid time names the database key", func(t *testing.T) {
302+ _, err := resolveCronSchedule("daily", "25:00", "backups.databases")
303+ require.Error(t, err)
304+ assert.Contains(t, err.Error(), "backups.databases.time")
305+ })
255 }306 }
M internal/app/run_entrypoint_migration_test.go +10 -0
@@ -37,6 +37,16 @@ func TestInitConfigRejectsRetiredAppKeys(t *testing.T) {
3737 require.ErrorContains(t, err, `"routes"`)
3838 }
3939
40+func TestInitConfigRejectsRetiredVolumeBackupInterval(t *testing.T) {
41+ configPath := filepath.Join(t.TempDir(), "gordon.toml")
42+ require.NoError(t, os.WriteFile(configPath, []byte("[backups.volumes]\ninterval = \"24h\"\n"), 0o600))
43+
44+ _, _, err := initConfig(configPath)
45+
46+ require.ErrorContains(t, err, "config-retired")
47+ require.ErrorContains(t, err, "backups.volumes.schedule")
48+}
49+
4050 func TestInitConfigRejectsRetiredEnvSection(t *testing.T) {
4151 configPath := filepath.Join(t.TempDir(), "gordon.toml")
4252 require.NoError(t, os.WriteFile(configPath, []byte("[env]\ndir = \"/tmp/env\"\n"), 0o600))
@@ -37,6 +37,16 @@ func TestInitConfigRejectsRetiredAppKeys(t *testing.T) {
37 require.ErrorContains(t, err, `"routes"`)37 require.ErrorContains(t, err, `"routes"`)
38 }38 }
39 39
40+func TestInitConfigRejectsRetiredVolumeBackupInterval(t *testing.T) {
41+ configPath := filepath.Join(t.TempDir(), "gordon.toml")
42+ require.NoError(t, os.WriteFile(configPath, []byte("[backups.volumes]\ninterval = \"24h\"\n"), 0o600))
43+
44+ _, _, err := initConfig(configPath)
45+
46+ require.ErrorContains(t, err, "config-retired")
47+ require.ErrorContains(t, err, "backups.volumes.schedule")
48+}
49+
40 func TestInitConfigRejectsRetiredEnvSection(t *testing.T) {50 func TestInitConfigRejectsRetiredEnvSection(t *testing.T) {
41 configPath := filepath.Join(t.TempDir(), "gordon.toml")51 configPath := filepath.Join(t.TempDir(), "gordon.toml")
42 require.NoError(t, os.WriteFile(configPath, []byte("[env]\ndir = \"/tmp/env\"\n"), 0o600))52 require.NoError(t, os.WriteFile(configPath, []byte("[env]\ndir = \"/tmp/env\"\n"), 0o600))
M internal/app/schedulers.go +53 -10
@@ -4,6 +4,7 @@ import (
44 "context"
55 "fmt"
66 "strings"
7+ "time"
78
89 "github.com/bnema/zerowrap"
910 "github.com/spf13/viper"
@@ -61,22 +62,23 @@ func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log ze
6162 return nil, nil
6263 }
6364
64- preset, err := resolveBackupSchedule(dbCfg.Schedule)
65+ schedule, err := resolveCronSchedule(dbCfg.Schedule, dbCfg.Time, "backups.databases")
6566 if err != nil {
6667 return nil, err
6768 }
69+ preset := schedule.Preset
6870
6971 scheduler := cronSvc.NewScheduler(log)
7072 err = scheduler.Add(
7173 "backup-scheduler",
7274 "Backups",
73- domain.CronSchedule{Preset: preset},
75+ schedule,
7476 func(jobCtx context.Context) error {
7577 if err := svc.backupSvc.RunForSchedule(jobCtx, preset); err != nil {
7678 return err
7779 }
7880 log.Info().
79- Str("schedule", string(preset)).
81+ Str("schedule", describeSchedule(schedule)).
8082 Msg("scheduled backup run complete")
8183 return nil
8284 },
@@ -87,14 +89,21 @@ func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log ze
8789
8890 scheduler.Start(ctx)
8991 log.Info().
90- Str("schedule", string(preset)).
92+ Str("schedule", describeSchedule(schedule)).
93+ Time("next_run", nextRunOf(scheduler, "backup-scheduler")).
9194 Msg("backup scheduler enabled")
9295
9396 return scheduler, nil
9497 }
9598
96-func resolveBackupSchedule(raw string) (domain.BackupSchedule, error) {
97- return resolveSchedulePreset(raw, "backups.databases.schedule", domain.ScheduleDaily)
99+// nextRunOf returns the next planned run of one registered job.
100+func nextRunOf(scheduler *cronSvc.Scheduler, id string) time.Time {
101+ for _, entry := range scheduler.List() {
102+ if entry.ID == id {
103+ return entry.NextRun
104+ }
105+ }
106+ return time.Time{}
98107 }
99108
100109 func startVolumeBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
@@ -111,13 +120,13 @@ func startVolumeBackupScheduler(ctx context.Context, cfg Config, svc *services,
111120 err = scheduler.Add(
112121 "volume-backup-scheduler",
113122 "Volume Backups",
114- domain.CronSchedule{Interval: volumeCfg.Interval},
123+ volumeCfg.Schedule,
115124 func(jobCtx context.Context) error {
116- if err := svc.volumeBackupSvc.RunVolumeBackupsForSchedule(jobCtx, ""); err != nil {
125+ if err := svc.volumeBackupSvc.RunVolumeBackupsForSchedule(jobCtx, volumeCfg.Schedule.Preset); err != nil {
117126 return err
118127 }
119128 log.Info().
120- Dur("interval", volumeCfg.Interval).
129+ Str("schedule", describeSchedule(volumeCfg.Schedule)).
121130 Msg("scheduled volume backup run complete")
122131 return nil
123132 },
@@ -128,7 +137,8 @@ func startVolumeBackupScheduler(ctx context.Context, cfg Config, svc *services,
128137
129138 scheduler.Start(ctx)
130139 log.Info().
131- Dur("interval", volumeCfg.Interval).
140+ Str("schedule", describeSchedule(volumeCfg.Schedule)).
141+ Time("next_run", nextRunOf(scheduler, "volume-backup-scheduler")).
132142 Msg("volume backup scheduler enabled")
133143
134144 return scheduler, nil
@@ -209,6 +219,39 @@ func resolveImagePruneSchedule(raw string) (domain.BackupSchedule, error) {
209219 return resolveSchedulePreset(raw, "images.prune.schedule", domain.ScheduleDaily)
210220 }
211221
222+// resolveCronSchedule builds a backup schedule from a preset and an
223+// optional HH:MM time read in the server's local timezone. prefix names
224+// the config section (e.g. "backups.volumes") for error messages.
225+func resolveCronSchedule(rawSchedule, rawTime, prefix string) (domain.CronSchedule, error) {
226+ preset, err := resolveSchedulePreset(rawSchedule, prefix+".schedule", domain.ScheduleDaily)
227+ if err != nil {
228+ return domain.CronSchedule{}, err
229+ }
230+ schedule := domain.CronSchedule{Preset: preset, Location: time.Local}
231+ if strings.TrimSpace(rawTime) == "" {
232+ return schedule, nil
233+ }
234+ at, err := domain.ParseTimeOfDay(rawTime)
235+ if err != nil {
236+ return domain.CronSchedule{}, fmt.Errorf("%s.time: %w", prefix, err)
237+ }
238+ schedule.At = &at
239+ return schedule, nil
240+}
241+
242+// describeSchedule renders a schedule for logs, e.g. "daily at 03:30 Europe/Paris".
243+func describeSchedule(s domain.CronSchedule) string {
244+ at := "default time"
245+ if s.At != nil {
246+ at = s.At.String()
247+ }
248+ loc := "UTC"
249+ if s.Location != nil {
250+ loc = s.Location.String()
251+ }
252+ return fmt.Sprintf("%s at %s %s", s.Preset, at, loc)
253+}
254+
212255 func resolveSchedulePreset(raw, name string, defaultVal domain.BackupSchedule) (domain.BackupSchedule, error) {
213256 schedule := domain.BackupSchedule(strings.ToLower(strings.TrimSpace(raw)))
214257 if schedule == "" {
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "strings"6 "strings"
7+ "time"
7 8
8 "github.com/bnema/zerowrap"9 "github.com/bnema/zerowrap"
9 "github.com/spf13/viper"10 "github.com/spf13/viper"
@@ -61,22 +62,23 @@ func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log ze
61 return nil, nil62 return nil, nil
62 }63 }
63 64
64- preset, err := resolveBackupSchedule(dbCfg.Schedule)65+ schedule, err := resolveCronSchedule(dbCfg.Schedule, dbCfg.Time, "backups.databases")
65 if err != nil {66 if err != nil {
66 return nil, err67 return nil, err
67 }68 }
69+ preset := schedule.Preset
68 70
69 scheduler := cronSvc.NewScheduler(log)71 scheduler := cronSvc.NewScheduler(log)
70 err = scheduler.Add(72 err = scheduler.Add(
71 "backup-scheduler",73 "backup-scheduler",
72 "Backups",74 "Backups",
73- domain.CronSchedule{Preset: preset},75+ schedule,
74 func(jobCtx context.Context) error {76 func(jobCtx context.Context) error {
75 if err := svc.backupSvc.RunForSchedule(jobCtx, preset); err != nil {77 if err := svc.backupSvc.RunForSchedule(jobCtx, preset); err != nil {
76 return err78 return err
77 }79 }
78 log.Info().80 log.Info().
79- Str("schedule", string(preset)).81+ Str("schedule", describeSchedule(schedule)).
80 Msg("scheduled backup run complete")82 Msg("scheduled backup run complete")
81 return nil83 return nil
82 },84 },
@@ -87,14 +89,21 @@ func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log ze
87 89
88 scheduler.Start(ctx)90 scheduler.Start(ctx)
89 log.Info().91 log.Info().
90- Str("schedule", string(preset)).92+ Str("schedule", describeSchedule(schedule)).
93+ Time("next_run", nextRunOf(scheduler, "backup-scheduler")).
91 Msg("backup scheduler enabled")94 Msg("backup scheduler enabled")
92 95
93 return scheduler, nil96 return scheduler, nil
94 }97 }
95 98
96-func resolveBackupSchedule(raw string) (domain.BackupSchedule, error) {99+// nextRunOf returns the next planned run of one registered job.
97- return resolveSchedulePreset(raw, "backups.databases.schedule", domain.ScheduleDaily)100+func nextRunOf(scheduler *cronSvc.Scheduler, id string) time.Time {
101+ for _, entry := range scheduler.List() {
102+ if entry.ID == id {
103+ return entry.NextRun
104+ }
105+ }
106+ return time.Time{}
98 }107 }
99 108
100 func startVolumeBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {109 func startVolumeBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
@@ -111,13 +120,13 @@ func startVolumeBackupScheduler(ctx context.Context, cfg Config, svc *services,
111 err = scheduler.Add(120 err = scheduler.Add(
112 "volume-backup-scheduler",121 "volume-backup-scheduler",
113 "Volume Backups",122 "Volume Backups",
114- domain.CronSchedule{Interval: volumeCfg.Interval},123+ volumeCfg.Schedule,
115 func(jobCtx context.Context) error {124 func(jobCtx context.Context) error {
116- if err := svc.volumeBackupSvc.RunVolumeBackupsForSchedule(jobCtx, ""); err != nil {125+ if err := svc.volumeBackupSvc.RunVolumeBackupsForSchedule(jobCtx, volumeCfg.Schedule.Preset); err != nil {
117 return err126 return err
118 }127 }
119 log.Info().128 log.Info().
120- Dur("interval", volumeCfg.Interval).129+ Str("schedule", describeSchedule(volumeCfg.Schedule)).
121 Msg("scheduled volume backup run complete")130 Msg("scheduled volume backup run complete")
122 return nil131 return nil
123 },132 },
@@ -128,7 +137,8 @@ func startVolumeBackupScheduler(ctx context.Context, cfg Config, svc *services,
128 137
129 scheduler.Start(ctx)138 scheduler.Start(ctx)
130 log.Info().139 log.Info().
131- Dur("interval", volumeCfg.Interval).140+ Str("schedule", describeSchedule(volumeCfg.Schedule)).
141+ Time("next_run", nextRunOf(scheduler, "volume-backup-scheduler")).
132 Msg("volume backup scheduler enabled")142 Msg("volume backup scheduler enabled")
133 143
134 return scheduler, nil144 return scheduler, nil
@@ -209,6 +219,39 @@ func resolveImagePruneSchedule(raw string) (domain.BackupSchedule, error) {
209 return resolveSchedulePreset(raw, "images.prune.schedule", domain.ScheduleDaily)219 return resolveSchedulePreset(raw, "images.prune.schedule", domain.ScheduleDaily)
210 }220 }
211 221
222+// resolveCronSchedule builds a backup schedule from a preset and an
223+// optional HH:MM time read in the server's local timezone. prefix names
224+// the config section (e.g. "backups.volumes") for error messages.
225+func resolveCronSchedule(rawSchedule, rawTime, prefix string) (domain.CronSchedule, error) {
226+ preset, err := resolveSchedulePreset(rawSchedule, prefix+".schedule", domain.ScheduleDaily)
227+ if err != nil {
228+ return domain.CronSchedule{}, err
229+ }
230+ schedule := domain.CronSchedule{Preset: preset, Location: time.Local}
231+ if strings.TrimSpace(rawTime) == "" {
232+ return schedule, nil
233+ }
234+ at, err := domain.ParseTimeOfDay(rawTime)
235+ if err != nil {
236+ return domain.CronSchedule{}, fmt.Errorf("%s.time: %w", prefix, err)
237+ }
238+ schedule.At = &at
239+ return schedule, nil
240+}
241+
242+// describeSchedule renders a schedule for logs, e.g. "daily at 03:30 Europe/Paris".
243+func describeSchedule(s domain.CronSchedule) string {
244+ at := "default time"
245+ if s.At != nil {
246+ at = s.At.String()
247+ }
248+ loc := "UTC"
249+ if s.Location != nil {
250+ loc = s.Location.String()
251+ }
252+ return fmt.Sprintf("%s at %s %s", s.Preset, at, loc)
253+}
254+
212 func resolveSchedulePreset(raw, name string, defaultVal domain.BackupSchedule) (domain.BackupSchedule, error) {255 func resolveSchedulePreset(raw, name string, defaultVal domain.BackupSchedule) (domain.BackupSchedule, error) {
213 schedule := domain.BackupSchedule(strings.ToLower(strings.TrimSpace(raw)))256 schedule := domain.BackupSchedule(strings.ToLower(strings.TrimSpace(raw)))
214 if schedule == "" {257 if schedule == "" {
M internal/app/services.go +20 -2
@@ -431,8 +431,8 @@ func (si *serviceInit) initHandlers() {
431431 RegistrySvc: si.svc.registrySvc,
432432 ReloadTrigger: si.svc.reloadCoordinator,
433433 Log: si.log,
434- BackupSvc: si.svc.backupSvc,
435- VolumeBackupSvc: si.svc.volumeBackupSvc,
434+ BackupSvc: backupServicePort(si.svc.backupSvc),
435+ VolumeBackupSvc: volumeBackupServicePort(si.svc.volumeBackupSvc),
436436 ImageSvc: si.svc.imageSvc,
437437 VolumeSvc: si.svc.volumeSvc,
438438 PublicTLSSvc: si.svc.publicTLSSvc,
@@ -442,6 +442,24 @@ func (si *serviceInit) initHandlers() {
442442 })
443443 }
444444
445+// backupServicePort returns the database backup service as a port, or an
446+// untyped nil when backups are disabled. Storing a nil *backup.Service in
447+// the interface would make it non-nil and handlers would call into it.
448+func backupServicePort(svc *backup.Service) in.BackupService {
449+ if svc == nil {
450+ return nil
451+ }
452+ return svc
453+}
454+
455+// volumeBackupServicePort is backupServicePort for volume backups.
456+func volumeBackupServicePort(svc *backup.VolumeService) in.VolumeBackupService {
457+ if svc == nil {
458+ return nil
459+ }
460+ return svc
461+}
462+
445463 // caInfoProvider returns the internal CA as a CAInfoService, or an untyped nil
446464 // when internal TLS is disabled so the admin handler sees a nil interface.
447465 func (si *serviceInit) caInfoProvider() in.CAInfoService {
@@ -431,8 +431,8 @@ func (si *serviceInit) initHandlers() {
431 RegistrySvc: si.svc.registrySvc,431 RegistrySvc: si.svc.registrySvc,
432 ReloadTrigger: si.svc.reloadCoordinator,432 ReloadTrigger: si.svc.reloadCoordinator,
433 Log: si.log,433 Log: si.log,
434- BackupSvc: si.svc.backupSvc,434+ BackupSvc: backupServicePort(si.svc.backupSvc),
435- VolumeBackupSvc: si.svc.volumeBackupSvc,435+ VolumeBackupSvc: volumeBackupServicePort(si.svc.volumeBackupSvc),
436 ImageSvc: si.svc.imageSvc,436 ImageSvc: si.svc.imageSvc,
437 VolumeSvc: si.svc.volumeSvc,437 VolumeSvc: si.svc.volumeSvc,
438 PublicTLSSvc: si.svc.publicTLSSvc,438 PublicTLSSvc: si.svc.publicTLSSvc,
@@ -442,6 +442,24 @@ func (si *serviceInit) initHandlers() {
442 })442 })
443 }443 }
444 444
445+// backupServicePort returns the database backup service as a port, or an
446+// untyped nil when backups are disabled. Storing a nil *backup.Service in
447+// the interface would make it non-nil and handlers would call into it.
448+func backupServicePort(svc *backup.Service) in.BackupService {
449+ if svc == nil {
450+ return nil
451+ }
452+ return svc
453+}
454+
455+// volumeBackupServicePort is backupServicePort for volume backups.
456+func volumeBackupServicePort(svc *backup.VolumeService) in.VolumeBackupService {
457+ if svc == nil {
458+ return nil
459+ }
460+ return svc
461+}
462+
445 // caInfoProvider returns the internal CA as a CAInfoService, or an untyped nil463 // caInfoProvider returns the internal CA as a CAInfoService, or an untyped nil
446 // when internal TLS is disabled so the admin handler sees a nil interface.464 // when internal TLS is disabled so the admin handler sees a nil interface.
447 func (si *serviceInit) caInfoProvider() in.CAInfoService {465 func (si *serviceInit) caInfoProvider() in.CAInfoService {
A internal/app/services_backup_ports_test.go +47 -0
@@ -0,0 +1,47 @@
1+package app
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/adapters/in/http/admin"
14+ "github.com/bnema/gordon/internal/domain"
15+ "github.com/bnema/gordon/internal/usecase/backup"
16+)
17+
18+func TestBackupServicePortsReturnUntypedNilWhenDisabled(t *testing.T) {
19+ assert.True(t, backupServicePort(nil) == nil)
20+ assert.True(t, volumeBackupServicePort(nil) == nil)
21+
22+ assert.True(t, backupServicePort(&backup.Service{}) != nil)
23+ assert.True(t, volumeBackupServicePort(&backup.VolumeService{}) != nil)
24+}
25+
26+// TestAdminBackupRoutesWhenBackupsDisabled proves the admin API answers
27+// 503 instead of panicking when database or volume backups are disabled.
28+func TestAdminBackupRoutesWhenBackupsDisabled(t *testing.T) {
29+ var disabled *backup.Service
30+ var disabledVolumes *backup.VolumeService
31+ handler := admin.NewHandler(admin.HandlerDeps{
32+ Log: zerowrap.Default(),
33+ BackupSvc: backupServicePort(disabled),
34+ VolumeBackupSvc: volumeBackupServicePort(disabledVolumes),
35+ })
36+
37+ for _, path := range []string{"/admin/backups/status", "/admin/backups/volumes/status"} {
38+ t.Run(path, func(t *testing.T) {
39+ req := httptest.NewRequest(http.MethodGet, path, nil)
40+ req = req.WithContext(context.WithValue(req.Context(), domain.ContextKeyScopes, []string{"admin:*:*"}))
41+ rec := httptest.NewRecorder()
42+
43+ require.NotPanics(t, func() { handler.ServeHTTP(rec, req) })
44+ assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
45+ })
46+ }
47+}
@@ -0,0 +1,47 @@
1+package app
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/adapters/in/http/admin"
14+ "github.com/bnema/gordon/internal/domain"
15+ "github.com/bnema/gordon/internal/usecase/backup"
16+)
17+
18+func TestBackupServicePortsReturnUntypedNilWhenDisabled(t *testing.T) {
19+ assert.True(t, backupServicePort(nil) == nil)
20+ assert.True(t, volumeBackupServicePort(nil) == nil)
21+
22+ assert.True(t, backupServicePort(&backup.Service{}) != nil)
23+ assert.True(t, volumeBackupServicePort(&backup.VolumeService{}) != nil)
24+}
25+
26+// TestAdminBackupRoutesWhenBackupsDisabled proves the admin API answers
27+// 503 instead of panicking when database or volume backups are disabled.
28+func TestAdminBackupRoutesWhenBackupsDisabled(t *testing.T) {
29+ var disabled *backup.Service
30+ var disabledVolumes *backup.VolumeService
31+ handler := admin.NewHandler(admin.HandlerDeps{
32+ Log: zerowrap.Default(),
33+ BackupSvc: backupServicePort(disabled),
34+ VolumeBackupSvc: volumeBackupServicePort(disabledVolumes),
35+ })
36+
37+ for _, path := range []string{"/admin/backups/status", "/admin/backups/volumes/status"} {
38+ t.Run(path, func(t *testing.T) {
39+ req := httptest.NewRequest(http.MethodGet, path, nil)
40+ req = req.WithContext(context.WithValue(req.Context(), domain.ContextKeyScopes, []string{"admin:*:*"}))
41+ rec := httptest.NewRecorder()
42+
43+ require.NotPanics(t, func() { handler.ServeHTTP(rec, req) })
44+ assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
45+ })
46+ }
47+}
M internal/domain/app_spec.go +10 -0
@@ -1074,6 +1074,16 @@ func SameAppService(a, b AppService) bool {
10741074 return len(diffService(a.Name, a, b)) == 0
10751075 }
10761076
1077+// SameAppServiceRuntime reports whether two service specs describe the same
1078+// running container. It is SameAppService minus the settings that never reach
1079+// the container: backup declarations only select backup targets, so a
1080+// backup-only change must not force a replacement. DiffAppSpec keeps
1081+// reporting those changes.
1082+func SameAppServiceRuntime(a, b AppService) bool {
1083+ a.Backup, b.Backup = AppBackup{}, AppBackup{}
1084+ return SameAppService(a, b)
1085+}
1086+
10771087 // diffService compares two services field by field.
10781088 func diffService(name string, desired, effective AppService) []string {
10791089 var changed []string
@@ -1074,6 +1074,16 @@ func SameAppService(a, b AppService) bool {
1074 return len(diffService(a.Name, a, b)) == 01074 return len(diffService(a.Name, a, b)) == 0
1075 }1075 }
1076 1076
1077+// SameAppServiceRuntime reports whether two service specs describe the same
1078+// running container. It is SameAppService minus the settings that never reach
1079+// the container: backup declarations only select backup targets, so a
1080+// backup-only change must not force a replacement. DiffAppSpec keeps
1081+// reporting those changes.
1082+func SameAppServiceRuntime(a, b AppService) bool {
1083+ a.Backup, b.Backup = AppBackup{}, AppBackup{}
1084+ return SameAppService(a, b)
1085+}
1086+
1077 // diffService compares two services field by field.1087 // diffService compares two services field by field.
1078 func diffService(name string, desired, effective AppService) []string {1088 func diffService(name string, desired, effective AppService) []string {
1079 var changed []string1089 var changed []string
M internal/domain/app_spec_test.go +17 -0
@@ -296,3 +296,20 @@ func TestActiveAppSpec_EmptyActiveHasEmptyEnv(t *testing.T) {
296296 assert.Empty(t, spec.Env)
297297 assert.NotNil(t, spec.Env)
298298 }
299+
300+func TestAppService_BackupOnlyChange(t *testing.T) {
301+ base := validSpec()
302+ changed := validSpec()
303+ changed.Services[0].Backup = domain.AppBackup{Volume: []string{"web-data"}}
304+
305+ // The user-facing diff keeps reporting the backup change.
306+ assert.Equal(t, []string{"service/web/backup"}, domain.DiffAppSpec(changed, base).Changed)
307+ assert.False(t, domain.SameAppService(changed.Services[0], base.Services[0]))
308+
309+ // The runtime comparison ignores it.
310+ assert.True(t, domain.SameAppServiceRuntime(changed.Services[0], base.Services[0]))
311+
312+ // A real runtime change is still detected alongside a backup change.
313+ changed.Services[0].Command = []string{"serve"}
314+ assert.False(t, domain.SameAppServiceRuntime(changed.Services[0], base.Services[0]))
315+}
@@ -296,3 +296,20 @@ func TestActiveAppSpec_EmptyActiveHasEmptyEnv(t *testing.T) {
296 assert.Empty(t, spec.Env)296 assert.Empty(t, spec.Env)
297 assert.NotNil(t, spec.Env)297 assert.NotNil(t, spec.Env)
298 }298 }
299+
300+func TestAppService_BackupOnlyChange(t *testing.T) {
301+ base := validSpec()
302+ changed := validSpec()
303+ changed.Services[0].Backup = domain.AppBackup{Volume: []string{"web-data"}}
304+
305+ // The user-facing diff keeps reporting the backup change.
306+ assert.Equal(t, []string{"service/web/backup"}, domain.DiffAppSpec(changed, base).Changed)
307+ assert.False(t, domain.SameAppService(changed.Services[0], base.Services[0]))
308+
309+ // The runtime comparison ignores it.
310+ assert.True(t, domain.SameAppServiceRuntime(changed.Services[0], base.Services[0]))
311+
312+ // A real runtime change is still detected alongside a backup change.
313+ changed.Services[0].Command = []string{"serve"}
314+ assert.False(t, domain.SameAppServiceRuntime(changed.Services[0], base.Services[0]))
315+}
M internal/domain/backup.go +1 -1
@@ -127,7 +127,7 @@ type DatabaseBackupConfig = BackupConfig
127127 // VolumeBackupConfig is the volume backup configuration.
128128 type VolumeBackupConfig struct {
129129 Enabled bool
130- Interval time.Duration
130+ Schedule CronSchedule
131131 Compression VolumeBackupCompression
132132 Retention VolumeBackupRetentionPolicy
133133 Timeout time.Duration
@@ -127,7 +127,7 @@ type DatabaseBackupConfig = BackupConfig
127 // VolumeBackupConfig is the volume backup configuration.127 // VolumeBackupConfig is the volume backup configuration.
128 type VolumeBackupConfig struct {128 type VolumeBackupConfig struct {
129 Enabled bool129 Enabled bool
130- Interval time.Duration130+ Schedule CronSchedule
131 Compression VolumeBackupCompression131 Compression VolumeBackupCompression
132 Retention VolumeBackupRetentionPolicy132 Retention VolumeBackupRetentionPolicy
133 Timeout time.Duration133 Timeout time.Duration
M internal/domain/cron.go +38 -6
@@ -1,15 +1,47 @@
11 package domain
22
3-import "time"
3+import (
4+ "fmt"
5+ "strconv"
6+ "strings"
7+ "time"
8+)
49
5-// CronSchedule represents a recurring schedule.
10+// CronSchedule represents a recurring schedule: a preset, an optional time
11+// of day, and the timezone that time is read in.
612 //
7-// Preset schedules currently use fixed UTC times by design (MVP scope).
8-// Timezone-aware scheduling is deferred until a timezone field is added.
9-// When timezone support lands, host system timezone should be the default.
13+// When At is nil the preset keeps its built-in time (daily 02:00, weekly
14+// Sunday 03:00, monthly day 1 04:00, hourly on the hour). When Location is
15+// nil times are read in UTC.
1016 type CronSchedule struct {
1117 Preset BackupSchedule
12- Interval time.Duration
18+ At *TimeOfDay
19+ Location *time.Location
20+}
21+
22+// TimeOfDay is a wall-clock time. Hourly schedules use only Minute.
23+type TimeOfDay struct {
24+ Hour int
25+ Minute int
26+}
27+
28+// String formats the time as HH:MM.
29+func (t TimeOfDay) String() string {
30+ return fmt.Sprintf("%02d:%02d", t.Hour, t.Minute)
31+}
32+
33+// ParseTimeOfDay parses a 24-hour "HH:MM" wall-clock time.
34+func ParseTimeOfDay(raw string) (TimeOfDay, error) {
35+ hh, mm, ok := strings.Cut(strings.TrimSpace(raw), ":")
36+ if !ok || len(hh) == 0 || len(hh) > 2 || len(mm) != 2 {
37+ return TimeOfDay{}, fmt.Errorf("time must use 24-hour HH:MM format, got %q", raw)
38+ }
39+ hour, errH := strconv.Atoi(hh)
40+ minute, errM := strconv.Atoi(mm)
41+ if errH != nil || errM != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 {
42+ return TimeOfDay{}, fmt.Errorf("time must use 24-hour HH:MM format, got %q", raw)
43+ }
44+ return TimeOfDay{Hour: hour, Minute: minute}, nil
1345 }
1446
1547 // CronEntry represents a registered cron job.
@@ -1,15 +1,47 @@
1 package domain1 package domain
2 2
3-import "time"3+import (
4+ "fmt"
5+ "strconv"
6+ "strings"
7+ "time"
8+)
4 9
5-// CronSchedule represents a recurring schedule.10+// CronSchedule represents a recurring schedule: a preset, an optional time
11+// of day, and the timezone that time is read in.
6 //12 //
7-// Preset schedules currently use fixed UTC times by design (MVP scope).13+// When At is nil the preset keeps its built-in time (daily 02:00, weekly
8-// Timezone-aware scheduling is deferred until a timezone field is added.14+// Sunday 03:00, monthly day 1 04:00, hourly on the hour). When Location is
9-// When timezone support lands, host system timezone should be the default.15+// nil times are read in UTC.
10 type CronSchedule struct {16 type CronSchedule struct {
11 Preset BackupSchedule17 Preset BackupSchedule
12- Interval time.Duration18+ At *TimeOfDay
19+ Location *time.Location
20+}
21+
22+// TimeOfDay is a wall-clock time. Hourly schedules use only Minute.
23+type TimeOfDay struct {
24+ Hour int
25+ Minute int
26+}
27+
28+// String formats the time as HH:MM.
29+func (t TimeOfDay) String() string {
30+ return fmt.Sprintf("%02d:%02d", t.Hour, t.Minute)
31+}
32+
33+// ParseTimeOfDay parses a 24-hour "HH:MM" wall-clock time.
34+func ParseTimeOfDay(raw string) (TimeOfDay, error) {
35+ hh, mm, ok := strings.Cut(strings.TrimSpace(raw), ":")
36+ if !ok || len(hh) == 0 || len(hh) > 2 || len(mm) != 2 {
37+ return TimeOfDay{}, fmt.Errorf("time must use 24-hour HH:MM format, got %q", raw)
38+ }
39+ hour, errH := strconv.Atoi(hh)
40+ minute, errM := strconv.Atoi(mm)
41+ if errH != nil || errM != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 {
42+ return TimeOfDay{}, fmt.Errorf("time must use 24-hour HH:MM format, got %q", raw)
43+ }
44+ return TimeOfDay{Hour: hour, Minute: minute}, nil
13 }45 }
14 46
15 // CronEntry represents a registered cron job.47 // CronEntry represents a registered cron job.
A internal/usecase/backup/volume_job_id_test.go +17 -0
@@ -0,0 +1,17 @@
1+package backup
2+
3+import (
4+ "regexp"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+// TestNewVolumeBackupJobIDIsShortAndUnique proves the volume job ID adds
11+// only a short random suffix: the storage key already holds the timestamp.
12+func TestNewVolumeBackupJobIDIsShortAndUnique(t *testing.T) {
13+ a, b := newVolumeBackupJobID(), newVolumeBackupJobID()
14+
15+ assert.Regexp(t, regexp.MustCompile(`^[0-9a-f]{8}$`), a)
16+ assert.NotEqual(t, a, b)
17+}
@@ -0,0 +1,17 @@
1+package backup
2+
3+import (
4+ "regexp"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+// TestNewVolumeBackupJobIDIsShortAndUnique proves the volume job ID adds
11+// only a short random suffix: the storage key already holds the timestamp.
12+func TestNewVolumeBackupJobIDIsShortAndUnique(t *testing.T) {
13+ a, b := newVolumeBackupJobID(), newVolumeBackupJobID()
14+
15+ assert.Regexp(t, regexp.MustCompile(`^[0-9a-f]{8}$`), a)
16+ assert.NotEqual(t, a, b)
17+}
M internal/usecase/backup/volume_service.go +40 -3
@@ -2,6 +2,8 @@ package backup
22
33 import (
44 "context"
5+ "crypto/rand"
6+ "encoding/hex"
57 "fmt"
68 "io"
79 "sort"
@@ -165,10 +167,10 @@ func (s *VolumeService) RunVolumeBackupsForSchedule(ctx context.Context, schedul
165167 if err != nil {
166168 return err
167169 }
170+ jobs := s.runVolumeBackupsConcurrently(ctx, targets)
168171 var firstErr error
169172 apps := map[string]struct{}{}
170- for _, target := range targets {
171- job := s.runVolumeBackup(ctx, target)
173+ for _, job := range jobs {
172174 if job.Status != domain.BackupStatusCompleted {
173175 if firstErr == nil {
174176 firstErr = fmt.Errorf("volume backup %s/%s: %s", job.App, job.VolumeName, job.Error)
@@ -177,6 +179,8 @@ func (s *VolumeService) RunVolumeBackupsForSchedule(ctx context.Context, schedul
177179 }
178180 apps[job.App] = struct{}{}
179181 }
182+ // Retention runs only after every archive finished, so a pass never
183+ // deletes an app's older archive while its replacement is uploading.
180184 for _, app := range sortedSet(apps) {
181185 if _, err := s.storage.ApplyVolumeRetention(ctx, app, s.config.Retention); err != nil {
182186 if firstErr == nil {
@@ -187,6 +191,29 @@ func (s *VolumeService) RunVolumeBackupsForSchedule(ctx context.Context, schedul
187191 return firstErr
188192 }
189193
194+// runVolumeBackupsConcurrently runs every target with at most
195+// MaxConcurrency exports in flight and returns the jobs in target order.
196+func (s *VolumeService) runVolumeBackupsConcurrently(ctx context.Context, targets []domain.VolumeBackupTarget) []domain.VolumeBackupJob {
197+ limit := s.config.MaxConcurrency
198+ if limit < 1 {
199+ limit = 1
200+ }
201+ jobs := make([]domain.VolumeBackupJob, len(targets))
202+ sem := make(chan struct{}, limit)
203+ var wg sync.WaitGroup
204+ for i, target := range targets {
205+ wg.Add(1)
206+ sem <- struct{}{}
207+ go func(i int, target domain.VolumeBackupTarget) {
208+ defer wg.Done()
209+ defer func() { <-sem }()
210+ jobs[i] = s.runVolumeBackup(ctx, target)
211+ }(i, target)
212+ }
213+ wg.Wait()
214+ return jobs
215+}
216+
190217 func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.VolumeBackupTarget) domain.VolumeBackupJob {
191218 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
192219 "app": target.App,
@@ -198,7 +225,7 @@ func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.Volum
198225 log := zerowrap.FromCtx(ctx)
199226 started := time.Now().UTC()
200227 job := domain.VolumeBackupJob{
201- ID: newBackupJobID(started),
228+ ID: newVolumeBackupJobID(),
202229 App: target.App,
203230 Service: target.Service,
204231 VolumeName: target.VolumeName,
@@ -251,6 +278,16 @@ func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.Volum
251278 return job
252279 }
253280
281+// newVolumeBackupJobID returns a short random job ID. Storage keys already
282+// carry the start timestamp, so the ID only has to make each run unique.
283+func newVolumeBackupJobID() string {
284+ random := make([]byte, 4)
285+ if _, err := rand.Read(random); err != nil {
286+ return fmt.Sprintf("%x", time.Now().UnixNano())
287+ }
288+ return hex.EncodeToString(random)
289+}
290+
254291 // volumeTargetKey reports the (service, volume) identity of one target.
255292 func volumeTargetKey(target domain.VolumeBackupTarget) (string, string) {
256293 return target.Service, target.VolumeName
@@ -2,6 +2,8 @@ package backup
2 2
3 import (3 import (
4 "context"4 "context"
5+ "crypto/rand"
6+ "encoding/hex"
5 "fmt"7 "fmt"
6 "io"8 "io"
7 "sort"9 "sort"
@@ -165,10 +167,10 @@ func (s *VolumeService) RunVolumeBackupsForSchedule(ctx context.Context, schedul
165 if err != nil {167 if err != nil {
166 return err168 return err
167 }169 }
170+ jobs := s.runVolumeBackupsConcurrently(ctx, targets)
168 var firstErr error171 var firstErr error
169 apps := map[string]struct{}{}172 apps := map[string]struct{}{}
170- for _, target := range targets {173+ for _, job := range jobs {
171- job := s.runVolumeBackup(ctx, target)
172 if job.Status != domain.BackupStatusCompleted {174 if job.Status != domain.BackupStatusCompleted {
173 if firstErr == nil {175 if firstErr == nil {
174 firstErr = fmt.Errorf("volume backup %s/%s: %s", job.App, job.VolumeName, job.Error)176 firstErr = fmt.Errorf("volume backup %s/%s: %s", job.App, job.VolumeName, job.Error)
@@ -177,6 +179,8 @@ func (s *VolumeService) RunVolumeBackupsForSchedule(ctx context.Context, schedul
177 }179 }
178 apps[job.App] = struct{}{}180 apps[job.App] = struct{}{}
179 }181 }
182+ // Retention runs only after every archive finished, so a pass never
183+ // deletes an app's older archive while its replacement is uploading.
180 for _, app := range sortedSet(apps) {184 for _, app := range sortedSet(apps) {
181 if _, err := s.storage.ApplyVolumeRetention(ctx, app, s.config.Retention); err != nil {185 if _, err := s.storage.ApplyVolumeRetention(ctx, app, s.config.Retention); err != nil {
182 if firstErr == nil {186 if firstErr == nil {
@@ -187,6 +191,29 @@ func (s *VolumeService) RunVolumeBackupsForSchedule(ctx context.Context, schedul
187 return firstErr191 return firstErr
188 }192 }
189 193
194+// runVolumeBackupsConcurrently runs every target with at most
195+// MaxConcurrency exports in flight and returns the jobs in target order.
196+func (s *VolumeService) runVolumeBackupsConcurrently(ctx context.Context, targets []domain.VolumeBackupTarget) []domain.VolumeBackupJob {
197+ limit := s.config.MaxConcurrency
198+ if limit < 1 {
199+ limit = 1
200+ }
201+ jobs := make([]domain.VolumeBackupJob, len(targets))
202+ sem := make(chan struct{}, limit)
203+ var wg sync.WaitGroup
204+ for i, target := range targets {
205+ wg.Add(1)
206+ sem <- struct{}{}
207+ go func(i int, target domain.VolumeBackupTarget) {
208+ defer wg.Done()
209+ defer func() { <-sem }()
210+ jobs[i] = s.runVolumeBackup(ctx, target)
211+ }(i, target)
212+ }
213+ wg.Wait()
214+ return jobs
215+}
216+
190 func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.VolumeBackupTarget) domain.VolumeBackupJob {217 func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.VolumeBackupTarget) domain.VolumeBackupJob {
191 ctx = zerowrap.CtxWithFields(ctx, map[string]any{218 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
192 "app": target.App,219 "app": target.App,
@@ -198,7 +225,7 @@ func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.Volum
198 log := zerowrap.FromCtx(ctx)225 log := zerowrap.FromCtx(ctx)
199 started := time.Now().UTC()226 started := time.Now().UTC()
200 job := domain.VolumeBackupJob{227 job := domain.VolumeBackupJob{
201- ID: newBackupJobID(started),228+ ID: newVolumeBackupJobID(),
202 App: target.App,229 App: target.App,
203 Service: target.Service,230 Service: target.Service,
204 VolumeName: target.VolumeName,231 VolumeName: target.VolumeName,
@@ -251,6 +278,16 @@ func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.Volum
251 return job278 return job
252 }279 }
253 280
281+// newVolumeBackupJobID returns a short random job ID. Storage keys already
282+// carry the start timestamp, so the ID only has to make each run unique.
283+func newVolumeBackupJobID() string {
284+ random := make([]byte, 4)
285+ if _, err := rand.Read(random); err != nil {
286+ return fmt.Sprintf("%x", time.Now().UnixNano())
287+ }
288+ return hex.EncodeToString(random)
289+}
290+
254 // volumeTargetKey reports the (service, volume) identity of one target.291 // volumeTargetKey reports the (service, volume) identity of one target.
255 func volumeTargetKey(target domain.VolumeBackupTarget) (string, string) {292 func volumeTargetKey(target domain.VolumeBackupTarget) (string, string) {
256 return target.Service, target.VolumeName293 return target.Service, target.VolumeName
M internal/usecase/backup/volume_service_test.go +74 -0
@@ -4,7 +4,9 @@ import (
44 "bytes"
55 "context"
66 "errors"
7+ "fmt"
78 "io"
9+ "sync"
810 "testing"
911 "time"
1012
@@ -22,12 +24,33 @@ func testLogger() zerowrap.Logger {
2224 }
2325
2426 type fakeVolumeArchiveExporter struct {
27+ mu sync.Mutex
2528 err error
2629 requested []domain.VolumeArchiveRequest
30+ // hold, when set, keeps each export open until released so tests can
31+ // observe how many run at once.
32+ hold chan struct{}
33+ inFlight int
34+ peak int
2735 }
2836
2937 func (f *fakeVolumeArchiveExporter) ExportVolumeArchive(_ context.Context, request domain.VolumeArchiveRequest) (*domain.VolumeArchiveResult, error) {
38+ f.mu.Lock()
3039 f.requested = append(f.requested, request)
40+ f.inFlight++
41+ if f.inFlight > f.peak {
42+ f.peak = f.inFlight
43+ }
44+ hold := f.hold
45+ f.mu.Unlock()
46+ defer func() {
47+ f.mu.Lock()
48+ f.inFlight--
49+ f.mu.Unlock()
50+ }()
51+ if hold != nil {
52+ <-hold
53+ }
3154 if f.err != nil {
3255 return nil, f.err
3356 }
@@ -35,6 +58,7 @@ func (f *fakeVolumeArchiveExporter) ExportVolumeArchive(_ context.Context, reque
3558 }
3659
3760 type fakeVolumeBackupStorage struct {
61+ mu sync.Mutex
3862 stored []domain.VolumeBackupJob
3963 retention []string
4064 jobs []domain.VolumeBackupJob
@@ -49,7 +73,9 @@ func (f *fakeVolumeBackupStorage) StoreVolumeArchive(_ context.Context, job doma
4973 if f.storeErr != nil {
5074 return "", f.storeErr
5175 }
76+ f.mu.Lock()
5277 f.stored = append(f.stored, job)
78+ f.mu.Unlock()
5379 return "s3://bucket/" + job.App + "/" + job.VolumeName, nil
5480 }
5581
@@ -194,6 +220,54 @@ func TestVolumeService_RunVolumeBackupsForSchedule_RunsEveryDeclaredTarget(t *te
194220 assert.Equal(t, []string{"shop"}, storage.retention)
195221 }
196222
223+// TestVolumeService_RunVolumeBackupsForSchedule_HonorsMaxConcurrency proves
224+// the scheduled pass runs targets in parallel, never more than
225+// MaxConcurrency at once, and still backs up every target.
226+func TestVolumeService_RunVolumeBackupsForSchedule_HonorsMaxConcurrency(t *testing.T) {
227+ for _, limit := range []int{1, 2} {
228+ t.Run(fmt.Sprintf("max_concurrency=%d", limit), func(t *testing.T) {
229+ ctx := context.Background()
230+ state := outmocks.NewMockAppStateReader(t)
231+ state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
232+ state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
233+ state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
234+ "a": volumeSpec("a", "data-a", "/a"),
235+ "b": volumeSpec("b", "data-b", "/b"),
236+ "c": volumeSpec("c", "data-c", "/c"),
237+ }, "ctr"), true, nil).Once()
238+
239+ hold := make(chan struct{})
240+ exporter := &fakeVolumeArchiveExporter{hold: hold}
241+ storage := &fakeVolumeBackupStorage{}
242+ svc := NewVolumeService(exporter, storage, domain.VolumeBackupConfig{
243+ Enabled: true,
244+ Compression: domain.VolumeBackupCompressionGzip,
245+ Retention: domain.VolumeBackupRetentionPolicy{Keep: 2},
246+ Timeout: time.Minute,
247+ MaxConcurrency: limit,
248+ HelperImage: "helper:latest",
249+ }, testLogger()).WithAppState(state)
250+
251+ done := make(chan error, 1)
252+ go func() { done <- svc.RunVolumeBackupsForSchedule(ctx, domain.ScheduleDaily) }()
253+
254+ // Wait until the limit is reached, then confirm it is never exceeded.
255+ require.Eventually(t, func() bool {
256+ exporter.mu.Lock()
257+ defer exporter.mu.Unlock()
258+ return exporter.inFlight == limit
259+ }, 2*time.Second, time.Millisecond)
260+ time.Sleep(20 * time.Millisecond)
261+ close(hold)
262+
263+ require.NoError(t, <-done)
264+ assert.Equal(t, limit, exporter.peak)
265+ assert.Len(t, storage.stored, 3)
266+ assert.Equal(t, []string{"shop"}, storage.retention)
267+ })
268+ }
269+}
270+
197271 // TestVolumeService_DisabledDoesNothing proves a disabled backup
198272 // configuration runs nothing at all.
199273 func TestVolumeService_DisabledDoesNothing(t *testing.T) {
@@ -4,7 +4,9 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "errors"6 "errors"
7+ "fmt"
7 "io"8 "io"
9+ "sync"
8 "testing"10 "testing"
9 "time"11 "time"
10 12
@@ -22,12 +24,33 @@ func testLogger() zerowrap.Logger {
22 }24 }
23 25
24 type fakeVolumeArchiveExporter struct {26 type fakeVolumeArchiveExporter struct {
27+ mu sync.Mutex
25 err error28 err error
26 requested []domain.VolumeArchiveRequest29 requested []domain.VolumeArchiveRequest
30+ // hold, when set, keeps each export open until released so tests can
31+ // observe how many run at once.
32+ hold chan struct{}
33+ inFlight int
34+ peak int
27 }35 }
28 36
29 func (f *fakeVolumeArchiveExporter) ExportVolumeArchive(_ context.Context, request domain.VolumeArchiveRequest) (*domain.VolumeArchiveResult, error) {37 func (f *fakeVolumeArchiveExporter) ExportVolumeArchive(_ context.Context, request domain.VolumeArchiveRequest) (*domain.VolumeArchiveResult, error) {
38+ f.mu.Lock()
30 f.requested = append(f.requested, request)39 f.requested = append(f.requested, request)
40+ f.inFlight++
41+ if f.inFlight > f.peak {
42+ f.peak = f.inFlight
43+ }
44+ hold := f.hold
45+ f.mu.Unlock()
46+ defer func() {
47+ f.mu.Lock()
48+ f.inFlight--
49+ f.mu.Unlock()
50+ }()
51+ if hold != nil {
52+ <-hold
53+ }
31 if f.err != nil {54 if f.err != nil {
32 return nil, f.err55 return nil, f.err
33 }56 }
@@ -35,6 +58,7 @@ func (f *fakeVolumeArchiveExporter) ExportVolumeArchive(_ context.Context, reque
35 }58 }
36 59
37 type fakeVolumeBackupStorage struct {60 type fakeVolumeBackupStorage struct {
61+ mu sync.Mutex
38 stored []domain.VolumeBackupJob62 stored []domain.VolumeBackupJob
39 retention []string63 retention []string
40 jobs []domain.VolumeBackupJob64 jobs []domain.VolumeBackupJob
@@ -49,7 +73,9 @@ func (f *fakeVolumeBackupStorage) StoreVolumeArchive(_ context.Context, job doma
49 if f.storeErr != nil {73 if f.storeErr != nil {
50 return "", f.storeErr74 return "", f.storeErr
51 }75 }
76+ f.mu.Lock()
52 f.stored = append(f.stored, job)77 f.stored = append(f.stored, job)
78+ f.mu.Unlock()
53 return "s3://bucket/" + job.App + "/" + job.VolumeName, nil79 return "s3://bucket/" + job.App + "/" + job.VolumeName, nil
54 }80 }
55 81
@@ -194,6 +220,54 @@ func TestVolumeService_RunVolumeBackupsForSchedule_RunsEveryDeclaredTarget(t *te
194 assert.Equal(t, []string{"shop"}, storage.retention)220 assert.Equal(t, []string{"shop"}, storage.retention)
195 }221 }
196 222
223+// TestVolumeService_RunVolumeBackupsForSchedule_HonorsMaxConcurrency proves
224+// the scheduled pass runs targets in parallel, never more than
225+// MaxConcurrency at once, and still backs up every target.
226+func TestVolumeService_RunVolumeBackupsForSchedule_HonorsMaxConcurrency(t *testing.T) {
227+ for _, limit := range []int{1, 2} {
228+ t.Run(fmt.Sprintf("max_concurrency=%d", limit), func(t *testing.T) {
229+ ctx := context.Background()
230+ state := outmocks.NewMockAppStateReader(t)
231+ state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
232+ state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
233+ state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
234+ "a": volumeSpec("a", "data-a", "/a"),
235+ "b": volumeSpec("b", "data-b", "/b"),
236+ "c": volumeSpec("c", "data-c", "/c"),
237+ }, "ctr"), true, nil).Once()
238+
239+ hold := make(chan struct{})
240+ exporter := &fakeVolumeArchiveExporter{hold: hold}
241+ storage := &fakeVolumeBackupStorage{}
242+ svc := NewVolumeService(exporter, storage, domain.VolumeBackupConfig{
243+ Enabled: true,
244+ Compression: domain.VolumeBackupCompressionGzip,
245+ Retention: domain.VolumeBackupRetentionPolicy{Keep: 2},
246+ Timeout: time.Minute,
247+ MaxConcurrency: limit,
248+ HelperImage: "helper:latest",
249+ }, testLogger()).WithAppState(state)
250+
251+ done := make(chan error, 1)
252+ go func() { done <- svc.RunVolumeBackupsForSchedule(ctx, domain.ScheduleDaily) }()
253+
254+ // Wait until the limit is reached, then confirm it is never exceeded.
255+ require.Eventually(t, func() bool {
256+ exporter.mu.Lock()
257+ defer exporter.mu.Unlock()
258+ return exporter.inFlight == limit
259+ }, 2*time.Second, time.Millisecond)
260+ time.Sleep(20 * time.Millisecond)
261+ close(hold)
262+
263+ require.NoError(t, <-done)
264+ assert.Equal(t, limit, exporter.peak)
265+ assert.Len(t, storage.stored, 3)
266+ assert.Equal(t, []string{"shop"}, storage.retention)
267+ })
268+ }
269+}
270+
197 // TestVolumeService_DisabledDoesNothing proves a disabled backup271 // TestVolumeService_DisabledDoesNothing proves a disabled backup
198 // configuration runs nothing at all.272 // configuration runs nothing at all.
199 func TestVolumeService_DisabledDoesNothing(t *testing.T) {273 func TestVolumeService_DisabledDoesNothing(t *testing.T) {
M internal/usecase/cron/scheduler.go +42 -31
@@ -12,7 +12,8 @@ import (
1212 "github.com/bnema/zerowrap"
1313 )
1414
15-// Scheduler runs recurring jobs based on backup schedules.
15+// Scheduler runs recurring jobs on preset schedules. Times are wall-clock
16+// times in each schedule's Location; nowFn returns an absolute instant.
1617 type Scheduler struct {
1718 entries map[string]*entry
1819 mu sync.RWMutex
@@ -275,55 +276,65 @@ func (s *Scheduler) dueEntries(now time.Time) []*entry {
275276 return entries
276277 }
277278
278-// calculateNextRun computes the next execution time using UTC-only preset rules.
279-//
280-// Current MVP behavior is intentionally fixed to UTC because domain.CronSchedule
281-// has no timezone field yet:
282-// - daily: 02:00 UTC
283-// - weekly: Sunday 03:00 UTC
284-// - monthly: first day of month 04:00 UTC
279+// presetDefaultTimes are the built-in times of day used when a schedule
280+// sets no explicit time.
281+var presetDefaultTimes = map[domain.BackupSchedule]domain.TimeOfDay{
282+ domain.ScheduleHourly: {Hour: 0, Minute: 0},
283+ domain.ScheduleDaily: {Hour: 2, Minute: 0},
284+ domain.ScheduleWeekly: {Hour: 3, Minute: 0},
285+ domain.ScheduleMonthly: {Hour: 4, Minute: 0},
286+}
287+
288+// calculateNextRun returns the first scheduled instant strictly after now.
285289 //
286-// This means users in non-UTC timezones should account for UTC offsets.
290+// Times are wall-clock times in schedule.Location (UTC when nil), so a
291+// daily 03:30 stays 03:30 across daylight-saving changes. A wall time that
292+// does not exist on a given day (spring-forward gap) is normalized forward
293+// by time.Date, so the run still happens exactly once that day.
287294 //
288-// TODO: add timezone support on domain.CronSchedule once timezone-aware
289-// scheduling is introduced. Default timezone should be the host system timezone
290-// when no explicit timezone is configured.
295+// The result only ever depends on now: a run missed while the process was
296+// down is skipped, never caught up.
297+// - hourly: every hour at At.Minute
298+// - daily: every day at At
299+// - weekly: every Sunday at At
300+// - monthly: the first day of every month at At
291301 func calculateNextRun(now time.Time, schedule domain.CronSchedule) (time.Time, error) {
292- now = now.UTC()
293- if schedule.Interval > 0 && schedule.Preset != "" {
294- return time.Time{}, fmt.Errorf("cron schedule cannot set both interval and preset")
302+ at, ok := presetDefaultTimes[schedule.Preset]
303+ if !ok {
304+ return time.Time{}, fmt.Errorf("unsupported schedule preset: %q", schedule.Preset)
295305 }
296- if schedule.Interval > 0 {
297- return now.Add(schedule.Interval), nil
306+ if schedule.At != nil {
307+ at = *schedule.At
298308 }
309+ loc := schedule.Location
310+ if loc == nil {
311+ loc = time.UTC
312+ }
313+ local := now.In(loc)
299314
315+ var next time.Time
300316 switch schedule.Preset {
301317 case domain.ScheduleHourly:
302- next := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, time.UTC)
318+ next = time.Date(local.Year(), local.Month(), local.Day(), local.Hour(), at.Minute, 0, 0, loc)
303319 if !next.After(now) {
304320 next = next.Add(time.Hour)
305321 }
306- return next, nil
307322 case domain.ScheduleDaily:
308- next := time.Date(now.Year(), now.Month(), now.Day(), 2, 0, 0, 0, time.UTC)
323+ next = time.Date(local.Year(), local.Month(), local.Day(), at.Hour, at.Minute, 0, 0, loc)
309324 if !next.After(now) {
310- next = next.AddDate(0, 0, 1)
325+ next = time.Date(local.Year(), local.Month(), local.Day()+1, at.Hour, at.Minute, 0, 0, loc)
311326 }
312- return next, nil
313327 case domain.ScheduleWeekly:
314- daysUntilSunday := (7 - int(now.Weekday())) % 7
315- next := time.Date(now.Year(), now.Month(), now.Day(), 3, 0, 0, 0, time.UTC).AddDate(0, 0, daysUntilSunday)
328+ daysUntilSunday := (7 - int(local.Weekday())) % 7
329+ next = time.Date(local.Year(), local.Month(), local.Day()+daysUntilSunday, at.Hour, at.Minute, 0, 0, loc)
316330 if !next.After(now) {
317- next = next.AddDate(0, 0, 7)
331+ next = time.Date(local.Year(), local.Month(), local.Day()+daysUntilSunday+7, at.Hour, at.Minute, 0, 0, loc)
318332 }
319- return next, nil
320333 case domain.ScheduleMonthly:
321- next := time.Date(now.Year(), now.Month(), 1, 4, 0, 0, 0, time.UTC)
334+ next = time.Date(local.Year(), local.Month(), 1, at.Hour, at.Minute, 0, 0, loc)
322335 if !next.After(now) {
323- next = next.AddDate(0, 1, 0)
336+ next = time.Date(local.Year(), local.Month()+1, 1, at.Hour, at.Minute, 0, 0, loc)
324337 }
325- return next, nil
326- default:
327- return time.Time{}, fmt.Errorf("unsupported schedule preset: %q", schedule.Preset)
328338 }
339+ return next, nil
329340 }
@@ -12,7 +12,8 @@ import (
12 "github.com/bnema/zerowrap"12 "github.com/bnema/zerowrap"
13 )13 )
14 14
15-// Scheduler runs recurring jobs based on backup schedules.15+// Scheduler runs recurring jobs on preset schedules. Times are wall-clock
16+// times in each schedule's Location; nowFn returns an absolute instant.
16 type Scheduler struct {17 type Scheduler struct {
17 entries map[string]*entry18 entries map[string]*entry
18 mu sync.RWMutex19 mu sync.RWMutex
@@ -275,55 +276,65 @@ func (s *Scheduler) dueEntries(now time.Time) []*entry {
275 return entries276 return entries
276 }277 }
277 278
278-// calculateNextRun computes the next execution time using UTC-only preset rules.279+// presetDefaultTimes are the built-in times of day used when a schedule
279-//280+// sets no explicit time.
280-// Current MVP behavior is intentionally fixed to UTC because domain.CronSchedule281+var presetDefaultTimes = map[domain.BackupSchedule]domain.TimeOfDay{
281-// has no timezone field yet:282+ domain.ScheduleHourly: {Hour: 0, Minute: 0},
282-// - daily: 02:00 UTC283+ domain.ScheduleDaily: {Hour: 2, Minute: 0},
283-// - weekly: Sunday 03:00 UTC284+ domain.ScheduleWeekly: {Hour: 3, Minute: 0},
284-// - monthly: first day of month 04:00 UTC285+ domain.ScheduleMonthly: {Hour: 4, Minute: 0},
286+}
287+
288+// calculateNextRun returns the first scheduled instant strictly after now.
285 //289 //
286-// This means users in non-UTC timezones should account for UTC offsets.290+// Times are wall-clock times in schedule.Location (UTC when nil), so a
291+// daily 03:30 stays 03:30 across daylight-saving changes. A wall time that
292+// does not exist on a given day (spring-forward gap) is normalized forward
293+// by time.Date, so the run still happens exactly once that day.
287 //294 //
288-// TODO: add timezone support on domain.CronSchedule once timezone-aware295+// The result only ever depends on now: a run missed while the process was
289-// scheduling is introduced. Default timezone should be the host system timezone296+// down is skipped, never caught up.
290-// when no explicit timezone is configured.297+// - hourly: every hour at At.Minute
298+// - daily: every day at At
299+// - weekly: every Sunday at At
300+// - monthly: the first day of every month at At
291 func calculateNextRun(now time.Time, schedule domain.CronSchedule) (time.Time, error) {301 func calculateNextRun(now time.Time, schedule domain.CronSchedule) (time.Time, error) {
292- now = now.UTC()302+ at, ok := presetDefaultTimes[schedule.Preset]
293- if schedule.Interval > 0 && schedule.Preset != "" {303+ if !ok {
294- return time.Time{}, fmt.Errorf("cron schedule cannot set both interval and preset")304+ return time.Time{}, fmt.Errorf("unsupported schedule preset: %q", schedule.Preset)
295 }305 }
296- if schedule.Interval > 0 {306+ if schedule.At != nil {
297- return now.Add(schedule.Interval), nil307+ at = *schedule.At
298 }308 }
309+ loc := schedule.Location
310+ if loc == nil {
311+ loc = time.UTC
312+ }
313+ local := now.In(loc)
299 314
315+ var next time.Time
300 switch schedule.Preset {316 switch schedule.Preset {
301 case domain.ScheduleHourly:317 case domain.ScheduleHourly:
302- next := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, time.UTC)318+ next = time.Date(local.Year(), local.Month(), local.Day(), local.Hour(), at.Minute, 0, 0, loc)
303 if !next.After(now) {319 if !next.After(now) {
304 next = next.Add(time.Hour)320 next = next.Add(time.Hour)
305 }321 }
306- return next, nil
307 case domain.ScheduleDaily:322 case domain.ScheduleDaily:
308- next := time.Date(now.Year(), now.Month(), now.Day(), 2, 0, 0, 0, time.UTC)323+ next = time.Date(local.Year(), local.Month(), local.Day(), at.Hour, at.Minute, 0, 0, loc)
309 if !next.After(now) {324 if !next.After(now) {
310- next = next.AddDate(0, 0, 1)325+ next = time.Date(local.Year(), local.Month(), local.Day()+1, at.Hour, at.Minute, 0, 0, loc)
311 }326 }
312- return next, nil
313 case domain.ScheduleWeekly:327 case domain.ScheduleWeekly:
314- daysUntilSunday := (7 - int(now.Weekday())) % 7328+ daysUntilSunday := (7 - int(local.Weekday())) % 7
315- next := time.Date(now.Year(), now.Month(), now.Day(), 3, 0, 0, 0, time.UTC).AddDate(0, 0, daysUntilSunday)329+ next = time.Date(local.Year(), local.Month(), local.Day()+daysUntilSunday, at.Hour, at.Minute, 0, 0, loc)
316 if !next.After(now) {330 if !next.After(now) {
317- next = next.AddDate(0, 0, 7)331+ next = time.Date(local.Year(), local.Month(), local.Day()+daysUntilSunday+7, at.Hour, at.Minute, 0, 0, loc)
318 }332 }
319- return next, nil
320 case domain.ScheduleMonthly:333 case domain.ScheduleMonthly:
321- next := time.Date(now.Year(), now.Month(), 1, 4, 0, 0, 0, time.UTC)334+ next = time.Date(local.Year(), local.Month(), 1, at.Hour, at.Minute, 0, 0, loc)
322 if !next.After(now) {335 if !next.After(now) {
323- next = next.AddDate(0, 1, 0)336+ next = time.Date(local.Year(), local.Month()+1, 1, at.Hour, at.Minute, 0, 0, loc)
324 }337 }
325- return next, nil
326- default:
327- return time.Time{}, fmt.Errorf("unsupported schedule preset: %q", schedule.Preset)
328 }338 }
339+ return next, nil
329 }340 }
M internal/usecase/cron/scheduler_test.go +49 -8
@@ -51,20 +51,61 @@ func TestCalculateNextRunPresets(t *testing.T) {
5151 }
5252 }
5353
54-func TestCalculateNextRunInterval(t *testing.T) {
55- now := time.Date(2026, 2, 7, 12, 34, 20, 0, time.UTC)
54+func TestCalculateNextRunAtLocalTime(t *testing.T) {
55+ paris, err := time.LoadLocation("Europe/Paris")
56+ require.NoError(t, err)
57+ at := &domain.TimeOfDay{Hour: 3, Minute: 30}
5658
57- next, err := calculateNextRun(now, domain.CronSchedule{Interval: 6 * time.Hour})
59+ tests := []struct {
60+ name string
61+ preset domain.BackupSchedule
62+ now time.Time
63+ expected time.Time
64+ }{
65+ {"daily later today", domain.ScheduleDaily,
66+ time.Date(2026, 10, 17, 1, 0, 0, 0, paris), time.Date(2026, 10, 17, 3, 30, 0, 0, paris)},
67+ {"daily already passed", domain.ScheduleDaily,
68+ time.Date(2026, 10, 17, 7, 24, 0, 0, paris), time.Date(2026, 10, 18, 3, 30, 0, 0, paris)},
69+ {"daily exactly now runs tomorrow", domain.ScheduleDaily,
70+ time.Date(2026, 10, 17, 3, 30, 0, 0, paris), time.Date(2026, 10, 18, 3, 30, 0, 0, paris)},
71+ // 2026-10-25: Paris leaves summer time at 03:00, so 03:30 is CET.
72+ {"daily across DST end keeps wall clock", domain.ScheduleDaily,
73+ time.Date(2026, 10, 24, 12, 0, 0, 0, paris), time.Date(2026, 10, 25, 3, 30, 0, 0, paris)},
74+ {"weekly next sunday", domain.ScheduleWeekly,
75+ time.Date(2026, 10, 17, 12, 0, 0, 0, paris), time.Date(2026, 10, 18, 3, 30, 0, 0, paris)},
76+ {"monthly next first day", domain.ScheduleMonthly,
77+ time.Date(2026, 10, 17, 12, 0, 0, 0, paris), time.Date(2026, 11, 1, 3, 30, 0, 0, paris)},
78+ {"hourly uses minute only", domain.ScheduleHourly,
79+ time.Date(2026, 10, 17, 12, 40, 0, 0, paris), time.Date(2026, 10, 17, 13, 30, 0, 0, paris)},
80+ }
81+ for _, tt := range tests {
82+ t.Run(tt.name, func(t *testing.T) {
83+ next, err := calculateNextRun(tt.now, domain.CronSchedule{Preset: tt.preset, At: at, Location: paris})
84+ require.NoError(t, err)
85+ assert.True(t, tt.expected.Equal(next), "want %s, got %s", tt.expected, next.In(paris))
86+ })
87+ }
88+}
5889
90+func TestCalculateNextRunDSTGapSkipsForward(t *testing.T) {
91+ paris, err := time.LoadLocation("Europe/Paris")
92+ require.NoError(t, err)
93+ // 2026-03-29: Paris skips 02:00-03:00. A 02:30 schedule runs once, at
94+ // the first valid instant after the gap, never twice or never.
95+ now := time.Date(2026, 3, 28, 12, 0, 0, 0, paris)
96+ next, err := calculateNextRun(now, domain.CronSchedule{
97+ Preset: domain.ScheduleDaily, At: &domain.TimeOfDay{Hour: 2, Minute: 30}, Location: paris,
98+ })
5999 require.NoError(t, err)
60- assert.Equal(t, now.Add(6*time.Hour), next)
100+ assert.Equal(t, 2026, next.Year())
101+ assert.Equal(t, time.March, next.In(paris).Month())
102+ assert.Equal(t, 29, next.In(paris).Day())
103+ assert.True(t, next.After(now))
61104 }
62105
63-func TestCalculateNextRunRejectsIntervalAndPreset(t *testing.T) {
64- _, err := calculateNextRun(time.Now(), domain.CronSchedule{Preset: domain.ScheduleDaily, Interval: time.Hour})
65-
106+func TestCalculateNextRunRejectsMissingPreset(t *testing.T) {
107+ _, err := calculateNextRun(time.Now(), domain.CronSchedule{})
66108 require.Error(t, err)
67- assert.Contains(t, err.Error(), "both interval and preset")
68109 }
69110
70111 func TestSchedulerAddListAndRunNow(t *testing.T) {
@@ -51,20 +51,61 @@ func TestCalculateNextRunPresets(t *testing.T) {
51 }51 }
52 }52 }
53 53
54-func TestCalculateNextRunInterval(t *testing.T) {54+func TestCalculateNextRunAtLocalTime(t *testing.T) {
55- now := time.Date(2026, 2, 7, 12, 34, 20, 0, time.UTC)55+ paris, err := time.LoadLocation("Europe/Paris")
56+ require.NoError(t, err)
57+ at := &domain.TimeOfDay{Hour: 3, Minute: 30}
56 58
57- next, err := calculateNextRun(now, domain.CronSchedule{Interval: 6 * time.Hour})59+ tests := []struct {
60+ name string
61+ preset domain.BackupSchedule
62+ now time.Time
63+ expected time.Time
64+ }{
65+ {"daily later today", domain.ScheduleDaily,
66+ time.Date(2026, 10, 17, 1, 0, 0, 0, paris), time.Date(2026, 10, 17, 3, 30, 0, 0, paris)},
67+ {"daily already passed", domain.ScheduleDaily,
68+ time.Date(2026, 10, 17, 7, 24, 0, 0, paris), time.Date(2026, 10, 18, 3, 30, 0, 0, paris)},
69+ {"daily exactly now runs tomorrow", domain.ScheduleDaily,
70+ time.Date(2026, 10, 17, 3, 30, 0, 0, paris), time.Date(2026, 10, 18, 3, 30, 0, 0, paris)},
71+ // 2026-10-25: Paris leaves summer time at 03:00, so 03:30 is CET.
72+ {"daily across DST end keeps wall clock", domain.ScheduleDaily,
73+ time.Date(2026, 10, 24, 12, 0, 0, 0, paris), time.Date(2026, 10, 25, 3, 30, 0, 0, paris)},
74+ {"weekly next sunday", domain.ScheduleWeekly,
75+ time.Date(2026, 10, 17, 12, 0, 0, 0, paris), time.Date(2026, 10, 18, 3, 30, 0, 0, paris)},
76+ {"monthly next first day", domain.ScheduleMonthly,
77+ time.Date(2026, 10, 17, 12, 0, 0, 0, paris), time.Date(2026, 11, 1, 3, 30, 0, 0, paris)},
78+ {"hourly uses minute only", domain.ScheduleHourly,
79+ time.Date(2026, 10, 17, 12, 40, 0, 0, paris), time.Date(2026, 10, 17, 13, 30, 0, 0, paris)},
80+ }
81+ for _, tt := range tests {
82+ t.Run(tt.name, func(t *testing.T) {
83+ next, err := calculateNextRun(tt.now, domain.CronSchedule{Preset: tt.preset, At: at, Location: paris})
84+ require.NoError(t, err)
85+ assert.True(t, tt.expected.Equal(next), "want %s, got %s", tt.expected, next.In(paris))
86+ })
87+ }
88+}
58 89
90+func TestCalculateNextRunDSTGapSkipsForward(t *testing.T) {
91+ paris, err := time.LoadLocation("Europe/Paris")
92+ require.NoError(t, err)
93+ // 2026-03-29: Paris skips 02:00-03:00. A 02:30 schedule runs once, at
94+ // the first valid instant after the gap, never twice or never.
95+ now := time.Date(2026, 3, 28, 12, 0, 0, 0, paris)
96+ next, err := calculateNextRun(now, domain.CronSchedule{
97+ Preset: domain.ScheduleDaily, At: &domain.TimeOfDay{Hour: 2, Minute: 30}, Location: paris,
98+ })
59 require.NoError(t, err)99 require.NoError(t, err)
60- assert.Equal(t, now.Add(6*time.Hour), next)100+ assert.Equal(t, 2026, next.Year())
101+ assert.Equal(t, time.March, next.In(paris).Month())
102+ assert.Equal(t, 29, next.In(paris).Day())
103+ assert.True(t, next.After(now))
61 }104 }
62 105
63-func TestCalculateNextRunRejectsIntervalAndPreset(t *testing.T) {106+func TestCalculateNextRunRejectsMissingPreset(t *testing.T) {
64- _, err := calculateNextRun(time.Now(), domain.CronSchedule{Preset: domain.ScheduleDaily, Interval: time.Hour})107+ _, err := calculateNextRun(time.Now(), domain.CronSchedule{})
65-
66 require.Error(t, err)108 require.Error(t, err)
67- assert.Contains(t, err.Error(), "both interval and preset")
68 }109 }
69 110
70 func TestSchedulerAddListAndRunNow(t *testing.T) {111 func TestSchedulerAddListAndRunNow(t *testing.T) {
M internal/usecase/deployment/deploy_unchanged_test.go +59 -0
@@ -22,6 +22,8 @@ type unchangedSeed struct {
2222 binds map[int]int
2323 desiredEnv map[string]string
2424 inhibitedBy string
25+ // mutateDesired alters the desired service after the ACTIVE one is seeded.
26+ mutateDesired func(*domain.AppService)
2527 }
2628
2729 // seedUnchangedApp seeds an app whose ACTIVE service runs exactly what the
@@ -50,6 +52,9 @@ func seedUnchangedApp(t *testing.T, ctx context.Context, store *appstate.Store,
5052 if seed.desiredEnv != nil {
5153 desired.Spec.Env = seed.desiredEnv
5254 }
55+ if seed.mutateDesired != nil {
56+ seed.mutateDesired(&desired.Spec.Services[0])
57+ }
5358 seedRevision(t, ctx, store, "intent-1", "rev-0", desired)
5459 if seed.inhibitedBy != "" {
5560 require.NoError(t, store.SaveRecoveryInhibition(ctx, domain.AppRecoveryInhibition{
@@ -123,6 +128,60 @@ func TestDeploy_SkipsServiceAlreadyRunningTheSameImage(t *testing.T) {
123128 assert.True(t, active.Converged, "an unchanged deploy still converges ACTIVE on the deployed revision")
124129 }
125130
131+// TestDeploy_KeepsContainerOnBackupOnlyChange proves a change limited to the
132+// service backup declaration keeps the running container, and that ACTIVE
133+// records the new declaration and revision so backup target resolution sees it.
134+func TestDeploy_KeepsContainerOnBackupOnlyChange(t *testing.T) {
135+ svc, runtime, _, ctx, store := unchangedDeployService(t, unchangedSeed{
136+ binds: map[int]int{8080: 18080},
137+ mutateDesired: func(s *domain.AppService) {
138+ s.Backup = domain.AppBackup{Volume: []string{"web-data"}}
139+ },
140+ })
141+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").
142+ Return(&domain.Container{ID: "c-old", Status: string(domain.ContainerStatusRunning)}, nil).Once()
143+
144+ result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "op-backup"})
145+ require.NoError(t, err)
146+ assert.Equal(t, deployment.ServiceResultUnchanged, result.Services["web"].Result)
147+ assert.Equal(t, "c-old", result.Services["web"].After)
148+ runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
149+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
150+
151+ active, ok, err := store.LoadActive(ctx, "blog")
152+ require.NoError(t, err)
153+ require.True(t, ok)
154+ web := active.Services["web"]
155+ assert.Equal(t, "c-old", web.Container)
156+ assert.Equal(t, "op-created", web.ActivatedBy)
157+ assert.Equal(t, []string{"web-data"}, web.Spec.Backup.Volume, "ACTIVE records the new backup declaration")
158+ assert.NotEqual(t, "rev-0", web.EffectiveRevision, "ACTIVE records the new effective revision")
159+ assert.Equal(t, active.ConvergedRevision, web.EffectiveRevision)
160+}
161+
162+// TestDeploy_ReplacesWhenRuntimeSettingsChangeAlongsideBackup proves a real
163+// runtime change still replaces the container even when backup also changed.
164+func TestDeploy_ReplacesWhenRuntimeSettingsChangeAlongsideBackup(t *testing.T) {
165+ svc, runtime, _, ctx, store := unchangedDeployService(t, unchangedSeed{
166+ binds: map[int]int{8080: 18080},
167+ mutateDesired: func(s *domain.AppService) {
168+ s.Backup = domain.AppBackup{Volume: []string{"web-data"}}
169+ s.Command = []string{"serve", "--fast"}
170+ },
171+ })
172+ expectReplacement(runtime)
173+
174+ result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "op-backup-cmd"})
175+ require.NoError(t, err)
176+ assert.Equal(t, "deployed", result.Services["web"].Result)
177+ assert.Equal(t, "c-new", result.Services["web"].After)
178+
179+ active, ok, err := store.LoadActive(ctx, "blog")
180+ require.NoError(t, err)
181+ require.True(t, ok)
182+ assert.Equal(t, []string{"web-data"}, active.Services["web"].Spec.Backup.Volume)
183+}
184+
126185 // TestDeploy_ReplacesSameDigestWhenTheServiceIsNotServing proves the skip
127186 // never keeps a container that is not fully published: missing routing
128187 // binds (a withdrawn generation), a recovery inhibition left by a failed
@@ -22,6 +22,8 @@ type unchangedSeed struct {
22 binds map[int]int22 binds map[int]int
23 desiredEnv map[string]string23 desiredEnv map[string]string
24 inhibitedBy string24 inhibitedBy string
25+ // mutateDesired alters the desired service after the ACTIVE one is seeded.
26+ mutateDesired func(*domain.AppService)
25 }27 }
26 28
27 // seedUnchangedApp seeds an app whose ACTIVE service runs exactly what the29 // seedUnchangedApp seeds an app whose ACTIVE service runs exactly what the
@@ -50,6 +52,9 @@ func seedUnchangedApp(t *testing.T, ctx context.Context, store *appstate.Store,
50 if seed.desiredEnv != nil {52 if seed.desiredEnv != nil {
51 desired.Spec.Env = seed.desiredEnv53 desired.Spec.Env = seed.desiredEnv
52 }54 }
55+ if seed.mutateDesired != nil {
56+ seed.mutateDesired(&desired.Spec.Services[0])
57+ }
53 seedRevision(t, ctx, store, "intent-1", "rev-0", desired)58 seedRevision(t, ctx, store, "intent-1", "rev-0", desired)
54 if seed.inhibitedBy != "" {59 if seed.inhibitedBy != "" {
55 require.NoError(t, store.SaveRecoveryInhibition(ctx, domain.AppRecoveryInhibition{60 require.NoError(t, store.SaveRecoveryInhibition(ctx, domain.AppRecoveryInhibition{
@@ -123,6 +128,60 @@ func TestDeploy_SkipsServiceAlreadyRunningTheSameImage(t *testing.T) {
123 assert.True(t, active.Converged, "an unchanged deploy still converges ACTIVE on the deployed revision")128 assert.True(t, active.Converged, "an unchanged deploy still converges ACTIVE on the deployed revision")
124 }129 }
125 130
131+// TestDeploy_KeepsContainerOnBackupOnlyChange proves a change limited to the
132+// service backup declaration keeps the running container, and that ACTIVE
133+// records the new declaration and revision so backup target resolution sees it.
134+func TestDeploy_KeepsContainerOnBackupOnlyChange(t *testing.T) {
135+ svc, runtime, _, ctx, store := unchangedDeployService(t, unchangedSeed{
136+ binds: map[int]int{8080: 18080},
137+ mutateDesired: func(s *domain.AppService) {
138+ s.Backup = domain.AppBackup{Volume: []string{"web-data"}}
139+ },
140+ })
141+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").
142+ Return(&domain.Container{ID: "c-old", Status: string(domain.ContainerStatusRunning)}, nil).Once()
143+
144+ result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "op-backup"})
145+ require.NoError(t, err)
146+ assert.Equal(t, deployment.ServiceResultUnchanged, result.Services["web"].Result)
147+ assert.Equal(t, "c-old", result.Services["web"].After)
148+ runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
149+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
150+
151+ active, ok, err := store.LoadActive(ctx, "blog")
152+ require.NoError(t, err)
153+ require.True(t, ok)
154+ web := active.Services["web"]
155+ assert.Equal(t, "c-old", web.Container)
156+ assert.Equal(t, "op-created", web.ActivatedBy)
157+ assert.Equal(t, []string{"web-data"}, web.Spec.Backup.Volume, "ACTIVE records the new backup declaration")
158+ assert.NotEqual(t, "rev-0", web.EffectiveRevision, "ACTIVE records the new effective revision")
159+ assert.Equal(t, active.ConvergedRevision, web.EffectiveRevision)
160+}
161+
162+// TestDeploy_ReplacesWhenRuntimeSettingsChangeAlongsideBackup proves a real
163+// runtime change still replaces the container even when backup also changed.
164+func TestDeploy_ReplacesWhenRuntimeSettingsChangeAlongsideBackup(t *testing.T) {
165+ svc, runtime, _, ctx, store := unchangedDeployService(t, unchangedSeed{
166+ binds: map[int]int{8080: 18080},
167+ mutateDesired: func(s *domain.AppService) {
168+ s.Backup = domain.AppBackup{Volume: []string{"web-data"}}
169+ s.Command = []string{"serve", "--fast"}
170+ },
171+ })
172+ expectReplacement(runtime)
173+
174+ result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "op-backup-cmd"})
175+ require.NoError(t, err)
176+ assert.Equal(t, "deployed", result.Services["web"].Result)
177+ assert.Equal(t, "c-new", result.Services["web"].After)
178+
179+ active, ok, err := store.LoadActive(ctx, "blog")
180+ require.NoError(t, err)
181+ require.True(t, ok)
182+ assert.Equal(t, []string{"web-data"}, active.Services["web"].Spec.Backup.Volume)
183+}
184+
126 // TestDeploy_ReplacesSameDigestWhenTheServiceIsNotServing proves the skip185 // TestDeploy_ReplacesSameDigestWhenTheServiceIsNotServing proves the skip
127 // never keeps a container that is not fully published: missing routing186 // never keeps a container that is not fully published: missing routing
128 // binds (a withdrawn generation), a recovery inhibition left by a failed187 // binds (a withdrawn generation), a recovery inhibition left by a failed
M internal/usecase/deployment/execute.go +3 -1
@@ -489,7 +489,9 @@ func (s *Service) unchangedRefusal(ctx context.Context, app string, p pinnedServ
489489 switch {
490490 case len(p.spec.Binds) > 0 || len(p.spec.Devices) > 0:
491491 return "host binds or devices"
492- case !domain.SameAppService(p.spec, eff.Spec):
492+ case !domain.SameAppServiceRuntime(p.spec, eff.Spec):
493+ // Backup declarations never reach the container, so a backup-only
494+ // change keeps it; publishService still records the new spec in ACTIVE.
493495 return "spec changed"
494496 case !bindsCover(eff, backendPorts(p.spec)):
495497 return "backend binds missing"
@@ -489,7 +489,9 @@ func (s *Service) unchangedRefusal(ctx context.Context, app string, p pinnedServ
489 switch {489 switch {
490 case len(p.spec.Binds) > 0 || len(p.spec.Devices) > 0:490 case len(p.spec.Binds) > 0 || len(p.spec.Devices) > 0:
491 return "host binds or devices"491 return "host binds or devices"
492- case !domain.SameAppService(p.spec, eff.Spec):492+ case !domain.SameAppServiceRuntime(p.spec, eff.Spec):
493+ // Backup declarations never reach the container, so a backup-only
494+ // change keeps it; publishService still records the new spec in ACTIVE.
493 return "spec changed"495 return "spec changed"
494 case !bindsCover(eff, backendPorts(p.spec)):496 case !bindsCover(eff, backendPorts(p.spec)):
495 return "backend binds missing"497 return "backend binds missing"

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close