Skip to content

Commit f0dc21c

Browse files
authored
feat: enhance backup process with detailed error handling and logging for skipped and failed containers (#665)
Co-authored-by: James Tufarelli <minituff@users.noreply.github.com>
1 parent 04785bf commit f0dc21c

5 files changed

Lines changed: 338 additions & 22 deletions

File tree

AGENTS.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Nautical Backup — Agent Guide
2+
3+
## Overview
4+
5+
Nautical Backup is a Docker-based container backup tool that automatically backs up Docker container data using rsync. It is configured via Docker labels on individual containers or via environment variables on the Nautical container itself.
6+
7+
- **GitHub**: https://github.com/Minituff/nautical-backup
8+
- **Language**: Python (FastAPI backend, rsync for file transfer)
9+
- **Distribution**: Docker image (`minituff/nautical-backup`)
10+
11+
## Active User Base — Backward Compatibility is Critical
12+
13+
~10,000 users run this app, many with **auto-updating Docker containers**. A push to the main branch can trigger automatic updates for a significant portion of the user base within hours.
14+
15+
- Every code change must be reviewed for backward compatibility before merging.
16+
- If a change breaks existing behavior, it **must be called out explicitly** in the PR and the version bumped using **semantic versioning (semver)**:
17+
- `PATCH` (x.x.1) — bug fixes, no behavior change for existing users
18+
- `MINOR` (x.1.x) — new features, fully backward-compatible
19+
- `MAJOR` (1.x.x) — breaking changes (avoid unless absolutely necessary)
20+
21+
## Docs Folder = Source of Truth
22+
23+
The `docs/` folder contains the MkDocs user-facing documentation. **Read the relevant docs pages before implementing any feature or fix.** If the docs describe a behavior, that behavior is the contract with users. If your change affects documented behavior, update the docs too.
24+
25+
Key docs pages:
26+
- `docs/labels.md` — all Docker label options (per-container config)
27+
- `docs/arguments.md` — all environment variable options (global config)
28+
- `docs/introduction.md` — high-level overview
29+
30+
## Tests — Do Not Change Without Explicit Justification
31+
32+
Tests in `pytest/` exist to enforce backward compatibility. Treat them as a contract.
33+
34+
- **Do not modify existing tests** unless there is a clear, meaningful reason (e.g., the test was wrong, or the code behavior intentionally changed).
35+
- **If you must change an existing test, call it out explicitly** when proposing or describing the change.
36+
- **Adding new tests is always fine** and encouraged.
37+
- When mocking `subprocess.run`, always set `mock_subprocess_run.return_value.returncode = 0` in new tests so the mock behaves like a successful rsync call.
38+
39+
Run tests with: `nb pytest` (inside the dev environment) or `python -m pytest pytest/` locally (excluding `pytest/test_api.py` if the dev DB is not initialized).
40+
41+
## Codebase Layout
42+
43+
```
44+
app/
45+
backup.py # Core backup orchestration (NauticalBackup class)
46+
nautical_env.py # Environment variable parsing (NauticalEnv class)
47+
api/ # FastAPI REST API
48+
db.py # Simple JSON key-value database
49+
logger.py # Logging utilities
50+
pytest/ # Test suite
51+
docs/ # MkDocs user documentation
52+
```

app/backup.py

Lines changed: 133 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ def __init__(self, docker_client: docker.DockerClient):
4343

4444
self.containers_completed = set()
4545
self.containers_skipped = set()
46+
self.containers_failed = set()
47+
self.container_skip_reasons: Dict[str, str] = {}
48+
self.container_failure_reasons: Dict[str, str] = {}
49+
self.error_messages: List[str] = []
4650
self.prefix = self.env.LABEL_PREFIX
4751

4852
# Grab the backup starting time
@@ -58,6 +62,52 @@ def log_this(self, log_message, log_priority="INFO", log_type=LogType.DEFAULT) -
5862
"""Wrapper for log this"""
5963
return self.logger.log_this(log_message, log_priority, log_type)
6064

65+
def _format_set_for_exec(self, input_set: set) -> str:
66+
"""Return stable comma-separated values for script-friendly env vars."""
67+
return ",".join(sorted(str(i) for i in input_set))
68+
69+
def _format_reasons_for_exec(self, reasons: Dict[str, str]) -> str:
70+
"""Return stable semicolon-separated name=reason pairs for script-friendly env vars."""
71+
return ";".join(f"{name}={reason}" for name, reason in sorted(reasons.items()))
72+
73+
def _format_error_messages_for_exec(self) -> str:
74+
cleaned_messages = []
75+
for message in self.error_messages:
76+
cleaned_messages.append(str(message).replace("\n", " ").replace(";", ",").strip())
77+
return ";".join(cleaned_messages)
78+
79+
def _record_error(self, message: str) -> None:
80+
if message not in self.error_messages:
81+
self.error_messages.append(message)
82+
83+
def _record_container_skipped(self, c: Container, reason: str, message: str, log=True) -> None:
84+
self.containers_skipped.add(c.name)
85+
self.container_skip_reasons.setdefault(c.name, reason)
86+
if log:
87+
self.log_this(message, "WARN")
88+
89+
def _record_container_failed(self, c: Container, reason: str, message: str, log=True) -> None:
90+
self.containers_failed.add(c.name)
91+
self.container_failure_reasons.setdefault(c.name, reason)
92+
self._record_error(message)
93+
if log:
94+
self.log_this(message, "ERROR")
95+
96+
def _backup_status(self) -> str:
97+
if self.containers_failed or self.error_messages:
98+
return "error"
99+
if self.containers_skipped:
100+
return "warning"
101+
return "success"
102+
103+
def _reset_outcomes(self) -> None:
104+
self.containers_completed.clear()
105+
self.containers_skipped.clear()
106+
self.containers_failed.clear()
107+
self.container_skip_reasons.clear()
108+
self.container_failure_reasons.clear()
109+
self.error_messages.clear()
110+
61111
def get_label(self, container: Container, target: str, default=None):
62112
"""Apply the label prefix and return the label value
63113
By default the label will look like: `nautical-backup.enable`
@@ -128,20 +178,32 @@ def _should_skip_container(self, c: Container) -> bool:
128178
# Attempt to pull info from container. Skip if not found
129179
info = str(c.name) + " " + str(c.id) + " " + str(c.image) + " " + str(c.labels)
130180
except ImageNotFound as e:
131-
self.log_this(f"Skipping container because it's info was not found.", "TRACE")
181+
self._record_container_skipped(c, "image_not_found", "Skipping container because its info was not found.")
132182
return True
133183

134184
if "minituff/nautical-backup" in str(c.image):
135-
self.log_this(f"Skipping {c.name} {c.id} because it's image matches 'minituff/nautical-backup'.", "TRACE")
185+
self._record_container_skipped(
186+
c,
187+
"nautical_backup_image",
188+
f"Skipping {c.name} {c.id} because its image matches 'minituff/nautical-backup'.",
189+
)
136190
return True
137191
if c.labels.get("org.opencontainers.image.title") == "nautical-backup":
138-
self.log_this(f"Skipping {c.name} {c.id} because it's image matches 'nautical-backup'.", "TRACE")
192+
self._record_container_skipped(
193+
c,
194+
"nautical_backup_image",
195+
f"Skipping {c.name} {c.id} because its image matches 'nautical-backup'.",
196+
)
139197
return True
140198
if c.id == SELF_CONTAINER_ID:
141-
self.log_this(f"Skipping {c.name} {c.id} because it's ID is the same as Nautical", "TRACE")
199+
self._record_container_skipped(
200+
c, "self_container", f"Skipping {c.name} {c.id} because its ID is the same as Nautical"
201+
)
142202
return True
143203
if c.name == SELF_CONTAINER_ID:
144-
self.log_this(f"Skipping {c.name} because it's ID is the same as Nautical", "TRACE")
204+
self._record_container_skipped(
205+
c, "self_container", f"Skipping {c.name} because its ID is the same as Nautical"
206+
)
145207
return True
146208

147209
# Read the environment variables
@@ -159,21 +221,23 @@ def _should_skip_container(self, c: Container) -> bool:
159221
nautical_backup_enable = None
160222

161223
if nautical_backup_enable == False:
162-
self.log_this(f"Skipping {c.name} based on label", "DEBUG")
224+
self._record_container_skipped(c, "enable_label_false", f"Skipping {c.name} based on label")
163225
return True
164226

165227
if self.env.REQUIRE_LABEL == True and nautical_backup_enable is not True:
166-
self.log_this(
167-
f"Skipping {c.name} as '{self.prefix}.enable=true' was not found and REQUIRE_LABEL is true.", "DEBUG"
228+
self._record_container_skipped(
229+
c,
230+
"require_label_missing",
231+
f"Skipping {c.name} as '{self.prefix}.enable=true' was not found and REQUIRE_LABEL is true.",
168232
)
169233
return True
170234

171235
if c.name in skip_containers_set:
172-
self.log_this(f"Skipping {c.name} based on name", "DEBUG")
236+
self._record_container_skipped(c, "skip_containers_name", f"Skipping {c.name} based on name")
173237
return True
174238

175239
if c.id in skip_containers_set:
176-
self.log_this(f"Skipping {c.name} based on ID {c.id}", "DEBUG")
240+
self._record_container_skipped(c, "skip_containers_id", f"Skipping {c.name} based on ID {c.id}")
177241
return True
178242

179243
# No reason to skip
@@ -196,7 +260,6 @@ def group_containers(self) -> Dict[str, List[Container]]:
196260

197261
for c in containers:
198262
if self._should_skip_container(c) == True:
199-
self.containers_skipped.add(c.name)
200263
continue # Skip this container
201264

202265
# Create a default group, so ungrouped items are not grouped together
@@ -322,6 +385,16 @@ def _run_exec(
322385
vars["NB_EXEC_TOTAL_CONTAINERS_COMPLETED"] = str(self.db.get("containers_completed", ""))
323386
vars["NB_EXEC_TOTAL_CONTAINERS_SKIPPED"] = str(self.db.get("containers_skipped", ""))
324387
vars["NB_EXEC_TOTAL_NUMBER_OF_CONTAINERS"] = str(self.db.get("number_of_containers", ""))
388+
vars["NB_EXEC_BACKUP_STATUS"] = self._backup_status()
389+
vars["NB_EXEC_BACKUP_STARTED_AT"] = self.start_time.isoformat()
390+
vars["NB_EXEC_BACKUP_FINISHED_AT"] = getattr(self, "end_time", datetime.now()).isoformat()
391+
vars["NB_EXEC_BACKUP_DURATION_SECONDS"] = str(self.db.get("last_backup_seconds_taken", ""))
392+
vars["NB_EXEC_CONTAINERS_COMPLETED"] = self._format_set_for_exec(self.containers_completed)
393+
vars["NB_EXEC_CONTAINERS_SKIPPED"] = self._format_set_for_exec(self.containers_skipped)
394+
vars["NB_EXEC_CONTAINERS_FAILED"] = self._format_set_for_exec(self.containers_failed)
395+
vars["NB_EXEC_CONTAINER_SKIP_REASONS"] = self._format_reasons_for_exec(self.container_skip_reasons)
396+
vars["NB_EXEC_CONTAINER_FAILURE_REASONS"] = self._format_reasons_for_exec(self.container_failure_reasons)
397+
vars["NB_EXEC_ERROR_MESSAGES"] = self._format_error_messages_for_exec()
325398

326399
self._set_exec_environment_variables(vars)
327400

@@ -610,13 +683,17 @@ def _backup_container_folders(self, c: Container, dest_path: Optional[Path] = No
610683
rsync_args = self._get_rsync_args(c)
611684
rsync_ok = self._run_rsync(c, rsync_args, src_dir, dest_dir)
612685
if not rsync_ok:
613-
self.containers_skipped.add(c.name)
686+
self._record_container_skipped(
687+
c, "rsync_failed", f"Skipping completion of {c.name} because rsync failed", log=False
688+
)
614689
elif src_dir_required == "false":
615690
# Do nothing. This container is still started and stopped, but there is nothing to backup
616691
# Likely this container is part of a group and the source directory is not required
617692
pass
618693
else:
619-
self.log_this(f"Source directory {src_dir} does not exist. Skipping", "DEBUG")
694+
self._record_container_skipped(
695+
c, "source_directory_missing", f"Source directory {src_dir} does not exist. Skipping"
696+
)
620697

621698
additional_folders_when = str(self.get_label(c, "additional-folders.when", "during")).lower()
622699
if not additional_folders_when or additional_folders_when == "during":
@@ -636,7 +713,12 @@ def _run_rsync(self, c: Optional[Container], rsync_args: str, src_dir: Path, des
636713

637714
if out.returncode != 0:
638715
name = c.name if c else "unknown"
639-
self.log_this(f"rsync exited with code {out.returncode} for {name}", "ERROR")
716+
message = f"rsync exited with code {out.returncode} for {name}"
717+
if c:
718+
self._record_container_failed(c, "rsync_failed", message)
719+
else:
720+
self._record_error(message)
721+
self.log_this(message, "ERROR")
640722
return False
641723
return True
642724

@@ -683,6 +765,7 @@ def reset_db(self) -> None:
683765
"""Reset the database values to their defaults"""
684766
self.db.put("containers_completed", 0)
685767
self.db.put("containers_skipped", 0)
768+
self.db.put("errors", 0)
686769
self.db.put("last_backup_seconds_taken", 0)
687770
self.db.put("last_cron", "None")
688771
self.db.put("completed", "0")
@@ -694,6 +777,7 @@ def backup(self):
694777

695778
self.log_this("Starting backup...", "INFO")
696779

780+
self._reset_outcomes()
697781
self.reset_db()
698782
self.db.put("backup_running", True)
699783

@@ -736,11 +820,21 @@ def backup(self):
736820
f"{c.name} - Source directory '{src_dir}' does not exist, but that's okay", "DEBUG"
737821
)
738822
else:
739-
self.log_this(f"{c.name} - Source directory '{src_dir}' does not exist. Skipping", "DEBUG")
740-
self.containers_skipped.add(c.name)
823+
self._record_container_skipped(
824+
c,
825+
"source_directory_missing",
826+
f"{c.name} - Source directory '{src_dir}' does not exist. Skipping",
827+
)
741828
continue
742829

743830
stop_result = self._stop_container(c) # Stop containers
831+
if not stop_result:
832+
self._record_container_failed(
833+
c,
834+
"stop_failed",
835+
f"Error stopping container {c.name}. Skipping backup for this container.",
836+
log=False,
837+
)
744838

745839
# During backup
746840
for c in containers:
@@ -759,8 +853,13 @@ def backup(self):
759853

760854
if stop_before_backup.lower() == "true" and stop_before_backup_env == True:
761855
if c.name not in self.containers_skipped:
762-
self.log_this(f"Skipping backup of {c.name} because it was not stopped", "WARN")
763-
self.containers_skipped.add(c.name)
856+
self._record_container_skipped(
857+
c, "not_stopped", f"Skipping backup of {c.name} because it was not stopped"
858+
)
859+
else:
860+
self._record_container_skipped(
861+
c, "not_stopped", f"Skipping backup of {c.name} because it was not stopped", log=False
862+
)
764863
continue
765864

766865
self._backup_container_folders(c)
@@ -775,6 +874,8 @@ def backup(self):
775874
for c in containers:
776875

777876
start_result = self._start_container(c) # Start containers
877+
if not start_result:
878+
self._record_container_failed(c, "start_failed", f"Error starting container {c.name}.", log=False)
778879

779880
self._run_lifecyle_hook(c, BeforeOrAfter.AFTER)
780881
self._run_exec(c, BeforeAfterorDuring.AFTER, attached_to_container=True)
@@ -790,19 +891,31 @@ def backup(self):
790891

791892
for dir in dest_dirs:
792893
self._backup_additional_folders_standalone(BeforeOrAfter.AFTER, dir)
793-
end_time = datetime.now()
794-
exeuction_time = end_time - self.start_time
894+
self.end_time = datetime.now()
895+
exeuction_time = self.end_time - self.start_time
795896
duration = datetime.fromtimestamp(exeuction_time.total_seconds())
796897

797898
self.db.put("backup_running", False)
798899
self.db.put("containers_completed", len(self.containers_completed))
799900
self.db.put("containers_skipped", len(self.containers_skipped))
901+
self.db.put("errors", len(self.error_messages))
800902
self.db.put("last_backup_seconds_taken", round(exeuction_time.total_seconds()))
801903

802904
self._run_exec(None, BeforeAfterorDuring.AFTER, attached_to_container=False)
803905

804906
self.log_this("Containers completed: " + self.logger.set_to_string(self.containers_completed), "DEBUG")
805907
self.log_this("Containers skipped: " + self.logger.set_to_string(self.containers_skipped), "DEBUG")
908+
if self.containers_skipped:
909+
skipped_names = self.logger.set_to_string(self.containers_skipped)
910+
self.log_this(
911+
f"Skipped {len(self.containers_skipped)} containers: {skipped_names}",
912+
"WARN",
913+
)
914+
if self.containers_failed:
915+
self.log_this(
916+
f"Failed {len(self.containers_failed)} containers: {self.logger.set_to_string(self.containers_failed)}",
917+
"ERROR",
918+
)
806919
self.log_this(f"Completed in {duration.strftime('%Mm %Ss')}", "INFO")
807920

808921
self.log_this(

docs/arguments.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,8 @@ Set the console log level for the container.
540540
LOG_LEVEL=INFO
541541
```
542542

543+
Skipped containers are logged at `WARN`. Backup failures, such as rsync failures or containers that cannot be stopped or started, are logged at `ERROR`.
544+
543545
## Report Log Level
544546
Set the log level for the generated report file.
545547
Only used if the report file is [enabled](#report-file).

0 commit comments

Comments
 (0)