6 Commits
v0.1.1 ... main

Author SHA1 Message Date
Gitea Actions
63c52d84c8 chore: update docker-compose tag to v0.1.2 2026-07-13 11:52:56 +00:00
14d7ce063d Merge pull request 'chore(main): release 0.1.2' (#3) from release-please--branches--main into main
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
Deploy on Release / Build + Push to Packages + Deploy to Prod (release) Successful in 20s
Reviewed-on: #3
2026-07-13 13:52:24 +02:00
Gitea Actions
53f15412a9 chore(main): release 0.1.2
All checks were successful
Create Release / Publish Release (pull_request) Successful in 11s
2026-07-13 11:52:10 +00:00
14f96738a0 Merge branch 'main' of https://git.tdpi.dev/TDPI/Dockhand_Backup
All checks were successful
Create Release PR / Create Release PR (push) Successful in 18s
2026-07-13 13:51:56 +02:00
07651a330a fix: Dockhand API Timeouts abfangen 2026-07-13 13:51:54 +02:00
Gitea Actions
bd58f8a43a chore: update docker-compose tag to v0.1.1 2026-07-13 11:36:10 +00:00
5 changed files with 114 additions and 26 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.1.1"
".": "0.1.2"
}

View File

@@ -1,5 +1,16 @@
# Changelog
## 0.1.2 (2026-07-13)
### Bug Fixes
* fix: Dockhand API Timeouts abfangen
### Chores
* chore: update docker-compose tag to v0.1.1
## 0.1.1 (2026-07-13)
### Bug Fixes

View File

@@ -163,36 +163,68 @@ def backup_stack(name: str, live_stack: dict, stack_cfg: dict, config: dict) ->
entry = {"stack": name, "ts": _iso_now(), "status": "ok",
"file": os.path.basename(tar_path), "size": 0, "error": "", "warnings": []}
if settings.DEV_MODE:
log.info("[dev] simulating backup of %s (%d paths)", name, len(paths))
_write_placeholder_tar(tar_path, paths, extra_files)
entry["status"] = "simulated"
if os.path.exists(tar_path):
entry["size"] = os.path.getsize(tar_path)
_finish(entry, folder, config)
return entry
# 1) bring the stack DOWN (client retries + verifies)
try:
if settings.DEV_MODE:
log.info("[dev] simulating backup of %s (%d paths)", name, len(paths))
_write_placeholder_tar(tar_path, paths, extra_files)
entry["status"] = "simulated"
else:
dockhand.client.down(name)
try:
warnings = _write_tar(tar_path, paths, extra_files)
entry["warnings"] = warnings
host_missing = [w for w in warnings]
if paths and len(host_missing) == len(paths):
entry["status"] = "error"
entry["error"] = "all host paths missing"
elif warnings:
entry["status"] = "partial"
finally:
dockhand.client.start(name) # never leave a stack down
dockhand.client.down(name)
except Exception as exc: # noqa: BLE001
log.exception("down of %s failed", name)
entry["status"] = "error"
entry["error"] = f"down fehlgeschlagen: {exc}"
# Stack is likely still up; don't tar an inconsistent state. Try to
# ensure it's up, then bail.
_safe_start(name, entry)
_finish(entry, folder, config)
return entry
# 2) TAR — record its result independently of the up step
try:
warnings = _write_tar(tar_path, paths, extra_files)
entry["warnings"] = warnings
if paths and len(warnings) == len(paths):
entry["status"] = "error"
entry["error"] = "alle Host-Pfade fehlen"
elif warnings:
entry["status"] = "partial"
if os.path.exists(tar_path):
entry["size"] = os.path.getsize(tar_path)
except Exception as exc: # noqa: BLE001
log.exception("backup of %s failed", name)
log.exception("tar of %s failed", name)
entry["status"] = "error"
entry["error"] = str(exc)
entry["error"] = f"Tar fehlgeschlagen: {exc}"
# 3) always bring it back UP — a failure here is a SEPARATE flag, it does
# NOT turn a good backup into an error.
_safe_start(name, entry)
_finish(entry, folder, config)
return entry
def _safe_start(name: str, entry: dict):
"""Bring a stack back up; record an up-failure as a warning, never overwrite
the tar result. The client already retries + verifies status."""
try:
dockhand.client.start(name)
except Exception as exc: # noqa: BLE001
log.error("could not bring %s back up: %s", name, exc)
entry["warnings"] = list(entry.get("warnings", [])) + [f"UP fehlgeschlagen: {exc}"]
entry["up_failed"] = True
def _finish(entry: dict, folder: str, config: dict):
deleted = _apply_retention(folder, config.get("retention_n", 7))
if deleted:
log.info("retention: deleted %d old backups for %s", deleted, name)
log.info("retention: deleted %d old backups for %s", deleted, entry["stack"])
store.update(lambda c: store.add_history(c, entry))
return entry
def backup_custom_paths() -> list:

