@@ -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 (
0 commit comments