safekeep 0.6.0__tar.gz → 0.7.0__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.6.0/safekeep.egg-info → safekeep-0.7.0}/PKG-INFO +60 -30
- {safekeep-0.6.0 → safekeep-0.7.0}/README.md +59 -29
- {safekeep-0.6.0 → safekeep-0.7.0}/pyproject.toml +5 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/safekeep/__init__.py +1 -1
- {safekeep-0.6.0 → safekeep-0.7.0}/safekeep/cli.py +68 -14
- safekeep-0.7.0/safekeep/daemonctl.py +349 -0
- safekeep-0.7.0/safekeep/data/com.safekeep.agent.plist.tmpl +71 -0
- safekeep-0.7.0/safekeep/data/safekeep.example +91 -0
- safekeep-0.7.0/safekeep/data/safekeep.service.tmpl +47 -0
- {safekeep-0.6.0 → safekeep-0.7.0/safekeep.egg-info}/PKG-INFO +60 -30
- {safekeep-0.6.0 → safekeep-0.7.0}/safekeep.egg-info/SOURCES.txt +5 -0
- safekeep-0.7.0/tests/test_daemonctl.py +651 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/tests/test_platform.py +48 -51
- {safekeep-0.6.0 → safekeep-0.7.0}/LICENSE +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/safekeep/config.py +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/safekeep/copier.py +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/safekeep/daemon.py +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/safekeep/matcher.py +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/safekeep/platform.py +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/safekeep/s3dest.py +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/safekeep/volumes.py +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/safekeep.egg-info/dependency_links.txt +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/safekeep.egg-info/entry_points.txt +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/safekeep.egg-info/requires.txt +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/safekeep.egg-info/top_level.txt +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/setup.cfg +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/tests/test_cli.py +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/tests/test_config.py +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/tests/test_copier.py +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/tests/test_daemon.py +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/tests/test_matcher.py +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/tests/test_s3_itest.py +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/tests/test_s3dest.py +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/tests/test_trend.py +0 -0
- {safekeep-0.6.0 → safekeep-0.7.0}/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.7.0
|
|
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
|
|
@@ -84,21 +84,22 @@ sudo dnf install fswatch # Fedora
|
|
|
84
84
|
```
|
|
85
85
|
|
|
86
86
|
The **agent** (daemon at boot: launchd on macOS, a systemd *user unit* on Linux)
|
|
87
|
-
is installed from
|
|
88
|
-
the wheel — see
|
|
87
|
+
is installed from the installed package with `safekeep daemon install` — the
|
|
88
|
+
unit templates ship inside the wheel, no checkout needed — see
|
|
89
|
+
[Agent](#agent) below. The old `./install.sh` wrapper is deprecated.
|
|
89
90
|
|
|
90
91
|
## Agent
|
|
91
92
|
|
|
92
93
|
```bash
|
|
93
|
-
|
|
94
|
-
cd safekeep
|
|
94
|
+
pipx install safekeep # or: pip install --user safekeep
|
|
95
95
|
|
|
96
96
|
# only external dependency, if not installed yet (see the per-OS list above)
|
|
97
|
-
brew install fswatch # macOS — the
|
|
97
|
+
brew install fswatch # macOS — the preflight suggests the right command on Linux
|
|
98
98
|
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
|
|
99
|
+
# preflight (fswatch / shim on PATH / python / WSL systemd-on), then renders
|
|
100
|
+
# the plist or systemd user unit from the embedded template into place —
|
|
101
|
+
# idempotent, does NOT start anything yet
|
|
102
|
+
safekeep daemon install # --dry-run to preview, nothing is written
|
|
102
103
|
```
|
|
103
104
|
|
|
104
105
|
Then:
|
|
@@ -112,16 +113,18 @@ Then:
|
|
|
112
113
|
3. load the agent:
|
|
113
114
|
|
|
114
115
|
```bash
|
|
115
|
-
# macOS (
|
|
116
|
-
|
|
117
|
-
# unload
|
|
118
|
-
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
#
|
|
122
|
-
|
|
123
|
-
#
|
|
124
|
-
#
|
|
116
|
+
safekeep daemon start # launchctl bootstrap (macOS) / systemctl --user enable --now (Linux)
|
|
117
|
+
safekeep daemon status # exit 0 while running, 1 otherwise
|
|
118
|
+
safekeep daemon stop # unload
|
|
119
|
+
|
|
120
|
+
# logs (Linux): journalctl --user -u safekeep -f
|
|
121
|
+
# remove the unit (config and logs stay): safekeep daemon uninstall
|
|
122
|
+
# remove everything, incl. ~/.safekeep: safekeep daemon uninstall --purge
|
|
123
|
+
|
|
124
|
+
# Linux - start at boot without a login session (linger = user units active
|
|
125
|
+
# without a login), either pass
|
|
126
|
+
safekeep daemon install --enable-linger
|
|
127
|
+
# or run once: loginctl enable-linger $USER
|
|
125
128
|
```
|
|
126
129
|
|
|
127
130
|
On macOS, once you have granted TCC/FDA (Full Disk Access) permissions to the
|
|
@@ -152,7 +155,8 @@ exactly those entries, as always. Without it safekeep switches to
|
|
|
152
155
|
**auto-discovery**: it scans `$HOME` for `.sync` files (skipping hidden
|
|
153
156
|
directories, `Library`, `.Trash`, `.cache`, `node_modules`, `.git`, `.venv`,
|
|
154
157
|
`__pycache__`, `venv`), watches `$HOME`, and picks up any `.sync` created after
|
|
155
|
-
startup. `dest:` stays mandatory in both modes
|
|
158
|
+
startup. `dest:` stays mandatory in both modes — unless `s3_bucket:` is set
|
|
159
|
+
(at least one of the two must be present).
|
|
156
160
|
|
|
157
161
|
`~/Code/myapp/.sync` (rules only, no destinations):
|
|
158
162
|
|
|
@@ -187,9 +191,12 @@ destinations. The client is [botocore](https://botocore.amazonaws.com/),
|
|
|
187
191
|
installed via the optional extra — the core package stays stdlib-only:
|
|
188
192
|
|
|
189
193
|
```bash
|
|
190
|
-
pipx install "safekeep[s3]"
|
|
194
|
+
pipx install "safekeep[s3]" # or: pip install "safekeep[s3]"
|
|
191
195
|
```
|
|
192
196
|
|
|
197
|
+
Without the extra the config still parses, but any `s3_*` key fails fast with
|
|
198
|
+
an install hint (`doctor` reports it as a **non-fatal** warning).
|
|
199
|
+
|
|
193
200
|
`~/.safekeep` (the `s3_*` keys are valid **only** in this file — a `.sync`
|
|
194
201
|
can't add them; keep the file at `chmod 600`):
|
|
195
202
|
|
|
@@ -205,6 +212,10 @@ s3_addressing: path # path (default) | virtual
|
|
|
205
212
|
s3_tls_check: true # false = self-signed certificates
|
|
206
213
|
```
|
|
207
214
|
|
|
215
|
+
With `s3_secret_key_env:` the variable must be in the **agent's** environment
|
|
216
|
+
(launchd `EnvironmentVariables`, systemd `Environment=`) — a shell `export`
|
|
217
|
+
alone doesn't reach a daemon started at boot.
|
|
218
|
+
|
|
208
219
|
Semantics (see [SPEC.md](SPEC.md) §18): upload-only with `needs_copy` based on
|
|
209
220
|
object metadata (`x-amz-meta-mtime` in nanoseconds, `x-amz-meta-mode`,
|
|
210
221
|
symlinks = zero-byte objects with `x-amz-meta-symlink`), same 1s mtime
|
|
@@ -215,14 +226,18 @@ that's an accepted v1 limitation.
|
|
|
215
226
|
|
|
216
227
|
Compatible services:
|
|
217
228
|
|
|
218
|
-
| Service | Example `s3_endpoint` | `s3_addressing` |
|
|
219
|
-
|
|
220
|
-
| MinIO (self-hosted) | `https://minio.example.com:9000` | `path` |
|
|
221
|
-
| Silo | `https://s3.example.com:443` | `path` |
|
|
222
|
-
| Ceph RGW | `https://rgw.example.com` | `path` |
|
|
223
|
-
| Wasabi | `https://s3.wasabisys.com` | `path` |
|
|
224
|
-
| Backblaze B2 | `https://s3.<region>.backblazeb2.com` | `virtual` |
|
|
225
|
-
| AWS S3 | *(omit `s3_endpoint`)* | `virtual` |
|
|
229
|
+
| Service | Example `s3_endpoint` | `s3_addressing` | `s3_region` | TLS |
|
|
230
|
+
|---|---|---|---|---|
|
|
231
|
+
| MinIO (self-hosted) | `https://minio.example.com:9000` | `path` | any (`us-east-1` default) | `https` + `s3_tls_check: false` if self-signed |
|
|
232
|
+
| Silo (fork of MinIO) | `https://s3.example.com:443` | `path` | any (`us-east-1` default) | as the endpoint's own certificate |
|
|
233
|
+
| Ceph RGW | `https://rgw.example.com` | `path` | any, must match the RGW zone (`us-east-1` default) | as configured on the RGW |
|
|
234
|
+
| Wasabi | `https://s3.wasabisys.com` | `path` | **required**, must match the bucket (`us-east-1` on that endpoint) | `https` |
|
|
235
|
+
| Backblaze B2 | `https://s3.<region>.backblazeb2.com` | `virtual` or `path` | **required**, must match the bucket region | `https` only — cleartext rejected |
|
|
236
|
+
| AWS S3 | *(omit `s3_endpoint`)* | `virtual` | must match the bucket (`us-east-1` default) | `https` |
|
|
237
|
+
|
|
238
|
+
All of them speak SigV4 (Signature Version 4) with the same client — no
|
|
239
|
+
service-specific code path. B2 wants an **Application Key** (not the Master
|
|
240
|
+
Key) as `s3_access_key`/`s3_secret_key`.
|
|
226
241
|
|
|
227
242
|
`safekeep status` shows the bucket as `[ok]`/`[offline]` (cached HeadBucket),
|
|
228
243
|
and `safekeep doctor` reports credentials, file permissions and bucket
|
|
@@ -240,6 +255,7 @@ safekeep <command> [--config PATH] [-v]
|
|
|
240
255
|
| `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) |
|
|
241
256
|
| `status` | read-only: config, sources, discovered projects with N rules, destination states |
|
|
242
257
|
| `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 |
|
|
258
|
+
| `daemon {install,start,stop,status,uninstall}` | manage the boot agent: `install` runs preflight (fswatch, shim on PATH, WSL systemd-on) and renders the launchd plist / systemd user unit from the embedded templates (idempotent, `--dry-run`, `--enable-linger`); `start`/`stop`/`status` load, unload and probe it; `uninstall` removes the unit (`--purge` also drops `~/.safekeep` and state) |
|
|
243
259
|
|
|
244
260
|
## Tests
|
|
245
261
|
|
|
@@ -247,7 +263,7 @@ safekeep <command> [--config PATH] [-v]
|
|
|
247
263
|
python3 -m unittest discover -s tests
|
|
248
264
|
```
|
|
249
265
|
|
|
250
|
-
Stdlib (`unittest`) suite, zero dependencies:
|
|
266
|
+
Stdlib (`unittest`) suite, zero dependencies: 287 tests covering the matcher,
|
|
251
267
|
config, atomic copy, volumes, daemon, auto-discovery, CLI, the permission-trend
|
|
252
268
|
wrapper and the POSIX portability layer (platform helper, per-OS fswatch
|
|
253
269
|
monitor, systemd template).
|
|
@@ -255,6 +271,20 @@ monitor, systemd template).
|
|
|
255
271
|
the real binary are skipped when it's missing — but CI installs it so the
|
|
256
272
|
Linux job exercises the real `inotify` monitor.
|
|
257
273
|
|
|
274
|
+
The S3 integration test talks to a real bucket and is **skipped** unless every
|
|
275
|
+
`SAFEKEEP_S3_TEST_*` variable is set (no credential is hard-coded; it uploads
|
|
276
|
+
under a throwaway timestamped prefix and performs **no** DeleteObject):
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
export SAFEKEEP_S3_TEST_ENDPOINT=https://s3.example.com:443 \
|
|
280
|
+
SAFEKEEP_S3_TEST_BUCKET=my-backup-bucket \
|
|
281
|
+
SAFEKEEP_S3_TEST_ACCESS_KEY=REPLACEME_ACCESS_KEY \
|
|
282
|
+
SAFEKEEP_S3_TEST_SECRET_KEY=REPLACEME_SECRET_KEY \
|
|
283
|
+
SAFEKEEP_S3_TEST_REGION=us-east-1 # optional, default us-east-1
|
|
284
|
+
# SAFEKEEP_S3_TEST_PREFIX=safekeep # optional, default safekeep-itest
|
|
285
|
+
python3 -m unittest tests.test_s3_itest -v
|
|
286
|
+
```
|
|
287
|
+
|
|
258
288
|
## Documentation
|
|
259
289
|
|
|
260
290
|
Everything in detail (config format, pattern semantics, event → sync flow,
|
|
@@ -57,21 +57,22 @@ sudo dnf install fswatch # Fedora
|
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
The **agent** (daemon at boot: launchd on macOS, a systemd *user unit* on Linux)
|
|
60
|
-
is installed from
|
|
61
|
-
the wheel — see
|
|
60
|
+
is installed from the installed package with `safekeep daemon install` — the
|
|
61
|
+
unit templates ship inside the wheel, no checkout needed — see
|
|
62
|
+
[Agent](#agent) below. The old `./install.sh` wrapper is deprecated.
|
|
62
63
|
|
|
63
64
|
## Agent
|
|
64
65
|
|
|
65
66
|
```bash
|
|
66
|
-
|
|
67
|
-
cd safekeep
|
|
67
|
+
pipx install safekeep # or: pip install --user safekeep
|
|
68
68
|
|
|
69
69
|
# only external dependency, if not installed yet (see the per-OS list above)
|
|
70
|
-
brew install fswatch # macOS — the
|
|
70
|
+
brew install fswatch # macOS — the preflight suggests the right command on Linux
|
|
71
71
|
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
|
|
72
|
+
# preflight (fswatch / shim on PATH / python / WSL systemd-on), then renders
|
|
73
|
+
# the plist or systemd user unit from the embedded template into place —
|
|
74
|
+
# idempotent, does NOT start anything yet
|
|
75
|
+
safekeep daemon install # --dry-run to preview, nothing is written
|
|
75
76
|
```
|
|
76
77
|
|
|
77
78
|
Then:
|
|
@@ -85,16 +86,18 @@ Then:
|
|
|
85
86
|
3. load the agent:
|
|
86
87
|
|
|
87
88
|
```bash
|
|
88
|
-
# macOS (
|
|
89
|
-
|
|
90
|
-
# unload
|
|
91
|
-
|
|
92
|
-
#
|
|
93
|
-
|
|
94
|
-
#
|
|
95
|
-
|
|
96
|
-
#
|
|
97
|
-
#
|
|
89
|
+
safekeep daemon start # launchctl bootstrap (macOS) / systemctl --user enable --now (Linux)
|
|
90
|
+
safekeep daemon status # exit 0 while running, 1 otherwise
|
|
91
|
+
safekeep daemon stop # unload
|
|
92
|
+
|
|
93
|
+
# logs (Linux): journalctl --user -u safekeep -f
|
|
94
|
+
# remove the unit (config and logs stay): safekeep daemon uninstall
|
|
95
|
+
# remove everything, incl. ~/.safekeep: safekeep daemon uninstall --purge
|
|
96
|
+
|
|
97
|
+
# Linux - start at boot without a login session (linger = user units active
|
|
98
|
+
# without a login), either pass
|
|
99
|
+
safekeep daemon install --enable-linger
|
|
100
|
+
# or run once: loginctl enable-linger $USER
|
|
98
101
|
```
|
|
99
102
|
|
|
100
103
|
On macOS, once you have granted TCC/FDA (Full Disk Access) permissions to the
|
|
@@ -125,7 +128,8 @@ exactly those entries, as always. Without it safekeep switches to
|
|
|
125
128
|
**auto-discovery**: it scans `$HOME` for `.sync` files (skipping hidden
|
|
126
129
|
directories, `Library`, `.Trash`, `.cache`, `node_modules`, `.git`, `.venv`,
|
|
127
130
|
`__pycache__`, `venv`), watches `$HOME`, and picks up any `.sync` created after
|
|
128
|
-
startup. `dest:` stays mandatory in both modes
|
|
131
|
+
startup. `dest:` stays mandatory in both modes — unless `s3_bucket:` is set
|
|
132
|
+
(at least one of the two must be present).
|
|
129
133
|
|
|
130
134
|
`~/Code/myapp/.sync` (rules only, no destinations):
|
|
131
135
|
|
|
@@ -160,9 +164,12 @@ destinations. The client is [botocore](https://botocore.amazonaws.com/),
|
|
|
160
164
|
installed via the optional extra — the core package stays stdlib-only:
|
|
161
165
|
|
|
162
166
|
```bash
|
|
163
|
-
pipx install "safekeep[s3]"
|
|
167
|
+
pipx install "safekeep[s3]" # or: pip install "safekeep[s3]"
|
|
164
168
|
```
|
|
165
169
|
|
|
170
|
+
Without the extra the config still parses, but any `s3_*` key fails fast with
|
|
171
|
+
an install hint (`doctor` reports it as a **non-fatal** warning).
|
|
172
|
+
|
|
166
173
|
`~/.safekeep` (the `s3_*` keys are valid **only** in this file — a `.sync`
|
|
167
174
|
can't add them; keep the file at `chmod 600`):
|
|
168
175
|
|
|
@@ -178,6 +185,10 @@ s3_addressing: path # path (default) | virtual
|
|
|
178
185
|
s3_tls_check: true # false = self-signed certificates
|
|
179
186
|
```
|
|
180
187
|
|
|
188
|
+
With `s3_secret_key_env:` the variable must be in the **agent's** environment
|
|
189
|
+
(launchd `EnvironmentVariables`, systemd `Environment=`) — a shell `export`
|
|
190
|
+
alone doesn't reach a daemon started at boot.
|
|
191
|
+
|
|
181
192
|
Semantics (see [SPEC.md](SPEC.md) §18): upload-only with `needs_copy` based on
|
|
182
193
|
object metadata (`x-amz-meta-mtime` in nanoseconds, `x-amz-meta-mode`,
|
|
183
194
|
symlinks = zero-byte objects with `x-amz-meta-symlink`), same 1s mtime
|
|
@@ -188,14 +199,18 @@ that's an accepted v1 limitation.
|
|
|
188
199
|
|
|
189
200
|
Compatible services:
|
|
190
201
|
|
|
191
|
-
| Service | Example `s3_endpoint` | `s3_addressing` |
|
|
192
|
-
|
|
193
|
-
| MinIO (self-hosted) | `https://minio.example.com:9000` | `path` |
|
|
194
|
-
| Silo | `https://s3.example.com:443` | `path` |
|
|
195
|
-
| Ceph RGW | `https://rgw.example.com` | `path` |
|
|
196
|
-
| Wasabi | `https://s3.wasabisys.com` | `path` |
|
|
197
|
-
| Backblaze B2 | `https://s3.<region>.backblazeb2.com` | `virtual` |
|
|
198
|
-
| AWS S3 | *(omit `s3_endpoint`)* | `virtual` |
|
|
202
|
+
| Service | Example `s3_endpoint` | `s3_addressing` | `s3_region` | TLS |
|
|
203
|
+
|---|---|---|---|---|
|
|
204
|
+
| MinIO (self-hosted) | `https://minio.example.com:9000` | `path` | any (`us-east-1` default) | `https` + `s3_tls_check: false` if self-signed |
|
|
205
|
+
| Silo (fork of MinIO) | `https://s3.example.com:443` | `path` | any (`us-east-1` default) | as the endpoint's own certificate |
|
|
206
|
+
| Ceph RGW | `https://rgw.example.com` | `path` | any, must match the RGW zone (`us-east-1` default) | as configured on the RGW |
|
|
207
|
+
| Wasabi | `https://s3.wasabisys.com` | `path` | **required**, must match the bucket (`us-east-1` on that endpoint) | `https` |
|
|
208
|
+
| Backblaze B2 | `https://s3.<region>.backblazeb2.com` | `virtual` or `path` | **required**, must match the bucket region | `https` only — cleartext rejected |
|
|
209
|
+
| AWS S3 | *(omit `s3_endpoint`)* | `virtual` | must match the bucket (`us-east-1` default) | `https` |
|
|
210
|
+
|
|
211
|
+
All of them speak SigV4 (Signature Version 4) with the same client — no
|
|
212
|
+
service-specific code path. B2 wants an **Application Key** (not the Master
|
|
213
|
+
Key) as `s3_access_key`/`s3_secret_key`.
|
|
199
214
|
|
|
200
215
|
`safekeep status` shows the bucket as `[ok]`/`[offline]` (cached HeadBucket),
|
|
201
216
|
and `safekeep doctor` reports credentials, file permissions and bucket
|
|
@@ -213,6 +228,7 @@ safekeep <command> [--config PATH] [-v]
|
|
|
213
228
|
| `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) |
|
|
214
229
|
| `status` | read-only: config, sources, discovered projects with N rules, destination states |
|
|
215
230
|
| `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 |
|
|
231
|
+
| `daemon {install,start,stop,status,uninstall}` | manage the boot agent: `install` runs preflight (fswatch, shim on PATH, WSL systemd-on) and renders the launchd plist / systemd user unit from the embedded templates (idempotent, `--dry-run`, `--enable-linger`); `start`/`stop`/`status` load, unload and probe it; `uninstall` removes the unit (`--purge` also drops `~/.safekeep` and state) |
|
|
216
232
|
|
|
217
233
|
## Tests
|
|
218
234
|
|
|
@@ -220,7 +236,7 @@ safekeep <command> [--config PATH] [-v]
|
|
|
220
236
|
python3 -m unittest discover -s tests
|
|
221
237
|
```
|
|
222
238
|
|
|
223
|
-
Stdlib (`unittest`) suite, zero dependencies:
|
|
239
|
+
Stdlib (`unittest`) suite, zero dependencies: 287 tests covering the matcher,
|
|
224
240
|
config, atomic copy, volumes, daemon, auto-discovery, CLI, the permission-trend
|
|
225
241
|
wrapper and the POSIX portability layer (platform helper, per-OS fswatch
|
|
226
242
|
monitor, systemd template).
|
|
@@ -228,6 +244,20 @@ monitor, systemd template).
|
|
|
228
244
|
the real binary are skipped when it's missing — but CI installs it so the
|
|
229
245
|
Linux job exercises the real `inotify` monitor.
|
|
230
246
|
|
|
247
|
+
The S3 integration test talks to a real bucket and is **skipped** unless every
|
|
248
|
+
`SAFEKEEP_S3_TEST_*` variable is set (no credential is hard-coded; it uploads
|
|
249
|
+
under a throwaway timestamped prefix and performs **no** DeleteObject):
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
export SAFEKEEP_S3_TEST_ENDPOINT=https://s3.example.com:443 \
|
|
253
|
+
SAFEKEEP_S3_TEST_BUCKET=my-backup-bucket \
|
|
254
|
+
SAFEKEEP_S3_TEST_ACCESS_KEY=REPLACEME_ACCESS_KEY \
|
|
255
|
+
SAFEKEEP_S3_TEST_SECRET_KEY=REPLACEME_SECRET_KEY \
|
|
256
|
+
SAFEKEEP_S3_TEST_REGION=us-east-1 # optional, default us-east-1
|
|
257
|
+
# SAFEKEEP_S3_TEST_PREFIX=safekeep # optional, default safekeep-itest
|
|
258
|
+
python3 -m unittest tests.test_s3_itest -v
|
|
259
|
+
```
|
|
260
|
+
|
|
231
261
|
## Documentation
|
|
232
262
|
|
|
233
263
|
Everything in detail (config format, pattern semantics, event → sync flow,
|
|
@@ -43,5 +43,10 @@ safekeep = "safekeep.cli:main"
|
|
|
43
43
|
# NO auto-discovery: bin/ e tests/ farebbero errore di build
|
|
44
44
|
packages = ["safekeep"]
|
|
45
45
|
|
|
46
|
+
[tool.setuptools.package-data]
|
|
47
|
+
# 0.7.0: template unit + example config embedded — `safekeep daemon install`
|
|
48
|
+
# funziona da PyPI senza git clone (sorgente unica: safekeep/data/)
|
|
49
|
+
safekeep = ["data/*"]
|
|
50
|
+
|
|
46
51
|
[tool.setuptools.dynamic]
|
|
47
52
|
version = {attr = "safekeep.__version__"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""CLI di safekeep: `run`, `sync-once`, `status` e `
|
|
1
|
+
"""CLI di safekeep: `run`, `sync-once`, `status`, `doctor` e `daemon` (SPEC.md §9).
|
|
2
2
|
|
|
3
3
|
Entry point del package (`safekeep.cli:main`): `bin/safekeep.py` è solo lo
|
|
4
4
|
shim che usano launchd/systemd e i test.
|
|
@@ -13,6 +13,7 @@ import sys
|
|
|
13
13
|
from datetime import datetime
|
|
14
14
|
|
|
15
15
|
from safekeep import __version__
|
|
16
|
+
from safekeep import daemonctl
|
|
16
17
|
from safekeep.config import ConfigError, discover_projects, parse_sync
|
|
17
18
|
from safekeep.copier import TMP_INFIX
|
|
18
19
|
from safekeep.daemon import Daemon, setup_logging
|
|
@@ -34,10 +35,8 @@ from safekeep.platform import (
|
|
|
34
35
|
from safekeep.s3dest import S3ExtraMissing, secret_perms_insecure
|
|
35
36
|
from safekeep.volumes import dest_state
|
|
36
37
|
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
40
|
-
EXAMPLE = os.path.join(REPO, 'examples', 'safekeep.example')
|
|
38
|
+
# Repo upstream: solo nell'hint degradato di `cmd_doctor` quando l'esempio
|
|
39
|
+
# embedded non fosse leggibile (install pip rotto — package data mancante).
|
|
41
40
|
REPO_URL = 'https://github.com/foxhound87/safekeep'
|
|
42
41
|
|
|
43
42
|
|
|
@@ -73,6 +72,38 @@ def build_parser():
|
|
|
73
72
|
doc.add_argument('--json', action='store_true',
|
|
74
73
|
help='stessi check, stesso exit code, ma un documento JSON su '
|
|
75
74
|
'stdout (SPEC.md §9.1)')
|
|
75
|
+
daem = sub.add_parser(
|
|
76
|
+
'daemon',
|
|
77
|
+
description="installa e gestisce l'agent di boot (launchd su macOS, "
|
|
78
|
+
'systemd user su Linux/WSL2 con systemd-on) — ExecStart = '
|
|
79
|
+
'shim `safekeep` sul PATH → `safekeep run`',
|
|
80
|
+
help="installa e gestisce l'agent di boot (launchd su macOS, systemd "
|
|
81
|
+
"user su Linux/WSL2) — ExecStart: safekeep run")
|
|
82
|
+
dsub = daem.add_subparsers(dest='daemon_cmd', required=True)
|
|
83
|
+
dinst = dsub.add_parser(
|
|
84
|
+
'install',
|
|
85
|
+
help='preflight (fswatch/shim/python/WSL-systemd) + render della unit da '
|
|
86
|
+
'template embedded + scrittura — idempotente, NON avvia')
|
|
87
|
+
dinst.add_argument('--enable-linger', action='store_true',
|
|
88
|
+
help='Linux: esegue `loginctl enable-linger $USER` '
|
|
89
|
+
'(default: solo hint — linger = unit user attive '
|
|
90
|
+
'anche senza login)')
|
|
91
|
+
dinst.add_argument('--dry-run', action='store_true',
|
|
92
|
+
help='mostra cosa installerebbe, senza scrivere nulla')
|
|
93
|
+
dsub.add_parser('start',
|
|
94
|
+
help="carica e avvia l'agent (launchctl bootstrap / "
|
|
95
|
+
'systemctl --user enable --now)')
|
|
96
|
+
dsub.add_parser('stop',
|
|
97
|
+
help="ferma l'agent (launchctl bootout / "
|
|
98
|
+
'systemctl --user disable --now)')
|
|
99
|
+
dsub.add_parser('status',
|
|
100
|
+
help="stato dell'agent — sola lettura, exit 0 se in "
|
|
101
|
+
'esecuzione, 1 altrimenti')
|
|
102
|
+
dun = dsub.add_parser('uninstall',
|
|
103
|
+
help='rimuove la unit — ~/.safekeep e i log restano '
|
|
104
|
+
'intatti')
|
|
105
|
+
dun.add_argument('--purge', action='store_true',
|
|
106
|
+
help='rimuove anche ~/.safekeep e ~/.local/state/safekeep/')
|
|
76
107
|
return parser
|
|
77
108
|
|
|
78
109
|
|
|
@@ -190,10 +221,13 @@ def cmd_doctor(args):
|
|
|
190
221
|
daemon = None
|
|
191
222
|
s3_missing = None
|
|
192
223
|
if not os.path.exists(cfg_path):
|
|
193
|
-
|
|
194
|
-
|
|
224
|
+
# Hint dall'example EMBEDDED (package data, task 4): un install pip non
|
|
225
|
+
# ha il repo → niente più "clone the repo"
|
|
226
|
+
embedded = daemonctl.example_path()
|
|
227
|
+
if os.path.exists(embedded):
|
|
228
|
+
hint = f'creala: cp {embedded} {cfg_path}'
|
|
195
229
|
else:
|
|
196
|
-
# install
|
|
230
|
+
# install rotto (niente package data): fallback minimo
|
|
197
231
|
hint = (f'clone {REPO_URL} and copy examples/safekeep.example to '
|
|
198
232
|
f'{cfg_path}')
|
|
199
233
|
out(False, f'config assente: {cfg_path} ({hint})',
|
|
@@ -377,7 +411,7 @@ def cmd_doctor(args):
|
|
|
377
411
|
'residui *.safekeep.tmp.*: ' + ', '.join(residui))
|
|
378
412
|
|
|
379
413
|
if is_darwin():
|
|
380
|
-
plist = os.path.expanduser(
|
|
414
|
+
plist = os.path.expanduser(daemonctl.PLIST_PATH)
|
|
381
415
|
if not os.path.exists(plist):
|
|
382
416
|
out(True, 'plist non installato (nessun agent da validare)')
|
|
383
417
|
elif shutil.which('plutil') is None:
|
|
@@ -391,8 +425,9 @@ def cmd_doctor(args):
|
|
|
391
425
|
except (OSError, subprocess.SubprocessError) as e:
|
|
392
426
|
out(False, f'plist lint non eseguibile: {e}', is_fatal=True)
|
|
393
427
|
else:
|
|
394
|
-
# Linux: user unit systemd al posto del plist (SPEC.md §14.3)
|
|
395
|
-
|
|
428
|
+
# Linux: user unit systemd al posto del plist (SPEC.md §14.3) — stesso
|
|
429
|
+
# path scritto da daemonctl (costanti condivise, task 4)
|
|
430
|
+
unit = os.path.expanduser(daemonctl.UNIT_PATH)
|
|
396
431
|
if not os.path.exists(unit):
|
|
397
432
|
out(True, 'unit systemd non installata (nessun agent da validare)')
|
|
398
433
|
else:
|
|
@@ -402,9 +437,12 @@ def cmd_doctor(args):
|
|
|
402
437
|
except (OSError, UnicodeError) as e:
|
|
403
438
|
out(False, f'unit systemd illeggibile: {unit}: {e}', is_fatal=True)
|
|
404
439
|
else:
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
440
|
+
residui = [p for p in daemonctl.PLACEHOLDERS if p in text]
|
|
441
|
+
if residui:
|
|
442
|
+
out(False, f'unit systemd: {unit} contiene ancora i '
|
|
443
|
+
f'placeholder del template '
|
|
444
|
+
f'({", ".join(residui)}) — rilancia '
|
|
445
|
+
f'safekeep daemon install', is_fatal=True)
|
|
408
446
|
else:
|
|
409
447
|
out(True, f'unit systemd: {unit}')
|
|
410
448
|
# linger: solo dove la unit è installata (SPEC.md §14.4) — non fatale
|
|
@@ -453,9 +491,25 @@ def cmd_doctor(args):
|
|
|
453
491
|
return code
|
|
454
492
|
|
|
455
493
|
|
|
494
|
+
def cmd_daemon(args):
|
|
495
|
+
"""Dispatch dei sottocomandi `safekeep daemon` (daemonctl, SPEC §14.3)."""
|
|
496
|
+
if args.daemon_cmd == 'install':
|
|
497
|
+
return daemonctl.install(enable_linger=args.enable_linger,
|
|
498
|
+
dry_run=args.dry_run)
|
|
499
|
+
if args.daemon_cmd == 'uninstall':
|
|
500
|
+
return daemonctl.uninstall(purge=args.purge)
|
|
501
|
+
if args.daemon_cmd == 'start':
|
|
502
|
+
return daemonctl.start()
|
|
503
|
+
if args.daemon_cmd == 'stop':
|
|
504
|
+
return daemonctl.stop()
|
|
505
|
+
return daemonctl.status()
|
|
506
|
+
|
|
507
|
+
|
|
456
508
|
def main(argv=None):
|
|
457
509
|
args = build_parser().parse_args(argv)
|
|
458
510
|
try:
|
|
511
|
+
if args.comando == 'daemon':
|
|
512
|
+
return cmd_daemon(args)
|
|
459
513
|
if args.comando == 'run':
|
|
460
514
|
return Daemon(args.config).run(verbose=args.v)
|
|
461
515
|
if args.comando == 'status':
|