safekeep 0.4.0__tar.gz → 0.5.1__tar.gz
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.
- {safekeep-0.4.0/safekeep.egg-info → safekeep-0.5.1}/PKG-INFO +6 -5
- {safekeep-0.4.0 → safekeep-0.5.1}/README.md +5 -4
- {safekeep-0.4.0 → safekeep-0.5.1}/safekeep/__init__.py +1 -1
- {safekeep-0.4.0 → safekeep-0.5.1}/safekeep/cli.py +63 -8
- {safekeep-0.4.0 → safekeep-0.5.1}/safekeep/copier.py +31 -1
- {safekeep-0.4.0 → safekeep-0.5.1}/safekeep/daemon.py +9 -2
- {safekeep-0.4.0 → safekeep-0.5.1}/safekeep/platform.py +42 -2
- {safekeep-0.4.0 → safekeep-0.5.1/safekeep.egg-info}/PKG-INFO +6 -5
- {safekeep-0.4.0 → safekeep-0.5.1}/safekeep.egg-info/SOURCES.txt +1 -0
- {safekeep-0.4.0 → safekeep-0.5.1}/tests/test_cli.py +116 -3
- {safekeep-0.4.0 → safekeep-0.5.1}/tests/test_copier.py +67 -0
- {safekeep-0.4.0 → safekeep-0.5.1}/tests/test_platform.py +87 -0
- safekeep-0.5.1/tests/test_trend.py +80 -0
- {safekeep-0.4.0 → safekeep-0.5.1}/LICENSE +0 -0
- {safekeep-0.4.0 → safekeep-0.5.1}/pyproject.toml +0 -0
- {safekeep-0.4.0 → safekeep-0.5.1}/safekeep/config.py +0 -0
- {safekeep-0.4.0 → safekeep-0.5.1}/safekeep/matcher.py +0 -0
- {safekeep-0.4.0 → safekeep-0.5.1}/safekeep/volumes.py +0 -0
- {safekeep-0.4.0 → safekeep-0.5.1}/safekeep.egg-info/dependency_links.txt +0 -0
- {safekeep-0.4.0 → safekeep-0.5.1}/safekeep.egg-info/entry_points.txt +0 -0
- {safekeep-0.4.0 → safekeep-0.5.1}/safekeep.egg-info/top_level.txt +0 -0
- {safekeep-0.4.0 → safekeep-0.5.1}/setup.cfg +0 -0
- {safekeep-0.4.0 → safekeep-0.5.1}/tests/test_config.py +0 -0
- {safekeep-0.4.0 → safekeep-0.5.1}/tests/test_daemon.py +0 -0
- {safekeep-0.4.0 → safekeep-0.5.1}/tests/test_matcher.py +0 -0
- {safekeep-0.4.0 → safekeep-0.5.1}/tests/test_volumes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: safekeep
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: Selective always-on backups for macOS and Linux: fswatch-driven copy of allow-listed files, never deletes
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Repository, https://github.com/foxhound87/safekeep
|
|
@@ -182,7 +182,7 @@ safekeep <command> [--config PATH] [-v]
|
|
|
182
182
|
| `run` | daemon: initial reconcile, fswatch loop, event dispatch, 24h timer |
|
|
183
183
|
| `sync-once [--dry-run] [--project PATH] [--prune]` | a single pass: walks the source and copies whatever differs (`--dry-run` only prints what it would copy; `--prune` also removes dest files whose source still exists but is no longer included) |
|
|
184
184
|
| `status` | read-only: config, sources, discovered projects with N rules, destination states |
|
|
185
|
-
| `doctor` | diagnostics: config, fswatch + platform monitor, python, TCC/launchd plist (macOS), inotify limit and systemd unit (Linux) — exits non-zero if a fatal check fails |
|
|
185
|
+
| `doctor [--json]` | diagnostics: config, fswatch + platform monitor, python, TCC/launchd plist (macOS), inotify limit and systemd unit (Linux) — exits non-zero if a fatal check fails; `--json` prints the same checks as one JSON document on stdout with the same exit code, including a structured `permission_errors` count of the last 24h |
|
|
186
186
|
|
|
187
187
|
## Tests
|
|
188
188
|
|
|
@@ -190,9 +190,10 @@ safekeep <command> [--config PATH] [-v]
|
|
|
190
190
|
python3 -m unittest discover -s tests
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
-
Stdlib (`unittest`) suite, zero dependencies:
|
|
194
|
-
config, atomic copy, volumes, daemon, auto-discovery, CLI
|
|
195
|
-
portability layer (platform helper, per-OS fswatch
|
|
193
|
+
Stdlib (`unittest`) suite, zero dependencies: 221 tests covering the matcher,
|
|
194
|
+
config, atomic copy, volumes, daemon, auto-discovery, CLI, the permission-trend
|
|
195
|
+
wrapper and the POSIX portability layer (platform helper, per-OS fswatch
|
|
196
|
+
monitor, systemd template).
|
|
196
197
|
`fswatch` is not needed to run the suite — the few `doctor` checks that talk to
|
|
197
198
|
the real binary are skipped when it's missing — but CI installs it so the
|
|
198
199
|
Linux job exercises the real `inotify` monitor.
|
|
@@ -157,7 +157,7 @@ safekeep <command> [--config PATH] [-v]
|
|
|
157
157
|
| `run` | daemon: initial reconcile, fswatch loop, event dispatch, 24h timer |
|
|
158
158
|
| `sync-once [--dry-run] [--project PATH] [--prune]` | a single pass: walks the source and copies whatever differs (`--dry-run` only prints what it would copy; `--prune` also removes dest files whose source still exists but is no longer included) |
|
|
159
159
|
| `status` | read-only: config, sources, discovered projects with N rules, destination states |
|
|
160
|
-
| `doctor` | diagnostics: config, fswatch + platform monitor, python, TCC/launchd plist (macOS), inotify limit and systemd unit (Linux) — exits non-zero if a fatal check fails |
|
|
160
|
+
| `doctor [--json]` | diagnostics: config, fswatch + platform monitor, python, TCC/launchd plist (macOS), inotify limit and systemd unit (Linux) — exits non-zero if a fatal check fails; `--json` prints the same checks as one JSON document on stdout with the same exit code, including a structured `permission_errors` count of the last 24h |
|
|
161
161
|
|
|
162
162
|
## Tests
|
|
163
163
|
|
|
@@ -165,9 +165,10 @@ safekeep <command> [--config PATH] [-v]
|
|
|
165
165
|
python3 -m unittest discover -s tests
|
|
166
166
|
```
|
|
167
167
|
|
|
168
|
-
Stdlib (`unittest`) suite, zero dependencies:
|
|
169
|
-
config, atomic copy, volumes, daemon, auto-discovery, CLI
|
|
170
|
-
portability layer (platform helper, per-OS fswatch
|
|
168
|
+
Stdlib (`unittest`) suite, zero dependencies: 221 tests covering the matcher,
|
|
169
|
+
config, atomic copy, volumes, daemon, auto-discovery, CLI, the permission-trend
|
|
170
|
+
wrapper and the POSIX portability layer (platform helper, per-OS fswatch
|
|
171
|
+
monitor, systemd template).
|
|
171
172
|
`fswatch` is not needed to run the suite — the few `doctor` checks that talk to
|
|
172
173
|
the real binary are skipped when it's missing — but CI installs it so the
|
|
173
174
|
Linux job exercises the real `inotify` monitor.
|
|
@@ -4,17 +4,22 @@ Entry point del package (`safekeep.cli:main`): `bin/safekeep.py` è solo lo
|
|
|
4
4
|
shim che usano launchd/systemd e i test.
|
|
5
5
|
"""
|
|
6
6
|
import argparse
|
|
7
|
+
import json
|
|
7
8
|
import os
|
|
9
|
+
import re
|
|
8
10
|
import shutil
|
|
9
11
|
import subprocess
|
|
10
12
|
import sys
|
|
13
|
+
from datetime import datetime
|
|
11
14
|
|
|
15
|
+
from safekeep import __version__
|
|
12
16
|
from safekeep.config import ConfigError, discover_projects, parse_sync
|
|
13
17
|
from safekeep.copier import TMP_INFIX
|
|
14
18
|
from safekeep.daemon import Daemon, setup_logging
|
|
15
19
|
from safekeep.platform import (
|
|
16
20
|
INOTIFY_MIN_WATCHES,
|
|
17
21
|
LOG_PATH,
|
|
22
|
+
daemon_exe,
|
|
18
23
|
fswatch_monitor,
|
|
19
24
|
inotify_limit,
|
|
20
25
|
install_hint,
|
|
@@ -58,11 +63,14 @@ def build_parser():
|
|
|
58
63
|
'non è più incluso dal matcher (default: mai cancellare)')
|
|
59
64
|
sub.add_parser('status', parents=[common],
|
|
60
65
|
help='config, progetti e stato delle dest — sola lettura')
|
|
61
|
-
sub.add_parser('doctor', parents=[common],
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
doc = sub.add_parser('doctor', parents=[common],
|
|
67
|
+
help='diagnostica: config, fswatch + monitor di piattaforma, '
|
|
68
|
+
'python, errori di permesso recenti nel log, TCC/plist '
|
|
69
|
+
'ed exe del daemon (macOS), inotify/systemd/linger (Linux) '
|
|
70
|
+
'— exit ≠ 0 se un check fatale fallisce')
|
|
71
|
+
doc.add_argument('--json', action='store_true',
|
|
72
|
+
help='stessi check, stesso exit code, ma un documento JSON su '
|
|
73
|
+
'stdout (SPEC.md §9.1)')
|
|
66
74
|
return parser
|
|
67
75
|
|
|
68
76
|
|
|
@@ -144,12 +152,28 @@ def check_sync_files(daemon):
|
|
|
144
152
|
|
|
145
153
|
|
|
146
154
|
def cmd_doctor(args):
|
|
147
|
-
"""Un check per riga con esito ✔/✗; exit 1 se almeno un check fatale fallisce.
|
|
155
|
+
"""Un check per riga con esito ✔/✗; exit 1 se almeno un check fatale fallisce.
|
|
156
|
+
|
|
157
|
+
Con `--json` le righe non vengono stampate: la stessa sequenza di check finisce
|
|
158
|
+
in un documento JSON su stdout, con lo stesso exit code (SPEC.md §9.1)."""
|
|
148
159
|
fatal = False
|
|
160
|
+
checks, ids = [], {}
|
|
149
161
|
|
|
150
162
|
def out(ok, msg, is_fatal=False):
|
|
151
163
|
nonlocal fatal
|
|
152
|
-
|
|
164
|
+
if args.json:
|
|
165
|
+
# id = slug del messaggio (prefisso fino a ':' o '('), id uguali
|
|
166
|
+
# ripetuti suffissati _2/_3 così restano chiave usabile (SPEC §9.1)
|
|
167
|
+
base = re.split(r'[:\(]', msg, maxsplit=1)[0]
|
|
168
|
+
ident = re.sub(r'[^a-z0-9]+', '_', base.lower()).strip('_') or 'check'
|
|
169
|
+
ids[ident] = ids.get(ident, 0) + 1
|
|
170
|
+
if ids[ident] > 1:
|
|
171
|
+
ident = f'{ident}_{ids[ident]}'
|
|
172
|
+
checks.append({'id': ident,
|
|
173
|
+
'status': 'ok' if ok else ('fail' if is_fatal else 'warn'),
|
|
174
|
+
'message': msg})
|
|
175
|
+
else:
|
|
176
|
+
print(('✔ ' if ok else '✗ ') + msg)
|
|
153
177
|
if not ok and is_fatal:
|
|
154
178
|
fatal = True
|
|
155
179
|
|
|
@@ -346,7 +370,38 @@ def cmd_doctor(args):
|
|
|
346
370
|
'senza login → loginctl enable-linger $USER — '
|
|
347
371
|
'warning non fatale')
|
|
348
372
|
|
|
349
|
-
|
|
373
|
+
# exe del daemon ancora su disco (SPEC.md §8.4/§14.4, 0.5.1): un processo
|
|
374
|
+
# può girare su un eseguibile cancellato da un upgrade sotto un daemon vivo
|
|
375
|
+
# → TCC (Transparency, Consent and Control) non lo identifica più e ogni
|
|
376
|
+
# copia fallisce con EPERM. Guarda il PROCESSO, non il plist (funziona
|
|
377
|
+
# anche col plist renderizzato col vecchio default). Non fatale: sintomo
|
|
378
|
+
# con rimedio manuale di una riga, e doctor deve girare anche in quello stato.
|
|
379
|
+
if is_darwin():
|
|
380
|
+
exe = daemon_exe()
|
|
381
|
+
if exe is None:
|
|
382
|
+
out(True, 'exe del daemon: processo non determinabile — check saltato')
|
|
383
|
+
elif os.path.exists(exe):
|
|
384
|
+
out(True, f'exe del daemon: {exe} presente su disco')
|
|
385
|
+
else:
|
|
386
|
+
out(False, f'exe del daemon: {exe} NON esiste su disco — il processo '
|
|
387
|
+
'gira su un eseguibile cancellato (upgrade sotto un daemon '
|
|
388
|
+
'vivo, SPEC §8.4) → launchctl kickstart -k '
|
|
389
|
+
'gui/$(id -u)/com.safekeep.agent — warning non fatale')
|
|
390
|
+
|
|
391
|
+
code = 1 if fatal else 0
|
|
392
|
+
if args.json:
|
|
393
|
+
count, last = recent if recent is not None else (0, None)
|
|
394
|
+
print(json.dumps({
|
|
395
|
+
'safekeep': __version__,
|
|
396
|
+
'timestamp': datetime.now().isoformat(timespec='seconds'),
|
|
397
|
+
'exit': code,
|
|
398
|
+
'checks': checks,
|
|
399
|
+
'permission_errors': {
|
|
400
|
+
'count_24h': count,
|
|
401
|
+
'last': last.strftime('%Y-%m-%d %H:%M:%S') if last else None,
|
|
402
|
+
},
|
|
403
|
+
}, ensure_ascii=False, indent=2))
|
|
404
|
+
return code
|
|
350
405
|
|
|
351
406
|
|
|
352
407
|
def main(argv=None):
|
|
@@ -20,6 +20,36 @@ MTIME_TOLERANCE = 1.0 # secondi: tolleranza mtime per volumi a risoluzione
|
|
|
20
20
|
TMP_INFIX = '.safekeep.tmp.'
|
|
21
21
|
# errori che dicono "questa dest non è usabile adesso" → pending + backoff (§8.3)
|
|
22
22
|
MOUNT_ERRNOS = (errno.ENODEV, errno.EBUSY, errno.ENOSPC)
|
|
23
|
+
# throttle delle copie fallite (SPEC.md §10.1, 0.5.1): stessa (path, errno) →
|
|
24
|
+
# una riga + `— ripetuto N volte` ogni THROTTLE_INTERVAL, non una riga a fallimento
|
|
25
|
+
THROTTLE_INTERVAL = 300.0 # secondi
|
|
26
|
+
_copy_errors = {} # (src, errno) → [ultimo log monotonic, non loggati]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def log_copy_error(src, dst, e, now=None):
|
|
30
|
+
"""`copia fallita src → dst: e` con dedup per **(path, errno)** (SPEC.md §10.1).
|
|
31
|
+
|
|
32
|
+
Prima occorrenza: riga storica invariata. Poi al massimo una riga ogni
|
|
33
|
+
`THROTTLE_INTERVAL` per chiave, col suffisso `— ripetuto N volte` (N =
|
|
34
|
+
fallimenti non loggati da quell'ultima riga). `recent_permission_errors`
|
|
35
|
+
(§14.4) somma N+1 su queste righe: `count_24h` resta il conteggio vero,
|
|
36
|
+
sono solo le righe sul file a essere di meno. I conteggi in memoria
|
|
37
|
+
(`stats['errati']`, `sync-once`) non leggono da qui.
|
|
38
|
+
"""
|
|
39
|
+
if now is None:
|
|
40
|
+
now = time.monotonic()
|
|
41
|
+
key = (src, e.errno)
|
|
42
|
+
entry = _copy_errors.get(key)
|
|
43
|
+
if entry is None or now - entry[0] >= THROTTLE_INTERVAL:
|
|
44
|
+
pending = 0 if entry is None else entry[1]
|
|
45
|
+
if pending:
|
|
46
|
+
log.error('copia fallita %s → %s: %s — ripetuto %d volte',
|
|
47
|
+
src, dst, e, pending)
|
|
48
|
+
else:
|
|
49
|
+
log.error('copia fallita %s → %s: %s', src, dst, e)
|
|
50
|
+
_copy_errors[key] = [now, 0]
|
|
51
|
+
else:
|
|
52
|
+
entry[1] += 1
|
|
23
53
|
|
|
24
54
|
|
|
25
55
|
def _tmp_path(dst):
|
|
@@ -215,7 +245,7 @@ def reconcile_project(source_root, dest_root, matcher, layout, dest_path_fn,
|
|
|
215
245
|
log.error('errore di mount su %s → %s: %s: la dest va in pending',
|
|
216
246
|
src, dst, e)
|
|
217
247
|
return True
|
|
218
|
-
|
|
248
|
+
log_copy_error(src, dst, e)
|
|
219
249
|
return False
|
|
220
250
|
|
|
221
251
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
@@ -25,7 +25,14 @@ from .config import (
|
|
|
25
25
|
source_root_for,
|
|
26
26
|
validate_dests,
|
|
27
27
|
)
|
|
28
|
-
from .copier import
|
|
28
|
+
from .copier import (
|
|
29
|
+
MOUNT_ERRNOS,
|
|
30
|
+
copy_one,
|
|
31
|
+
log_copy_error,
|
|
32
|
+
needs_copy,
|
|
33
|
+
prune_project,
|
|
34
|
+
reconcile_project,
|
|
35
|
+
)
|
|
29
36
|
from .matcher import BUILTIN_RULES, Matcher, _glob, parse_rule
|
|
30
37
|
from .platform import LOG_PATH, fswatch_monitor, init_system
|
|
31
38
|
from .volumes import backoff_schedule, dest_state, partition_dests
|
|
@@ -629,7 +636,7 @@ class Daemon:
|
|
|
629
636
|
src, dst, e)
|
|
630
637
|
self.mark_pending(project, dest)
|
|
631
638
|
else:
|
|
632
|
-
|
|
639
|
+
log_copy_error(src, dst, e)
|
|
633
640
|
|
|
634
641
|
def walk_dir(self, dirpath, project):
|
|
635
642
|
"""Mini-walk: i figli di una directory appena creata potrebbero non
|
|
@@ -15,6 +15,7 @@ import os
|
|
|
15
15
|
import platform as _platform
|
|
16
16
|
import re
|
|
17
17
|
import shutil
|
|
18
|
+
import subprocess
|
|
18
19
|
import sys
|
|
19
20
|
from datetime import datetime
|
|
20
21
|
|
|
@@ -26,6 +27,9 @@ SYSTEMD_RUN_PATH = '/run/systemd/system' # assente su WSL1/systemd of
|
|
|
26
27
|
# EPERM (Errno 1) / EACCES (Errno 13) come li scrive il logger: codice errno
|
|
27
28
|
# + testo del kernel (SPEC.md §14.4)
|
|
28
29
|
PERM_ERR_RE = re.compile(r'\[Errno (?:1|13)\]|Operation not permitted|Permission denied')
|
|
30
|
+
# suffisso del throttle (SPEC.md §10.1, 0.5.1): la riga rappresenta se stessa
|
|
31
|
+
# + i N fallimenti compressi → count_24h la conta come N+1, non 1 (onesto)
|
|
32
|
+
REPEAT_RE = re.compile(r'ripetuto (\d+) volte')
|
|
29
33
|
|
|
30
34
|
|
|
31
35
|
def is_darwin():
|
|
@@ -75,7 +79,10 @@ def recent_permission_errors(path=None, now=None, window=86400):
|
|
|
75
79
|
(SPEC.md §14.4: check non fatale, finestra 24h — segue l'incidente EPERM).
|
|
76
80
|
|
|
77
81
|
Il timestamp è il prefisso `%(asctime)s` della riga (`YYYY-MM-DD HH:MM:SS`,
|
|
78
|
-
prime 19 posizioni).
|
|
82
|
+
prime 19 posizioni). Le righe col suffisso del throttle `— ripetuto N volte`
|
|
83
|
+
(§10.1) valgono **N + 1**: la riga stessa + i N fallimenti compressi, così
|
|
84
|
+
il conteggio resta il numero vero di errori anche con le righe throttolate.
|
|
85
|
+
**Limite dichiarato**: una riga senza timestamp
|
|
79
86
|
parsificabile o fuori dalla finestra (clock skew incluso) non viene contata —
|
|
80
87
|
non essendo sicuri che sia recente, non la contiamo: il conteggio è un minimo."""
|
|
81
88
|
if path is None:
|
|
@@ -95,7 +102,8 @@ def recent_permission_errors(path=None, now=None, window=86400):
|
|
|
95
102
|
age = (now - ts).total_seconds()
|
|
96
103
|
if not 0 <= age <= window:
|
|
97
104
|
continue # troppo vecchio (o nel futuro: clock skew)
|
|
98
|
-
|
|
105
|
+
rep = REPEAT_RE.search(line)
|
|
106
|
+
count += 1 + (int(rep.group(1)) if rep else 0)
|
|
99
107
|
if last is None or ts > last:
|
|
100
108
|
last = ts
|
|
101
109
|
except OSError:
|
|
@@ -103,6 +111,38 @@ def recent_permission_errors(path=None, now=None, window=86400):
|
|
|
103
111
|
return count, last
|
|
104
112
|
|
|
105
113
|
|
|
114
|
+
DAEMON_LABEL = 'com.safekeep.agent' # label launchd dell'agent (§8.1)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def daemon_exe(label=DAEMON_LABEL):
|
|
118
|
+
"""→ eseguibile assoluto del processo launchd `label`, o None se non
|
|
119
|
+
determinabile (SPEC.md §8.4/§14.4, 0.5.1: job non caricato, processo non
|
|
120
|
+
trovato, `launchctl`/`ps` assenti o falliti, non-Darwin). Solo lettura.
|
|
121
|
+
|
|
122
|
+
Serve al check `doctor` (§14.4): un processo può girare su un eseguibile
|
|
123
|
+
**cancellato** da un upgrade sotto un daemon vivo (brew upgrade → path
|
|
124
|
+
Cellar sparito → TCC non identifica il processo → EPERM su ogni copia).
|
|
125
|
+
"""
|
|
126
|
+
if not is_darwin():
|
|
127
|
+
return None
|
|
128
|
+
try:
|
|
129
|
+
job = subprocess.run(
|
|
130
|
+
['launchctl', 'print', f'gui/{os.getuid()}/{label}'],
|
|
131
|
+
capture_output=True, text=True, timeout=10)
|
|
132
|
+
except (OSError, subprocess.SubprocessError):
|
|
133
|
+
return None
|
|
134
|
+
pid = re.search(r'^\s*pid = (\d+)', job.stdout, re.M)
|
|
135
|
+
if job.returncode != 0 or pid is None:
|
|
136
|
+
return None # job non caricato o senza processo
|
|
137
|
+
try:
|
|
138
|
+
ps = subprocess.run(['ps', '-p', pid.group(1), '-o', 'comm='],
|
|
139
|
+
capture_output=True, text=True, timeout=10)
|
|
140
|
+
except (OSError, subprocess.SubprocessError):
|
|
141
|
+
return None
|
|
142
|
+
exe = ps.stdout.strip().splitlines()[0].strip() if ps.stdout.strip() else ''
|
|
143
|
+
return exe if ps.returncode == 0 and exe else None
|
|
144
|
+
|
|
145
|
+
|
|
106
146
|
def linger_enabled(user=None, linger_dir=None):
|
|
107
147
|
"""`True`/`False` se l'utente è in `/var/lib/systemd/linger/`, None se la
|
|
108
148
|
directory non c'è (niente systemd → non determinabile, SPEC.md §14.4).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: safekeep
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: Selective always-on backups for macOS and Linux: fswatch-driven copy of allow-listed files, never deletes
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Repository, https://github.com/foxhound87/safekeep
|
|
@@ -182,7 +182,7 @@ safekeep <command> [--config PATH] [-v]
|
|
|
182
182
|
| `run` | daemon: initial reconcile, fswatch loop, event dispatch, 24h timer |
|
|
183
183
|
| `sync-once [--dry-run] [--project PATH] [--prune]` | a single pass: walks the source and copies whatever differs (`--dry-run` only prints what it would copy; `--prune` also removes dest files whose source still exists but is no longer included) |
|
|
184
184
|
| `status` | read-only: config, sources, discovered projects with N rules, destination states |
|
|
185
|
-
| `doctor` | diagnostics: config, fswatch + platform monitor, python, TCC/launchd plist (macOS), inotify limit and systemd unit (Linux) — exits non-zero if a fatal check fails |
|
|
185
|
+
| `doctor [--json]` | diagnostics: config, fswatch + platform monitor, python, TCC/launchd plist (macOS), inotify limit and systemd unit (Linux) — exits non-zero if a fatal check fails; `--json` prints the same checks as one JSON document on stdout with the same exit code, including a structured `permission_errors` count of the last 24h |
|
|
186
186
|
|
|
187
187
|
## Tests
|
|
188
188
|
|
|
@@ -190,9 +190,10 @@ safekeep <command> [--config PATH] [-v]
|
|
|
190
190
|
python3 -m unittest discover -s tests
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
-
Stdlib (`unittest`) suite, zero dependencies:
|
|
194
|
-
config, atomic copy, volumes, daemon, auto-discovery, CLI
|
|
195
|
-
portability layer (platform helper, per-OS fswatch
|
|
193
|
+
Stdlib (`unittest`) suite, zero dependencies: 221 tests covering the matcher,
|
|
194
|
+
config, atomic copy, volumes, daemon, auto-discovery, CLI, the permission-trend
|
|
195
|
+
wrapper and the POSIX portability layer (platform helper, per-OS fswatch
|
|
196
|
+
monitor, systemd template).
|
|
196
197
|
`fswatch` is not needed to run the suite — the few `doctor` checks that talk to
|
|
197
198
|
the real binary are skipped when it's missing — but CI installs it so the
|
|
198
199
|
Linux job exercises the real `inotify` monitor.
|
|
@@ -6,6 +6,7 @@ import argparse
|
|
|
6
6
|
import contextlib
|
|
7
7
|
import getpass
|
|
8
8
|
import io
|
|
9
|
+
import json
|
|
9
10
|
import os
|
|
10
11
|
import shutil
|
|
11
12
|
import subprocess
|
|
@@ -110,7 +111,7 @@ class DoctorTest(CliTestCase):
|
|
|
110
111
|
"""SPEC.md §14.4: su Linux il limite di watch basso è informativo —
|
|
111
112
|
doctor esce 0, non 1. I rami Linux si esercitano patchando l'helper,
|
|
112
113
|
senza girare su Linux."""
|
|
113
|
-
args = argparse.Namespace(config=self.cfg, v=False)
|
|
114
|
+
args = argparse.Namespace(config=self.cfg, v=False, json=False)
|
|
114
115
|
buf = io.StringIO()
|
|
115
116
|
with mock.patch.object(cli, 'is_linux', return_value=True), \
|
|
116
117
|
mock.patch.object(cli, 'is_darwin', return_value=False), \
|
|
@@ -146,6 +147,42 @@ class DoctorTest(CliTestCase):
|
|
|
146
147
|
self.assertEqual(r.returncode, 0, r.stdout)
|
|
147
148
|
|
|
148
149
|
|
|
150
|
+
class DoctorExeDaemonTest(CliTestCase):
|
|
151
|
+
"""SPEC.md §8.4/§14.4 (0.5.1): exe del processo launchd su disco —
|
|
152
|
+
esistente ✔, cancellato ✗ **non fatale**, non determinabile → saltato."""
|
|
153
|
+
|
|
154
|
+
def doctor_darwin(self, exe):
|
|
155
|
+
args = argparse.Namespace(config=self.cfg, v=False, json=False)
|
|
156
|
+
buf = io.StringIO()
|
|
157
|
+
with mock.patch.object(cli, 'is_darwin', return_value=True), \
|
|
158
|
+
mock.patch.object(cli, 'daemon_exe', return_value=exe), \
|
|
159
|
+
contextlib.redirect_stdout(buf):
|
|
160
|
+
code = cli.cmd_doctor(args)
|
|
161
|
+
line = next((l for l in buf.getvalue().splitlines()
|
|
162
|
+
if 'exe del daemon' in l), '')
|
|
163
|
+
return code, buf.getvalue(), line
|
|
164
|
+
|
|
165
|
+
def test_exe_esistente_verde(self):
|
|
166
|
+
code, _, line = self.doctor_darwin(sys.executable)
|
|
167
|
+
self.assertTrue(line.startswith('✔'), line)
|
|
168
|
+
self.assertIn('presente su disco', line)
|
|
169
|
+
self.assertEqual(code, 0)
|
|
170
|
+
|
|
171
|
+
def test_exe_cancellato_warning_non_fatale(self):
|
|
172
|
+
exe = os.path.join(self.tmp, 'Cellar', 'python@3.14', '3.14.6', 'Python')
|
|
173
|
+
code, _, line = self.doctor_darwin(exe) # non creato: inesistente
|
|
174
|
+
self.assertTrue(line.startswith('✗'), line)
|
|
175
|
+
for needle in ('NON esiste su disco', 'kickstart', 'warning non fatale'):
|
|
176
|
+
self.assertIn(needle, line)
|
|
177
|
+
self.assertEqual(code, 0, 'sintomo con rimedio manuale: NON fatale')
|
|
178
|
+
|
|
179
|
+
def test_non_determinabile_check_saltato(self):
|
|
180
|
+
code, _, line = self.doctor_darwin(None)
|
|
181
|
+
self.assertTrue(line.startswith('✔'), line)
|
|
182
|
+
self.assertIn('check saltato', line)
|
|
183
|
+
self.assertEqual(code, 0)
|
|
184
|
+
|
|
185
|
+
|
|
149
186
|
class AutoDiscoveryCliTest(CliTestCase):
|
|
150
187
|
"""Config SENZA `source:` → modalità auto-discovery (SPEC.md §6)."""
|
|
151
188
|
|
|
@@ -329,7 +366,7 @@ class DoctorLingerTest(CliTestCase):
|
|
|
329
366
|
self.addCleanup(shutil.rmtree, linger_dir, ignore_errors=True)
|
|
330
367
|
for name in linger:
|
|
331
368
|
open(os.path.join(linger_dir, name), 'w').close()
|
|
332
|
-
args = argparse.Namespace(config=self.cfg, v=False)
|
|
369
|
+
args = argparse.Namespace(config=self.cfg, v=False, json=False)
|
|
333
370
|
buf = io.StringIO()
|
|
334
371
|
with mock.patch.dict(os.environ, {'HOME': self.home}), \
|
|
335
372
|
mock.patch.object(cli, 'is_linux', return_value=True), \
|
|
@@ -368,7 +405,7 @@ class DoctorWslTest(CliTestCase):
|
|
|
368
405
|
|
|
369
406
|
def doctor_wsl(self, distro='Ubuntu', systemd=False, dest='/mnt/c/backup'):
|
|
370
407
|
cfg = write(os.path.join(self.tmp, 'wsl.cfg'), f'dest: {dest}\n')
|
|
371
|
-
args = argparse.Namespace(config=cfg, v=False)
|
|
408
|
+
args = argparse.Namespace(config=cfg, v=False, json=False)
|
|
372
409
|
buf = io.StringIO()
|
|
373
410
|
with mock.patch.dict(os.environ, {'HOME': self.home}), \
|
|
374
411
|
mock.patch.object(cli, 'is_wsl', return_value=True), \
|
|
@@ -426,5 +463,81 @@ class DoctorWslTest(CliTestCase):
|
|
|
426
463
|
self.assertNotIn('drvfs', r.stdout)
|
|
427
464
|
|
|
428
465
|
|
|
466
|
+
class DoctorJsonTest(CliTestCase):
|
|
467
|
+
"""SPEC.md §9.1: `doctor --json` — stessi check, stesso ordine, stesso exit
|
|
468
|
+
code della modalità normale, tutto in un documento JSON su stdout."""
|
|
469
|
+
|
|
470
|
+
def json_doctor(self, *args):
|
|
471
|
+
r = self.cli('doctor', '--json', *args)
|
|
472
|
+
# json.loads sul stdout intero: basta una riga di log mista e cade qui
|
|
473
|
+
return r, json.loads(r.stdout)
|
|
474
|
+
|
|
475
|
+
@staticmethod
|
|
476
|
+
def righe(stdout):
|
|
477
|
+
"""`(status_atteso, message)` di ogni riga della modalità normale."""
|
|
478
|
+
out = []
|
|
479
|
+
for line in stdout.splitlines():
|
|
480
|
+
if not line.strip():
|
|
481
|
+
continue
|
|
482
|
+
status = 'ok' if line.startswith('✔') else 'fail-o-warn'
|
|
483
|
+
out.append((status, line[2:]))
|
|
484
|
+
return out
|
|
485
|
+
|
|
486
|
+
def test_checks_1a1_con_la_modalita_normale(self):
|
|
487
|
+
normale = self.cli('doctor', '--config', self.cfg)
|
|
488
|
+
r, doc = self.json_doctor('--config', self.cfg)
|
|
489
|
+
attese = self.righe(normale.stdout)
|
|
490
|
+
self.assertEqual([c['message'] for c in doc['checks']],
|
|
491
|
+
[msg for _, msg in attese],
|
|
492
|
+
'stessi check, stesso ordine della modalità normale')
|
|
493
|
+
for (atteso, _), check in zip(attese, doc['checks']):
|
|
494
|
+
if atteso == 'ok':
|
|
495
|
+
self.assertEqual(check['status'], 'ok', check)
|
|
496
|
+
else: # riga ✗: warn o fail
|
|
497
|
+
self.assertIn(check['status'], ('warn', 'fail'), check)
|
|
498
|
+
self.assertEqual(len({c['id'] for c in doc['checks']}),
|
|
499
|
+
len(doc['checks']), 'id duplicati')
|
|
500
|
+
self.assertEqual(r.returncode, normale.returncode, 'stesso exit code')
|
|
501
|
+
self.assertEqual(doc['exit'], r.returncode)
|
|
502
|
+
|
|
503
|
+
def test_header_e_campi_decisionali(self):
|
|
504
|
+
_, doc = self.json_doctor('--config', self.cfg)
|
|
505
|
+
self.assertEqual(doc['safekeep'], cli.__version__)
|
|
506
|
+
datetime.fromisoformat(doc['timestamp']) # ISO 8601 valido
|
|
507
|
+
self.assertIsInstance(doc['checks'], list)
|
|
508
|
+
self.assertTrue(doc['checks'])
|
|
509
|
+
for check in doc['checks']:
|
|
510
|
+
self.assertEqual(set(check), {'id', 'status', 'message'})
|
|
511
|
+
self.assertIn(check['status'], ('ok', 'warn', 'fail'))
|
|
512
|
+
self.assertEqual(set(doc['permission_errors']), {'count_24h', 'last'})
|
|
513
|
+
|
|
514
|
+
def test_permission_errors_strutturati_sul_log_recente(self):
|
|
515
|
+
log = os.path.join(self.home, '.local/state/safekeep/safekeep.log')
|
|
516
|
+
stamp = (datetime.now() - timedelta(hours=3)).strftime('%Y-%m-%d %H:%M:%S')
|
|
517
|
+
write(log, f'{stamp},000 ERROR copia fallita: [Errno 1] Operation not permitted\n')
|
|
518
|
+
r, doc = self.json_doctor('--config', self.cfg)
|
|
519
|
+
perm = doc['permission_errors']
|
|
520
|
+
self.assertEqual(perm['count_24h'], 1)
|
|
521
|
+
self.assertEqual(perm['last'], stamp) # ultimo timestamp
|
|
522
|
+
check = next(c for c in doc['checks']
|
|
523
|
+
if 'errori di permesso' in c['message'])
|
|
524
|
+
self.assertEqual(check['status'], 'warn') # warning, NON fatale
|
|
525
|
+
self.assertEqual(r.returncode, 0, r.stdout)
|
|
526
|
+
|
|
527
|
+
def test_log_assente_count_zero_last_null(self):
|
|
528
|
+
_, doc = self.json_doctor('--config', self.cfg) # HOME finto: niente log
|
|
529
|
+
self.assertEqual(doc['permission_errors'],
|
|
530
|
+
{'count_24h': 0, 'last': None})
|
|
531
|
+
|
|
532
|
+
@unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
|
|
533
|
+
def test_check_fatale_esce_1_e_lo_dichiara(self):
|
|
534
|
+
bad = write(os.path.join(self.tmp, 'bad.cfg'), 'riga nuda invalida\n')
|
|
535
|
+
r, doc = self.json_doctor('--config', bad)
|
|
536
|
+
self.assertEqual(r.returncode, 1, r.stdout)
|
|
537
|
+
self.assertEqual(doc['exit'], 1)
|
|
538
|
+
self.assertTrue(any(c['status'] == 'fail' for c in doc['checks']),
|
|
539
|
+
'il check fatale deve comparire come fail')
|
|
540
|
+
|
|
541
|
+
|
|
429
542
|
if __name__ == '__main__':
|
|
430
543
|
unittest.main()
|
|
@@ -296,6 +296,27 @@ class ErroriPerFileTest(CopierTestCase):
|
|
|
296
296
|
self.assertFalse(os.path.exists(self.dst_root),
|
|
297
297
|
'il walk si ferma: niente scritture a metà')
|
|
298
298
|
|
|
299
|
+
def test_reconcile_ripetuti_stessi_errori_pochissime_righe(self):
|
|
300
|
+
"""SPEC.md §10.1 (0.5.1): 5 reconcile con gli stessi 3 file negati
|
|
301
|
+
(EPERM) → 3 righe di log (una per (path, errno)), non 15. I conteggi
|
|
302
|
+
in memoria restano intatti: 3 errori per passata."""
|
|
303
|
+
copier._copy_errors.clear()
|
|
304
|
+
self.addCleanup(copier._copy_errors.clear)
|
|
305
|
+
|
|
306
|
+
def boom(*args, **kwargs):
|
|
307
|
+
raise OSError(1, 'Operation not permitted')
|
|
308
|
+
|
|
309
|
+
with mock.patch.object(copier, 'copy_one', new=boom), \
|
|
310
|
+
mock.patch.object(copier.log, 'error') as errlog:
|
|
311
|
+
for _ in range(5):
|
|
312
|
+
stats = {}
|
|
313
|
+
reconcile_project(stats=stats, **self.kwargs)
|
|
314
|
+
self.assertEqual(stats['errati'], 3,
|
|
315
|
+
'il conteggio non passa dal log')
|
|
316
|
+
self.assertEqual(errlog.call_count, 3,
|
|
317
|
+
'una riga per (path, errno), le 4 passate successive '
|
|
318
|
+
'rientrano nell\'intervallo di throttle')
|
|
319
|
+
|
|
299
320
|
|
|
300
321
|
class PruneTest(CopierTestCase):
|
|
301
322
|
"""`sync-once --prune`: cancellazione solo certa e solo sotto il prefisso."""
|
|
@@ -332,5 +353,51 @@ class PruneTest(CopierTestCase):
|
|
|
332
353
|
self.assertTrue(os.path.exists(os.path.join(self.dst_root, 'proj/old.txt')))
|
|
333
354
|
|
|
334
355
|
|
|
356
|
+
class ThrottleLogTest(CopierTestCase):
|
|
357
|
+
"""SPEC.md §10.1 (0.5.1): stessa (path, errno) → ≤1 riga ogni
|
|
358
|
+
THROTTLE_INTERVAL con `— ripetuto N volte`, non una riga a fallimento."""
|
|
359
|
+
|
|
360
|
+
def setUp(self):
|
|
361
|
+
super().setUp()
|
|
362
|
+
copier._copy_errors.clear()
|
|
363
|
+
self.addCleanup(copier._copy_errors.clear)
|
|
364
|
+
|
|
365
|
+
@staticmethod
|
|
366
|
+
def err(no=1):
|
|
367
|
+
return OSError(no, 'Operation not permitted')
|
|
368
|
+
|
|
369
|
+
def test_50_failimenti_stesso_path_una_sola_riga(self):
|
|
370
|
+
with self.assertLogs(LOGGER, 'ERROR') as cm:
|
|
371
|
+
for _ in range(50):
|
|
372
|
+
copier.log_copy_error('/s/a', '/d/a', self.err(), now=1000.0)
|
|
373
|
+
self.assertEqual(len(cm.output), 1, cm.output)
|
|
374
|
+
self.assertNotIn('ripetuto', cm.output[0], 'nessun suffisso sulla 1ª riga')
|
|
375
|
+
|
|
376
|
+
def test_dopo_intervallo_una_riga_con_il_conteggio(self):
|
|
377
|
+
now = 1000.0
|
|
378
|
+
copier.log_copy_error('/s/a', '/d/a', self.err(), now=now)
|
|
379
|
+
with mock.patch.object(copier.log, 'error') as errlog: # 9 dentro la finestra
|
|
380
|
+
for _ in range(9):
|
|
381
|
+
copier.log_copy_error('/s/a', '/d/a', self.err(), now=now)
|
|
382
|
+
errlog.assert_not_called()
|
|
383
|
+
with self.assertLogs(LOGGER, 'ERROR') as cm: # finestra scaduta
|
|
384
|
+
copier.log_copy_error('/s/a', '/d/a', self.err(),
|
|
385
|
+
now=now + copier.THROTTLE_INTERVAL + 1)
|
|
386
|
+
for _ in range(5): # poi di nuovo silenzio
|
|
387
|
+
copier.log_copy_error('/s/a', '/d/a', self.err(),
|
|
388
|
+
now=now + copier.THROTTLE_INTERVAL + 1)
|
|
389
|
+
self.assertEqual(len(cm.output), 1, cm.output)
|
|
390
|
+
self.assertIn('ripetuto 9 volte', cm.output[0])
|
|
391
|
+
|
|
392
|
+
def test_chiavi_distinte_indipendenti(self):
|
|
393
|
+
now = 1000.0
|
|
394
|
+
with self.assertLogs(LOGGER, 'ERROR') as cm:
|
|
395
|
+
copier.log_copy_error('/s/a', '/d/a', self.err(1), now=now)
|
|
396
|
+
copier.log_copy_error('/s/a', '/d/a', self.err(1), now=now)
|
|
397
|
+
copier.log_copy_error('/s/b', '/d/b', self.err(1), now=now)
|
|
398
|
+
copier.log_copy_error('/s/a', '/d/a', self.err(13), now=now)
|
|
399
|
+
self.assertEqual(len(cm.output), 3, cm.output) # (a,1) una + b + (a,13)
|
|
400
|
+
|
|
401
|
+
|
|
335
402
|
if __name__ == '__main__':
|
|
336
403
|
unittest.main()
|
|
@@ -146,6 +146,21 @@ class AgentTemplateTest(unittest.TestCase):
|
|
|
146
146
|
for needle in ('RunAtLoad', 'KeepAlive', '__REPO__', '__PYTHON__'):
|
|
147
147
|
self.assertIn(needle, text, needle)
|
|
148
148
|
|
|
149
|
+
def test_install_sh_interprete_stabile(self):
|
|
150
|
+
"""SPEC.md §8.4 (0.5.1): il default renderizzato è lo shim di sistema
|
|
151
|
+
/usr/bin/python3, MAI un path Cellar di Homebrew — un `brew upgrade`
|
|
152
|
+
cancella il Cellar sotto il processo vivo e TCC non lo identifica più."""
|
|
153
|
+
with open(os.path.join(REPO, 'install.sh'), encoding='utf-8') as fh:
|
|
154
|
+
text = fh.read()
|
|
155
|
+
self.assertIn('SAFEKEEP_PYTHON:-/usr/bin/python3', text)
|
|
156
|
+
self.assertNotIn('/opt/homebrew/bin/python3', text,
|
|
157
|
+
'il default non deve più puntare al Cellar di brew')
|
|
158
|
+
self.assertIn('py_ok', text, "validazione ≥ 3.9 dell'interprete")
|
|
159
|
+
for name in ('com.safekeep.agent.plist', 'com.safekeep.trend.plist'):
|
|
160
|
+
with open(os.path.join(REPO, 'launchd', name), encoding='utf-8') as fh:
|
|
161
|
+
tpl = fh.read()
|
|
162
|
+
self.assertIn('default: /usr/bin/python3', tpl, name)
|
|
163
|
+
|
|
149
164
|
|
|
150
165
|
class RecentPermissionErrorsTest(unittest.TestCase):
|
|
151
166
|
"""SPEC.md §14.4: errori di permesso recenti nel log, finestra 24h,
|
|
@@ -199,6 +214,78 @@ class RecentPermissionErrorsTest(unittest.TestCase):
|
|
|
199
214
|
self.assertIsNotNone(plat.PERM_ERR_RE.search(riga))
|
|
200
215
|
self.assertIsNone(plat.PERM_ERR_RE.search('INFO reconcile: 0 copie'))
|
|
201
216
|
|
|
217
|
+
def test_suffisso_ripetuto_del_throttle_contato_come_N_piu_1(self):
|
|
218
|
+
"""SPEC.md §10.1/§14.4 (0.5.1): una riga `— ripetuto N volte`
|
|
219
|
+
rappresenta se stessa + i N fallimenti compressi → count_24h resta il
|
|
220
|
+
numero VERO di errori anche con le righe throttolate."""
|
|
221
|
+
text = (
|
|
222
|
+
f'{self._stamp(self.NOW - timedelta(hours=2))} ERROR copia fallita '
|
|
223
|
+
"a → b: [Errno 1] Operation not permitted: '/x.tmp.1'\n"
|
|
224
|
+
f'{self._stamp(self.NOW - timedelta(hours=1))} ERROR copia fallita '
|
|
225
|
+
"a → b: [Errno 1] Operation not permitted: '/x.tmp.1' — ripetuto 49 volte\n"
|
|
226
|
+
)
|
|
227
|
+
count, last = plat.recent_permission_errors(self._log(text), now=self.NOW)
|
|
228
|
+
self.assertEqual(count, 1 + 1 + 49, 'riga base + (riga suffisso + 49)')
|
|
229
|
+
self.assertEqual(last, self.NOW - timedelta(hours=1))
|
|
230
|
+
|
|
231
|
+
def test_riga_senza_suffisso_restano_una_ciascuna(self):
|
|
232
|
+
text = (f'{self._stamp(self.NOW - timedelta(hours=2))} ERROR copia fallita '
|
|
233
|
+
"a → b: [Errno 1] Operation not permitted: '/x'\n"
|
|
234
|
+
f'{self._stamp(self.NOW - timedelta(hours=1))} ERROR copia fallita '
|
|
235
|
+
"a → b: [Errno 1] Operation not permitted: '/y'\n")
|
|
236
|
+
count, _ = plat.recent_permission_errors(self._log(text), now=self.NOW)
|
|
237
|
+
self.assertEqual(count, 2)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class DaemonExeTest(unittest.TestCase):
|
|
241
|
+
"""SPEC.md §14.4 (0.5.1): exe del processo launchd, None se non
|
|
242
|
+
determinabile — Darwin-only, solo lettura, casi finti."""
|
|
243
|
+
|
|
244
|
+
LAUNCHCTL_OK = subprocess.CompletedProcess(
|
|
245
|
+
[], 0, stdout='\tstate = running\n\tpid = 4242\n', stderr='')
|
|
246
|
+
PS_OK = subprocess.CompletedProcess(
|
|
247
|
+
[], 0, stdout='/opt/shim/python3\n', stderr='')
|
|
248
|
+
|
|
249
|
+
def run_mock(self, results):
|
|
250
|
+
with mock.patch('sys.platform', 'darwin'), \
|
|
251
|
+
mock.patch.object(plat.subprocess, 'run',
|
|
252
|
+
side_effect=results) as run:
|
|
253
|
+
return plat.daemon_exe(), run
|
|
254
|
+
|
|
255
|
+
def test_non_darwin_ritorna_none_senza_lanciare_niente(self):
|
|
256
|
+
with mock.patch('sys.platform', 'linux'), \
|
|
257
|
+
mock.patch.object(plat.subprocess, 'run') as run:
|
|
258
|
+
self.assertIsNone(plat.daemon_exe())
|
|
259
|
+
run.assert_not_called()
|
|
260
|
+
|
|
261
|
+
def test_job_non_caricato_o_senza_pid(self):
|
|
262
|
+
for result in (subprocess.CompletedProcess([], 1, stdout='slot absent', stderr=''),
|
|
263
|
+
subprocess.CompletedProcess([], 0, stdout='state = not running\n',
|
|
264
|
+
stderr='')):
|
|
265
|
+
with self.subTest(result=result.stdout):
|
|
266
|
+
exe, run = self.run_mock([result])
|
|
267
|
+
self.assertIsNone(exe)
|
|
268
|
+
# job ok ma `ps` fallito → non determinabile
|
|
269
|
+
exe, _ = self.run_mock([self.LAUNCHCTL_OK,
|
|
270
|
+
subprocess.CompletedProcess([], 1, stdout='', stderr='x')])
|
|
271
|
+
self.assertIsNone(exe)
|
|
272
|
+
|
|
273
|
+
def test_launchctl_o_ps_assenti(self):
|
|
274
|
+
exe, run = self.run_mock([OSError('launchctl mancante')])
|
|
275
|
+
self.assertIsNone(exe)
|
|
276
|
+
|
|
277
|
+
def test_eseguibile_letto_da_ps(self):
|
|
278
|
+
exe, run = self.run_mock([self.LAUNCHCTL_OK, self.PS_OK])
|
|
279
|
+
self.assertEqual(exe, '/opt/shim/python3')
|
|
280
|
+
self.assertEqual(run.call_args_list[1].args[0][:4],
|
|
281
|
+
['ps', '-p', '4242', '-o'])
|
|
282
|
+
|
|
283
|
+
def test_ps_senza_output(self):
|
|
284
|
+
exe, _ = self.run_mock([self.LAUNCHCTL_OK,
|
|
285
|
+
subprocess.CompletedProcess([], 0, stdout='\n',
|
|
286
|
+
stderr='')])
|
|
287
|
+
self.assertIsNone(exe)
|
|
288
|
+
|
|
202
289
|
|
|
203
290
|
class LingerTest(unittest.TestCase):
|
|
204
291
|
"""SPEC.md §14.4: linger systemd, directory finta al posto di
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""SPEC.md §9.2: `bin/safekeep-trend.sh` — wrapper trend errori di permesso.
|
|
2
|
+
|
|
3
|
+
Ogni run è un subprocess con HOME finto: niente scritture nella home reale e
|
|
4
|
+
niente tocco ai log reali. Copre: header scritto una sola volta (idempotente),
|
|
5
|
+
riga parseabile a 4 campi, riga appendata anche quando `doctor` esce 1, override
|
|
6
|
+
del path CSV via env.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import unittest
|
|
13
|
+
|
|
14
|
+
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
15
|
+
TREND = os.path.join(REPO, 'bin', 'safekeep-trend.sh')
|
|
16
|
+
HEADER = 'timestamp,exit,count_24h,last'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@unittest.skipUnless(shutil.which('bash'), 'bash non disponibile')
|
|
20
|
+
class TrendTestCase(unittest.TestCase):
|
|
21
|
+
def setUp(self):
|
|
22
|
+
self.tmp = tempfile.mkdtemp()
|
|
23
|
+
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
|
|
24
|
+
self.home = os.path.join(self.tmp, 'home') # HOME finto
|
|
25
|
+
os.makedirs(self.home)
|
|
26
|
+
self.csv = os.path.join(self.home, '.local', 'state', 'safekeep',
|
|
27
|
+
'permission-trend.csv')
|
|
28
|
+
|
|
29
|
+
def trend(self, *args, csv=None):
|
|
30
|
+
env = dict(os.environ, HOME=self.home)
|
|
31
|
+
if csv is not None:
|
|
32
|
+
env['SAFEKEEP_TREND_CSV'] = csv
|
|
33
|
+
return subprocess.run(['bash', TREND, *args], capture_output=True,
|
|
34
|
+
text=True, env=env, cwd=REPO, timeout=120)
|
|
35
|
+
|
|
36
|
+
def lines(self, path=None):
|
|
37
|
+
with open(path or self.csv, encoding='utf-8') as fh:
|
|
38
|
+
return fh.read().splitlines()
|
|
39
|
+
|
|
40
|
+
def test_header_e_riga_parseabile(self):
|
|
41
|
+
r = self.trend()
|
|
42
|
+
self.assertEqual(r.returncode, 0, r.stderr)
|
|
43
|
+
lines = self.lines()
|
|
44
|
+
self.assertEqual(lines[0], HEADER)
|
|
45
|
+
self.assertEqual(len(lines), 2) # header + 1 run
|
|
46
|
+
row = lines[1].split(',')
|
|
47
|
+
self.assertEqual(len(row), 4) # split campi, niente quoting
|
|
48
|
+
self.assertEqual(row[1], '0') # exit di doctor (JSON §9.1)
|
|
49
|
+
self.assertEqual(int(row[2]), int(row[2])) # count_24h intero
|
|
50
|
+
self.assertTrue(row[0].startswith('20')) # timestamp ISO 8601
|
|
51
|
+
|
|
52
|
+
def test_run_multipla_non_duplica_header(self):
|
|
53
|
+
self.assertEqual(self.trend().returncode, 0)
|
|
54
|
+
self.assertEqual(self.trend().returncode, 0)
|
|
55
|
+
lines = self.lines()
|
|
56
|
+
self.assertEqual(len(lines), 3) # header + 2 run
|
|
57
|
+
self.assertEqual(lines.count(HEADER), 1) # header una sola volta
|
|
58
|
+
self.assertEqual(lines[0], HEADER)
|
|
59
|
+
|
|
60
|
+
def test_doctor_exit_1_genera_comunque_riga(self):
|
|
61
|
+
# config inesistente passata esplicitamente → check fatale → exit 1,
|
|
62
|
+
# ma l'exit code è un dato: la riga va scritta (SPEC §9.2)
|
|
63
|
+
missing = os.path.join(self.tmp, 'non-esiste.cfg')
|
|
64
|
+
r = self.trend('--config', missing)
|
|
65
|
+
self.assertEqual(r.returncode, 0, r.stderr)
|
|
66
|
+
row = self.lines()[1].split(',')
|
|
67
|
+
self.assertEqual(row[1], '1')
|
|
68
|
+
self.assertEqual(int(row[2]), int(row[2]))
|
|
69
|
+
|
|
70
|
+
def test_override_csv_path(self):
|
|
71
|
+
alt = os.path.join(self.tmp, 'alt', 'trend.csv')
|
|
72
|
+
r = self.trend(csv=alt)
|
|
73
|
+
self.assertEqual(r.returncode, 0, r.stderr)
|
|
74
|
+
self.assertTrue(os.path.exists(alt))
|
|
75
|
+
self.assertFalse(os.path.exists(self.csv)) # default non toccato
|
|
76
|
+
self.assertEqual(self.lines(alt)[0], HEADER)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == '__main__':
|
|
80
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|