safekeep 0.3.0__tar.gz → 0.3.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.3.0/safekeep.egg-info → safekeep-0.3.1}/PKG-INFO +1 -1
- {safekeep-0.3.0 → safekeep-0.3.1}/safekeep/__init__.py +1 -1
- {safekeep-0.3.0 → safekeep-0.3.1}/safekeep/cli.py +30 -1
- {safekeep-0.3.0 → safekeep-0.3.1}/safekeep/daemon.py +2 -2
- safekeep-0.3.1/safekeep/platform.py +119 -0
- {safekeep-0.3.0 → safekeep-0.3.1/safekeep.egg-info}/PKG-INFO +1 -1
- {safekeep-0.3.0 → safekeep-0.3.1}/tests/test_cli.py +93 -1
- {safekeep-0.3.0 → safekeep-0.3.1}/tests/test_platform.py +81 -0
- safekeep-0.3.0/safekeep/platform.py +0 -57
- {safekeep-0.3.0 → safekeep-0.3.1}/LICENSE +0 -0
- {safekeep-0.3.0 → safekeep-0.3.1}/README.md +0 -0
- {safekeep-0.3.0 → safekeep-0.3.1}/pyproject.toml +0 -0
- {safekeep-0.3.0 → safekeep-0.3.1}/safekeep/config.py +0 -0
- {safekeep-0.3.0 → safekeep-0.3.1}/safekeep/copier.py +0 -0
- {safekeep-0.3.0 → safekeep-0.3.1}/safekeep/matcher.py +0 -0
- {safekeep-0.3.0 → safekeep-0.3.1}/safekeep/volumes.py +0 -0
- {safekeep-0.3.0 → safekeep-0.3.1}/safekeep.egg-info/SOURCES.txt +0 -0
- {safekeep-0.3.0 → safekeep-0.3.1}/safekeep.egg-info/dependency_links.txt +0 -0
- {safekeep-0.3.0 → safekeep-0.3.1}/safekeep.egg-info/entry_points.txt +0 -0
- {safekeep-0.3.0 → safekeep-0.3.1}/safekeep.egg-info/top_level.txt +0 -0
- {safekeep-0.3.0 → safekeep-0.3.1}/setup.cfg +0 -0
- {safekeep-0.3.0 → safekeep-0.3.1}/tests/test_config.py +0 -0
- {safekeep-0.3.0 → safekeep-0.3.1}/tests/test_copier.py +0 -0
- {safekeep-0.3.0 → safekeep-0.3.1}/tests/test_daemon.py +0 -0
- {safekeep-0.3.0 → safekeep-0.3.1}/tests/test_matcher.py +0 -0
- {safekeep-0.3.0 → safekeep-0.3.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.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -14,11 +14,14 @@ from safekeep.copier import TMP_INFIX
|
|
|
14
14
|
from safekeep.daemon import Daemon, setup_logging
|
|
15
15
|
from safekeep.platform import (
|
|
16
16
|
INOTIFY_MIN_WATCHES,
|
|
17
|
+
LOG_PATH,
|
|
17
18
|
fswatch_monitor,
|
|
18
19
|
inotify_limit,
|
|
19
20
|
install_hint,
|
|
20
21
|
is_darwin,
|
|
21
22
|
is_linux,
|
|
23
|
+
linger_enabled,
|
|
24
|
+
recent_permission_errors,
|
|
22
25
|
)
|
|
23
26
|
from safekeep.volumes import dest_state
|
|
24
27
|
|
|
@@ -54,7 +57,8 @@ def build_parser():
|
|
|
54
57
|
help='config, progetti e stato delle dest — sola lettura')
|
|
55
58
|
sub.add_parser('doctor', parents=[common],
|
|
56
59
|
help='diagnostica: config, fswatch + monitor di piattaforma, python, '
|
|
57
|
-
'TCC/plist (macOS),
|
|
60
|
+
'errori di permesso recenti nel log, TCC/plist (macOS), '
|
|
61
|
+
'inotify/systemd/linger (Linux) — exit ≠ 0 se un check '
|
|
58
62
|
'fatale fallisce')
|
|
59
63
|
return parser
|
|
60
64
|
|
|
@@ -226,6 +230,20 @@ def cmd_doctor(args):
|
|
|
226
230
|
out(sys.version_info >= (3, 9),
|
|
227
231
|
f'python {sys.version.split()[0]} (≥ 3.9)', is_fatal=True)
|
|
228
232
|
|
|
233
|
+
# Errori di permesso recenti nel log corrente (SPEC.md §14.4): sintomo
|
|
234
|
+
# delle ultime 24h, non stato del sistema → sempre NON fatale
|
|
235
|
+
log = os.path.expanduser(LOG_PATH)
|
|
236
|
+
recent = recent_permission_errors()
|
|
237
|
+
if recent is None:
|
|
238
|
+
out(True, f'errori di permesso recenti: log assente — check saltato ({log})')
|
|
239
|
+
elif recent[0] > 0:
|
|
240
|
+
out(False, f'errori di permesso recenti: {recent[0]} nelle ultime 24h '
|
|
241
|
+
f'(ultimo: {recent[1]}) — log: {log} — warning non fatale: '
|
|
242
|
+
'vedi i pending con `safekeep sync-once --dry-run`, poi '
|
|
243
|
+
'riconcedere i permessi al volume (FDA/Full Disk Access)')
|
|
244
|
+
else:
|
|
245
|
+
out(True, f'errori di permesso recenti: nessuno nelle ultime 24h — log: {log}')
|
|
246
|
+
|
|
229
247
|
# Linux: il limite di watch di inotify è il rischio #1 sugli alberi grandi
|
|
230
248
|
# (SPEC.md §14.4) — informativo, NON fatale
|
|
231
249
|
if is_linux():
|
|
@@ -296,6 +314,17 @@ def cmd_doctor(args):
|
|
|
296
314
|
'del template — rilancia install.sh', is_fatal=True)
|
|
297
315
|
else:
|
|
298
316
|
out(True, f'unit systemd: {unit}')
|
|
317
|
+
# linger: solo dove la unit è installata (SPEC.md §14.4) — non fatale
|
|
318
|
+
enabled = linger_enabled()
|
|
319
|
+
if enabled is None:
|
|
320
|
+
out(True, 'linger: non verificabile (/var/lib/systemd/linger '
|
|
321
|
+
'assente) — check saltato')
|
|
322
|
+
elif enabled:
|
|
323
|
+
out(True, 'linger: abilitato (agent al boot anche senza login)')
|
|
324
|
+
else:
|
|
325
|
+
out(False, "linger: NON abilitato — l'agent non partirà al boot "
|
|
326
|
+
'senza login → loginctl enable-linger $USER — '
|
|
327
|
+
'warning non fatale')
|
|
299
328
|
|
|
300
329
|
return 1 if fatal else 0
|
|
301
330
|
|
|
@@ -27,7 +27,7 @@ from .config import (
|
|
|
27
27
|
)
|
|
28
28
|
from .copier import MOUNT_ERRNOS, copy_one, needs_copy, prune_project, reconcile_project
|
|
29
29
|
from .matcher import BUILTIN_RULES, Matcher, _glob, parse_rule
|
|
30
|
-
from .platform import fswatch_monitor, init_system
|
|
30
|
+
from .platform import LOG_PATH, fswatch_monitor, init_system
|
|
31
31
|
from .volumes import backoff_schedule, dest_state, partition_dests
|
|
32
32
|
|
|
33
33
|
log = logging.getLogger('safekeep')
|
|
@@ -271,7 +271,7 @@ def setup_logging(level='info', logfile=None):
|
|
|
271
271
|
logger._safekeep_handlers = True
|
|
272
272
|
fmt = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
|
|
273
273
|
if logfile is None:
|
|
274
|
-
logfile = os.path.expanduser(
|
|
274
|
+
logfile = os.path.expanduser(LOG_PATH)
|
|
275
275
|
try:
|
|
276
276
|
os.makedirs(os.path.dirname(logfile), exist_ok=True)
|
|
277
277
|
handler = logging.handlers.RotatingFileHandler(
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Rilevamento piattaforma (SPEC.md §14.1): l'unico posto che legge `sys.platform`.
|
|
2
|
+
|
|
3
|
+
Funzioni/constanti minime: monitor fswatch atteso, hint di installazione,
|
|
4
|
+
init system, limite di watch di inotify, log corrente, linger systemd.
|
|
5
|
+
Tutto letto **a chiamata** (mai a import), così i test patchano `sys.platform`
|
|
6
|
+
ed esercitano entrambi i rami anche girando su una sola piattaforma.
|
|
7
|
+
|
|
8
|
+
I nomi dei monitor sono quelli stampati da `fswatch -M`: `fsevents_monitor`
|
|
9
|
+
(FSEvents, macOS) e `inotify_monitor` (inotify, Linux) — attenzione, su Linux
|
|
10
|
+
il nome NON è `inotify` (SPEC.md §14.2).
|
|
11
|
+
"""
|
|
12
|
+
import getpass
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import sys
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
|
|
19
|
+
INOTIFY_LIMIT_PATH = '/proc/sys/fs/inotify/max_user_watches'
|
|
20
|
+
INOTIFY_MIN_WATCHES = 16384 # sotto questa soglia un home medio lo supera
|
|
21
|
+
LOG_PATH = '~/.local/state/safekeep/safekeep.log' # log corrente (SPEC.md §3)
|
|
22
|
+
LINGER_DIR = '/var/lib/systemd/linger'
|
|
23
|
+
# EPERM (Errno 1) / EACCES (Errno 13) come li scrive il logger: codice errno
|
|
24
|
+
# + testo del kernel (SPEC.md §14.4)
|
|
25
|
+
PERM_ERR_RE = re.compile(r'\[Errno (?:1|13)\]|Operation not permitted|Permission denied')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_darwin():
|
|
29
|
+
return sys.platform == 'darwin'
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_linux():
|
|
33
|
+
return sys.platform == 'linux'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def fswatch_monitor():
|
|
37
|
+
"""Monitor fswatch per la piattaforma (SPEC.md §14.2), usato da
|
|
38
|
+
`daemon.fswatch_argv()` e dal check monitor di `doctor`."""
|
|
39
|
+
return 'fsevents_monitor' if is_darwin() else 'inotify_monitor'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def init_system():
|
|
43
|
+
"""`launchd` (Darwin) o `systemd` (Linux) — SPEC.md §14.3."""
|
|
44
|
+
return 'launchd' if is_darwin() else 'systemd'
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def install_hint():
|
|
48
|
+
"""Come installare `fswatch` su questa macchina (SPEC.md §14.4)."""
|
|
49
|
+
if is_darwin():
|
|
50
|
+
return 'brew install fswatch'
|
|
51
|
+
for pm, cmd in (('pacman', 'pacman -S fswatch'),
|
|
52
|
+
('apt-get', 'sudo apt-get install fswatch'),
|
|
53
|
+
('dnf', 'sudo dnf install fswatch')):
|
|
54
|
+
if shutil.which(pm):
|
|
55
|
+
return cmd
|
|
56
|
+
return 'installa fswatch col package manager della tua distro'
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def inotify_limit(path=INOTIFY_LIMIT_PATH):
|
|
60
|
+
"""`fs.inotify.max_user_watches`, o None se il file non c'è o è illeggibile
|
|
61
|
+
(SPEC.md §14.4: su Linux è il rischio #1 sugli alberi grandi)."""
|
|
62
|
+
try:
|
|
63
|
+
with open(path) as fh:
|
|
64
|
+
return int(fh.read().strip())
|
|
65
|
+
except (OSError, ValueError):
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def recent_permission_errors(path=None, now=None, window=86400):
|
|
70
|
+
"""`(conteggio, ultimo timestamp)` degli errori di permesso nelle ultime
|
|
71
|
+
`window` secondi del log corrente, o None se il log non c'è/non è leggibile
|
|
72
|
+
(SPEC.md §14.4: check non fatale, finestra 24h — segue l'incidente EPERM).
|
|
73
|
+
|
|
74
|
+
Il timestamp è il prefisso `%(asctime)s` della riga (`YYYY-MM-DD HH:MM:SS`,
|
|
75
|
+
prime 19 posizioni). **Limite dichiarato**: una riga senza timestamp
|
|
76
|
+
parsificabile o fuori dalla finestra (clock skew incluso) non viene contata —
|
|
77
|
+
non essendo sicuri che sia recente, non la contiamo: il conteggio è un minimo."""
|
|
78
|
+
if path is None:
|
|
79
|
+
path = os.path.expanduser(LOG_PATH)
|
|
80
|
+
if now is None:
|
|
81
|
+
now = datetime.now()
|
|
82
|
+
count, last = 0, None
|
|
83
|
+
try:
|
|
84
|
+
with open(path, encoding='utf-8', errors='replace') as fh:
|
|
85
|
+
for line in fh:
|
|
86
|
+
if not PERM_ERR_RE.search(line):
|
|
87
|
+
continue
|
|
88
|
+
try:
|
|
89
|
+
ts = datetime.strptime(line[:19], '%Y-%m-%d %H:%M:%S')
|
|
90
|
+
except ValueError:
|
|
91
|
+
continue # timestamp illeggibile → non sicuri, salta
|
|
92
|
+
age = (now - ts).total_seconds()
|
|
93
|
+
if not 0 <= age <= window:
|
|
94
|
+
continue # troppo vecchio (o nel futuro: clock skew)
|
|
95
|
+
count += 1
|
|
96
|
+
if last is None or ts > last:
|
|
97
|
+
last = ts
|
|
98
|
+
except OSError:
|
|
99
|
+
return None
|
|
100
|
+
return count, last
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def linger_enabled(user=None, linger_dir=None):
|
|
104
|
+
"""`True`/`False` se l'utente è in `/var/lib/systemd/linger/`, None se la
|
|
105
|
+
directory non c'è (niente systemd → non determinabile, SPEC.md §14.4).
|
|
106
|
+
Legge la directory direttamente: stessa informazione di
|
|
107
|
+
`loginctl show-user $USER -p Linger`, senza root."""
|
|
108
|
+
if linger_dir is None:
|
|
109
|
+
linger_dir = LINGER_DIR
|
|
110
|
+
try:
|
|
111
|
+
entries = os.listdir(linger_dir)
|
|
112
|
+
except OSError:
|
|
113
|
+
return None
|
|
114
|
+
if user is None:
|
|
115
|
+
try:
|
|
116
|
+
user = getpass.getuser()
|
|
117
|
+
except OSError:
|
|
118
|
+
return None
|
|
119
|
+
return user in entries
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: safekeep
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -4,6 +4,7 @@ Ogni run è un subprocess con HOME finto: niente scritture nella home reale.
|
|
|
4
4
|
"""
|
|
5
5
|
import argparse
|
|
6
6
|
import contextlib
|
|
7
|
+
import getpass
|
|
7
8
|
import io
|
|
8
9
|
import os
|
|
9
10
|
import shutil
|
|
@@ -11,9 +12,10 @@ import subprocess
|
|
|
11
12
|
import sys
|
|
12
13
|
import tempfile
|
|
13
14
|
import unittest
|
|
15
|
+
from datetime import datetime, timedelta
|
|
14
16
|
from unittest import mock
|
|
15
17
|
|
|
16
|
-
from safekeep import cli
|
|
18
|
+
from safekeep import cli, platform as plat
|
|
17
19
|
from safekeep.platform import fswatch_monitor
|
|
18
20
|
|
|
19
21
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
@@ -271,5 +273,95 @@ class PruneCliTest(CliTestCase):
|
|
|
271
273
|
self.assertTrue(os.path.exists(os.path.join(self.dest, keep)), keep)
|
|
272
274
|
|
|
273
275
|
|
|
276
|
+
class DoctorPermessiRecentiTest(CliTestCase):
|
|
277
|
+
"""SPEC.md §14.4: errori di permesso nel log corrente — finestra 24h,
|
|
278
|
+
warning non fatale, log assente ⇒ check saltato."""
|
|
279
|
+
|
|
280
|
+
def log_path(self):
|
|
281
|
+
return os.path.join(self.home, '.local/state/safekeep/safekeep.log')
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def stamp(delta):
|
|
285
|
+
return (datetime.now() - delta).strftime('%Y-%m-%d %H:%M:%S,000')
|
|
286
|
+
|
|
287
|
+
def riga(self, delta, msg='[Errno 1] Operation not permitted: /x.tmp.1'):
|
|
288
|
+
return f'{self.stamp(delta)} ERROR copia fallita a → b: {msg}\n'
|
|
289
|
+
|
|
290
|
+
def riga_doctor(self, stdout):
|
|
291
|
+
self.assertIn('errori di permesso', stdout, stdout)
|
|
292
|
+
return next(l for l in stdout.splitlines() if 'errori di permesso' in l)
|
|
293
|
+
|
|
294
|
+
def test_errore_recente_warning_non_fatale(self):
|
|
295
|
+
write(self.log_path(), self.riga(timedelta(hours=3)))
|
|
296
|
+
r = self.cli('doctor', '--config', self.cfg)
|
|
297
|
+
line = self.riga_doctor(r.stdout)
|
|
298
|
+
self.assertTrue(line.startswith('✗'), line)
|
|
299
|
+
for needle in ('nelle ultime 24h', 'warning non fatale',
|
|
300
|
+
'sync-once --dry-run', 'Full Disk Access',
|
|
301
|
+
self.log_path()):
|
|
302
|
+
self.assertIn(needle, line, needle)
|
|
303
|
+
self.assertEqual(r.returncode, 0, r.stdout) # NON fatale
|
|
304
|
+
|
|
305
|
+
def test_errore_vecchio_verde(self):
|
|
306
|
+
write(self.log_path(),
|
|
307
|
+
self.riga(timedelta(days=3), '[Errno 13] Permission denied'))
|
|
308
|
+
r = self.cli('doctor', '--config', self.cfg)
|
|
309
|
+
line = self.riga_doctor(r.stdout)
|
|
310
|
+
self.assertTrue(line.startswith('✔'), line)
|
|
311
|
+
self.assertIn('nessuno nelle ultime 24h', line)
|
|
312
|
+
|
|
313
|
+
def test_log_assente_check_saltato(self):
|
|
314
|
+
r = self.cli('doctor', '--config', self.cfg) # nessun log nel HOME finto
|
|
315
|
+
line = self.riga_doctor(r.stdout)
|
|
316
|
+
self.assertTrue(line.startswith('✔'), line)
|
|
317
|
+
self.assertIn('check saltato', line)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@unittest.skipUnless(shutil.which('fswatch'), 'fswatch non installato')
|
|
321
|
+
class DoctorLingerTest(CliTestCase):
|
|
322
|
+
"""SPEC.md §14.4: linger solo dove c'è la unit systemd, warning non fatale."""
|
|
323
|
+
|
|
324
|
+
def doctor_linux(self, unit=True, linger=()):
|
|
325
|
+
if unit:
|
|
326
|
+
write(os.path.join(self.home, '.config/systemd/user/safekeep.service'),
|
|
327
|
+
'[Unit]\nDescription=safekeep\n')
|
|
328
|
+
linger_dir = tempfile.mkdtemp()
|
|
329
|
+
self.addCleanup(shutil.rmtree, linger_dir, ignore_errors=True)
|
|
330
|
+
for name in linger:
|
|
331
|
+
open(os.path.join(linger_dir, name), 'w').close()
|
|
332
|
+
args = argparse.Namespace(config=self.cfg, v=False)
|
|
333
|
+
buf = io.StringIO()
|
|
334
|
+
with mock.patch.dict(os.environ, {'HOME': self.home}), \
|
|
335
|
+
mock.patch.object(cli, 'is_linux', return_value=True), \
|
|
336
|
+
mock.patch.object(cli, 'is_darwin', return_value=False), \
|
|
337
|
+
mock.patch.object(plat, 'LINGER_DIR', linger_dir), \
|
|
338
|
+
contextlib.redirect_stdout(buf):
|
|
339
|
+
code = cli.cmd_doctor(args)
|
|
340
|
+
return code, buf.getvalue()
|
|
341
|
+
|
|
342
|
+
@staticmethod
|
|
343
|
+
def riga_linger(out):
|
|
344
|
+
return next(l for l in out.splitlines() if 'linger' in l)
|
|
345
|
+
|
|
346
|
+
def test_unit_assenta_niente_linger(self):
|
|
347
|
+
code, out = self.doctor_linux(unit=False)
|
|
348
|
+
self.assertNotIn('linger', out, 'il check esiste solo con la unit installata')
|
|
349
|
+
self.assertEqual(code, 0, out)
|
|
350
|
+
|
|
351
|
+
def test_linger_disabilitato_warning_non_fatale(self):
|
|
352
|
+
code, out = self.doctor_linux(unit=True)
|
|
353
|
+
line = self.riga_linger(out)
|
|
354
|
+
self.assertTrue(line.startswith('✗'), line)
|
|
355
|
+
self.assertIn('loginctl enable-linger', line)
|
|
356
|
+
self.assertIn('warning non fatale', line)
|
|
357
|
+
self.assertEqual(code, 0, out) # NON fatale
|
|
358
|
+
|
|
359
|
+
def test_linger_abilitato_verde(self):
|
|
360
|
+
code, out = self.doctor_linux(unit=True, linger=(getpass.getuser(),))
|
|
361
|
+
line = self.riga_linger(out)
|
|
362
|
+
self.assertTrue(line.startswith('✔'), line)
|
|
363
|
+
self.assertEqual(code, 0, out)
|
|
364
|
+
|
|
365
|
+
|
|
274
366
|
if __name__ == '__main__':
|
|
275
367
|
unittest.main()
|
|
@@ -8,6 +8,7 @@ import shutil
|
|
|
8
8
|
import subprocess
|
|
9
9
|
import tempfile
|
|
10
10
|
import unittest
|
|
11
|
+
from datetime import datetime, timedelta
|
|
11
12
|
from unittest import mock
|
|
12
13
|
|
|
13
14
|
from safekeep import platform as plat
|
|
@@ -146,5 +147,85 @@ class AgentTemplateTest(unittest.TestCase):
|
|
|
146
147
|
self.assertIn(needle, text, needle)
|
|
147
148
|
|
|
148
149
|
|
|
150
|
+
class RecentPermissionErrorsTest(unittest.TestCase):
|
|
151
|
+
"""SPEC.md §14.4: errori di permesso recenti nel log, finestra 24h,
|
|
152
|
+
non fatale; righe senza timestamp sicuro non vengono contate."""
|
|
153
|
+
|
|
154
|
+
NOW = datetime(2026, 10, 4, 20, 0, 0)
|
|
155
|
+
|
|
156
|
+
def _log(self, text):
|
|
157
|
+
fd, path = tempfile.mkstemp(suffix='.log')
|
|
158
|
+
self.addCleanup(os.unlink, path)
|
|
159
|
+
with os.fdopen(fd, 'w') as fh:
|
|
160
|
+
fh.write(text)
|
|
161
|
+
return path
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _stamp(dt):
|
|
165
|
+
return dt.strftime('%Y-%m-%d %H:%M:%S,000') # formato %(asctime)s
|
|
166
|
+
|
|
167
|
+
def test_ricente_vecchio_e_normale_nello_stesso_log(self):
|
|
168
|
+
text = (
|
|
169
|
+
f'{self._stamp(self.NOW - timedelta(hours=2))} ERROR copia fallita '
|
|
170
|
+
"a → b: [Errno 1] Operation not permitted: '/x.tmp.1'\n"
|
|
171
|
+
f'{self._stamp(self.NOW - timedelta(days=3))} ERROR copia fallita '
|
|
172
|
+
"a → b: [Errno 13] Permission denied: '/y'\n"
|
|
173
|
+
f'{self._stamp(self.NOW - timedelta(minutes=5))} INFO reconcile: 0 copie\n'
|
|
174
|
+
)
|
|
175
|
+
count, last = plat.recent_permission_errors(self._log(text), now=self.NOW)
|
|
176
|
+
self.assertEqual(count, 1, 'solo la riga nelle ultime 24h')
|
|
177
|
+
self.assertEqual(last, self.NOW - timedelta(hours=2))
|
|
178
|
+
|
|
179
|
+
def test_log_assente(self):
|
|
180
|
+
self.assertIsNone(
|
|
181
|
+
plat.recent_permission_errors('/non/esiste/safekeep.log'),
|
|
182
|
+
'nessun log ⇒ check saltato, non warning')
|
|
183
|
+
|
|
184
|
+
def test_timestamp_illeggibile_non_e_contato(self):
|
|
185
|
+
text = ("senza-data ERROR [Errno 1] Operation not permitted: '/x'\n"
|
|
186
|
+
+ f'{self._stamp(self.NOW - timedelta(hours=1))} ERROR '
|
|
187
|
+
"[Errno 1] Operation not permitted: '/y'\n"
|
|
188
|
+
+ f'{self._stamp(self.NOW + timedelta(hours=1))} ERROR '
|
|
189
|
+
"[Errno 1] Operation not permitted: '/z'\n")
|
|
190
|
+
count, _ = plat.recent_permission_errors(self._log(text), now=self.NOW)
|
|
191
|
+
self.assertEqual(count, 1, 'non sicuri (o clock skew) ⇒ non contiamo')
|
|
192
|
+
|
|
193
|
+
def test_copre_eperm_e_eacces(self):
|
|
194
|
+
for riga in ('[Errno 1] Operation not permitted',
|
|
195
|
+
'qualcosa Operation not permitted',
|
|
196
|
+
'[Errno 13] Permission denied',
|
|
197
|
+
'qualcosa Permission denied'):
|
|
198
|
+
with self.subTest(riga=riga):
|
|
199
|
+
self.assertIsNotNone(plat.PERM_ERR_RE.search(riga))
|
|
200
|
+
self.assertIsNone(plat.PERM_ERR_RE.search('INFO reconcile: 0 copie'))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class LingerTest(unittest.TestCase):
|
|
204
|
+
"""SPEC.md §14.4: linger systemd, directory finta al posto di
|
|
205
|
+
/var/lib/systemd/linger."""
|
|
206
|
+
|
|
207
|
+
def _dir(self, *entries):
|
|
208
|
+
path = tempfile.mkdtemp()
|
|
209
|
+
self.addCleanup(shutil.rmtree, path, ignore_errors=True)
|
|
210
|
+
for name in entries:
|
|
211
|
+
open(os.path.join(path, name), 'w').close()
|
|
212
|
+
return path
|
|
213
|
+
|
|
214
|
+
def test_abilitato_disabilitato_e_dir_assente(self):
|
|
215
|
+
d = self._dir('utente')
|
|
216
|
+
self.assertTrue(plat.linger_enabled(user='utente', linger_dir=d))
|
|
217
|
+
self.assertFalse(plat.linger_enabled(user='altro', linger_dir=d))
|
|
218
|
+
self.assertIsNone(
|
|
219
|
+
plat.linger_enabled(user='utente',
|
|
220
|
+
linger_dir=os.path.join(os.sep, 'niente', 'qui')),
|
|
221
|
+
'niente systemd ⇒ non determinabile, check saltato')
|
|
222
|
+
|
|
223
|
+
def test_user_default_e_costante(self):
|
|
224
|
+
self.assertEqual(plat.LINGER_DIR, '/var/lib/systemd/linger')
|
|
225
|
+
with mock.patch('getpass.getuser', return_value='chi'):
|
|
226
|
+
self.assertTrue(plat.linger_enabled(linger_dir=self._dir('chi')))
|
|
227
|
+
self.assertFalse(plat.linger_enabled(linger_dir=self._dir()))
|
|
228
|
+
|
|
229
|
+
|
|
149
230
|
if __name__ == '__main__':
|
|
150
231
|
unittest.main()
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
"""Rilevamento piattaforma (SPEC.md §14.1): l'unico posto che legge `sys.platform`.
|
|
2
|
-
|
|
3
|
-
Funzioni/constanti minime: monitor fswatch atteso, hint di installazione,
|
|
4
|
-
init system, limite di watch di inotify. Tutto letto **a chiamata** (mai a
|
|
5
|
-
import), così i test patchano `sys.platform` ed esercitano entrambi i rami
|
|
6
|
-
anche girando su una sola piattaforma.
|
|
7
|
-
|
|
8
|
-
I nomi dei monitor sono quelli stampati da `fswatch -M`: `fsevents_monitor`
|
|
9
|
-
(FSEvents, macOS) e `inotify_monitor` (inotify, Linux) — attenzione, su Linux
|
|
10
|
-
il nome NON è `inotify` (SPEC.md §14.2).
|
|
11
|
-
"""
|
|
12
|
-
import shutil
|
|
13
|
-
import sys
|
|
14
|
-
|
|
15
|
-
INOTIFY_LIMIT_PATH = '/proc/sys/fs/inotify/max_user_watches'
|
|
16
|
-
INOTIFY_MIN_WATCHES = 16384 # sotto questa soglia un home medio lo supera
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def is_darwin():
|
|
20
|
-
return sys.platform == 'darwin'
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def is_linux():
|
|
24
|
-
return sys.platform == 'linux'
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def fswatch_monitor():
|
|
28
|
-
"""Monitor fswatch per la piattaforma (SPEC.md §14.2), usato da
|
|
29
|
-
`daemon.fswatch_argv()` e dal check monitor di `doctor`."""
|
|
30
|
-
return 'fsevents_monitor' if is_darwin() else 'inotify_monitor'
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def init_system():
|
|
34
|
-
"""`launchd` (Darwin) o `systemd` (Linux) — SPEC.md §14.3."""
|
|
35
|
-
return 'launchd' if is_darwin() else 'systemd'
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def install_hint():
|
|
39
|
-
"""Come installare `fswatch` su questa macchina (SPEC.md §14.4)."""
|
|
40
|
-
if is_darwin():
|
|
41
|
-
return 'brew install fswatch'
|
|
42
|
-
for pm, cmd in (('pacman', 'pacman -S fswatch'),
|
|
43
|
-
('apt-get', 'sudo apt-get install fswatch'),
|
|
44
|
-
('dnf', 'sudo dnf install fswatch')):
|
|
45
|
-
if shutil.which(pm):
|
|
46
|
-
return cmd
|
|
47
|
-
return 'installa fswatch col package manager della tua distro'
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def inotify_limit(path=INOTIFY_LIMIT_PATH):
|
|
51
|
-
"""`fs.inotify.max_user_watches`, o None se il file non c'è o è illeggibile
|
|
52
|
-
(SPEC.md §14.4: su Linux è il rischio #1 sugli alberi grandi)."""
|
|
53
|
-
try:
|
|
54
|
-
with open(path) as fh:
|
|
55
|
-
return int(fh.read().strip())
|
|
56
|
-
except (OSError, ValueError):
|
|
57
|
-
return None
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|