Skip to content

Commit e139b53

Browse files
committed
refactor: unify backup restic execution via container
1 parent 9bd521f commit e139b53

3 files changed

Lines changed: 42 additions & 45 deletions

File tree

apphub/src/services/back_manager.py

Lines changed: 11 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import json
22
import os
33
import re
4-
import subprocess
54
import time
65
import docker
76
import requests
@@ -69,7 +68,7 @@ def _container_state(container: Dict[str, Any]) -> str:
6968

7069

7170
class BackupManager:
72-
"""Volume Backup Manager using Restic — hybrid local binary + Docker runner"""
71+
"""Volume Backup Manager using Restic container runner"""
7372

7473
RESTIC_LOCAL_ARGS = ["--insecure-no-password", "--json"]
7574

@@ -81,10 +80,6 @@ def __init__(self):
8180
self.repository_path = config_manager.get_value("volume_backup", "repopath")
8281
self.restic_image = config_manager.get_value("volume_backup", "image") or "restic/restic"
8382

84-
# Verify local restic binary for read-only operations
85-
if not os.path.isfile("/usr/local/bin/restic") or not os.access("/usr/local/bin/restic", os.X_OK):
86-
raise CustomException(500, "Restic binary not found at /usr/local/bin/restic", "Restic Unavailable")
87-
8883
# Ensure cache dir exists
8984
os.makedirs(RESTIC_CACHE_PATH, exist_ok=True)
9085

@@ -96,27 +91,7 @@ def __init__(self):
9691
raise CustomException()
9792

