dbara 20260714__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dbara/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "20260714"
dbara/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from dbara.cli import main
4
+
5
+ sys.exit(main())
dbara/archive.py ADDED
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ from dbara.log import Logger
8
+
9
+ _GLOB_PATTERNS = (
10
+ "*_{app}_*.bkup.tar.zst",
11
+ "*_{app}_*.bkup.tar.tgz",
12
+ "*_{app}_*.bkup.tar.gz",
13
+ )
14
+
15
+ _ARCHIVE_DATE_RE = re.compile(r"^(.+)_(\d{8}_\d{6})\.bkup\.tar\.(zst|tgz|gz)$")
16
+
17
+
18
+ def backup_ext(compressor: str) -> str:
19
+ return "zst" if compressor == "zstd" else "tgz"
20
+
21
+
22
+ def build_archive_name(
23
+ app: str,
24
+ compressor: str,
25
+ optional_prefix: str = "",
26
+ hostname: str = "",
27
+ timestamp: datetime | None = None,
28
+ ) -> str:
29
+ """Return the backup archive filename (no directory component)."""
30
+ if timestamp is None:
31
+ timestamp = datetime.now()
32
+ date_str = timestamp.strftime("%Y%m%d_%H%M%S")
33
+ prefix_part = f"{optional_prefix}_" if optional_prefix else ""
34
+ ext = backup_ext(compressor)
35
+ return f"{hostname}_{prefix_part}{app}_{date_str}.bkup.tar.{ext}"
36
+
37
+
38
+ def select_newest_archive(directory: Path, app: str) -> Path | None:
39
+ """Return the most-recently-modified backup archive for app in directory, or None."""
40
+ candidates: list[tuple[float, Path]] = []
41
+ for pattern_tmpl in _GLOB_PATTERNS:
42
+ for p in directory.glob(pattern_tmpl.format(app=app)):
43
+ if p.is_file():
44
+ candidates.append((p.stat().st_mtime, p))
45
+ if not candidates:
46
+ return None
47
+ return max(candidates, key=lambda t: t[0])[1]
48
+
49
+
50
+ def prune_old_backups(
51
+ backup_dir: Path,
52
+ app: str,
53
+ keep_last: int,
54
+ logger: Logger,
55
+ ) -> None:
56
+ """Delete the oldest archives for app, keeping only the last keep_last.
57
+
58
+ Also removes associated .sha256sum and .xxh128 checksum sidecar files.
59
+ No-op when keep_last <= 0.
60
+ """
61
+ if keep_last <= 0:
62
+ return
63
+ logger.info(f"Pruning old backups for {app!r} (keeping last {keep_last})...")
64
+ archives: list[tuple[float, Path]] = []
65
+ for pattern_tmpl in _GLOB_PATTERNS:
66
+ for p in backup_dir.glob(pattern_tmpl.format(app=app)):
67
+ if p.is_file():
68
+ archives.append((p.stat().st_mtime, p))
69
+ archives.sort(key=lambda t: t[0]) # oldest first
70
+ count = len(archives)
71
+ if count <= keep_last:
72
+ logger.debug(f"Only {count} archive(s) for {app!r}; nothing to prune.")
73
+ return
74
+ for _, old in archives[: count - keep_last]:
75
+ logger.info(f"Pruning old backup: {old}")
76
+ old.unlink(missing_ok=True)
77
+ Path(str(old) + ".sha256sum").unlink(missing_ok=True)
78
+ Path(str(old) + ".xxh128").unlink(missing_ok=True)
79
+
80
+
81
+ def list_apps_in_backup_dir(backup_dir: Path) -> list[str]:
82
+ """Return sorted unique app names inferred from archive filenames in backup_dir.
83
+
84
+ Parses the archive naming convention: {hostname}[_{prefix}]{app}_{YYYYMMDD_HHMMSS}.bkup.tar.EXT
85
+ The app name is the last underscore-delimited segment before the timestamp.
86
+ App names containing underscores are not supported by this auto-discovery.
87
+ """
88
+ if not backup_dir.is_dir():
89
+ return []
90
+ apps: set[str] = set()
91
+ for f in backup_dir.iterdir():
92
+ if not f.is_file():
93
+ continue
94
+ m = _ARCHIVE_DATE_RE.match(f.name)
95
+ if not m:
96
+ continue
97
+ stem = m.group(1) # e.g. "hostname_prefix_appname"
98
+ app = stem.rsplit("_", 1)[-1]
99
+ if app:
100
+ apps.add(app)
101
+ return sorted(apps)
dbara/checksum.py ADDED
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from dbara.log import Logger
8
+ from dbara.runner import CommandRunner
9
+
10
+
11
+ def hash_program(fast_hash: bool, runner: CommandRunner) -> str:
12
+ """Return 'xxh128sum' if fast_hash is enabled and the tool is available, else 'sha256sum'."""
13
+ if fast_hash and runner.tool_available("xxh128sum"):
14
+ return "xxh128sum"
15
+ return "sha256sum"
16
+
17
+
18
+ def checksum_suffix(program: str) -> str:
19
+ return "xxh128" if program == "xxh128sum" else "sha256sum"
20
+
21
+
22
+ def create_checksum_file(
23
+ app: str,
24
+ backup_path: Path,
25
+ base_dir: Path,
26
+ runner: CommandRunner,
27
+ logger: Logger,
28
+ fast_hash: bool,
29
+ ) -> Path:
30
+ """Generate a checksum file for all files under base_dir/app.
31
+
32
+ The file is named {backup_path}.sha256sum or {backup_path}.xxh128 and uses
33
+ the same format as sha256sum/xxh128sum so it can be verified externally.
34
+ """
35
+ import shlex
36
+
37
+ hasher = hash_program(fast_hash, runner)
38
+ suffix = checksum_suffix(hasher)
39
+ out_path = Path(str(backup_path) + f".{suffix}")
40
+ logger.info(f"Generating {hasher} checksums -> {out_path.name}")
41
+ # Pipe find → xargs → hasher, with output redirect — requires a shell for the pipeline.
42
+ # All dynamic values are shlex-quoted; none come from user input at this point.
43
+ shell_cmd = (
44
+ f"find {shlex.quote(app)} -type f -print0 "
45
+ f"| xargs -0 {shlex.quote(hasher)} "
46
+ f"> {shlex.quote(str(out_path))}"
47
+ )
48
+ runner.run_io_niced(["bash", "-c", shell_cmd], cwd=base_dir)
49
+ return out_path
50
+
51
+
52
+ def verify_checksum_file(
53
+ base_dir: Path,
54
+ checksum_file: Path,
55
+ runner: CommandRunner,
56
+ logger: Logger,
57
+ fast_hash: bool,
58
+ ) -> bool:
59
+ """Verify checksum_file from base_dir.
60
+
61
+ Manifest lines are app-prefixed relative paths (see create_checksum_file),
62
+ so base_dir must be the directory CONTAINING the restored app directory.
63
+
64
+ Returns True if all checksums pass (or file missing — non-fatal).
65
+ Returns False and logs warnings on any mismatch.
66
+ """
67
+ if not checksum_file.is_file():
68
+ logger.warn(f"Checksum file {checksum_file} missing, skipping verification.")
69
+ return True
70
+
71
+ hasher = hash_program(fast_hash, runner)
72
+ logger.info(f"Verifying checksums with {hasher}...")
73
+ result = runner.run(
74
+ [hasher, "-c", str(checksum_file)],
75
+ cwd=base_dir,
76
+ capture_output=True,
77
+ check=False,
78
+ )
79
+ failed = False
80
+ for line in (result.stdout + result.stderr).splitlines():
81
+ if "FAILED" in line or "No such file" in line:
82
+ logger.warn(f"Checksum issue: {line}")
83
+ failed = True
84
+ return not failed
85
+
86
+
87
+ def signature_of_dir(directory: Path) -> str:
88
+ """Pure-Python change-detection signature.
89
+
90
+ Walks directory, collects (relative_path, size, mtime_ns) for every file
91
+ sorted by path, then returns a hex SHA-256 digest. Fully unit-testable
92
+ without subprocess.
93
+
94
+ NOTE: Incompatible with the bash version's .sig files (which used
95
+ find -printf | xxh128sum). On first Python run, every app will appear
96
+ changed and receive one unconditional backup — safe by design.
97
+ """
98
+ h = hashlib.sha256()
99
+ entries: list[tuple[str, int, int]] = []
100
+ for dirpath, _dirs, filenames in os.walk(directory):
101
+ for fname in filenames:
102
+ full = Path(dirpath) / fname
103
+ try:
104
+ st = full.stat()
105
+ except OSError:
106
+ continue
107
+ rel = str(full.relative_to(directory))
108
+ entries.append((rel, st.st_size, st.st_mtime_ns))
109
+ for rel, size, mtime_ns in sorted(entries):
110
+ h.update(f"{rel}\x00{size}\x00{mtime_ns}\n".encode())
111
+ return h.hexdigest()
dbara/cli.py ADDED
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import shutil
5
+ import sys
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+
9
+ from dbara import __version__
10
+ from dbara.config import Config
11
+ from dbara.docker_ops import DockerClient
12
+ from dbara.engine import BackupEngine, RestoreEngine
13
+ from dbara.log import Logger
14
+ from dbara.runner import CommandRunner
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ p = argparse.ArgumentParser(
19
+ prog="dbara",
20
+ description="Docker Backup and Restore Application",
21
+ formatter_class=argparse.RawDescriptionHelpFormatter,
22
+ epilog="""
23
+ Examples:
24
+ Backup one app:
25
+ dbara -m backup -a jellyfin -d /srv/apps -b /srv/backups -p prod -v
26
+
27
+ Backup all apps, keep last 7 archives each:
28
+ dbara -m backup -d /srv/apps -b /srv/backups -A --keep-last 7
29
+
30
+ Restore latest backup for one app:
31
+ dbara -m restore -a jellyfin -d /srv/apps -b /srv/backups -r /srv/apps -o 1000:1000
32
+
33
+ Hooks:
34
+ Place executable scripts at: HOOKS_DIR/<app>/{pre-backup,post-backup,pre-restore,post-restore}
35
+ """,
36
+ )
37
+
38
+ # Required
39
+ req = p.add_argument_group("required")
40
+ req.add_argument(
41
+ "-m", "--mode", required=True, choices=["backup", "restore"], help="Operation mode"
42
+ )
43
+ req.add_argument(
44
+ "-d", "--app-folder-dir", required=True, type=Path,
45
+ help="Parent directory containing per-app folders",
46
+ )
47
+ req.add_argument(
48
+ "-b", "--backup-dest-dir", required=True, type=Path,
49
+ help="Backup destination (directory for backup; file or directory for restore)",
50
+ )
51
+
52
+ # Common
53
+ com = p.add_argument_group("common options")
54
+ com.add_argument("-a", "--app-name", default="", help="Single app name")
55
+ com.add_argument(
56
+ "--apps", nargs="+", metavar="APP", default=None,
57
+ help="Restore a specific list of apps (restore mode only)",
58
+ )
59
+ com.add_argument(
60
+ "-A", "--all-apps", action="store_true",
61
+ help="Backup/restore all apps (backup: scans --app-folder-dir; restore: scans --backup-dest-dir)", # noqa: E501
62
+ )
63
+ com.add_argument("-r", "--restore-dest-dir", type=Path, default=None)
64
+ com.add_argument(
65
+ "-o", "--owner-group", default="",
66
+ help="Set ownership after restore as user:group (overrides --ownership-map)",
67
+ )
68
+ com.add_argument("-p", "--optional-prefix", default="", help="Prefix in backup filename")
69
+ com.add_argument(
70
+ "-x", "--remove-after-backup", action="store_true",
71
+ help="Remove app directory after successful backup (archive integrity verified first)",
72
+ )
73
+ com.add_argument(
74
+ "-s", "--no-start-container", action="store_true",
75
+ help="Do NOT start the container after the operation completes",
76
+ )
77
+ com.add_argument(
78
+ "-f", "--force", action="store_true",
79
+ help="Force backup even when no changes are detected",
80
+ )
81
+ com.add_argument("--strict", action="store_true", help="Treat warnings as fatal errors")
82
+ com.add_argument(
83
+ "--skip-checksum", action="store_true", help="Skip checksum generation/verification"
84
+ )
85
+ com.add_argument(
86
+ "-v", "--verbose", action="count", default=0,
87
+ help="-v enables debug output; -vv additionally writes a logfile",
88
+ )
89
+
90
+ # Performance / behaviour
91
+ perf = p.add_argument_group("performance / behaviour")
92
+ perf.add_argument("--compress", choices=["zstd", "gzip"], default="zstd")
93
+ perf.add_argument(
94
+ "--zstd-opts", default="-T0 -3", help='Options forwarded to zstd (default: "-T0 -3")'
95
+ )
96
+ perf.add_argument(
97
+ "--fast-hash", action=argparse.BooleanOptionalAction, default=True,
98
+ help="Use xxh128sum when available (default: enabled)",
99
+ )
100
+ perf.add_argument(
101
+ "--io-nice", action=argparse.BooleanOptionalAction, default=True,
102
+ help="Wrap heavy operations with ionice+nice (default: enabled)",
103
+ )
104
+ perf.add_argument(
105
+ "--keep-last", type=int, default=0, metavar="N",
106
+ help="Keep last N backups per app and prune older ones (0 = disabled)",
107
+ )
108
+ perf.add_argument(
109
+ "--state-dir", type=Path, default=Path("/var/lib/app-backup/state"),
110
+ help="Directory for change-detection state files",
111
+ )
112
+ perf.add_argument(
113
+ "--hooks-dir", type=Path, default=Path("/etc/app-backup/hooks"),
114
+ help="Root directory for per-app hook scripts",
115
+ )
116
+ perf.add_argument(
117
+ "--lock-dir", type=Path, default=Path("/var/lock"),
118
+ help="Directory for per-app lock files (default: /var/lock)",
119
+ )
120
+ perf.add_argument("--ownership-map", type=Path, default=None, dest="ownership_map_file")
121
+ perf.add_argument("--stop-timeout", type=int, default=30, metavar="SEC")
122
+ perf.add_argument("--retry-max", type=int, default=5, metavar="N")
123
+ perf.add_argument("--retry-base-sleep", type=int, default=2, metavar="SEC")
124
+ perf.add_argument(
125
+ "--logfile", type=Path, default=Path("/var/log/app-backup-restore.log"),
126
+ help="Log file path (written when -vv is used)",
127
+ )
128
+
129
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
130
+ return p
131
+
132
+
133
+ def _build_config(args: argparse.Namespace) -> Config:
134
+ return Config(
135
+ mode=args.mode,
136
+ app_name=args.app_name,
137
+ app_names=args.apps or [],
138
+ app_folder_dir=args.app_folder_dir,
139
+ backup_dest_dir=args.backup_dest_dir,
140
+ restore_dest_dir=args.restore_dest_dir,
141
+ remove_after_backup=args.remove_after_backup,
142
+ start_container=not args.no_start_container,
143
+ all_apps=args.all_apps,
144
+ force=args.force,
145
+ strict=args.strict,
146
+ skip_checksum=args.skip_checksum,
147
+ compressor=args.compress,
148
+ zstd_opts=args.zstd_opts,
149
+ fast_hash=args.fast_hash,
150
+ io_nice=args.io_nice,
151
+ keep_last=args.keep_last,
152
+ state_dir=args.state_dir,
153
+ hooks_dir=args.hooks_dir,
154
+ lock_dir=args.lock_dir,
155
+ ownership_map_file=args.ownership_map_file,
156
+ owner_group=args.owner_group,
157
+ optional_prefix=args.optional_prefix,
158
+ stop_timeout=args.stop_timeout,
159
+ retry_max=args.retry_max,
160
+ retry_base_sleep=args.retry_base_sleep,
161
+ verbosity=args.verbose,
162
+ logfile=args.logfile,
163
+ )
164
+
165
+
166
+ def main(argv: Sequence[str] | None = None) -> int:
167
+ parser = build_parser()
168
+ args = parser.parse_args(list(argv) if argv is not None else None)
169
+
170
+ try:
171
+ config = _build_config(args)
172
+ config.validate()
173
+ except ValueError as exc:
174
+ print(f"[ERROR] {exc}", file=sys.stderr)
175
+ return 1
176
+
177
+ # Initialise logger
178
+ logfile: Path | None = config.logfile if config.verbosity >= 2 else None
179
+ if logfile is not None:
180
+ logfile.parent.mkdir(parents=True, exist_ok=True)
181
+ logger = Logger(verbosity=config.verbosity, strict=config.strict, logfile=logfile)
182
+
183
+ # Prerequisites
184
+ if not shutil.which("docker"):
185
+ logger.fatal("Docker is not installed or not in PATH.")
186
+ if config.compressor == "zstd" and not shutil.which("zstd"):
187
+ logger.warn("zstd not found; please install it for best performance.")
188
+ if not shutil.which("xxh128sum"):
189
+ logger.warn("xxh128sum not found; falling back to sha256sum for checksums.")
190
+
191
+ # Shared dependencies
192
+ try:
193
+ config.state_dir.mkdir(parents=True, exist_ok=True)
194
+ except PermissionError:
195
+ logger.fatal(
196
+ f"Cannot create state directory {config.state_dir}: permission denied. "
197
+ f"Run with sudo or use --state-dir to specify a writable location."
198
+ )
199
+ runner = CommandRunner(config=config, logger=logger)
200
+ docker = DockerClient(
201
+ runner=runner,
202
+ logger=logger,
203
+ stop_timeout=config.stop_timeout,
204
+ start_container=config.start_container,
205
+ )
206
+
207
+ # Dispatch
208
+ if config.mode == "backup":
209
+ engine = BackupEngine(config=config, runner=runner, docker=docker, logger=logger)
210
+ if config.all_apps:
211
+ engine.backup_all_apps()
212
+ else:
213
+ # Match backup_all_apps: only restart containers that were running
214
+ # before the backup, so a deliberately stopped container stays stopped.
215
+ was_running = docker.container_exists(config.app_name) and docker.container_running(
216
+ config.app_name
217
+ )
218
+ docker.stop(config.app_name)
219
+ engine.backup_single_app(config.app_name)
220
+ if was_running:
221
+ docker.start(config.app_name)
222
+ else:
223
+ engine_r = RestoreEngine(config=config, runner=runner, docker=docker, logger=logger)
224
+ if config.all_apps:
225
+ engine_r.restore_all_apps()
226
+ elif config.app_names:
227
+ engine_r.restore_apps(config.app_names)
228
+ else:
229
+ engine_r.restore_app()
230
+
231
+ logger.info("✅ Script completed successfully!")
232
+ logger.print_summary()
233
+ return 0
234
+
235
+
236
+ if __name__ == "__main__":
237
+ sys.exit(main())
dbara/config.py ADDED
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass
8
+ class Config:
9
+ mode: str
10
+ app_name: str
11
+ app_folder_dir: Path
12
+ backup_dest_dir: Path
13
+ restore_dest_dir: Path | None
14
+ remove_after_backup: bool
15
+ start_container: bool
16
+ all_apps: bool
17
+ force: bool
18
+ strict: bool
19
+ skip_checksum: bool
20
+ compressor: str
21
+ zstd_opts: str
22
+ fast_hash: bool
23
+ io_nice: bool
24
+ keep_last: int
25
+ state_dir: Path
26
+ hooks_dir: Path
27
+ lock_dir: Path
28
+ ownership_map_file: Path | None
29
+ owner_group: str
30
+ optional_prefix: str
31
+ stop_timeout: int
32
+ retry_max: int
33
+ retry_base_sleep: int
34
+ verbosity: int
35
+ logfile: Path
36
+ app_names: list[str] = field(default_factory=list)
37
+
38
+ def validate(self) -> None:
39
+ if self.mode not in ("backup", "restore"):
40
+ raise ValueError(f"Invalid mode {self.mode!r}. Must be 'backup' or 'restore'.")
41
+ if self.compressor not in ("zstd", "gzip"):
42
+ raise ValueError(f"Invalid compressor {self.compressor!r}. Must be 'zstd' or 'gzip'.")
43
+ if self.keep_last < 0:
44
+ raise ValueError(f"--keep-last must be >= 0, got {self.keep_last}.")
45
+ if self.stop_timeout <= 0:
46
+ raise ValueError(f"--stop-timeout must be > 0, got {self.stop_timeout}.")
47
+ if self.retry_max < 1:
48
+ raise ValueError(f"--retry-max must be >= 1, got {self.retry_max}.")
49
+ if self.retry_base_sleep < 1:
50
+ raise ValueError(f"--retry-base-sleep must be >= 1, got {self.retry_base_sleep}.")
51
+ if self.mode == "restore":
52
+ has_app = bool(self.app_name or self.app_names or self.all_apps)
53
+ if not has_app:
54
+ raise ValueError(
55
+ "Restore mode requires --app-name (-a), --apps, or --all-apps (-A)."
56
+ )
57
+ if self.restore_dest_dir is None:
58
+ raise ValueError("Restore mode requires --restore-dest-dir (-r).")
59
+ if self.mode == "backup" and not self.all_apps and not self.app_name:
60
+ raise ValueError("Backup mode requires --app-name (-a) unless --all-apps is set.")
61
+
62
+
63
+ def parse_ownership_map(map_file: Path, app: str) -> str | None:
64
+ """Return 'user:group' for app from an ownership map file, or None if not found."""
65
+ for line in map_file.read_text().splitlines():
66
+ line = line.strip()
67
+ if not line or line.startswith("#"):
68
+ continue
69
+ if "=" not in line:
70
+ continue
71
+ key, _, val = line.partition("=")
72
+ if key.strip() == app and val.strip():
73
+ return val.strip()
74
+ return None
dbara/docker_ops.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from dbara.log import Logger
4
+ from dbara.runner import CommandRunner
5
+
6
+
7
+ class DockerClient:
8
+ def __init__(
9
+ self,
10
+ runner: CommandRunner,
11
+ logger: Logger,
12
+ stop_timeout: int,
13
+ start_container: bool,
14
+ ) -> None:
15
+ self._runner = runner
16
+ self._logger = logger
17
+ self._stop_timeout = stop_timeout
18
+ self._start_container = start_container
19
+
20
+ def container_exists(self, name: str) -> bool:
21
+ result = self._runner.run(
22
+ ["docker", "ps", "-a", "-q", "--filter", f"name=^/{name}$"],
23
+ capture_output=True,
24
+ )
25
+ return bool(result.stdout.strip())
26
+
27
+ def container_running(self, name: str) -> bool:
28
+ result = self._runner.run(
29
+ ["docker", "ps", "-q", "--filter", f"name=^/{name}$"],
30
+ capture_output=True,
31
+ )
32
+ return bool(result.stdout.strip())
33
+
34
+ def stop(self, name: str) -> None:
35
+ if not self.container_exists(name):
36
+ self._logger.info(f"No container named {name!r} exists; skipping stop.")
37
+ return
38
+ if not self.container_running(name):
39
+ self._logger.info(f"Container {name!r} exists but is not running; nothing to stop.")
40
+ return
41
+ self._logger.info(f"Stopping container {name!r} (timeout {self._stop_timeout}s)...")
42
+ self._runner.run_with_retry(["docker", "stop", "-t", str(self._stop_timeout), name])
43
+
44
+ def start(self, name: str) -> None:
45
+ if not self._start_container:
46
+ self._logger.info("Start container disabled by user.")
47
+ return
48
+ if not self.container_exists(name):
49
+ self._logger.info(f"No container named {name!r} exists; skipping start.")
50
+ return
51
+ if self.container_running(name):
52
+ self._logger.info(f"Container {name!r} already running; no start needed.")
53
+ return
54
+ self._logger.info(f"Starting container {name!r}...")
55
+ self._runner.run_with_retry(["docker", "start", name])