safekeep 0.2.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.2.0 → safekeep-0.3.1}/PKG-INFO +51 -23
- safekeep-0.2.0/safekeep.egg-info/PKG-INFO → safekeep-0.3.1/README.md +40 -37
- {safekeep-0.2.0 → safekeep-0.3.1}/pyproject.toml +10 -4
- safekeep-0.3.1/safekeep/__init__.py +6 -0
- {safekeep-0.2.0 → safekeep-0.3.1}/safekeep/cli.py +107 -31
- {safekeep-0.2.0 → safekeep-0.3.1}/safekeep/daemon.py +10 -7
- safekeep-0.3.1/safekeep/platform.py +119 -0
- safekeep-0.2.0/README.md → safekeep-0.3.1/safekeep.egg-info/PKG-INFO +65 -18
- {safekeep-0.2.0 → safekeep-0.3.1}/safekeep.egg-info/SOURCES.txt +2 -0
- {safekeep-0.2.0 → safekeep-0.3.1}/tests/test_cli.py +148 -2
- {safekeep-0.2.0 → safekeep-0.3.1}/tests/test_daemon.py +18 -3
- safekeep-0.3.1/tests/test_platform.py +231 -0
- safekeep-0.2.0/safekeep/__init__.py +0 -6
- {safekeep-0.2.0 → safekeep-0.3.1}/LICENSE +0 -0
- {safekeep-0.2.0 → safekeep-0.3.1}/safekeep/config.py +0 -0
- {safekeep-0.2.0 → safekeep-0.3.1}/safekeep/copier.py +0 -0
- {safekeep-0.2.0 → safekeep-0.3.1}/safekeep/matcher.py +0 -0
- {safekeep-0.2.0 → safekeep-0.3.1}/safekeep/volumes.py +0 -0
- {safekeep-0.2.0 → safekeep-0.3.1}/safekeep.egg-info/dependency_links.txt +0 -0
- {safekeep-0.2.0 → safekeep-0.3.1}/safekeep.egg-info/entry_points.txt +0 -0
- {safekeep-0.2.0 → safekeep-0.3.1}/safekeep.egg-info/top_level.txt +0 -0
- {safekeep-0.2.0 → safekeep-0.3.1}/setup.cfg +0 -0
- {safekeep-0.2.0 → safekeep-0.3.1}/tests/test_config.py +0 -0
- {safekeep-0.2.0 → safekeep-0.3.1}/tests/test_copier.py +0 -0
- {safekeep-0.2.0 → safekeep-0.3.1}/tests/test_matcher.py +0 -0
- {safekeep-0.2.0 → safekeep-0.3.1}/tests/test_volumes.py +0 -0
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: safekeep
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Selective always-on backups for macOS: fswatch-driven copy of allow-listed files, never deletes
|
|
3
|
+
Version: 0.3.1
|
|
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
|
-
Project-URL:
|
|
7
|
-
Project-URL:
|
|
8
|
-
|
|
6
|
+
Project-URL: Repository, https://github.com/foxhound87/safekeep
|
|
7
|
+
Project-URL: Homepage, https://foxhound87.github.io/safekeep/
|
|
8
|
+
Project-URL: Documentation, https://foxhound87.github.io/safekeep/
|
|
9
|
+
Keywords: backup,macos,linux,fswatch,sync,allow-list,launchd,systemd
|
|
9
10
|
Classifier: Development Status :: 4 - Beta
|
|
10
11
|
Classifier: Environment :: Console
|
|
11
12
|
Classifier: Intended Audience :: System Administrators
|
|
12
13
|
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: POSIX
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
16
|
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
20
|
Classifier: Topic :: System :: Archiving :: Backup
|
|
15
21
|
Requires-Python: >=3.9
|
|
16
22
|
Description-Content-Type: text/markdown
|
|
@@ -19,12 +25,17 @@ Dynamic: license-file
|
|
|
19
25
|
|
|
20
26
|
# safekeep
|
|
21
27
|
|
|
22
|
-
|
|
28
|
+
[](https://pypi.org/project/safekeep/)
|
|
29
|
+
[](https://pypi.org/project/safekeep/)
|
|
30
|
+
|
|
31
|
+
**Selective always-on backups for macOS and Linux**: watches source folders with
|
|
23
32
|
[fswatch](https://github.com/emcrisostomo/fswatch) and copies only the files you
|
|
24
33
|
chose to their destinations — an **allow-list** model that **never deletes**
|
|
25
34
|
anything in the backup (a file removed or renamed at the source stays in the
|
|
26
35
|
backup).
|
|
27
36
|
|
|
37
|
+
Documentation: https://foxhound87.github.io/safekeep/
|
|
38
|
+
|
|
28
39
|
## Features
|
|
29
40
|
|
|
30
41
|
- **Allow-list**: a file is copied only if it matches at least one `include:`;
|
|
@@ -38,7 +49,8 @@ backup).
|
|
|
38
49
|
don't matter, the next reconcile brings everything back in sync.
|
|
39
50
|
- **Unmounted volumes**: missing destination → `pending` state with exponential
|
|
40
51
|
backoff (1s → 60s cap), then a reconcile once the mount is back.
|
|
41
|
-
- **launchd at boot**: an agent with `RunAtLoad` +
|
|
52
|
+
- **launchd (macOS) or systemd (Linux) at boot**: an agent with `RunAtLoad` +
|
|
53
|
+
`KeepAlive` (or `WantedBy=default.target` + `Restart=always`) keeps the process
|
|
42
54
|
alive and restarts it if it dies.
|
|
43
55
|
- **`.sync` carries rules only, destinations live only in `~/.safekeep`**: a
|
|
44
56
|
`.sync` file can neither add nor remove destinations (a `dest:` line is an
|
|
@@ -53,27 +65,31 @@ pipx install safekeep # recommended for a CLI
|
|
|
53
65
|
pip install safekeep
|
|
54
66
|
```
|
|
55
67
|
|
|
56
|
-
**Requirements**: macOS, Python >= 3.9 and
|
|
68
|
+
**Requirements**: macOS or Linux (POSIX with systemd), Python >= 3.9 and
|
|
57
69
|
[fswatch](https://github.com/emcrisostomo/fswatch):
|
|
58
70
|
|
|
59
71
|
```bash
|
|
60
|
-
brew install fswatch
|
|
72
|
+
brew install fswatch # macOS
|
|
73
|
+
sudo pacman -S fswatch # Arch / Omarchy
|
|
74
|
+
sudo apt install fswatch # Debian / Ubuntu
|
|
75
|
+
sudo dnf install fswatch # Fedora
|
|
61
76
|
```
|
|
62
77
|
|
|
63
|
-
The **
|
|
64
|
-
repository with `./install.sh` — not from
|
|
65
|
-
[Agent
|
|
78
|
+
The **agent** (daemon at boot: launchd on macOS, a systemd *user unit* on Linux)
|
|
79
|
+
is installed from a checkout of this repository with `./install.sh` — not from
|
|
80
|
+
the wheel — see [Agent](#agent) below.
|
|
66
81
|
|
|
67
|
-
## Agent
|
|
82
|
+
## Agent
|
|
68
83
|
|
|
69
84
|
```bash
|
|
70
|
-
git clone https://
|
|
85
|
+
git clone https://github.com/foxhound87/safekeep.git
|
|
71
86
|
cd safekeep
|
|
72
87
|
|
|
73
|
-
# only external dependency, if not installed yet
|
|
74
|
-
brew install fswatch
|
|
88
|
+
# only external dependency, if not installed yet (see the per-OS list above)
|
|
89
|
+
brew install fswatch # macOS — the script suggests the right command on Linux
|
|
75
90
|
|
|
76
|
-
# copies the example into ~/.safekeep, renders the plist
|
|
91
|
+
# copies the example into ~/.safekeep, renders the plist / systemd unit,
|
|
92
|
+
# runs preflight checks
|
|
77
93
|
bash install.sh
|
|
78
94
|
```
|
|
79
95
|
|
|
@@ -88,12 +104,21 @@ Then:
|
|
|
88
104
|
3. load the agent:
|
|
89
105
|
|
|
90
106
|
```bash
|
|
107
|
+
# macOS (launchd)
|
|
91
108
|
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.safekeep.agent.plist
|
|
92
109
|
# unload with: launchctl bootout gui/$(id -u)/com.safekeep.agent
|
|
110
|
+
|
|
111
|
+
# Linux (systemd user unit)
|
|
112
|
+
systemctl --user enable --now safekeep
|
|
113
|
+
# status with: systemctl --user status safekeep
|
|
114
|
+
# logs with: journalctl --user -u safekeep -f
|
|
115
|
+
# unload with: systemctl --user disable --now safekeep
|
|
116
|
+
# start at boot without a login session: loginctl enable-linger $USER
|
|
93
117
|
```
|
|
94
118
|
|
|
95
|
-
|
|
96
|
-
interpreter and to `fswatch`, run `safekeep doctor` for diagnostics.
|
|
119
|
+
On macOS, once you have granted TCC/FDA (Full Disk Access) permissions to the
|
|
120
|
+
Python interpreter and to `fswatch`, run `safekeep doctor` for diagnostics. On
|
|
121
|
+
Linux the same command checks the `inotify` watch limit instead.
|
|
97
122
|
|
|
98
123
|
## Quickstart
|
|
99
124
|
|
|
@@ -157,7 +182,7 @@ safekeep <command> [--config PATH] [-v]
|
|
|
157
182
|
| `run` | daemon: initial reconcile, fswatch loop, event dispatch, 24h timer |
|
|
158
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) |
|
|
159
184
|
| `status` | read-only: config, sources, discovered projects with N rules, destination states |
|
|
160
|
-
| `doctor` | diagnostics: config, fswatch,
|
|
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 |
|
|
161
186
|
|
|
162
187
|
## Tests
|
|
163
188
|
|
|
@@ -165,9 +190,12 @@ safekeep <command> [--config PATH] [-v]
|
|
|
165
190
|
python3 -m unittest discover -s tests
|
|
166
191
|
```
|
|
167
192
|
|
|
168
|
-
Stdlib (`unittest`) suite, zero dependencies:
|
|
169
|
-
config, atomic copy, volumes, daemon, auto-discovery
|
|
170
|
-
|
|
193
|
+
Stdlib (`unittest`) suite, zero dependencies: 175 tests covering the matcher,
|
|
194
|
+
config, atomic copy, volumes, daemon, auto-discovery, CLI and the POSIX
|
|
195
|
+
portability layer (platform helper, per-OS fswatch monitor, systemd template).
|
|
196
|
+
`fswatch` is not needed to run the suite — the few `doctor` checks that talk to
|
|
197
|
+
the real binary are skipped when it's missing — but CI installs it so the
|
|
198
|
+
Linux job exercises the real `inotify` monitor.
|
|
171
199
|
|
|
172
200
|
## Documentation
|
|
173
201
|
|
|
@@ -1,30 +1,16 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: safekeep
|
|
3
|
-
Version: 0.2.0
|
|
4
|
-
Summary: Selective always-on backups for macOS: fswatch-driven copy of allow-listed files, never deletes
|
|
5
|
-
License-Expression: MIT
|
|
6
|
-
Project-URL: Homepage, https://gitlab.com/foxhound87/safekeep
|
|
7
|
-
Project-URL: Repository, https://gitlab.com/foxhound87/safekeep
|
|
8
|
-
Keywords: backup,macos,fswatch,sync,allow-list,launchd
|
|
9
|
-
Classifier: Development Status :: 4 - Beta
|
|
10
|
-
Classifier: Environment :: Console
|
|
11
|
-
Classifier: Intended Audience :: System Administrators
|
|
12
|
-
Classifier: Operating System :: MacOS
|
|
13
|
-
Classifier: Programming Language :: Python :: 3
|
|
14
|
-
Classifier: Topic :: System :: Archiving :: Backup
|
|
15
|
-
Requires-Python: >=3.9
|
|
16
|
-
Description-Content-Type: text/markdown
|
|
17
|
-
License-File: LICENSE
|
|
18
|
-
Dynamic: license-file
|
|
19
|
-
|
|
20
1
|
# safekeep
|
|
21
2
|
|
|
22
|
-
|
|
3
|
+
[](https://pypi.org/project/safekeep/)
|
|
4
|
+
[](https://pypi.org/project/safekeep/)
|
|
5
|
+
|
|
6
|
+
**Selective always-on backups for macOS and Linux**: watches source folders with
|
|
23
7
|
[fswatch](https://github.com/emcrisostomo/fswatch) and copies only the files you
|
|
24
8
|
chose to their destinations — an **allow-list** model that **never deletes**
|
|
25
9
|
anything in the backup (a file removed or renamed at the source stays in the
|
|
26
10
|
backup).
|
|
27
11
|
|
|
12
|
+
Documentation: https://foxhound87.github.io/safekeep/
|
|
13
|
+
|
|
28
14
|
## Features
|
|
29
15
|
|
|
30
16
|
- **Allow-list**: a file is copied only if it matches at least one `include:`;
|
|
@@ -38,7 +24,8 @@ backup).
|
|
|
38
24
|
don't matter, the next reconcile brings everything back in sync.
|
|
39
25
|
- **Unmounted volumes**: missing destination → `pending` state with exponential
|
|
40
26
|
backoff (1s → 60s cap), then a reconcile once the mount is back.
|
|
41
|
-
- **launchd at boot**: an agent with `RunAtLoad` +
|
|
27
|
+
- **launchd (macOS) or systemd (Linux) at boot**: an agent with `RunAtLoad` +
|
|
28
|
+
`KeepAlive` (or `WantedBy=default.target` + `Restart=always`) keeps the process
|
|
42
29
|
alive and restarts it if it dies.
|
|
43
30
|
- **`.sync` carries rules only, destinations live only in `~/.safekeep`**: a
|
|
44
31
|
`.sync` file can neither add nor remove destinations (a `dest:` line is an
|
|
@@ -53,27 +40,31 @@ pipx install safekeep # recommended for a CLI
|
|
|
53
40
|
pip install safekeep
|
|
54
41
|
```
|
|
55
42
|
|
|
56
|
-
**Requirements**: macOS, Python >= 3.9 and
|
|
43
|
+
**Requirements**: macOS or Linux (POSIX with systemd), Python >= 3.9 and
|
|
57
44
|
[fswatch](https://github.com/emcrisostomo/fswatch):
|
|
58
45
|
|
|
59
46
|
```bash
|
|
60
|
-
brew install fswatch
|
|
47
|
+
brew install fswatch # macOS
|
|
48
|
+
sudo pacman -S fswatch # Arch / Omarchy
|
|
49
|
+
sudo apt install fswatch # Debian / Ubuntu
|
|
50
|
+
sudo dnf install fswatch # Fedora
|
|
61
51
|
```
|
|
62
52
|
|
|
63
|
-
The **
|
|
64
|
-
repository with `./install.sh` — not from
|
|
65
|
-
[Agent
|
|
53
|
+
The **agent** (daemon at boot: launchd on macOS, a systemd *user unit* on Linux)
|
|
54
|
+
is installed from a checkout of this repository with `./install.sh` — not from
|
|
55
|
+
the wheel — see [Agent](#agent) below.
|
|
66
56
|
|
|
67
|
-
## Agent
|
|
57
|
+
## Agent
|
|
68
58
|
|
|
69
59
|
```bash
|
|
70
|
-
git clone https://
|
|
60
|
+
git clone https://github.com/foxhound87/safekeep.git
|
|
71
61
|
cd safekeep
|
|
72
62
|
|
|
73
|
-
# only external dependency, if not installed yet
|
|
74
|
-
brew install fswatch
|
|
63
|
+
# only external dependency, if not installed yet (see the per-OS list above)
|
|
64
|
+
brew install fswatch # macOS — the script suggests the right command on Linux
|
|
75
65
|
|
|
76
|
-
# copies the example into ~/.safekeep, renders the plist
|
|
66
|
+
# copies the example into ~/.safekeep, renders the plist / systemd unit,
|
|
67
|
+
# runs preflight checks
|
|
77
68
|
bash install.sh
|
|
78
69
|
```
|
|
79
70
|
|
|
@@ -88,12 +79,21 @@ Then:
|
|
|
88
79
|
3. load the agent:
|
|
89
80
|
|
|
90
81
|
```bash
|
|
82
|
+
# macOS (launchd)
|
|
91
83
|
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.safekeep.agent.plist
|
|
92
84
|
# unload with: launchctl bootout gui/$(id -u)/com.safekeep.agent
|
|
85
|
+
|
|
86
|
+
# Linux (systemd user unit)
|
|
87
|
+
systemctl --user enable --now safekeep
|
|
88
|
+
# status with: systemctl --user status safekeep
|
|
89
|
+
# logs with: journalctl --user -u safekeep -f
|
|
90
|
+
# unload with: systemctl --user disable --now safekeep
|
|
91
|
+
# start at boot without a login session: loginctl enable-linger $USER
|
|
93
92
|
```
|
|
94
93
|
|
|
95
|
-
|
|
96
|
-
interpreter and to `fswatch`, run `safekeep doctor` for diagnostics.
|
|
94
|
+
On macOS, once you have granted TCC/FDA (Full Disk Access) permissions to the
|
|
95
|
+
Python interpreter and to `fswatch`, run `safekeep doctor` for diagnostics. On
|
|
96
|
+
Linux the same command checks the `inotify` watch limit instead.
|
|
97
97
|
|
|
98
98
|
## Quickstart
|
|
99
99
|
|
|
@@ -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,
|
|
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 |
|
|
161
161
|
|
|
162
162
|
## Tests
|
|
163
163
|
|
|
@@ -165,9 +165,12 @@ 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
|
|
170
|
-
|
|
168
|
+
Stdlib (`unittest`) suite, zero dependencies: 175 tests covering the matcher,
|
|
169
|
+
config, atomic copy, volumes, daemon, auto-discovery, CLI and the POSIX
|
|
170
|
+
portability layer (platform helper, per-OS fswatch monitor, systemd template).
|
|
171
|
+
`fswatch` is not needed to run the suite — the few `doctor` checks that talk to
|
|
172
|
+
the real binary are skipped when it's missing — but CI installs it so the
|
|
173
|
+
Linux job exercises the real `inotify` monitor.
|
|
171
174
|
|
|
172
175
|
## Documentation
|
|
173
176
|
|
|
@@ -6,25 +6,31 @@ build-backend = "setuptools.build_meta"
|
|
|
6
6
|
[project]
|
|
7
7
|
name = "safekeep"
|
|
8
8
|
dynamic = ["version"]
|
|
9
|
-
description = "Selective always-on backups for macOS: fswatch-driven copy of allow-listed files, never deletes"
|
|
9
|
+
description = "Selective always-on backups for macOS and Linux: fswatch-driven copy of allow-listed files, never deletes"
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
license = "MIT"
|
|
12
12
|
license-files = ["LICENSE"]
|
|
13
13
|
requires-python = ">=3.9"
|
|
14
|
-
keywords = ["backup", "macos", "fswatch", "sync", "allow-list", "launchd"]
|
|
14
|
+
keywords = ["backup", "macos", "linux", "fswatch", "sync", "allow-list", "launchd", "systemd"]
|
|
15
15
|
classifiers = [
|
|
16
16
|
"Development Status :: 4 - Beta",
|
|
17
17
|
"Environment :: Console",
|
|
18
18
|
"Intended Audience :: System Administrators",
|
|
19
19
|
"Operating System :: MacOS",
|
|
20
|
+
"Operating System :: POSIX",
|
|
21
|
+
"Operating System :: POSIX :: Linux",
|
|
20
22
|
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
21
26
|
"Topic :: System :: Archiving :: Backup",
|
|
22
27
|
]
|
|
23
28
|
# zero runtime dependencies: solo stdlib (fswatch è un binario esterno)
|
|
24
29
|
|
|
25
30
|
[project.urls]
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
Repository = "https://github.com/foxhound87/safekeep"
|
|
32
|
+
Homepage = "https://foxhound87.github.io/safekeep/"
|
|
33
|
+
Documentation = "https://foxhound87.github.io/safekeep/"
|
|
28
34
|
|
|
29
35
|
[project.scripts]
|
|
30
36
|
safekeep = "safekeep.cli:main"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""CLI di safekeep: `run`, `sync-once`, `status` e `doctor` (SPEC.md §9).
|
|
2
2
|
|
|
3
3
|
Entry point del package (`safekeep.cli:main`): `bin/safekeep.py` è solo lo
|
|
4
|
-
shim che usano launchd e i test.
|
|
4
|
+
shim che usano launchd/systemd e i test.
|
|
5
5
|
"""
|
|
6
6
|
import argparse
|
|
7
7
|
import os
|
|
@@ -12,13 +12,24 @@ import sys
|
|
|
12
12
|
from safekeep.config import ConfigError, discover_projects, parse_sync
|
|
13
13
|
from safekeep.copier import TMP_INFIX
|
|
14
14
|
from safekeep.daemon import Daemon, setup_logging
|
|
15
|
+
from safekeep.platform import (
|
|
16
|
+
INOTIFY_MIN_WATCHES,
|
|
17
|
+
LOG_PATH,
|
|
18
|
+
fswatch_monitor,
|
|
19
|
+
inotify_limit,
|
|
20
|
+
install_hint,
|
|
21
|
+
is_darwin,
|
|
22
|
+
is_linux,
|
|
23
|
+
linger_enabled,
|
|
24
|
+
recent_permission_errors,
|
|
25
|
+
)
|
|
15
26
|
from safekeep.volumes import dest_state
|
|
16
27
|
|
|
17
28
|
# Radice del repo quando si gira da checkout; in un install pip punta altrove
|
|
18
29
|
# (site-packages) e i riferimenti a file del repo degradano (vedi cmd_doctor).
|
|
19
30
|
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
20
31
|
EXAMPLE = os.path.join(REPO, 'examples', 'safekeep.example')
|
|
21
|
-
REPO_URL = 'https://
|
|
32
|
+
REPO_URL = 'https://github.com/foxhound87/safekeep'
|
|
22
33
|
|
|
23
34
|
|
|
24
35
|
def build_parser():
|
|
@@ -29,7 +40,7 @@ def build_parser():
|
|
|
29
40
|
|
|
30
41
|
parser = argparse.ArgumentParser(
|
|
31
42
|
prog='safekeep',
|
|
32
|
-
description='safekeep — backup event-driven per macOS (vedi SPEC.md)')
|
|
43
|
+
description='safekeep — backup event-driven per macOS e Linux (vedi SPEC.md)')
|
|
33
44
|
sub = parser.add_subparsers(dest='comando', required=True)
|
|
34
45
|
sub.add_parser('run', parents=[common],
|
|
35
46
|
help='daemon: reconcile iniziale + watch fswatch + dispatch eventi + timer 24h')
|
|
@@ -45,7 +56,10 @@ def build_parser():
|
|
|
45
56
|
sub.add_parser('status', parents=[common],
|
|
46
57
|
help='config, progetti e stato delle dest — sola lettura')
|
|
47
58
|
sub.add_parser('doctor', parents=[common],
|
|
48
|
-
help='diagnostica: config, fswatch
|
|
59
|
+
help='diagnostica: config, fswatch + monitor di piattaforma, python, '
|
|
60
|
+
'errori di permesso recenti nel log, TCC/plist (macOS), '
|
|
61
|
+
'inotify/systemd/linger (Linux) — exit ≠ 0 se un check '
|
|
62
|
+
'fatale fallisce')
|
|
49
63
|
return parser
|
|
50
64
|
|
|
51
65
|
|
|
@@ -186,7 +200,7 @@ def cmd_doctor(args):
|
|
|
186
200
|
|
|
187
201
|
exe = shutil.which('fswatch')
|
|
188
202
|
if exe is None:
|
|
189
|
-
out(False, 'fswatch non trovato — installa con:
|
|
203
|
+
out(False, f'fswatch non trovato — installa con: {install_hint()}',
|
|
190
204
|
is_fatal=True)
|
|
191
205
|
else:
|
|
192
206
|
version = None
|
|
@@ -200,31 +214,64 @@ def cmd_doctor(args):
|
|
|
200
214
|
out(version is not None,
|
|
201
215
|
f'fswatch: {version}' if version else 'fswatch --version senza output',
|
|
202
216
|
is_fatal=True)
|
|
217
|
+
# monitor atteso per piattaforma (SPEC.md §14.2/§14.4)
|
|
218
|
+
expected = fswatch_monitor()
|
|
203
219
|
try:
|
|
204
220
|
monitors = subprocess.run([exe, '-M'], capture_output=True,
|
|
205
221
|
text=True, timeout=15)
|
|
206
|
-
|
|
222
|
+
present = expected in monitors.stdout
|
|
207
223
|
except (OSError, subprocess.SubprocessError):
|
|
208
|
-
|
|
209
|
-
out(
|
|
210
|
-
'fswatch monitor:
|
|
211
|
-
'fswatch monitor:
|
|
224
|
+
present = False
|
|
225
|
+
out(present,
|
|
226
|
+
f'fswatch monitor: {expected} presente' if present else
|
|
227
|
+
f'fswatch monitor: {expected} ASSENTE in `fswatch -M`',
|
|
212
228
|
is_fatal=True)
|
|
213
229
|
|
|
214
230
|
out(sys.version_info >= (3, 9),
|
|
215
231
|
f'python {sys.version.split()[0]} (≥ 3.9)', is_fatal=True)
|
|
216
232
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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)')
|
|
220
244
|
else:
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
245
|
+
out(True, f'errori di permesso recenti: nessuno nelle ultime 24h — log: {log}')
|
|
246
|
+
|
|
247
|
+
# Linux: il limite di watch di inotify è il rischio #1 sugli alberi grandi
|
|
248
|
+
# (SPEC.md §14.4) — informativo, NON fatale
|
|
249
|
+
if is_linux():
|
|
250
|
+
limit = inotify_limit()
|
|
251
|
+
if limit is None:
|
|
252
|
+
out(True, 'inotify: limite di watch non leggibile — check saltato')
|
|
253
|
+
elif limit < INOTIFY_MIN_WATCHES:
|
|
254
|
+
out(False, f'inotify: fs.inotify.max_user_watches={limit} '
|
|
255
|
+
f'(< {INOTIFY_MIN_WATCHES}) — su alberi grandi (~25k file) i '
|
|
256
|
+
'watch finiscono: sudo sysctl -w fs.inotify.max_user_watches=524288 '
|
|
257
|
+
'— warning non fatale')
|
|
258
|
+
else:
|
|
259
|
+
out(True, f'inotify: fs.inotify.max_user_watches={limit} '
|
|
260
|
+
f'(≥ {INOTIFY_MIN_WATCHES})')
|
|
261
|
+
|
|
262
|
+
# TCC (Transparency, Consent and Control) è un meccanismo di macOS (SPEC.md §11)
|
|
263
|
+
if is_darwin():
|
|
264
|
+
docs = os.path.expanduser('~/Documents')
|
|
265
|
+
if not os.path.isdir(docs):
|
|
266
|
+
out(True, 'TCC (Transparency, Consent and Control): ~/Documents assente — probe saltato')
|
|
226
267
|
else:
|
|
227
|
-
|
|
268
|
+
try:
|
|
269
|
+
os.listdir(docs)
|
|
270
|
+
except PermissionError:
|
|
271
|
+
out(False, 'TCC: PermissionError su ~/Documents — serve FDA '
|
|
272
|
+
'(Full Disk Access) per i progetti in cartelle protette')
|
|
273
|
+
else:
|
|
274
|
+
out(True, 'TCC: ~/Documents leggibile')
|
|
228
275
|
|
|
229
276
|
if cfg is not None:
|
|
230
277
|
residui = [os.path.join(dirpath, name)
|
|
@@ -236,19 +283,48 @@ def cmd_doctor(args):
|
|
|
236
283
|
'residui *.safekeep.tmp.*: nessuno' if not residui else
|
|
237
284
|
'residui *.safekeep.tmp.*: ' + ', '.join(residui))
|
|
238
285
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
286
|
+
if is_darwin():
|
|
287
|
+
plist = os.path.expanduser('~/Library/LaunchAgents/com.safekeep.agent.plist')
|
|
288
|
+
if not os.path.exists(plist):
|
|
289
|
+
out(True, 'plist non installato (nessun agent da validare)')
|
|
290
|
+
elif shutil.which('plutil') is None:
|
|
291
|
+
out(True, 'plutil assente — lint del plist saltato')
|
|
292
|
+
else:
|
|
293
|
+
try:
|
|
294
|
+
lint = subprocess.run(['plutil', '-lint', plist],
|
|
295
|
+
capture_output=True, text=True, timeout=15)
|
|
296
|
+
detail = (lint.stdout + lint.stderr).strip() or plist
|
|
297
|
+
out(lint.returncode == 0, f'plist lint: {detail}', is_fatal=True)
|
|
298
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
299
|
+
out(False, f'plist lint non eseguibile: {e}', is_fatal=True)
|
|
244
300
|
else:
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
301
|
+
# Linux: user unit systemd al posto del plist (SPEC.md §14.3)
|
|
302
|
+
unit = os.path.expanduser('~/.config/systemd/user/safekeep.service')
|
|
303
|
+
if not os.path.exists(unit):
|
|
304
|
+
out(True, 'unit systemd non installata (nessun agent da validare)')
|
|
305
|
+
else:
|
|
306
|
+
try:
|
|
307
|
+
with open(unit, encoding='utf-8') as fh:
|
|
308
|
+
text = fh.read()
|
|
309
|
+
except (OSError, UnicodeError) as e:
|
|
310
|
+
out(False, f'unit systemd illeggibile: {unit}: {e}', is_fatal=True)
|
|
311
|
+
else:
|
|
312
|
+
if '__REPO__' in text or '__PYTHON__' in text:
|
|
313
|
+
out(False, f'unit systemd: {unit} contiene ancora i placeholder '
|
|
314
|
+
'del template — rilancia install.sh', is_fatal=True)
|
|
315
|
+
else:
|
|
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')
|
|
252
328
|
|
|
253
329
|
return 1 if fatal else 0
|
|
254
330
|
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
Un solo processo, nessun thread: `step()` fa `select` su stdout di fswatch con
|
|
5
5
|
timeout pari alla scadenza più vicina (reconcile giornaliero, retry dest
|
|
6
6
|
absent, respawn fswatch). Le regex `-e` sono un pre-filtro grezzo: la verità
|
|
7
|
-
su cosa copiare resta al matcher Python (SPEC.md §6.4).
|
|
7
|
+
su cosa copiare resta al matcher Python (SPEC.md §6.4). Il monitor di fswatch
|
|
8
|
+
viene da `safekeep.platform` (SPEC.md §14.1).
|
|
8
9
|
"""
|
|
9
10
|
import logging
|
|
10
11
|
import logging.handlers
|
|
@@ -26,6 +27,7 @@ from .config import (
|
|
|
26
27
|
)
|
|
27
28
|
from .copier import MOUNT_ERRNOS, copy_one, needs_copy, prune_project, reconcile_project
|
|
28
29
|
from .matcher import BUILTIN_RULES, Matcher, _glob, parse_rule
|
|
30
|
+
from .platform import LOG_PATH, fswatch_monitor, init_system
|
|
29
31
|
from .volumes import backoff_schedule, dest_state, partition_dests
|
|
30
32
|
|
|
31
33
|
log = logging.getLogger('safekeep')
|
|
@@ -34,7 +36,7 @@ FLOOD_LIMIT = 5000 # path per progetto in un batch → collapse a reconci
|
|
|
34
36
|
DEDUP_WINDOW = 2.0 # secondi: stessa path entro la finestra → un solo evento
|
|
35
37
|
RECONCILE_INTERVAL = 24 * 3600
|
|
36
38
|
DEATH_WINDOW = 60 # finestra di conteggio delle morti di fswatch
|
|
37
|
-
MAX_DEATHS = 5 # 5 morti in 60s → esco con codice ≠ 0 (
|
|
39
|
+
MAX_DEATHS = 5 # 5 morti in 60s → esco con codice ≠ 0 (l'init system ci riprova)
|
|
38
40
|
RESPAWN_CAP = 60 # backoff respawn 1 → 60s
|
|
39
41
|
|
|
40
42
|
SKIP = 'skip'
|
|
@@ -269,7 +271,7 @@ def setup_logging(level='info', logfile=None):
|
|
|
269
271
|
logger._safekeep_handlers = True
|
|
270
272
|
fmt = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
|
|
271
273
|
if logfile is None:
|
|
272
|
-
logfile = os.path.expanduser(
|
|
274
|
+
logfile = os.path.expanduser(LOG_PATH)
|
|
273
275
|
try:
|
|
274
276
|
os.makedirs(os.path.dirname(logfile), exist_ok=True)
|
|
275
277
|
handler = logging.handlers.RotatingFileHandler(
|
|
@@ -409,8 +411,9 @@ class Daemon:
|
|
|
409
411
|
# --- fswatch ---------------------------------------------------------
|
|
410
412
|
|
|
411
413
|
def fswatch_argv(self):
|
|
412
|
-
"""`fswatch -0 -m
|
|
413
|
-
|
|
414
|
+
"""`fswatch -0 -m <monitor> -r -l 1.0 -e <regex> -- <roots>` (§6.4, §14.2):
|
|
415
|
+
il monitor è dichiarato per piattaforma, mai il default implicito."""
|
|
416
|
+
argv = ['fswatch', '-0', '-m', fswatch_monitor(), '-r', '-l', '1.0']
|
|
414
417
|
for regex in self.exclude_regexes():
|
|
415
418
|
argv += ['-e', regex]
|
|
416
419
|
return argv + ['--'] + self.discovery_roots()[0]
|
|
@@ -462,8 +465,8 @@ class Daemon:
|
|
|
462
465
|
self.deaths.append(now)
|
|
463
466
|
if len(self.deaths) >= MAX_DEATHS:
|
|
464
467
|
log.error('fswatch morto %d volte in %ds: esco con codice != 0 '
|
|
465
|
-
'(
|
|
466
|
-
len(self.deaths), DEATH_WINDOW)
|
|
468
|
+
'(%s ci riproverà)',
|
|
469
|
+
len(self.deaths), DEATH_WINDOW, init_system())
|
|
467
470
|
self.fatal = True
|
|
468
471
|
return
|
|
469
472
|
if now - self.last_spawn >= DEATH_WINDOW:
|