View File

@@ -16,6 +16,7 @@ requests.Session. On 401 we log in once and retry.
import json
import logging
import threading
import time
import urllib.parse
import requests
@@ -158,14 +159,57 @@ class DockhandClient:
return data
return {}
def status(self, name: str):
"""Current status of a stack, or None if unknown/unreachable."""
try:
for s in self.list_stacks():
if s.get("name") == name:
return s.get("status")
except Exception as exc: # noqa: BLE001
log.warning("status check failed for %s: %s", name, exc)
return None
def _lifecycle(self, name, action, ok_states, *, tolerate=(), body=None,
attempts=5) -> str:
"""Run a lifecycle action, then VERIFY via status — with retries.
Dockhand is known to drop the connection during UP even though the
operation completes server-side, so we always re-check the status
after a (possibly failed) call instead of trusting the response alone.
"""
path = f"/api/stacks/{_q(name)}/{action}"
last = None
for i in range(attempts):
try:
self._run_sse(path, body=body)
except (requests.exceptions.RequestException, DockhandError) as exc:
last = exc
log.warning("%s %s attempt %d/%d: %s", action, name, i + 1, attempts, exc)
st = self.status(name)
if st in ok_states:
return f"{action} ok (status={st})"
if i < attempts - 1:
time.sleep(min(2 ** i, 15))
st = self.status(name)
if st in ok_states:
return f"{action} ok (status={st})"
if st in tolerate:
log.warning("%s %s ended in tolerated state '%s'", action, name, st)
return f"{action} tolerated (status={st})"
raise DockhandError(
f"{action} {name} failed after {attempts} tries (status={st}); last={last}")
def start(self, name: str) -> str:
return self._run_sse(f"/api/stacks/{_q(name)}/start")
# running = fully up; partial (some containers up) is tolerated, not down.
return self._lifecycle(name, "start", ok_states={"running"},
tolerate={"partial"})
def stop(self, name: str) -> str:
return self._run_sse(f"/api/stacks/{_q(name)}/stop")
return self._lifecycle(name, "stop", ok_states={"stopped", "created", "exited"})
def down(self, name: str) -> str:
return self._run_sse(f"/api/stacks/{_q(name)}/down", body={"removeVolumes": False})
return self._lifecycle(name, "down", ok_states={"stopped", "created", "exited"},
body={"removeVolumes": False})
# ------------------------------------------------------------------ mock

View File

@@ -3,7 +3,7 @@
# ALL config comes from a .env next to this file (NOT committed). No secrets here.
services:
dockhand-backup:
image: git.tdpi.dev/tdpi/dockhand_backup:latest
image: git.tdpi.dev/tdpi/dockhand_backup:v0.1.2
container_name: dockhand-backup
restart: unless-stopped
environment:
@@ -27,6 +27,7 @@ services:
- /mnt/cache/appdata:/mnt/cache/appdata:ro
- /mnt/docker/appdata:/mnt/docker/appdata:ro
- /mnt/user/appdata:/mnt/user/appdata:ro
- /mnt/user/git:/mnt/user/git:ro
networks:
- nginx