close1 0.7.1__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.
- close1/__init__.py +3 -0
- close1/__main__.py +3 -0
- close1/backup.py +214 -0
- close1/cli.py +168 -0
- close1/config.py +271 -0
- close1/messages.py +38 -0
- close1/rotate.py +21 -0
- close1/ssh.py +199 -0
- close1-0.7.1.dist-info/METADATA +118 -0
- close1-0.7.1.dist-info/RECORD +14 -0
- close1-0.7.1.dist-info/WHEEL +5 -0
- close1-0.7.1.dist-info/entry_points.txt +2 -0
- close1-0.7.1.dist-info/licenses/LICENSE +21 -0
- close1-0.7.1.dist-info/top_level.txt +1 -0
close1/__init__.py
ADDED
close1/__main__.py
ADDED
close1/backup.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Backing up a single server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import logging
|
|
7
|
+
import posixpath
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from close1.config import FileItem, HostConfig
|
|
14
|
+
from close1.messages import t
|
|
15
|
+
from close1.rotate import to_delete
|
|
16
|
+
from close1.ssh import Remote, RemoteError, remote_path
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("close1")
|
|
19
|
+
|
|
20
|
+
_DATE_RE = re.compile(r"\$\{DATE\}|\$DATE\b")
|
|
21
|
+
ARCHIVE_EXT = {"tar.gz": ".tar.gz", "zip": ".zip"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BackupError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def substitute(text: str, date: str) -> str:
|
|
29
|
+
"""$DATE and ${DATE} -> 2026-12-31."""
|
|
30
|
+
return _DATE_RE.sub(date, text)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def archive_type(name: str | None, default: str) -> str:
|
|
34
|
+
"""Archive format for a directory: from the explicit name's extension, else the compress option."""
|
|
35
|
+
if name:
|
|
36
|
+
if name.endswith(".zip"):
|
|
37
|
+
return "zip"
|
|
38
|
+
if name.endswith((".tar.gz", ".tgz")):
|
|
39
|
+
return "tar.gz"
|
|
40
|
+
return default
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Plan:
|
|
45
|
+
"""What to copy and under which local name."""
|
|
46
|
+
|
|
47
|
+
remote: str
|
|
48
|
+
local: str
|
|
49
|
+
archive: str | None # None for a regular file
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def plan_files(items: list[tuple[FileItem, bool]], compress: str, reserved: set[str]) -> list[Plan]:
|
|
53
|
+
"""Choose local names. items are (item, is_dir); reserved are names taken by stdout.
|
|
54
|
+
|
|
55
|
+
Explicit names are claimed first; the rest get the basename, or the full path joined with '-' on collision.
|
|
56
|
+
"""
|
|
57
|
+
used = set(reserved)
|
|
58
|
+
locals_: list[str | None] = []
|
|
59
|
+
for item, is_dir in items:
|
|
60
|
+
local = None
|
|
61
|
+
if item.name:
|
|
62
|
+
local = item.name
|
|
63
|
+
archive = archive_type(item.name, compress) if is_dir else None
|
|
64
|
+
if archive and not local.endswith((".zip", ".tar.gz", ".tgz")):
|
|
65
|
+
local += ARCHIVE_EXT[archive]
|
|
66
|
+
if local in used:
|
|
67
|
+
raise BackupError(t("name_collision", path=item.remote))
|
|
68
|
+
used.add(local)
|
|
69
|
+
locals_.append(local)
|
|
70
|
+
|
|
71
|
+
plans = []
|
|
72
|
+
for (item, is_dir), local in zip(items, locals_):
|
|
73
|
+
archive = archive_type(item.name, compress) if is_dir else None
|
|
74
|
+
if local is None:
|
|
75
|
+
ext = ARCHIVE_EXT[archive] if archive else ""
|
|
76
|
+
stripped = item.remote.strip("/")
|
|
77
|
+
base = posixpath.basename(stripped) or "root"
|
|
78
|
+
full = stripped.replace("/", "-") or "root"
|
|
79
|
+
local = next((c + ext for c in (base, full) if c + ext not in used), None)
|
|
80
|
+
if local is None:
|
|
81
|
+
raise BackupError(t("name_collision", path=item.remote))
|
|
82
|
+
used.add(local)
|
|
83
|
+
plans.append(Plan(remote=item.remote, local=local, archive=archive))
|
|
84
|
+
return plans
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def remote_kind(remote: Remote, path: str) -> str:
|
|
88
|
+
q = remote_path(path)
|
|
89
|
+
out = remote.run(f"if [ -d {q} ]; then echo dir; elif [ -e {q} ]; then echo file; else echo none; fi", retry=True)
|
|
90
|
+
return out.strip()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _require_tool(remote: Remote, tool: str) -> None:
|
|
94
|
+
if remote.run(f"command -v {tool} >/dev/null 2>&1 && echo yes || echo no", retry=True).strip() != "yes":
|
|
95
|
+
raise BackupError(t("tool_missing", tool=tool))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def fetch(remote: Remote, plan: Plan, target: Path) -> None:
|
|
99
|
+
q = remote_path(plan.remote)
|
|
100
|
+
if plan.archive == "tar.gz":
|
|
101
|
+
cmd = f"tar -C {q} -czf - ."
|
|
102
|
+
elif plan.archive == "zip":
|
|
103
|
+
cmd = f"cd {q} && zip -qr - ."
|
|
104
|
+
else:
|
|
105
|
+
cmd = f"cat -- {q}"
|
|
106
|
+
with open(target, "wb") as f:
|
|
107
|
+
remote.run(cmd, stdout=f, retry=True)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class HostBackup:
|
|
111
|
+
def __init__(self, host: HostConfig, agent_sock: str | None, force: bool = False,
|
|
112
|
+
today: dt.date | None = None):
|
|
113
|
+
self.host = host
|
|
114
|
+
self.force = force
|
|
115
|
+
self.today = today or dt.date.today()
|
|
116
|
+
self.date = self.today.isoformat()
|
|
117
|
+
self.remote = Remote(host, agent_sock)
|
|
118
|
+
self.host_dir = host.dest / host.name
|
|
119
|
+
self.final = self.host_dir / self.date
|
|
120
|
+
self.partial = self.host_dir / f"{self.date}.partial"
|
|
121
|
+
|
|
122
|
+
def run(self) -> bool:
|
|
123
|
+
"""True if the backup was made or is not needed, False on error."""
|
|
124
|
+
name = self.host.section
|
|
125
|
+
if self.final.exists() and not self.force:
|
|
126
|
+
log.info(t("already_done", name=name, date=self.date))
|
|
127
|
+
return True
|
|
128
|
+
if self.host.ssh_key and not self.remote.agent_sock:
|
|
129
|
+
log.warning(t("key_not_found", name=name, fp=self.host.ssh_key))
|
|
130
|
+
return True # not an error: the key may be loaded later
|
|
131
|
+
with self.remote:
|
|
132
|
+
try:
|
|
133
|
+
self.remote.check()
|
|
134
|
+
except RemoteError as e:
|
|
135
|
+
log.warning(t("unreachable", name=name, error=e.stderr or e.code))
|
|
136
|
+
return True # not an error: retry on the next run
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
self._backup()
|
|
140
|
+
except (RemoteError, BackupError, OSError) as e:
|
|
141
|
+
log.error(t("failed", name=name, error=e))
|
|
142
|
+
return False
|
|
143
|
+
log.info(t("done", name=name, path=self.final))
|
|
144
|
+
self._rotate()
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
def _run_stage(self, stage: str, commands: list[str]) -> None:
|
|
148
|
+
for cmd in commands:
|
|
149
|
+
cmd = substitute(cmd, self.date)
|
|
150
|
+
log.info(t("run_cmd", name=self.host.section, stage=stage, cmd=cmd))
|
|
151
|
+
out = self.remote.run(cmd)
|
|
152
|
+
if out.strip():
|
|
153
|
+
log.debug(out.rstrip())
|
|
154
|
+
|
|
155
|
+
def _backup(self) -> None:
|
|
156
|
+
h = self.host
|
|
157
|
+
if self.partial.exists():
|
|
158
|
+
shutil.rmtree(self.partial)
|
|
159
|
+
self.partial.mkdir(parents=True)
|
|
160
|
+
log.info(t("start", name=h.section, path=self.final))
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
self._run_stage("pre", h.pre)
|
|
164
|
+
self._copy_files()
|
|
165
|
+
for local, cmd in h.stdout.items():
|
|
166
|
+
cmd = substitute(cmd, self.date)
|
|
167
|
+
log.info(t("run_cmd", name=h.section, stage="stdout", cmd=f"{cmd} > {local}"))
|
|
168
|
+
with open(self.partial / local, "wb") as f:
|
|
169
|
+
self.remote.run(cmd, stdout=f)
|
|
170
|
+
except BaseException:
|
|
171
|
+
# run post even on failure, e.g. to turn maintenance mode off
|
|
172
|
+
try:
|
|
173
|
+
self._run_stage("post", h.post)
|
|
174
|
+
except RemoteError as e:
|
|
175
|
+
log.error(t("failed", name=h.section, error=e))
|
|
176
|
+
raise
|
|
177
|
+
self._run_stage("post", h.post)
|
|
178
|
+
|
|
179
|
+
if self.final.exists():
|
|
180
|
+
old = self.host_dir / f"{self.date}.old"
|
|
181
|
+
if old.exists():
|
|
182
|
+
shutil.rmtree(old)
|
|
183
|
+
self.final.rename(old)
|
|
184
|
+
self.partial.rename(self.final)
|
|
185
|
+
shutil.rmtree(old)
|
|
186
|
+
else:
|
|
187
|
+
self.partial.rename(self.final)
|
|
188
|
+
|
|
189
|
+
def _copy_files(self) -> None:
|
|
190
|
+
h = self.host
|
|
191
|
+
items = []
|
|
192
|
+
for item in h.files:
|
|
193
|
+
path = substitute(item.remote, self.date)
|
|
194
|
+
kind = remote_kind(self.remote, path)
|
|
195
|
+
if kind == "none":
|
|
196
|
+
raise BackupError(t("remote_missing", path=path))
|
|
197
|
+
items.append((FileItem(remote=path, name=item.name), kind == "dir"))
|
|
198
|
+
|
|
199
|
+
tools = {"tar" if a == "tar.gz" else "zip"
|
|
200
|
+
for a in (archive_type(i.name, h.compress) for i, d in items if d)}
|
|
201
|
+
for tool in sorted(tools):
|
|
202
|
+
_require_tool(self.remote, tool)
|
|
203
|
+
|
|
204
|
+
for plan in plan_files(items, h.compress, set(h.stdout)):
|
|
205
|
+
log.info(t("copy", name=h.section, remote=plan.remote, local=plan.local))
|
|
206
|
+
fetch(self.remote, plan, self.partial / plan.local)
|
|
207
|
+
|
|
208
|
+
def _rotate(self) -> None:
|
|
209
|
+
for p in self.host_dir.glob("*.partial"):
|
|
210
|
+
if p.is_dir():
|
|
211
|
+
shutil.rmtree(p)
|
|
212
|
+
for p in to_delete(self.host_dir, self.host.keep):
|
|
213
|
+
log.info(t("rotate_delete", name=self.host.section, path=p))
|
|
214
|
+
shutil.rmtree(p)
|
close1/cli.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""close1 command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import fcntl
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from close1 import __version__
|
|
14
|
+
from close1.backup import HostBackup
|
|
15
|
+
from close1.config import Config, ConfigError, config_search_paths, find_config, is_root, load_config
|
|
16
|
+
from close1.messages import t
|
|
17
|
+
from close1.ssh import Remote, RemoteError, find_agent, list_agents
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("close1")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def default_log_path() -> Path:
|
|
23
|
+
if is_root():
|
|
24
|
+
return Path("/var/log/close1.log")
|
|
25
|
+
base = os.environ.get("XDG_STATE_HOME") or "~/.local/state"
|
|
26
|
+
return Path(base).expanduser() / "close1" / "close1.log"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def setup_logging(log_path: Path, verbose: bool) -> None:
|
|
30
|
+
log.setLevel(logging.DEBUG)
|
|
31
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
fh = logging.FileHandler(log_path, encoding="utf-8")
|
|
33
|
+
fh.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
34
|
+
fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
|
35
|
+
log.addHandler(fh)
|
|
36
|
+
|
|
37
|
+
# Under cron (no tty) only errors go to stderr, so cron mails only on real problems.
|
|
38
|
+
sh = logging.StreamHandler(sys.stderr)
|
|
39
|
+
if verbose:
|
|
40
|
+
sh.setLevel(logging.DEBUG)
|
|
41
|
+
else:
|
|
42
|
+
sh.setLevel(logging.WARNING if sys.stderr.isatty() else logging.ERROR)
|
|
43
|
+
sh.setFormatter(logging.Formatter("%(levelname)s %(message)s"))
|
|
44
|
+
log.addHandler(sh)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
48
|
+
p = argparse.ArgumentParser(prog="close1", description="Back up files from servers over SSH")
|
|
49
|
+
p.add_argument("hosts", nargs="*", metavar="HOST", help="config sections to back up (default: all), or 'log' to show the log")
|
|
50
|
+
p.add_argument("-c", "--config", type=Path, help="config file (default: first of $CLOSE1_CONFIG, ~/.config/close1/close1.yml, /etc/close1.yml)")
|
|
51
|
+
p.add_argument("-f", "--force", action="store_true", help="overwrite today's backup")
|
|
52
|
+
p.add_argument("-v", "--verbose", action="store_true", help="verbose output")
|
|
53
|
+
p.add_argument("--check", action="store_true", help="check the SSH agent and host reachability, copy nothing")
|
|
54
|
+
p.add_argument("--version", action="version", version=f"close1 {__version__}")
|
|
55
|
+
return p
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def select_hosts(cfg: Config, names: list[str]):
|
|
59
|
+
if not names:
|
|
60
|
+
return cfg.hosts
|
|
61
|
+
by_name = {h.section: h for h in cfg.hosts}
|
|
62
|
+
missing = [n for n in names if n not in by_name]
|
|
63
|
+
if missing:
|
|
64
|
+
raise ConfigError(t("unknown_host", name=", ".join(missing)))
|
|
65
|
+
return [by_name[n] for n in names]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def check(cfg: Config, hosts, sock: str | None, log_path: Path) -> int:
|
|
69
|
+
print(t("check_header", version=__version__, config=cfg.path, log=log_path))
|
|
70
|
+
agents = list_agents(cfg.ssh_auth_sock)
|
|
71
|
+
for agent_sock, keys in agents:
|
|
72
|
+
print(t("agent_found", sock=agent_sock, count=len(keys)))
|
|
73
|
+
for k in keys:
|
|
74
|
+
print(" " + k)
|
|
75
|
+
if not agents:
|
|
76
|
+
print(t("agent_not_found"))
|
|
77
|
+
rc = 0
|
|
78
|
+
for h in hosts:
|
|
79
|
+
host_sock = sock
|
|
80
|
+
if h.ssh_key:
|
|
81
|
+
host_sock = find_agent(cfg.ssh_auth_sock, h.ssh_key)[0]
|
|
82
|
+
if not host_sock:
|
|
83
|
+
print(t("check_fail", name=h.section, error=t("check_key_missing", fp=h.ssh_key)))
|
|
84
|
+
rc = 1
|
|
85
|
+
continue
|
|
86
|
+
print(t("check_agent", name=h.section, sock=host_sock))
|
|
87
|
+
try:
|
|
88
|
+
with Remote(h, host_sock) as remote:
|
|
89
|
+
remote.check()
|
|
90
|
+
print(t("check_ok", name=h.section))
|
|
91
|
+
except RemoteError as e:
|
|
92
|
+
print(t("check_fail", name=h.section, error=e.stderr or e.code))
|
|
93
|
+
rc = 1
|
|
94
|
+
return rc
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def show_log(log_path: Path, verbose: bool) -> int:
|
|
98
|
+
"""close1 log: print the log file (with -v, first a '# path' line)."""
|
|
99
|
+
if verbose:
|
|
100
|
+
print(f"# {log_path}", flush=True)
|
|
101
|
+
try:
|
|
102
|
+
with open(log_path, "rb") as f:
|
|
103
|
+
shutil.copyfileobj(f, sys.stdout.buffer)
|
|
104
|
+
sys.stdout.flush()
|
|
105
|
+
except FileNotFoundError:
|
|
106
|
+
print(t("log_missing", path=log_path), file=sys.stderr)
|
|
107
|
+
return 1
|
|
108
|
+
except BrokenPipeError: # e.g. close1 log | head
|
|
109
|
+
sys.stderr.close()
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main(argv: list[str] | None = None) -> int:
|
|
114
|
+
args = build_parser().parse_args(argv)
|
|
115
|
+
log_cmd = args.hosts == ["log"]
|
|
116
|
+
if log_cmd:
|
|
117
|
+
args.hosts = []
|
|
118
|
+
|
|
119
|
+
if args.config:
|
|
120
|
+
cfg_path = args.config
|
|
121
|
+
if not cfg_path.is_file():
|
|
122
|
+
print(t("config_not_found", path=cfg_path), file=sys.stderr)
|
|
123
|
+
return 2
|
|
124
|
+
else:
|
|
125
|
+
cfg_path = find_config()
|
|
126
|
+
if cfg_path is None:
|
|
127
|
+
paths = "\n".join(f" {p}" for p in config_search_paths())
|
|
128
|
+
print(t("config_not_found_search", paths=paths), file=sys.stderr)
|
|
129
|
+
return 2
|
|
130
|
+
try:
|
|
131
|
+
cfg = load_config(cfg_path)
|
|
132
|
+
hosts = select_hosts(cfg, args.hosts)
|
|
133
|
+
except ConfigError as e:
|
|
134
|
+
print(t("config_error", path=cfg_path, error=e), file=sys.stderr)
|
|
135
|
+
return 2
|
|
136
|
+
|
|
137
|
+
log_path = Path(cfg.log).expanduser() if cfg.log else default_log_path()
|
|
138
|
+
if log_cmd:
|
|
139
|
+
return show_log(log_path, args.verbose)
|
|
140
|
+
setup_logging(log_path, args.verbose)
|
|
141
|
+
log.debug(t("log_file", version=__version__, config=cfg_path, path=log_path))
|
|
142
|
+
|
|
143
|
+
sock, keys = find_agent(cfg.ssh_auth_sock)
|
|
144
|
+
if args.check:
|
|
145
|
+
return check(cfg, hosts, sock, log_path)
|
|
146
|
+
|
|
147
|
+
lock_path = log_path.with_suffix(".lock")
|
|
148
|
+
with open(lock_path, "w") as lock:
|
|
149
|
+
try:
|
|
150
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
151
|
+
except BlockingIOError:
|
|
152
|
+
log.info(t("already_running", path=lock_path))
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
if sock:
|
|
156
|
+
log.debug(t("agent_found", sock=sock, count=len(keys)))
|
|
157
|
+
else:
|
|
158
|
+
log.warning(t("agent_not_found"))
|
|
159
|
+
|
|
160
|
+
ok = True
|
|
161
|
+
for h in hosts:
|
|
162
|
+
host_sock = find_agent(cfg.ssh_auth_sock, h.ssh_key)[0] if h.ssh_key else sock
|
|
163
|
+
ok = HostBackup(h, host_sock, force=args.force).run() and ok
|
|
164
|
+
return 0 if ok else 1
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
raise SystemExit(main())
|
close1/config.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Loading and validating the close1 config."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
DEFAULT_SECTION = "_default"
|
|
14
|
+
COMPRESS_TYPES = ("tar.gz", "zip")
|
|
15
|
+
|
|
16
|
+
# Allowed both in _default and in a host section.
|
|
17
|
+
COMMON_KEYS = {
|
|
18
|
+
"dest", "compress", "keep", "user", "port", "ssh_options", "ssh_key",
|
|
19
|
+
"connect_timeout", "retries", "retry_delay",
|
|
20
|
+
}
|
|
21
|
+
# Only in _default.
|
|
22
|
+
GLOBAL_KEYS = {"ssh_auth_sock", "log"}
|
|
23
|
+
# Only in a host section.
|
|
24
|
+
HOST_KEYS = {"name", "pre", "files", "stdout", "post"}
|
|
25
|
+
|
|
26
|
+
BUILTIN_DEFAULTS: dict[str, Any] = {
|
|
27
|
+
"dest": "~/backups",
|
|
28
|
+
"compress": "tar.gz",
|
|
29
|
+
"keep": 7,
|
|
30
|
+
"connect_timeout": 30,
|
|
31
|
+
"retries": 3,
|
|
32
|
+
"retry_delay": 10,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_HOST_RE = re.compile(r"^(?:(?P<user>[^@:\s]+)@)?(?P<host>[^@:\s]+)(?::(?P<port>\d+))?$")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConfigError(Exception):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class FileItem:
|
|
44
|
+
remote: str
|
|
45
|
+
name: str | None = None # explicit local name
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class HostConfig:
|
|
50
|
+
section: str
|
|
51
|
+
name: str # directory name under dest
|
|
52
|
+
host: str
|
|
53
|
+
user: str | None
|
|
54
|
+
port: int | None
|
|
55
|
+
ssh_options: list[str]
|
|
56
|
+
ssh_key: str | None # SHA256 fingerprint of the key to look for in agents
|
|
57
|
+
connect_timeout: int
|
|
58
|
+
retries: int
|
|
59
|
+
retry_delay: float
|
|
60
|
+
dest: Path
|
|
61
|
+
compress: str
|
|
62
|
+
keep: int # how many latest backups to keep
|
|
63
|
+
pre: list[str] = field(default_factory=list)
|
|
64
|
+
files: list[FileItem] = field(default_factory=list)
|
|
65
|
+
stdout: dict[str, str] = field(default_factory=dict)
|
|
66
|
+
post: list[str] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Config:
|
|
71
|
+
path: Path
|
|
72
|
+
hosts: list[HostConfig]
|
|
73
|
+
ssh_auth_sock: str | None = None
|
|
74
|
+
log: str | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_root() -> bool:
|
|
78
|
+
return os.geteuid() == 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
SYSTEM_CONFIG = Path("/etc/close1.yml")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def config_search_paths() -> list[Path]:
|
|
85
|
+
"""Config search order: $CLOSE1_CONFIG, ~/.config/close1/close1.yml, /etc/close1.yml."""
|
|
86
|
+
paths = []
|
|
87
|
+
env = os.environ.get("CLOSE1_CONFIG")
|
|
88
|
+
if env:
|
|
89
|
+
paths.append(Path(env).expanduser())
|
|
90
|
+
base = os.environ.get("XDG_CONFIG_HOME") or "~/.config"
|
|
91
|
+
paths.append(Path(base).expanduser() / "close1" / "close1.yml")
|
|
92
|
+
paths.append(SYSTEM_CONFIG)
|
|
93
|
+
return paths
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def find_config() -> Path | None:
|
|
97
|
+
"""The first existing config from config_search_paths()."""
|
|
98
|
+
return next((p for p in config_search_paths() if p.is_file()), None)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_host_key(key: str) -> tuple[str | None, str, int | None]:
|
|
102
|
+
"""'backup@bot:12234' -> ('backup', 'bot', 12234)."""
|
|
103
|
+
m = _HOST_RE.match(key)
|
|
104
|
+
if not m:
|
|
105
|
+
raise ConfigError(f"invalid host name: {key!r}")
|
|
106
|
+
port = int(m["port"]) if m["port"] else None
|
|
107
|
+
return m["user"], m["host"], port
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def parse_keep(value: Any) -> int:
|
|
111
|
+
"""keep: how many latest backups to keep (a whole number >= 1)."""
|
|
112
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
113
|
+
raise ConfigError(f"keep: expected a whole number >= 1, got {value!r}")
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def check_local_name(name: Any, where: str) -> str:
|
|
118
|
+
if not isinstance(name, str) or not name or "/" in name or name in (".", ".."):
|
|
119
|
+
raise ConfigError(f"{where}: invalid local name {name!r}")
|
|
120
|
+
return name
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _str_list(value: Any, where: str) -> list[str]:
|
|
124
|
+
if value is None:
|
|
125
|
+
return []
|
|
126
|
+
if isinstance(value, str):
|
|
127
|
+
return [value]
|
|
128
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
129
|
+
raise ConfigError(f"{where}: expected a list of strings")
|
|
130
|
+
return value
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _parse_files(value: Any, where: str) -> list[FileItem]:
|
|
134
|
+
if value is None:
|
|
135
|
+
return []
|
|
136
|
+
if not isinstance(value, list):
|
|
137
|
+
raise ConfigError(f"{where}: expected a list")
|
|
138
|
+
items = []
|
|
139
|
+
for entry in value:
|
|
140
|
+
if isinstance(entry, str):
|
|
141
|
+
items.append(FileItem(remote=entry))
|
|
142
|
+
elif isinstance(entry, dict) and len(entry) == 1:
|
|
143
|
+
# "local name: remote path" — the key is the local name, as in stdout
|
|
144
|
+
((name, remote),) = entry.items()
|
|
145
|
+
if isinstance(name, str) and "/" in name and isinstance(remote, str) and "/" not in remote:
|
|
146
|
+
raise ConfigError(f"{where}: use 'name: /path' (got '{name}: {remote}')")
|
|
147
|
+
if not isinstance(remote, str) or not remote:
|
|
148
|
+
raise ConfigError(f"{where}: invalid path {remote!r}")
|
|
149
|
+
items.append(FileItem(remote=remote, name=check_local_name(name, where)))
|
|
150
|
+
else:
|
|
151
|
+
raise ConfigError(f"{where}: expected a path or 'name: path', got {entry!r}")
|
|
152
|
+
return items
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _parse_stdout(value: Any, where: str) -> dict[str, str]:
|
|
156
|
+
if value is None:
|
|
157
|
+
return {}
|
|
158
|
+
if not isinstance(value, dict):
|
|
159
|
+
raise ConfigError(f"{where}: expected a mapping of 'file_name: command'")
|
|
160
|
+
result = {}
|
|
161
|
+
for name, cmd in value.items():
|
|
162
|
+
check_local_name(name, where)
|
|
163
|
+
if not isinstance(cmd, str) or not cmd.strip():
|
|
164
|
+
raise ConfigError(f"{where}.{name}: expected a command")
|
|
165
|
+
result[name] = cmd
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _build_host(section: str, raw: dict[str, Any], defaults: dict[str, Any]) -> HostConfig:
|
|
170
|
+
unknown = set(raw) - COMMON_KEYS - HOST_KEYS
|
|
171
|
+
if unknown:
|
|
172
|
+
raise ConfigError(f"{section}: unknown options: {', '.join(sorted(unknown))}")
|
|
173
|
+
opts = {**BUILTIN_DEFAULTS, **defaults, **raw}
|
|
174
|
+
|
|
175
|
+
user, host, port = parse_host_key(section)
|
|
176
|
+
user = opts.get("user") if "user" in raw or user is None else user
|
|
177
|
+
if "port" in raw or port is None:
|
|
178
|
+
port = opts.get("port")
|
|
179
|
+
if port is not None and not isinstance(port, int):
|
|
180
|
+
raise ConfigError(f"{section}.port: expected a number")
|
|
181
|
+
|
|
182
|
+
ssh_key = opts.get("ssh_key")
|
|
183
|
+
if ssh_key is not None and not (isinstance(ssh_key, str) and ssh_key.startswith("SHA256:")):
|
|
184
|
+
raise ConfigError(f"{section}.ssh_key: expected a fingerprint like SHA256:..., got {ssh_key!r}")
|
|
185
|
+
|
|
186
|
+
for opt, minimum in (("connect_timeout", 1), ("retries", 0), ("retry_delay", 0)):
|
|
187
|
+
value = opts[opt]
|
|
188
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value < minimum:
|
|
189
|
+
raise ConfigError(f"{section}.{opt}: expected a number >= {minimum}, got {value!r}")
|
|
190
|
+
if not isinstance(opts["connect_timeout"], int) or not isinstance(opts["retries"], int):
|
|
191
|
+
raise ConfigError(f"{section}: connect_timeout and retries must be whole numbers")
|
|
192
|
+
|
|
193
|
+
compress = opts["compress"]
|
|
194
|
+
if compress not in COMPRESS_TYPES:
|
|
195
|
+
raise ConfigError(f"{section}.compress: expected {' | '.join(COMPRESS_TYPES)}, got {compress!r}")
|
|
196
|
+
|
|
197
|
+
hc = HostConfig(
|
|
198
|
+
section=section,
|
|
199
|
+
name=check_local_name(raw.get("name", host), f"{section}.name"),
|
|
200
|
+
host=host,
|
|
201
|
+
user=user,
|
|
202
|
+
port=port,
|
|
203
|
+
ssh_options=_str_list(opts.get("ssh_options"), f"{section}.ssh_options"),
|
|
204
|
+
ssh_key=ssh_key,
|
|
205
|
+
connect_timeout=opts["connect_timeout"],
|
|
206
|
+
retries=opts["retries"],
|
|
207
|
+
retry_delay=opts["retry_delay"],
|
|
208
|
+
dest=Path(str(opts["dest"])).expanduser(),
|
|
209
|
+
compress=compress,
|
|
210
|
+
keep=parse_keep(opts["keep"]),
|
|
211
|
+
pre=_str_list(raw.get("pre"), f"{section}.pre"),
|
|
212
|
+
files=_parse_files(raw.get("files"), f"{section}.files"),
|
|
213
|
+
stdout=_parse_stdout(raw.get("stdout"), f"{section}.stdout"),
|
|
214
|
+
post=_str_list(raw.get("post"), f"{section}.post"),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
explicit = [f.name for f in hc.files if f.name] + list(hc.stdout)
|
|
218
|
+
dupes = {n for n in explicit if explicit.count(n) > 1}
|
|
219
|
+
if dupes:
|
|
220
|
+
raise ConfigError(f"{section}: duplicate local names: {', '.join(sorted(dupes))}")
|
|
221
|
+
return hc
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def parse_config(data: Any, path: Path) -> Config:
|
|
225
|
+
if data is None:
|
|
226
|
+
data = {}
|
|
227
|
+
if not isinstance(data, dict):
|
|
228
|
+
raise ConfigError("expected a mapping of sections")
|
|
229
|
+
|
|
230
|
+
defaults = data.get(DEFAULT_SECTION) or {}
|
|
231
|
+
if not isinstance(defaults, dict):
|
|
232
|
+
raise ConfigError(f"{DEFAULT_SECTION}: expected a mapping")
|
|
233
|
+
unknown = set(defaults) - COMMON_KEYS - GLOBAL_KEYS
|
|
234
|
+
if unknown:
|
|
235
|
+
raise ConfigError(f"{DEFAULT_SECTION}: unknown options: {', '.join(sorted(unknown))}")
|
|
236
|
+
host_defaults = {k: v for k, v in defaults.items() if k in COMMON_KEYS}
|
|
237
|
+
|
|
238
|
+
hosts = []
|
|
239
|
+
for section, raw in data.items():
|
|
240
|
+
if section == DEFAULT_SECTION:
|
|
241
|
+
continue
|
|
242
|
+
if raw is None:
|
|
243
|
+
raw = {}
|
|
244
|
+
if not isinstance(section, str) or not isinstance(raw, dict):
|
|
245
|
+
raise ConfigError(f"{section}: expected a mapping of options")
|
|
246
|
+
hosts.append(_build_host(section, raw, host_defaults))
|
|
247
|
+
|
|
248
|
+
seen: dict[tuple[Path, str], str] = {}
|
|
249
|
+
for h in hosts:
|
|
250
|
+
key = (h.dest, h.name)
|
|
251
|
+
if key in seen:
|
|
252
|
+
raise ConfigError(
|
|
253
|
+
f"sections {seen[key]!r} and {h.section!r} write to the same directory {h.name!r}, set 'name'"
|
|
254
|
+
)
|
|
255
|
+
seen[key] = h.section
|
|
256
|
+
|
|
257
|
+
return Config(
|
|
258
|
+
path=path,
|
|
259
|
+
hosts=hosts,
|
|
260
|
+
ssh_auth_sock=defaults.get("ssh_auth_sock"),
|
|
261
|
+
log=defaults.get("log"),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def load_config(path: Path) -> Config:
|
|
266
|
+
with open(path, encoding="utf-8") as f:
|
|
267
|
+
try:
|
|
268
|
+
data = yaml.safe_load(f)
|
|
269
|
+
except yaml.YAMLError as e:
|
|
270
|
+
raise ConfigError(str(e)) from e
|
|
271
|
+
return parse_config(data, path)
|
close1/messages.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""User-facing messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
MESSAGES: dict[str, str] = {
|
|
6
|
+
"config_not_found": "Config not found: {path}",
|
|
7
|
+
"config_not_found_search": "Config not found. Search order:\n{paths}\nCreate one (see close1-example.yml) or use -c",
|
|
8
|
+
"config_error": "Config error in {path}: {error}",
|
|
9
|
+
"unknown_host": "Host not found in config: {name}",
|
|
10
|
+
"log_file": "close1 {version}, config: {config}, log: {path}",
|
|
11
|
+
"log_missing": "No log yet: {path}",
|
|
12
|
+
"already_running": "close1 is already running (lock: {path}), exiting",
|
|
13
|
+
"agent_found": "SSH agent: {sock} (keys: {count})",
|
|
14
|
+
"agent_not_found": "No SSH agent with keys found, connections will most likely fail",
|
|
15
|
+
"key_not_found": "[{name}] key {fp} not found in any SSH agent, skipping",
|
|
16
|
+
"check_key_missing": "key {fp} not found in any SSH agent",
|
|
17
|
+
"check_agent": " {name}: agent {sock}",
|
|
18
|
+
"retry": "[{name}] connection problem, retry {n}/{total} in {delay}s: {error}",
|
|
19
|
+
"already_done": "[{name}] backup for {date} already exists, skipping (use -f to overwrite)",
|
|
20
|
+
"unreachable": "[{name}] host unreachable or no key in agent, skipping: {error}",
|
|
21
|
+
"start": "[{name}] starting backup to {path}",
|
|
22
|
+
"run_cmd": "[{name}] {stage}: {cmd}",
|
|
23
|
+
"copy": "[{name}] {remote} -> {local}",
|
|
24
|
+
"done": "[{name}] backup finished: {path}",
|
|
25
|
+
"failed": "[{name}] backup failed: {error}",
|
|
26
|
+
"rotate_delete": "[{name}] rotation: removing {path}",
|
|
27
|
+
"cmd_failed": "command exited with code {code}: {cmd}: {stderr}",
|
|
28
|
+
"remote_missing": "no such file or directory on server: {path}",
|
|
29
|
+
"tool_missing": "{tool} not found on server",
|
|
30
|
+
"name_collision": "cannot find a unique local name for {path}",
|
|
31
|
+
"check_header": "close1 {version}, config: {config}, log: {log}",
|
|
32
|
+
"check_ok": " {name}: OK",
|
|
33
|
+
"check_fail": " {name}: FAIL: {error}",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def t(key: str, **kwargs: object) -> str:
|
|
38
|
+
return MESSAGES[key].format(**kwargs)
|
close1/rotate.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Backup rotation: keep the N latest backups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
DATE_DIR_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def backup_dirs(host_dir: Path) -> list[Path]:
|
|
12
|
+
"""Backup directories named YYYY-MM-DD, newest first."""
|
|
13
|
+
if not host_dir.is_dir():
|
|
14
|
+
return []
|
|
15
|
+
dirs = [p for p in host_dir.iterdir() if p.is_dir() and DATE_DIR_RE.match(p.name)]
|
|
16
|
+
return sorted(dirs, key=lambda p: p.name, reverse=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def to_delete(host_dir: Path, keep: int) -> list[Path]:
|
|
20
|
+
"""Backups beyond the `keep` latest ones, no matter how old they are."""
|
|
21
|
+
return backup_dirs(host_dir)[keep:]
|
close1/ssh.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""SSH: agent discovery and running commands on a server via the system ssh."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import IO
|
|
15
|
+
|
|
16
|
+
from close1.config import HostConfig
|
|
17
|
+
from close1.messages import t
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("close1")
|
|
20
|
+
|
|
21
|
+
# Always added: no interactive prompts (cron) and no agent forwarding to the backed-up server.
|
|
22
|
+
BASE_OPTIONS = ["BatchMode=yes", "ForwardAgent=no"]
|
|
23
|
+
|
|
24
|
+
# ssh exits with 255 on its own errors. These ones will not go away on retry.
|
|
25
|
+
PERMANENT_ERRORS = (
|
|
26
|
+
"Permission denied",
|
|
27
|
+
"Host key verification failed",
|
|
28
|
+
"Could not resolve hostname",
|
|
29
|
+
"REMOTE HOST IDENTIFICATION HAS CHANGED",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RemoteError(Exception):
|
|
34
|
+
"""A remote command (or ssh itself) failed."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, cmd: str, code: int, stderr: str):
|
|
37
|
+
super().__init__(cmd, code, stderr)
|
|
38
|
+
self.cmd, self.code, self.stderr = cmd, code, stderr
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
return t("cmd_failed", code=self.code, cmd=self.cmd, stderr=self.stderr)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_connection_error(e: RemoteError) -> bool:
|
|
45
|
+
"""A transient ssh connection problem (worth retrying)."""
|
|
46
|
+
return e.code == 255 and not any(m in e.stderr for m in PERMANENT_ERRORS)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def agent_candidates() -> list[str]:
|
|
50
|
+
"""Usual locations of the user's ssh-agent socket (cron does not pass SSH_AUTH_SOCK)."""
|
|
51
|
+
result = []
|
|
52
|
+
env = os.environ.get("SSH_AUTH_SOCK")
|
|
53
|
+
if env:
|
|
54
|
+
result.append(env)
|
|
55
|
+
run = Path(os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}")
|
|
56
|
+
for rel in ("openssh_agent", "ssh-agent.socket", "gcr/ssh", "keyring/ssh", "gnupg/S.gpg-agent.ssh"):
|
|
57
|
+
p = str(run / rel)
|
|
58
|
+
if p not in result:
|
|
59
|
+
result.append(p)
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def agent_keys(sock: str) -> list[str]:
|
|
64
|
+
"""Keys in the agent (ssh-add -l output), or an empty list."""
|
|
65
|
+
try:
|
|
66
|
+
if not stat.S_ISSOCK(os.stat(sock).st_mode):
|
|
67
|
+
return []
|
|
68
|
+
r = subprocess.run(
|
|
69
|
+
["ssh-add", "-l"],
|
|
70
|
+
env={**os.environ, "SSH_AUTH_SOCK": sock},
|
|
71
|
+
capture_output=True,
|
|
72
|
+
text=True,
|
|
73
|
+
timeout=10,
|
|
74
|
+
)
|
|
75
|
+
except (OSError, subprocess.SubprocessError):
|
|
76
|
+
return []
|
|
77
|
+
if r.returncode != 0:
|
|
78
|
+
return []
|
|
79
|
+
return [line for line in r.stdout.splitlines() if line.strip()]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def has_key(keys: list[str], fingerprint: str) -> bool:
|
|
83
|
+
"""Whether ssh-add -l lines contain the fingerprint (second field)."""
|
|
84
|
+
return any(len(line.split()) > 1 and line.split()[1] == fingerprint for line in keys)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def list_agents(explicit: str | None = None) -> list[tuple[str, list[str]]]:
|
|
88
|
+
"""Agent sockets that hold keys, with their keys, in search order."""
|
|
89
|
+
candidates = [os.path.expanduser(explicit)] if explicit else agent_candidates()
|
|
90
|
+
return [(sock, keys) for sock in candidates if (keys := agent_keys(sock))]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def find_agent(explicit: str | None = None, fingerprint: str | None = None) -> tuple[str | None, list[str]]:
|
|
94
|
+
"""The first agent socket that holds keys (or the given key, if fingerprint is set)."""
|
|
95
|
+
for sock, keys in list_agents(explicit):
|
|
96
|
+
if fingerprint is None or has_key(keys, fingerprint):
|
|
97
|
+
return sock, keys
|
|
98
|
+
return None, []
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def remote_path(path: str) -> str:
|
|
102
|
+
"""Quote a path for the remote shell, keeping ~/ expansion."""
|
|
103
|
+
if path == "~":
|
|
104
|
+
return '"$HOME"'
|
|
105
|
+
if path.startswith("~/"):
|
|
106
|
+
return '"$HOME"/' + shlex.quote(path[2:])
|
|
107
|
+
return shlex.quote(path)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Remote:
|
|
111
|
+
"""Runs commands on one server over a single multiplexed connection (ControlMaster).
|
|
112
|
+
|
|
113
|
+
Use as a context manager: the master connection is closed on exit.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(self, host: HostConfig, agent_sock: str | None = None):
|
|
117
|
+
self.host = host
|
|
118
|
+
self.agent_sock = agent_sock
|
|
119
|
+
self.env = dict(os.environ)
|
|
120
|
+
if agent_sock:
|
|
121
|
+
self.env["SSH_AUTH_SOCK"] = agent_sock
|
|
122
|
+
self.control_dir: str | None = None
|
|
123
|
+
|
|
124
|
+
def __enter__(self) -> "Remote":
|
|
125
|
+
# private (0700) directory for the control socket, removed on exit
|
|
126
|
+
self.control_dir = tempfile.mkdtemp(prefix="close1-")
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def __exit__(self, *exc: object) -> None:
|
|
130
|
+
if not self.control_dir:
|
|
131
|
+
return
|
|
132
|
+
try:
|
|
133
|
+
subprocess.run(
|
|
134
|
+
self._base() + ["-O", "exit", "--", self.host.host],
|
|
135
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
136
|
+
env=self.env, timeout=30,
|
|
137
|
+
)
|
|
138
|
+
except (OSError, subprocess.SubprocessError):
|
|
139
|
+
pass
|
|
140
|
+
shutil.rmtree(self.control_dir, ignore_errors=True)
|
|
141
|
+
self.control_dir = None
|
|
142
|
+
|
|
143
|
+
def _base(self) -> list[str]:
|
|
144
|
+
cmd = ["ssh", "-o", f"ConnectTimeout={self.host.connect_timeout}"]
|
|
145
|
+
for opt in BASE_OPTIONS + self.host.ssh_options:
|
|
146
|
+
cmd += ["-o", opt]
|
|
147
|
+
if self.control_dir:
|
|
148
|
+
cmd += ["-o", "ControlMaster=auto",
|
|
149
|
+
"-o", f"ControlPath={self.control_dir}/%C",
|
|
150
|
+
"-o", "ControlPersist=60"]
|
|
151
|
+
if self.host.ssh_key and self.agent_sock:
|
|
152
|
+
# a specific agent was chosen for this host: override IdentityAgent from ~/.ssh/config too
|
|
153
|
+
cmd += ["-o", f"IdentityAgent={self.agent_sock}"]
|
|
154
|
+
if self.host.user:
|
|
155
|
+
cmd += ["-l", self.host.user]
|
|
156
|
+
if self.host.port:
|
|
157
|
+
cmd += ["-p", str(self.host.port)]
|
|
158
|
+
return cmd
|
|
159
|
+
|
|
160
|
+
def command(self, remote_cmd: str) -> list[str]:
|
|
161
|
+
return self._base() + ["--", self.host.host, remote_cmd]
|
|
162
|
+
|
|
163
|
+
def _run_once(self, remote_cmd: str, stdout: IO[bytes] | None) -> str:
|
|
164
|
+
r = subprocess.run(
|
|
165
|
+
self.command(remote_cmd),
|
|
166
|
+
stdin=subprocess.DEVNULL,
|
|
167
|
+
stdout=stdout if stdout is not None else subprocess.PIPE,
|
|
168
|
+
stderr=subprocess.PIPE,
|
|
169
|
+
env=self.env,
|
|
170
|
+
)
|
|
171
|
+
if r.returncode != 0:
|
|
172
|
+
err = r.stderr.decode(errors="replace").strip()
|
|
173
|
+
raise RemoteError(remote_cmd, r.returncode, err)
|
|
174
|
+
return r.stdout.decode(errors="replace") if stdout is None else ""
|
|
175
|
+
|
|
176
|
+
def run(self, remote_cmd: str, stdout: IO[bytes] | None = None, retry: bool = False) -> str:
|
|
177
|
+
"""Run a command; stdout goes to the given file or is returned as a string.
|
|
178
|
+
|
|
179
|
+
retry=True repeats the command after connection errors. Use it only for commands
|
|
180
|
+
that are safe to repeat (reads): after a dropped connection we cannot know whether
|
|
181
|
+
the command already ran.
|
|
182
|
+
"""
|
|
183
|
+
attempts = 1 + (self.host.retries if retry else 0)
|
|
184
|
+
for attempt in range(1, attempts + 1):
|
|
185
|
+
try:
|
|
186
|
+
return self._run_once(remote_cmd, stdout)
|
|
187
|
+
except RemoteError as e:
|
|
188
|
+
if attempt == attempts or not is_connection_error(e):
|
|
189
|
+
raise
|
|
190
|
+
log.warning(t("retry", name=self.host.section, n=attempt, total=attempts - 1,
|
|
191
|
+
delay=self.host.retry_delay, error=e.stderr or e.code))
|
|
192
|
+
time.sleep(self.host.retry_delay)
|
|
193
|
+
if stdout is not None:
|
|
194
|
+
stdout.seek(0)
|
|
195
|
+
stdout.truncate()
|
|
196
|
+
raise AssertionError("unreachable")
|
|
197
|
+
|
|
198
|
+
def check(self) -> None:
|
|
199
|
+
self.run("true", retry=True)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: close1
|
|
3
|
+
Version: 0.7.1
|
|
4
|
+
Summary: Simple backups from servers over SSH
|
|
5
|
+
Author: Yaroslav Polyakov
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yaroslaff/close1
|
|
8
|
+
Project-URL: Repository, https://github.com/yaroslaff/close1
|
|
9
|
+
Project-URL: Issues, https://github.com/yaroslaff/close1/issues
|
|
10
|
+
Keywords: backup,ssh,cron,pull,sqlite,mysql
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: System :: Archiving :: Backup
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: PyYAML>=5.4
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# close1
|
|
27
|
+
|
|
28
|
+
A simple tool for daily backups of files and databases over SSH.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
- Pulls files and directories (.tar.gz/.zip) from the servers to your machine; nothing is installed on the servers.
|
|
32
|
+
- Runs commands on the servers (for example, `mysqldump`, `.backup` in sqlite3, or stopping a web server for the duration of the backup and starting it again afterwards).
|
|
33
|
+
- Good SSH integration: uses your key through the SSH agent (even if it is passphrase-protected). ControlMaster for speed and for reliability on lossy networks.
|
|
34
|
+
- Tolerates missed cron runs (e.g. when the computer was off). You can run close1 from cron every hour and it still makes one backup per day.
|
|
35
|
+
- Careful rotation: you set how many backups to keep (say, 7). Rotation removes the oldest ones, but it will not delete a 10-day-old backup if there were no newer ones. Even in the worst case you still have some backups.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
pipx install . # or: pip install -e '.[dev]' in a venv
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
close1 --check # SSH agent, keys, host reachability (copies nothing)
|
|
47
|
+
close1 # back up all hosts from the config
|
|
48
|
+
close1 blog -f -v # only blog, overwrite today's backup, verbose
|
|
49
|
+
close1 log # show the log (-v: print its path first)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Config: `-c path`, otherwise the first one found of `$CLOSE1_CONFIG`, `~/.config/close1/close1.yml`, `/etc/close1.yml`
|
|
53
|
+
(no merging). See `close1-example.yml` (short) and `close1-full-example.yml` (every option explained).
|
|
54
|
+
Log: `~/.local/state/close1/close1.log` (root: `/var/log/close1.log`).
|
|
55
|
+
|
|
56
|
+
## Config
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
_default:
|
|
60
|
+
dest: ~/backups # -> ~/backups/<host>/<YYYY-MM-DD>/
|
|
61
|
+
keep: 7
|
|
62
|
+
|
|
63
|
+
blog:
|
|
64
|
+
pre:
|
|
65
|
+
- mysqldump blog | gzip > /tmp/blog-${DATE}.sql.gz
|
|
66
|
+
files:
|
|
67
|
+
- blog.sql.gz: /tmp/blog-${DATE}.sql.gz
|
|
68
|
+
- /var/www/blog/uploads
|
|
69
|
+
- /etc/nginx/sites-enabled
|
|
70
|
+
post:
|
|
71
|
+
- rm /tmp/blog-${DATE}.sql.gz
|
|
72
|
+
|
|
73
|
+
"backup@bot:2222":
|
|
74
|
+
files:
|
|
75
|
+
- /opt/bot/config.yml
|
|
76
|
+
stdout:
|
|
77
|
+
packages.txt: dpkg -l
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
All options with explanations: [close1-full-example.yml](close1-full-example.yml).
|
|
81
|
+
|
|
82
|
+
### SQLite
|
|
83
|
+
|
|
84
|
+
Do not copy a live SQLite file (in WAL mode part of the data lives in `-wal`). Take a snapshot in `pre` and fetch it:
|
|
85
|
+
|
|
86
|
+
```yaml
|
|
87
|
+
pre:
|
|
88
|
+
- umask 077 && sqlite3 /opt/app/app.db ".backup /tmp/app-${DATE}.db" && gzip -f /tmp/app-${DATE}.db
|
|
89
|
+
files:
|
|
90
|
+
- app.db.gz: /tmp/app-${DATE}.db.gz
|
|
91
|
+
post:
|
|
92
|
+
- rm -f /tmp/app-${DATE}.db.gz
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Unreliable connections
|
|
96
|
+
|
|
97
|
+
All commands to a host go through one SSH connection (ControlMaster), so a backup needs a single handshake.
|
|
98
|
+
After a connection error (ssh exit code 255) close1 retries the connection check, file checks and downloads
|
|
99
|
+
(`retries` times, `retry_delay` seconds apart). `pre`, `post` and `stdout` commands are not retried: after a dropped
|
|
100
|
+
connection it is unknown whether they ran. If they fail, the backup fails and the next cron run starts it again.
|
|
101
|
+
|
|
102
|
+
## cron and ssh-agent
|
|
103
|
+
|
|
104
|
+
close1 authenticates through ssh-agent. If `SSH_AUTH_SOCK` is not set (as under cron), it looks for the socket in
|
|
105
|
+
`/run/user/$UID/` (`openssh_agent`, `ssh-agent.socket`, `gcr/ssh`, `keyring/ssh`, `gnupg/S.gpg-agent.ssh`). ssh is always
|
|
106
|
+
called with `BatchMode=yes` and `ForwardAgent=no`. While you are not logged in or the key is not loaded into the agent,
|
|
107
|
+
the backup is skipped and retried on the next run.
|
|
108
|
+
|
|
109
|
+
By default the first agent that holds any key is used. If you have several agents (e.g. ssh-agent and gnome-keyring)
|
|
110
|
+
with different keys, set `ssh_key: SHA256:...` (globally or per host): close1 then uses the agent that holds this key
|
|
111
|
+
and passes it to ssh as `IdentityAgent`. If the key is in no agent, the host is skipped. `close1 --check` lists all
|
|
112
|
+
agents with their key fingerprints. The key comment (`user@host`) is not used for matching: it is arbitrary text.
|
|
113
|
+
|
|
114
|
+
```cron
|
|
115
|
+
17 * * * * $HOME/.local/bin/close1
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Only errors go to stderr (and so to cron mail); everything else goes to the log.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
close1/__init__.py,sha256=jcd2L-ziBcbmeI8nWpxrl1M6YO-koFEdfcPS5Q7lgs8,78
|
|
2
|
+
close1/__main__.py,sha256=xPRazbQoIrTanWfx2QEQsurf9oOglLX2l9GwF8zHDEk,54
|
|
3
|
+
close1/backup.py,sha256=_RknlTMv6a5Nw7tB8hC4zFULf0LccBoekCsUAsnOx8Q,7765
|
|
4
|
+
close1/cli.py,sha256=2AUFFg6LnxwAh6GGa0v6Oem3wBKdE4upiK7kjyXF-SE,6052
|
|
5
|
+
close1/config.py,sha256=9M2ytwzCDJVY1aX1xwBh_IA0OrOK6-WDDL6TPXarmEc,9292
|
|
6
|
+
close1/messages.py,sha256=rFFrcAxOAForhWDdQVlXvxMMCktDscapK3a6wezF7Co,1949
|
|
7
|
+
close1/rotate.py,sha256=Sn1C9CW-249c9PQZjmeBeJN12Vm3bJrRAMuYMo5yYdY,652
|
|
8
|
+
close1/ssh.py,sha256=o6DcZlarKn4KLLHdwaSMQKFsRpvmc2LPuDC9ZbXybao,7368
|
|
9
|
+
close1-0.7.1.dist-info/licenses/LICENSE,sha256=hBsUwj1tQbFk1jd9i-yDQuIEK0aK2UARCrt52wQ-FyI,1074
|
|
10
|
+
close1-0.7.1.dist-info/METADATA,sha256=O8tzBMRplZBuHTMCuvJSOahtir0pKwzyS35o6--gzCM,4834
|
|
11
|
+
close1-0.7.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
12
|
+
close1-0.7.1.dist-info/entry_points.txt,sha256=BKgUgRg_ZvkCQokRvNhcxuzzNsQVAG9ef97w9OGcG_Y,43
|
|
13
|
+
close1-0.7.1.dist-info/top_level.txt,sha256=xtwqa419VPq-4EgzrdbJ6onNeAWNsAn60Krc3knrgh4,7
|
|
14
|
+
close1-0.7.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yaroslav Polyakov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
close1
|