9893
# ------------------------------------------------------------------
99-
# Local Restic execution — for list / delete / repo ops (fast, no Docker)
100-
# ------------------------------------------------------------------
101-
def _run_restic_local(self, command: List[str]) -> str:
102-
full_cmd = ["/usr/local/bin/restic", "-r", self.repository_path] + command + self.RESTIC_LOCAL_ARGS
103-
try:
104-
result = subprocess.run(full_cmd, capture_output=True, text=True, timeout=3600)
105-
if result.returncode != 0:
106-
err = result.stderr.strip() or f"exit code {result.returncode}"
107-
raise CustomException(500, err, "Restic Error")
108-
return result.stdout
109-
except CustomException:
110-
raise
111-
except subprocess.TimeoutExpired:
112-
raise CustomException(500, "Restic operation timed out after 3600s", "Restic Timeout")
113-
except FileNotFoundError:
114-
raise CustomException(500, "Restic binary not found", "Restic Unavailable")
115-
except Exception as e:
116-
raise CustomException(500, f"Restic command failed: {e}", "Restic Error")
117-
118-
# ------------------------------------------------------------------
119-
# Docker Restic execution — for backup / restore (needs volume mounts)
94+
# Docker Restic execution — all Restic operations use the same runtime
12095
# ------------------------------------------------------------------
12196
def _resolve_host_path(self, container_path: str) -> str:
12297
"""Resolve a container-internal path to its host-level equivalent
@@ -245,12 +220,15 @@ def _build_restic_volume_mounts(self, volumes_info: List[Dict[str, Any]]) -> tup
245220

246221
return extra_volumes, container_paths
247222

223+
def _run_restic_repo_command(self, command: List[str]) -> str:
224+
return self._run_restic_container(command, {})
225+
248226
# ------------------------------------------------------------------
249-
# Repository management (local — only touches repo path)
227+
# Repository management
250228
# ------------------------------------------------------------------
251229
def _check_repository(self) -> bool:
252230
try:
253-
cfg = json.loads(self._run_restic_local(["cat", "config"]))
231+
cfg = json.loads(self._run_restic_repo_command(["cat", "config"]))
254232
return bool(cfg.get("id") and cfg.get("version"))
255233
except CustomException:
256234
return False
@@ -261,7 +239,7 @@ def _init_repository(self):
261239
if self._check_repository():
262240
return
263241
try:
264-
output = self._run_restic_local(["init"])
242+
output = self._run_restic_repo_command(["init"])
265243
result = json.loads(output)
266244
if result.get("message_type") != "initialized":
267245
logger.error(f"Unexpected init response: {result}")
@@ -270,7 +248,7 @@ def _init_repository(self):
270248
raise
271249

272250
# ------------------------------------------------------------------
273-
# Read-only operations — local binary (fast, no Docker)
251+
# Repository operations — container Restic
274252
# ------------------------------------------------------------------
275253
def list_snapshots(self, app_id: Optional[str] = None) -> List[Dict[str, Any]]:
276254
try:
@@ -281,7 +259,7 @@ def list_snapshots(self, app_id: Optional[str] = None) -> List[Dict[str, Any]]:
281259
if app_id:
282260
command.extend(["--tag", app_id])
283261

284-
output = self._run_restic_local(command)
262+
output = self._run_restic_repo_command(command)
285263
return json.loads(output) if output.strip() else []
286264
except (json.JSONDecodeError, KeyError):
287265
raise CustomException(500, "Failed to parse snapshot list", "Parse Error")
@@ -295,7 +273,7 @@ def delete_snapshot(self, snapshot_id: str) -> None:
295273
if not self._check_repository():
296274
raise CustomException(400, "Repository not initialized", "Repository Error")
297275

298-
output = self._run_restic_local(["forget", snapshot_id])
276+
output = self._run_restic_repo_command(["forget", snapshot_id])
299277
if output.strip():
300278
raise CustomException(400, f"Delete failed: {output}", f"Snapshot: {snapshot_id}")
301279
except CustomException:

apphub/tests/test_backup_restore.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,4 +165,34 @@ def test_build_restic_volume_mounts_resolves_host_mountpoints(monkeypatch):
165165
assert extra_volumes == {
166166
'/host-volumes/wordpress_demo/_data': {'bind': '/wordpress_demo', 'mode': 'rw'},
167167
'/host-volumes/wordpress_demo_db/_data': {'bind': '/wordpress_demo_db', 'mode': 'rw'},
168-
}
168+
}
169+
170+
171+
def test_repo_operations_use_restic_container_runner(monkeypatch):
172+
manager = _build_manager()
173+
commands = []
174+
175+
def fake_run_restic_container(command, extra_volumes):
176+
commands.append((command, extra_volumes))
177+
if command == ['cat', 'config']:
178+
return '{"id":"repo-id","version":2}'
179+
if command == ['snapshots', '--tag', 'wordpress_demo']:
180+
return '[{"id":"snap-1","short_id":"snap-1"}]'
181+
if command == ['forget', 'snap-1']:
182+
return ''
183+
raise AssertionError(f'unexpected command: {command}')
184+
185+
monkeypatch.setattr(manager, '_run_restic_container', fake_run_restic_container)
186+
187+
assert manager._check_repository() is True
188+
snapshots = manager.list_snapshots('wordpress_demo')
189+
manager.delete_snapshot('snap-1')
190+
191+
assert snapshots == [{"id": "snap-1", "short_id": "snap-1"}]
192+
assert commands == [
193+
(['cat', 'config'], {}),
194+
(['cat', 'config'], {}),
195+
(['snapshots', '--tag', 'wordpress_demo'], {}),
196+
(['cat', 'config'], {}),
197+
(['forget', 'snap-1'], {}),
198+
]

docker/Dockerfile

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -89,17 +89,6 @@ RUN sed -i 's|http://deb.debian.org/debian|http://mirrors.aliyun.com/debian|g; s
8989
ARG WEBSOFT9_PRODUCT_VERSION=
9090
ARG WEBSOFT9_APPSTORE_CHANNEL=release
9191

92-
# Restic backup binary — single static build, no runtime deps
93-
RUN RESTIC_URL="https://github.com/restic/restic/releases/download/v0.17.3/restic_0.17.3_linux_amd64.bz2" \
94-
&& echo "Downloading restic from: $RESTIC_URL" \
95-
&& curl -fsSL --retry 3 --retry-delay 5 --connect-timeout 30 --max-time 300 -o /tmp/restic.bz2 "$RESTIC_URL" \
96-
&& bzip2 -t /tmp/restic.bz2 \
97-
&& bzip2 -d -c /tmp/restic.bz2 > /usr/local/bin/restic \
98-
&& chmod +x /usr/local/bin/restic \
99-
&& restic version \
100-
&& rm -f /tmp/restic.bz2 \
101-
&& echo "Restic installed successfully"
102-
10392
COPY --from=npm-runtime /app /app
10493
COPY --from=npm-runtime /etc/letsencrypt.ini /etc/letsencrypt.ini
10594
COPY --from=npm-runtime /etc/nginx /etc/nginx

0 commit comments

Comments
 (0)