511 lines
22 KiB
Python
511 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""ownCloud server client for the `owncloud` skill (GPS backup verification).
|
|
|
|
Talks to ACG's live ownCloud 10.16 server (cloud.acghosting.com / 172.16.3.22)
|
|
over SSH and drives the `occ` admin CLI. Read-only: enumerates users, their
|
|
quota + actual storage used, and last login so the GPS backup audit can see who
|
|
is actually backing up to ownCloud and how much data they hold.
|
|
|
|
There is NO ownCloud admin WEB/OCS account in the vault, so the access channel is
|
|
SSH (root@172.16.3.22, paramiko password auth) + `occ`, not the OCS HTTP API.
|
|
`occ` must run as the web/service user, so every invocation is:
|
|
|
|
sudo -u apache php /var/www/owncloud/occ <cmd>
|
|
|
|
Per-user "used bytes" is not exposed by occ on this version (no `user:info`).
|
|
Instead we read the authoritative figure straight from ownCloud's filecache: the
|
|
size ownCloud itself reports for each user's root. DB creds are read live from
|
|
the server's config.php (we already have root), so nothing is duplicated and it
|
|
survives a DB-password rotation. If a user's cached size is dirty (-1), we fall
|
|
back to `du -sb <home>/files` for that one user (slower, generous timeout).
|
|
|
|
Credentials load lazily from the SOPS vault (never hardcoded):
|
|
`infrastructure/owncloud-vm.sops.yaml` -> credentials.username / credentials.password.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shlex
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
import paramiko
|
|
|
|
# Import the shared backup helpers from .claude/scripts.
|
|
_SKILL_DIR = Path(__file__).resolve().parent.parent # .../skills/owncloud
|
|
_SCRIPTS_DIR = _SKILL_DIR.parent.parent / "scripts" # .../.claude/scripts
|
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
import backup_common as bc # noqa: E402
|
|
|
|
VAULT_ENTRY = "infrastructure/owncloud-vm.sops.yaml"
|
|
VAULT_FIELD_USER = "credentials.username"
|
|
VAULT_FIELD_PASS = "credentials.password"
|
|
|
|
DEFAULT_HOST = "172.16.3.22"
|
|
SSH_PORT = 22
|
|
# occ must run as the web user (apache) with the system php against the install dir.
|
|
OCC_INSTALL_DIR = "/var/www/owncloud"
|
|
OCC = f"sudo -u apache php {OCC_INSTALL_DIR}/occ"
|
|
CONFIG_PHP = f"{OCC_INSTALL_DIR}/config/config.php"
|
|
|
|
EXEC_TIMEOUT = 60 # seconds for normal occ / mysql calls
|
|
DU_TIMEOUT = 900 # du on a dirty-cache home can be hundreds of GB
|
|
|
|
|
|
class OwncloudClient:
|
|
def __init__(self, host: Optional[str] = None):
|
|
self.host = host or DEFAULT_HOST
|
|
self._ssh: Optional[paramiko.SSHClient] = None
|
|
self._db: Optional[dict] = None
|
|
|
|
# -- ssh -------------------------------------------------------------------
|
|
def _connect(self) -> paramiko.SSHClient:
|
|
if self._ssh is not None:
|
|
return self._ssh
|
|
user = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_USER)
|
|
pw = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_PASS)
|
|
cli = paramiko.SSHClient()
|
|
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
try:
|
|
cli.connect(self.host, port=SSH_PORT, username=user, password=pw,
|
|
timeout=20, look_for_keys=False, allow_agent=False)
|
|
except Exception as exc: # paramiko raises several distinct types
|
|
raise bc.BackupError(f"SSH connect to {user}@{self.host} failed: {exc}") from exc
|
|
self._ssh = cli
|
|
return cli
|
|
|
|
def run(self, cmd: str, timeout: int = EXEC_TIMEOUT) -> tuple[int, str, str]:
|
|
"""Run a shell command over SSH; return (exit_status, stdout, stderr)."""
|
|
cli = self._connect()
|
|
try:
|
|
_in, out, err = cli.exec_command(cmd, timeout=timeout)
|
|
stdout = out.read().decode("utf-8", "replace")
|
|
stderr = err.read().decode("utf-8", "replace")
|
|
rc = out.channel.recv_exit_status()
|
|
except Exception as exc:
|
|
raise bc.BackupError(f"SSH command failed ({cmd[:60]}...): {exc}") from exc
|
|
return rc, stdout, stderr
|
|
|
|
def occ(self, args: str, timeout: int = EXEC_TIMEOUT) -> tuple[int, str, str]:
|
|
return self.run(f"{OCC} {args}", timeout=timeout)
|
|
|
|
def close(self) -> None:
|
|
if self._ssh is not None:
|
|
try:
|
|
self._ssh.close()
|
|
finally:
|
|
self._ssh = None
|
|
|
|
# -- server status ---------------------------------------------------------
|
|
def server_status(self) -> dict:
|
|
"""occ status (installed/version/edition) plus the `occ -V` version string."""
|
|
rc, out, err = self.occ("status --output=json")
|
|
if rc != 0:
|
|
raise bc.BackupError(f"occ status failed (rc={rc}): {err.strip()[:300]}")
|
|
try:
|
|
status = json.loads(out)
|
|
except json.JSONDecodeError:
|
|
raise bc.BackupError(f"occ status returned non-JSON: {out.strip()[:300]}")
|
|
rc2, ver, _ = self.occ("-V")
|
|
status["occ_version"] = ver.strip() if rc2 == 0 else None
|
|
status["host"] = self.host
|
|
return status
|
|
|
|
# -- storage used (filecache primary, du fallback) -------------------------
|
|
def _db_config(self) -> dict:
|
|
"""Read DB name/user/password/prefix live from config.php (we have root)."""
|
|
if self._db is not None:
|
|
return self._db
|
|
rc, out, err = self.run(
|
|
"grep -E \"'db(name|user|password|tableprefix)'\" " + CONFIG_PHP)
|
|
if rc != 0:
|
|
raise bc.BackupError(f"reading {CONFIG_PHP} failed (rc={rc}): {err.strip()[:200]}")
|
|
cfg = {"dbname": "owncloud", "dbuser": "owncloud",
|
|
"dbpassword": "", "dbtableprefix": "oc_"}
|
|
for line in out.splitlines():
|
|
# form: 'dbname' => 'value',
|
|
if "=>" not in line:
|
|
continue
|
|
key, _, val = line.partition("=>")
|
|
k = key.strip().strip("'\" ")
|
|
v = val.strip().rstrip(",").strip().strip("'\"")
|
|
if k in cfg:
|
|
cfg[k] = v
|
|
self._db = cfg
|
|
return cfg
|
|
|
|
def _mysql(self, sql: str) -> str:
|
|
cfg = self._db_config()
|
|
cmd = (f"MYSQL_PWD={shlex.quote(cfg['dbpassword'])} "
|
|
f"mysql -N -B -u {shlex.quote(cfg['dbuser'])} "
|
|
f"{shlex.quote(cfg['dbname'])} -e {shlex.quote(sql)}")
|
|
rc, out, err = self.run(cmd)
|
|
if rc != 0:
|
|
raise bc.BackupError(f"mysql query failed (rc={rc}): {err.strip()[:200]}")
|
|
return out
|
|
|
|
def _filecache_sizes(self) -> dict[str, int]:
|
|
"""uid -> cached used bytes for each per-user home storage (ownCloud's own
|
|
figure). A size of -1 means the cache is dirty; such uids are omitted here
|
|
and handled by the du fallback."""
|
|
pfx = self._db_config()["dbtableprefix"]
|
|
sql = (f"SELECT s.id, fc.size FROM {pfx}storages s "
|
|
f"JOIN {pfx}filecache fc ON fc.storage=s.numeric_id "
|
|
f"WHERE s.id LIKE 'home::%' AND fc.path='';")
|
|
out = self._mysql(sql)
|
|
sizes: dict[str, int] = {}
|
|
for row in out.splitlines():
|
|
if "\t" not in row:
|
|
continue
|
|
sid, _, val = row.partition("\t")
|
|
if not sid.startswith("home::"):
|
|
continue
|
|
uid = sid[len("home::"):]
|
|
try:
|
|
n = int(val.strip())
|
|
except ValueError:
|
|
continue
|
|
if n >= 0:
|
|
sizes[uid] = n
|
|
return sizes
|
|
|
|
def _du_bytes(self, uid: str, home: str) -> Optional[int]:
|
|
"""Fallback used-bytes via du on the on-disk home (dirty-cache users only)."""
|
|
if not home:
|
|
return None
|
|
rc, out, err = self.run(
|
|
f"du -sb {shlex.quote(home + '/files')} 2>/dev/null | cut -f1",
|
|
timeout=DU_TIMEOUT)
|
|
try:
|
|
return int(out.strip())
|
|
except (ValueError, AttributeError):
|
|
return None
|
|
|
|
# -- users -----------------------------------------------------------------
|
|
def list_users(self, with_usage: bool = True) -> list[dict]:
|
|
"""All users with attributes (uid, display name, email, quota, enabled,
|
|
last login, home, backend). When with_usage, enrich each with used bytes
|
|
from the filecache, falling back to du for dirty-cache users."""
|
|
rc, out, err = self.occ("user:list --show-all-attributes --output=json")
|
|
if rc != 0:
|
|
raise bc.BackupError(f"occ user:list failed (rc={rc}): {err.strip()[:300]}")
|
|
try:
|
|
raw = json.loads(out)
|
|
except json.JSONDecodeError:
|
|
raise bc.BackupError(f"occ user:list returned non-JSON: {out.strip()[:300]}")
|
|
|
|
users: list[dict] = []
|
|
for uid, attrs in raw.items():
|
|
attrs = attrs or {}
|
|
users.append({
|
|
"uid": uid,
|
|
"display_name": attrs.get("displayName"),
|
|
"email": attrs.get("email"),
|
|
"quota": attrs.get("quota"),
|
|
"enabled": _as_bool(attrs.get("enabled")),
|
|
"last_login": _epoch(attrs.get("lastLogin")),
|
|
"creation_time": _epoch(attrs.get("creationTime")),
|
|
"backend": attrs.get("backend"),
|
|
"home": attrs.get("home"),
|
|
"used_bytes": None,
|
|
"used_source": None,
|
|
})
|
|
|
|
if with_usage:
|
|
sizes = self._filecache_sizes()
|
|
for u in users:
|
|
uid = u["uid"]
|
|
if uid in sizes:
|
|
u["used_bytes"] = sizes[uid]
|
|
u["used_source"] = "filecache"
|
|
else:
|
|
# dirty cache (-1) or no storage row: compute on disk
|
|
du = self._du_bytes(uid, u.get("home"))
|
|
u["used_bytes"] = du
|
|
u["used_source"] = "du" if du is not None else None
|
|
return users
|
|
|
|
# -- occ helpers for management ops ----------------------------------------
|
|
def occ_env(self, env: dict, args: str,
|
|
timeout: int = EXEC_TIMEOUT) -> tuple[int, str, str]:
|
|
"""Run occ with extra environment (e.g. OC_PASS) exported for the apache
|
|
user. `sudo -u apache env KEY=val php occ ...` — the explicit `env` sets
|
|
the var inside apache's context regardless of sudo's env_reset policy.
|
|
|
|
Caveat: the value is briefly visible in `ps` on the server to root; this
|
|
is occ's own documented mechanism for passwords and the box is root-only.
|
|
"""
|
|
prefix = " ".join(f"{k}={shlex.quote(str(v))}" for k, v in env.items())
|
|
cmd = f"sudo -u apache env {prefix} php {OCC_INSTALL_DIR}/occ {args}"
|
|
return self.run(cmd, timeout=timeout)
|
|
|
|
def _occ_ok(self, args: str, timeout: int = EXEC_TIMEOUT) -> str:
|
|
"""Run occ, raise BackupError on non-zero, return stdout."""
|
|
rc, out, err = self.occ(args, timeout=timeout)
|
|
if rc != 0:
|
|
label = args.split()[0] if args else "occ"
|
|
raise bc.BackupError(f"occ {label} failed (rc={rc}): "
|
|
f"{(err or out).strip()[:300]}")
|
|
return out
|
|
|
|
def _occ_env_ok(self, env: dict, args: str,
|
|
timeout: int = EXEC_TIMEOUT) -> str:
|
|
"""occ_env variant of _occ_ok (occ_env is not wrapped by _occ_ok)."""
|
|
rc, out, err = self.occ_env(env, args, timeout=timeout)
|
|
if rc != 0:
|
|
label = args.split()[0] if args else "occ"
|
|
raise bc.BackupError(f"occ {label} failed (rc={rc}): "
|
|
f"{(err or out).strip()[:300]}")
|
|
return out
|
|
|
|
def _occ_json(self, args: str, timeout: int = EXEC_TIMEOUT) -> Any:
|
|
"""occ with --output=json; parse defensively so a non-JSON banner
|
|
(maintenance notice, PHP deprecation, update line) surfaces as a clean
|
|
BackupError rather than an uncaught JSONDecodeError traceback."""
|
|
out = self._occ_ok(args, timeout=timeout).strip()
|
|
try:
|
|
return json.loads(out or "null")
|
|
except json.JSONDecodeError:
|
|
label = args.split()[0] if args else "occ"
|
|
raise bc.BackupError(f"occ {label} returned non-JSON: {out[:300]}")
|
|
|
|
# -- user lifecycle --------------------------------------------------------
|
|
def create_user(self, uid: str, password: str,
|
|
display_name: Optional[str] = None,
|
|
email: Optional[str] = None,
|
|
groups: Optional[list[str]] = None) -> dict:
|
|
# options first, then `--`, then the positional uid so a uid that
|
|
# happens to start with '-' is never parsed by occ as an option flag.
|
|
parts = ["user:add --password-from-env -n"]
|
|
if display_name:
|
|
parts.append(f"--display-name={shlex.quote(display_name)}")
|
|
if email:
|
|
parts.append(f"--email={shlex.quote(email)}")
|
|
for g in (groups or []):
|
|
parts.append(f"--group={shlex.quote(g)}")
|
|
parts.append(f"-- {shlex.quote(uid)}")
|
|
out = self._occ_env_ok({"OC_PASS": password}, " ".join(parts))
|
|
return {"uid": uid, "output": out.strip()}
|
|
|
|
def delete_user(self, uid: str, force: bool = False) -> str:
|
|
return self._occ_ok(f"user:delete{' -f' if force else ''} -- "
|
|
f"{shlex.quote(uid)}", timeout=DU_TIMEOUT).strip()
|
|
|
|
def set_user_enabled(self, uid: str, enabled: bool) -> str:
|
|
verb = "user:enable" if enabled else "user:disable"
|
|
return self._occ_ok(f"{verb} -- {shlex.quote(uid)}").strip()
|
|
|
|
def reset_password(self, uid: str, password: str) -> str:
|
|
return self._occ_env_ok(
|
|
{"OC_PASS": password},
|
|
f"user:resetpassword --password-from-env -- {shlex.quote(uid)}").strip()
|
|
|
|
def modify_user(self, uid: str, key: str, value: str) -> str:
|
|
"""key is one of: displayname, email (per occ user:modify)."""
|
|
return self._occ_ok(
|
|
f"user:modify -- {shlex.quote(uid)} {shlex.quote(key)} "
|
|
f"{shlex.quote(value)}").strip()
|
|
|
|
# -- quota (occ user:setting <uid> files quota) ----------------------------
|
|
def get_quota(self, uid: str) -> Optional[str]:
|
|
"""Return the user's quota string, or None when it is unset (= default).
|
|
A non-existent user or a genuine occ failure raises BackupError - occ
|
|
returns rc=1 for both 'setting unset' and 'user missing', so we key off
|
|
the message ('setting does not exist' == unset)."""
|
|
rc, out, err = self.occ(f"user:setting -- {shlex.quote(uid)} files quota")
|
|
if rc == 0:
|
|
return out.strip() or None
|
|
msg = (out or err).strip()
|
|
if "setting does not exist" in msg.lower():
|
|
return None
|
|
raise bc.BackupError(f"occ user:setting quota failed (rc={rc}): {msg[:300]}")
|
|
|
|
def set_quota(self, uid: str, value: str) -> str:
|
|
return self._occ_ok(
|
|
f"user:setting --value={shlex.quote(value)} "
|
|
f"-- {shlex.quote(uid)} files quota").strip()
|
|
|
|
def reset_quota(self, uid: str) -> str:
|
|
return self._occ_ok(
|
|
f"user:setting --delete -- {shlex.quote(uid)} files quota").strip()
|
|
|
|
# -- groups ----------------------------------------------------------------
|
|
def list_groups(self) -> Any:
|
|
return self._occ_json("group:list --output=json")
|
|
|
|
def group_members(self, group: str) -> Any:
|
|
return self._occ_json(
|
|
f"group:list-members --output=json -- {shlex.quote(group)}")
|
|
|
|
def user_groups(self, uid: str) -> Any:
|
|
return self._occ_json(
|
|
f"user:list-groups --output=json -- {shlex.quote(uid)}")
|
|
|
|
def create_group(self, name: str) -> str:
|
|
return self._occ_ok(f"group:add -- {shlex.quote(name)}").strip()
|
|
|
|
def delete_group(self, name: str) -> str:
|
|
return self._occ_ok(f"group:delete -- {shlex.quote(name)}").strip()
|
|
|
|
def add_group_members(self, group: str, members: list[str]) -> str:
|
|
# --member= binds each value even if it starts with '-'.
|
|
ms = " ".join(f"--member={shlex.quote(m)}" for m in members)
|
|
return self._occ_ok(
|
|
f"group:add-member {ms} -- {shlex.quote(group)}").strip()
|
|
|
|
def remove_group_members(self, group: str, members: list[str]) -> str:
|
|
ms = " ".join(f"--member={shlex.quote(m)}" for m in members)
|
|
return self._occ_ok(
|
|
f"group:remove-member {ms} -- {shlex.quote(group)}").strip()
|
|
|
|
# -- shares (read-only, straight from the DB; occ has no share list) --------
|
|
SHARE_TYPES = {0: "user", 1: "group", 3: "public_link",
|
|
4: "email", 6: "federated"}
|
|
|
|
def list_shares(self, uid: Optional[str] = None) -> list[dict]:
|
|
# NB: uid is NOT inlined into SQL. MySQL honours backslash escapes by
|
|
# default, so ''-doubling would not safely contain a hostile/odd uid
|
|
# (and occ allows "'" in uids). Fetch all rows, filter in Python.
|
|
pfx = self._db_config()["dbtableprefix"]
|
|
sql = (f"SELECT s.id, s.share_type, s.share_with, s.uid_owner, "
|
|
f"s.file_target, s.permissions, s.stime, fc.path "
|
|
f"FROM {pfx}share s "
|
|
f"LEFT JOIN {pfx}filecache fc ON fc.fileid=s.file_source "
|
|
f"ORDER BY s.stime DESC;")
|
|
out = self._mysql(sql)
|
|
shares: list[dict] = []
|
|
for row in out.splitlines():
|
|
f = [None if v in ("NULL", "") else v for v in row.split("\t")]
|
|
if len(f) < 8:
|
|
continue
|
|
if uid and uid not in (f[2], f[3]): # share_with / owner
|
|
continue
|
|
st = _int(f[1])
|
|
shares.append({
|
|
"id": _int(f[0]),
|
|
"share_type": self.SHARE_TYPES.get(st, str(st)),
|
|
"share_with": f[2],
|
|
"owner": f[3],
|
|
"target": f[4],
|
|
"permissions": _int(f[5]),
|
|
"created": _epoch(f[6]),
|
|
"path": f[7],
|
|
})
|
|
return shares
|
|
|
|
# -- server / apps / config ------------------------------------------------
|
|
def maintenance_mode(self, on: bool) -> str:
|
|
return self._occ_ok("maintenance:mode " + ("--on" if on else "--off")).strip()
|
|
|
|
def maintenance_status(self) -> bool:
|
|
# reuse config_get so a genuine occ failure raises (not a false 'off');
|
|
# the maintenance key is always set, so None would itself be anomalous.
|
|
val = self.config_get("maintenance")
|
|
return (val or "").strip().lower() in ("true", "1", "yes")
|
|
|
|
def list_apps(self) -> Any:
|
|
return self._occ_json("app:list --output=json")
|
|
|
|
def enable_app(self, app: str) -> str:
|
|
return self._occ_ok(f"app:enable -- {shlex.quote(app)}").strip()
|
|
|
|
def disable_app(self, app: str) -> str:
|
|
return self._occ_ok(f"app:disable -- {shlex.quote(app)}").strip()
|
|
|
|
def config_get(self, name: str) -> Optional[str]:
|
|
"""Return the system config value, or None when the key is genuinely
|
|
unset (occ rc=1 with empty output). A non-zero rc that carries an error
|
|
message is a real failure and raises, so callers never mistake a broken
|
|
occ read for 'value is unset'."""
|
|
rc, out, err = self.occ(f"config:system:get -- {shlex.quote(name)}")
|
|
if rc == 0:
|
|
return out.strip()
|
|
msg = (err or out).strip()
|
|
if not msg:
|
|
return None
|
|
raise bc.BackupError(f"occ config:system:get failed (rc={rc}): {msg[:300]}")
|
|
|
|
def config_list(self) -> Any:
|
|
return self._occ_json("config:list system --output=json")
|
|
|
|
def config_set(self, name: str, value: str, vtype: str = "string") -> str:
|
|
return self._occ_ok(
|
|
f"config:system:set {shlex.quote(name)} "
|
|
f"--value={shlex.quote(value)} --type={shlex.quote(vtype)}").strip()
|
|
|
|
# -- files admin -----------------------------------------------------------
|
|
def files_scan(self, uid: Optional[str] = None, all_users: bool = False,
|
|
path: Optional[str] = None) -> str:
|
|
opts = "files:scan"
|
|
if path:
|
|
opts += f" --path={shlex.quote(path)}"
|
|
if all_users:
|
|
args = f"{opts} --all"
|
|
elif uid:
|
|
args = f"{opts} -- {shlex.quote(uid)}"
|
|
else:
|
|
raise bc.BackupError("files_scan requires a uid or all_users=True")
|
|
return self._occ_ok(args, timeout=DU_TIMEOUT).strip()
|
|
|
|
def transfer_ownership(self, src: str, dst: str,
|
|
path: Optional[str] = None) -> str:
|
|
args = "files:transfer-ownership -s"
|
|
if path:
|
|
args += f" --path={shlex.quote(path)}"
|
|
args += f" -- {shlex.quote(src)} {shlex.quote(dst)}"
|
|
return self._occ_ok(args, timeout=DU_TIMEOUT).strip()
|
|
|
|
def trashbin_cleanup(self, users: list[str]) -> str:
|
|
us = (" -- " + " ".join(shlex.quote(u) for u in users)) if users else ""
|
|
return self._occ_ok(f"trashbin:cleanup{us}", timeout=DU_TIMEOUT).strip()
|
|
|
|
def versions_cleanup(self, users: list[str]) -> str:
|
|
us = (" -- " + " ".join(shlex.quote(u) for u in users)) if users else ""
|
|
return self._occ_ok(f"versions:cleanup{us}", timeout=DU_TIMEOUT).strip()
|
|
|
|
|
|
def _int(v: Any) -> Optional[int]:
|
|
try:
|
|
return int(str(v).strip())
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _epoch(v: Any) -> Optional[str]:
|
|
"""occ emits lastLogin/creationTime as Unix epoch seconds; 0 = never."""
|
|
try:
|
|
n = int(v)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if n <= 0:
|
|
return None
|
|
return datetime.fromtimestamp(n, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
|
def _as_bool(v: Any) -> bool:
|
|
if isinstance(v, bool):
|
|
return v
|
|
if isinstance(v, str):
|
|
return v.strip().lower() in ("true", "yes", "1", "enabled")
|
|
return bool(v)
|
|
|
|
|
|
def main() -> int:
|
|
c = OwncloudClient()
|
|
try:
|
|
s = c.server_status()
|
|
print(f"[OK] ownCloud reachable at {c.host}; version={s.get('versionstring')} "
|
|
f"edition={s.get('edition')}")
|
|
return 0
|
|
except bc.BackupError as exc:
|
|
print(f"[ERROR] {exc}", file=sys.stderr)
|
|
return 1
|
|
finally:
|
|
c.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|