cfg-guard 1.0.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.
- cfg_guard-1.0.0/LICENSE +21 -0
- cfg_guard-1.0.0/PKG-INFO +291 -0
- cfg_guard-1.0.0/README.md +242 -0
- cfg_guard-1.0.0/cfg_guard.egg-info/PKG-INFO +291 -0
- cfg_guard-1.0.0/cfg_guard.egg-info/SOURCES.txt +13 -0
- cfg_guard-1.0.0/cfg_guard.egg-info/dependency_links.txt +1 -0
- cfg_guard-1.0.0/cfg_guard.egg-info/entry_points.txt +2 -0
- cfg_guard-1.0.0/cfg_guard.egg-info/top_level.txt +1 -0
- cfg_guard-1.0.0/cfgguard/__init__.py +87 -0
- cfg_guard-1.0.0/cfgguard/__main__.py +8 -0
- cfg_guard-1.0.0/cfgguard/cli.py +453 -0
- cfg_guard-1.0.0/cfgguard/lockedit.py +330 -0
- cfg_guard-1.0.0/cfgguard/watch.py +347 -0
- cfg_guard-1.0.0/pyproject.toml +43 -0
- cfg_guard-1.0.0/setup.cfg +4 -0
cfg_guard-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ameobius
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
cfg_guard-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cfg-guard
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A lock and a witness for config files too many hands edit: flock + a content-hash promise to stop the clobber, inotify + /proc to name the writer.
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 ameobius
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Project-URL: Homepage, https://gitlab.com/ameobius-ai/cfg-guard
|
|
28
|
+
Project-URL: Source, https://gitlab.com/ameobius-ai/cfg-guard
|
|
29
|
+
Project-URL: Issues, https://gitlab.com/ameobius-ai/cfg-guard/-/issues
|
|
30
|
+
Keywords: config,configuration,flock,inotify,concurrency,race-condition,ops,sysadmin
|
|
31
|
+
Classifier: Development Status :: 4 - Beta
|
|
32
|
+
Classifier: Environment :: Console
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: Intended Audience :: System Administrators
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Operating System :: POSIX
|
|
37
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
38
|
+
Classifier: Programming Language :: Python :: 3
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
43
|
+
Classifier: Topic :: System :: Filesystems
|
|
44
|
+
Classifier: Topic :: System :: Systems Administration
|
|
45
|
+
Requires-Python: >=3.10
|
|
46
|
+
Description-Content-Type: text/markdown
|
|
47
|
+
License-File: LICENSE
|
|
48
|
+
Dynamic: license-file
|
|
49
|
+
|
|
50
|
+
<div align="center">
|
|
51
|
+
|
|
52
|
+
# cfg-guard
|
|
53
|
+
|
|
54
|
+
**A lock and a witness for config files too many hands edit.**
|
|
55
|
+
`flock` + a content-hash promise to stop the clobber, `inotify` + `/proc` to name the writer.
|
|
56
|
+
|
|
57
|
+
[](https://www.python.org/downloads/)
|
|
58
|
+
[](#install)
|
|
59
|
+
[](LICENSE)
|
|
60
|
+
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
A config that several hands write is a config that silently loses edits. Two
|
|
66
|
+
agents read the same file, each changes its own copy, the second write lands on
|
|
67
|
+
top of the first — no exception, no conflict marker, just a key that is quietly
|
|
68
|
+
gone. The agent whose change vanished will not notice until something downstream
|
|
69
|
+
reads a config it never wrote.
|
|
70
|
+
|
|
71
|
+
`cfg-guard` is two tools for that one problem. A **lock and a promise** (`flock`
|
|
72
|
+
plus a content-hash compare-and-swap) stop *you* from clobbering others and
|
|
73
|
+
refuse a write whose base bytes moved. A **witness** (`inotify` plus `/proc`)
|
|
74
|
+
names whoever else writes the file while you are not looking. It grew out of
|
|
75
|
+
running a live [cliproxy] config that several agents and a few cron jobs edit at
|
|
76
|
+
once, and learning the hard way how many "the config is wrong again" incidents
|
|
77
|
+
were really a race nobody could see.
|
|
78
|
+
|
|
79
|
+
## The two failures
|
|
80
|
+
|
|
81
|
+
Both are silent. Both are the reason this exists.
|
|
82
|
+
|
|
83
|
+
1. **Clobber.** Two writers, one file, last write wins — and the loser never
|
|
84
|
+
hears about it. `cfgguard.lockedit` prevents it two ways: `flock` serializes
|
|
85
|
+
the writers polite enough to take the lock, and a content-hash promise
|
|
86
|
+
(compare-and-swap) refuses a write whose base bytes moved, catching the ones
|
|
87
|
+
that never heard of the lock. A refusal is not a failure; it is the clobber
|
|
88
|
+
being *prevented*. Exit `3` means the tool did its job.
|
|
89
|
+
|
|
90
|
+
2. **Invisible writers.** A vendor agent, a hot-reload, a cron mutates the file
|
|
91
|
+
and nobody can say when or by whom. `cfgguard.watch` is the witness: it sits
|
|
92
|
+
on `inotify` and attributes every real change to a live `/proc` fd holder,
|
|
93
|
+
separating content edits from open-and-close no-ops by sha256. `inotify` never
|
|
94
|
+
hands you a PID, so the watcher snapshots `/proc/*/fd` *first* on every event
|
|
95
|
+
— the fd trail fades in milliseconds.
|
|
96
|
+
|
|
97
|
+
## Install
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pip install cfg-guard
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
No PyPI release yet? Install straight from the checkout:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
git clone https://gitlab.com/ameobius-ai/cfg-guard
|
|
107
|
+
cd cfg-guard && pip install .
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The engine has **zero runtime dependencies** — pure stdlib. `flock` is POSIX and
|
|
111
|
+
`inotify` + `/proc` are Linux, so the witness is Linux-only; everywhere else the
|
|
112
|
+
lock degrades to a no-op (the hash promise still holds) and `watch` says so
|
|
113
|
+
instead of pretending. See [Platform](#platform).
|
|
114
|
+
|
|
115
|
+
## Quickstart
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# 1. Snapshot the promise you will hand back as --expect
|
|
119
|
+
H=$(cfg-guard hash gateway.yaml)
|
|
120
|
+
|
|
121
|
+
# 2. Edit under lock + promise: a stdin->stdout filter, refused if the base moved
|
|
122
|
+
cfg-guard edit gateway.yaml --expect "$H" -- yq '.timeout = 30'
|
|
123
|
+
|
|
124
|
+
# 3. Or replace the whole file from stdin, still under the same guard
|
|
125
|
+
cfg-guard edit gateway.yaml --expect "$H" --stdin < new.yaml
|
|
126
|
+
|
|
127
|
+
# 4. Or open $EDITOR, serialized by flock so a second editor waits
|
|
128
|
+
cfg-guard edit gateway.yaml
|
|
129
|
+
|
|
130
|
+
# 5. Witness who else writes it, live, with the writer named from /proc
|
|
131
|
+
cfg-guard watch gateway.yaml
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`edit` is the only command that writes. Everything after the first bare `--` is
|
|
135
|
+
the filter argv — `cfg-guard` splits it off before argparse sees it, so the
|
|
136
|
+
filter's own flags are never mistaken for ours.
|
|
137
|
+
|
|
138
|
+
### What a guarded write looks like
|
|
139
|
+
|
|
140
|
+
```console
|
|
141
|
+
$ H=$(cfg-guard hash gateway.yaml)
|
|
142
|
+
$ cfg-guard edit gateway.yaml --expect "$H" -- yq '.timeout = 30'
|
|
143
|
+
gateway.yaml: wrote 3f2a9c1d4e5b -> 8c1f0a2b3d4e
|
|
144
|
+
cfg-guard: previous copy kept at gateway.yaml.bak
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
One honest line: what moved. The pre-write bytes are copied to `.bak` unless you
|
|
148
|
+
pass `--no-backup`. An identical write is detected and skipped, so a caller can
|
|
149
|
+
re-assert the whole desired config every run without churning backups:
|
|
150
|
+
|
|
151
|
+
```console
|
|
152
|
+
$ cfg-guard edit gateway.yaml --stdin < same.yaml
|
|
153
|
+
cfg-guard: no-op, gateway.yaml already 8c1f0a2b3d4e
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### What a prevented clobber looks like
|
|
157
|
+
|
|
158
|
+
Someone else wrote the file between your `hash` and your `edit`. The promise no
|
|
159
|
+
longer matches, so the write is refused and **nothing on disk is touched**:
|
|
160
|
+
|
|
161
|
+
```console
|
|
162
|
+
$ cfg-guard edit gateway.yaml --expect "$H" -- yq '.timeout = 30'
|
|
163
|
+
cfg-guard: error: gateway.yaml: expected hash 3f2a9c1d4e5b but on-disk is 8c1f0a2b3d4e — another writer moved it; re-read and redo the edit
|
|
164
|
+
$ echo $?
|
|
165
|
+
3
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Re-read, redo the edit against the new bytes. That loop is the whole point —
|
|
169
|
+
better a refused write than a vanished one.
|
|
170
|
+
|
|
171
|
+
### What the witness sees
|
|
172
|
+
|
|
173
|
+
```console
|
|
174
|
+
$ cfg-guard watch ~/.config/gateway.yaml
|
|
175
|
+
cfg-guard: witnessing /home/ops/.config/gateway.yaml (Ctrl-C to stop)
|
|
176
|
+
watching /home/ops/.config/gateway.yaml (baseline 3f2a9c1d4e5b)
|
|
177
|
+
no-op x1: ATTRIB content unchanged (3f2a9c1d4e5b)
|
|
178
|
+
CONTENT CHANGED MODIFY|CLOSE_WRITE: 3f2a9c1d4e5b -> 8c1f0a2b3d4e
|
|
179
|
+
fd-holder file pid=4821 comm=yq yq .timeout = 30 gateway.yaml
|
|
180
|
+
inotify-watcher pid=3390 comm=hermes
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The `no-op` line is a metadata touch that did not change a single byte — the
|
|
184
|
+
sha256 gate keeps it from flooding the log. The `CONTENT CHANGED` block names the
|
|
185
|
+
process caught holding the file open (`fd-holder`) and anyone else watching it
|
|
186
|
+
(`inotify-watcher`). When a writer is fast enough to close its descriptor before
|
|
187
|
+
the snapshot, the witness says so rather than guessing:
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
ATTRIBUTION-MISS: writer closed its fd before the snapshot; last known: 4821 yq
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Commands
|
|
194
|
+
|
|
195
|
+
| Command | What it does | Writes? |
|
|
196
|
+
|---------|--------------|:-------:|
|
|
197
|
+
| `hash PATH` | Print the file's sha256 promise, or the `MISSING` sentinel. Machine-clean on stdout — no colour, no note — so `H=$(cfg-guard hash f)` is exact. | no |
|
|
198
|
+
| `edit PATH` | The only writer. A `-- cmd` filter, `--stdin`, or `$EDITOR`, all under `flock` + the hash promise. Refuses a base that moved (exit `3`). | yes |
|
|
199
|
+
| `watch PATH` | Witness the file: attribute every real change to a live `/proc` fd holder, suppress no-ops by sha256. Linux-only. | no |
|
|
200
|
+
| `selfcheck` | Offline assert suite — lockedit, watch, the exit-code contract, parser wiring, colour policy, editor resolution. **This is what CI runs.** | no |
|
|
201
|
+
|
|
202
|
+
Common options — `edit`: `--expect HASH`, `--stdin`, `--editor ARGV`,
|
|
203
|
+
`--no-backup`, `--timeout S`, `--color {auto,always,never}`, `-q/--quiet`.
|
|
204
|
+
`watch`: `--heartbeat S`, `--max-events N`, `--color {auto,always,never}`.
|
|
205
|
+
|
|
206
|
+
`--expect MISSING` turns a write into a create-only guard: it lands only if
|
|
207
|
+
nobody made the file first.
|
|
208
|
+
|
|
209
|
+
## Exit codes
|
|
210
|
+
|
|
211
|
+
A cron contract. `3` is a finding, not noise.
|
|
212
|
+
|
|
213
|
+
| Code | Meaning |
|
|
214
|
+
|:----:|---------|
|
|
215
|
+
| `0` | Done. A refused stale write is **not** this — see `3`. |
|
|
216
|
+
| `2` | Usage error, unreadable file, a failed filter command, or `watch` off Linux. |
|
|
217
|
+
| `3` | Stale promise: the bytes moved since you read them. Nothing was written — the clobber was prevented. |
|
|
218
|
+
| `4` | Lock busy: another writer held the flock past `--timeout`. |
|
|
219
|
+
| `130` | Interrupted. |
|
|
220
|
+
|
|
221
|
+
## How it holds
|
|
222
|
+
|
|
223
|
+
**`lockedit` — the lock and the promise.** `flock` on a sidecar `<file>.lock`
|
|
224
|
+
serializes every writer polite enough to take the lock. A content-hash
|
|
225
|
+
compare-and-swap catches the impolite ones: you snapshot the hash at read time
|
|
226
|
+
and hand it back as `--expect`; if the bytes moved in between, the write is
|
|
227
|
+
refused and nothing is touched. Writes are atomic (a temp file plus
|
|
228
|
+
`os.replace`). Correctness never rests on the lock alone — on a platform without
|
|
229
|
+
`flock` the lock is a no-op and the promise still holds.
|
|
230
|
+
|
|
231
|
+
**`watch` — the witness.** `inotify` hands you a mask and a filename but never a
|
|
232
|
+
PID, so on each event the watcher snapshots `/proc/*/fd` *first* — before hashing,
|
|
233
|
+
before logging — because a fast writer closes its descriptor in milliseconds and
|
|
234
|
+
any delay loses the trail. It then separates real content changes from no-ops by
|
|
235
|
+
sha256, and reads `/proc/*/fdinfo` to name the other inotify watchers sharing the
|
|
236
|
+
file (an agent that auto-saves a config usually watches it too).
|
|
237
|
+
|
|
238
|
+
Three sidecar files, all next to the target:
|
|
239
|
+
|
|
240
|
+
| Suffix | What it is |
|
|
241
|
+
|--------|------------|
|
|
242
|
+
| `<file>.lock` | The `flock` sidecar. Not the config itself, so an atomic temp+rename that replaces the inode never drops the lock. |
|
|
243
|
+
| `<file>.bak` | A copy of the pre-write bytes, kept unless `--no-backup`. |
|
|
244
|
+
| `<file>.tmp` | The atomic-write scratch, renamed over the target then unlinked. |
|
|
245
|
+
|
|
246
|
+
## Platform
|
|
247
|
+
|
|
248
|
+
`flock` is POSIX; `inotify` + `/proc` are Linux. The two cores degrade
|
|
249
|
+
independently and honestly:
|
|
250
|
+
|
|
251
|
+
- **Linux** — everything: serialized writes, the hash promise, and a witness that
|
|
252
|
+
names the writer.
|
|
253
|
+
- **Other POSIX (macOS/BSD)** — `hash` and `edit` work in full; the lock is a
|
|
254
|
+
no-op, so the content-hash promise is what prevents the clobber. `watch`
|
|
255
|
+
reports itself unavailable.
|
|
256
|
+
- **Windows** — same: `hash`/`edit` ride on the promise, `watch` exits `2` with a
|
|
257
|
+
plain "this platform has neither inotify nor /proc" rather than a fake watch.
|
|
258
|
+
|
|
259
|
+
## Library use
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
from cfgguard import file_hash, guarded_write, StaleConfig
|
|
263
|
+
|
|
264
|
+
seen = file_hash("gateway.yaml") # snapshot at read time
|
|
265
|
+
try:
|
|
266
|
+
guarded_write("gateway.yaml", new_bytes, expect=seen)
|
|
267
|
+
except StaleConfig:
|
|
268
|
+
... # someone moved it; re-read and redo
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
The witness is a library too: `Witness`, `fd_holders`, `inotify_watchers`, and
|
|
272
|
+
`watch_available` are all re-exported from the package root. `transform()` runs a
|
|
273
|
+
stdin→stdout filter under the same lock + promise; `edit_inplace()` serializes an
|
|
274
|
+
interactive editor.
|
|
275
|
+
|
|
276
|
+
## Development
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
python -m cfgguard selfcheck # the whole offline contract; CI runs exactly this
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
`selfcheck` needs no network, no config file and no state on disk. On Linux it
|
|
283
|
+
drives a real inotify round-trip — write a file, catch the attributed event,
|
|
284
|
+
assert `CONTENT CHANGED`. If it passes, the lock, the promise, the witness, the
|
|
285
|
+
exit-code contract and the parser wiring are all behaving.
|
|
286
|
+
|
|
287
|
+
## License
|
|
288
|
+
|
|
289
|
+
MIT — see [LICENSE](LICENSE).
|
|
290
|
+
|
|
291
|
+
[cliproxy]: https://github.com/router-for-me/CLIProxyAPI
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# cfg-guard
|
|
4
|
+
|
|
5
|
+
**A lock and a witness for config files too many hands edit.**
|
|
6
|
+
`flock` + a content-hash promise to stop the clobber, `inotify` + `/proc` to name the writer.
|
|
7
|
+
|
|
8
|
+
[](https://www.python.org/downloads/)
|
|
9
|
+
[](#install)
|
|
10
|
+
[](LICENSE)
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
A config that several hands write is a config that silently loses edits. Two
|
|
17
|
+
agents read the same file, each changes its own copy, the second write lands on
|
|
18
|
+
top of the first — no exception, no conflict marker, just a key that is quietly
|
|
19
|
+
gone. The agent whose change vanished will not notice until something downstream
|
|
20
|
+
reads a config it never wrote.
|
|
21
|
+
|
|
22
|
+
`cfg-guard` is two tools for that one problem. A **lock and a promise** (`flock`
|
|
23
|
+
plus a content-hash compare-and-swap) stop *you* from clobbering others and
|
|
24
|
+
refuse a write whose base bytes moved. A **witness** (`inotify` plus `/proc`)
|
|
25
|
+
names whoever else writes the file while you are not looking. It grew out of
|
|
26
|
+
running a live [cliproxy] config that several agents and a few cron jobs edit at
|
|
27
|
+
once, and learning the hard way how many "the config is wrong again" incidents
|
|
28
|
+
were really a race nobody could see.
|
|
29
|
+
|
|
30
|
+
## The two failures
|
|
31
|
+
|
|
32
|
+
Both are silent. Both are the reason this exists.
|
|
33
|
+
|
|
34
|
+
1. **Clobber.** Two writers, one file, last write wins — and the loser never
|
|
35
|
+
hears about it. `cfgguard.lockedit` prevents it two ways: `flock` serializes
|
|
36
|
+
the writers polite enough to take the lock, and a content-hash promise
|
|
37
|
+
(compare-and-swap) refuses a write whose base bytes moved, catching the ones
|
|
38
|
+
that never heard of the lock. A refusal is not a failure; it is the clobber
|
|
39
|
+
being *prevented*. Exit `3` means the tool did its job.
|
|
40
|
+
|
|
41
|
+
2. **Invisible writers.** A vendor agent, a hot-reload, a cron mutates the file
|
|
42
|
+
and nobody can say when or by whom. `cfgguard.watch` is the witness: it sits
|
|
43
|
+
on `inotify` and attributes every real change to a live `/proc` fd holder,
|
|
44
|
+
separating content edits from open-and-close no-ops by sha256. `inotify` never
|
|
45
|
+
hands you a PID, so the watcher snapshots `/proc/*/fd` *first* on every event
|
|
46
|
+
— the fd trail fades in milliseconds.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install cfg-guard
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
No PyPI release yet? Install straight from the checkout:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
git clone https://gitlab.com/ameobius-ai/cfg-guard
|
|
58
|
+
cd cfg-guard && pip install .
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The engine has **zero runtime dependencies** — pure stdlib. `flock` is POSIX and
|
|
62
|
+
`inotify` + `/proc` are Linux, so the witness is Linux-only; everywhere else the
|
|
63
|
+
lock degrades to a no-op (the hash promise still holds) and `watch` says so
|
|
64
|
+
instead of pretending. See [Platform](#platform).
|
|
65
|
+
|
|
66
|
+
## Quickstart
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# 1. Snapshot the promise you will hand back as --expect
|
|
70
|
+
H=$(cfg-guard hash gateway.yaml)
|
|
71
|
+
|
|
72
|
+
# 2. Edit under lock + promise: a stdin->stdout filter, refused if the base moved
|
|
73
|
+
cfg-guard edit gateway.yaml --expect "$H" -- yq '.timeout = 30'
|
|
74
|
+
|
|
75
|
+
# 3. Or replace the whole file from stdin, still under the same guard
|
|
76
|
+
cfg-guard edit gateway.yaml --expect "$H" --stdin < new.yaml
|
|
77
|
+
|
|
78
|
+
# 4. Or open $EDITOR, serialized by flock so a second editor waits
|
|
79
|
+
cfg-guard edit gateway.yaml
|
|
80
|
+
|
|
81
|
+
# 5. Witness who else writes it, live, with the writer named from /proc
|
|
82
|
+
cfg-guard watch gateway.yaml
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`edit` is the only command that writes. Everything after the first bare `--` is
|
|
86
|
+
the filter argv — `cfg-guard` splits it off before argparse sees it, so the
|
|
87
|
+
filter's own flags are never mistaken for ours.
|
|
88
|
+
|
|
89
|
+
### What a guarded write looks like
|
|
90
|
+
|
|
91
|
+
```console
|
|
92
|
+
$ H=$(cfg-guard hash gateway.yaml)
|
|
93
|
+
$ cfg-guard edit gateway.yaml --expect "$H" -- yq '.timeout = 30'
|
|
94
|
+
gateway.yaml: wrote 3f2a9c1d4e5b -> 8c1f0a2b3d4e
|
|
95
|
+
cfg-guard: previous copy kept at gateway.yaml.bak
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
One honest line: what moved. The pre-write bytes are copied to `.bak` unless you
|
|
99
|
+
pass `--no-backup`. An identical write is detected and skipped, so a caller can
|
|
100
|
+
re-assert the whole desired config every run without churning backups:
|
|
101
|
+
|
|
102
|
+
```console
|
|
103
|
+
$ cfg-guard edit gateway.yaml --stdin < same.yaml
|
|
104
|
+
cfg-guard: no-op, gateway.yaml already 8c1f0a2b3d4e
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### What a prevented clobber looks like
|
|
108
|
+
|
|
109
|
+
Someone else wrote the file between your `hash` and your `edit`. The promise no
|
|
110
|
+
longer matches, so the write is refused and **nothing on disk is touched**:
|
|
111
|
+
|
|
112
|
+
```console
|
|
113
|
+
$ cfg-guard edit gateway.yaml --expect "$H" -- yq '.timeout = 30'
|
|
114
|
+
cfg-guard: error: gateway.yaml: expected hash 3f2a9c1d4e5b but on-disk is 8c1f0a2b3d4e — another writer moved it; re-read and redo the edit
|
|
115
|
+
$ echo $?
|
|
116
|
+
3
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Re-read, redo the edit against the new bytes. That loop is the whole point —
|
|
120
|
+
better a refused write than a vanished one.
|
|
121
|
+
|
|
122
|
+
### What the witness sees
|
|
123
|
+
|
|
124
|
+
```console
|
|
125
|
+
$ cfg-guard watch ~/.config/gateway.yaml
|
|
126
|
+
cfg-guard: witnessing /home/ops/.config/gateway.yaml (Ctrl-C to stop)
|
|
127
|
+
watching /home/ops/.config/gateway.yaml (baseline 3f2a9c1d4e5b)
|
|
128
|
+
no-op x1: ATTRIB content unchanged (3f2a9c1d4e5b)
|
|
129
|
+
CONTENT CHANGED MODIFY|CLOSE_WRITE: 3f2a9c1d4e5b -> 8c1f0a2b3d4e
|
|
130
|
+
fd-holder file pid=4821 comm=yq yq .timeout = 30 gateway.yaml
|
|
131
|
+
inotify-watcher pid=3390 comm=hermes
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The `no-op` line is a metadata touch that did not change a single byte — the
|
|
135
|
+
sha256 gate keeps it from flooding the log. The `CONTENT CHANGED` block names the
|
|
136
|
+
process caught holding the file open (`fd-holder`) and anyone else watching it
|
|
137
|
+
(`inotify-watcher`). When a writer is fast enough to close its descriptor before
|
|
138
|
+
the snapshot, the witness says so rather than guessing:
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
ATTRIBUTION-MISS: writer closed its fd before the snapshot; last known: 4821 yq
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Commands
|
|
145
|
+
|
|
146
|
+
| Command | What it does | Writes? |
|
|
147
|
+
|---------|--------------|:-------:|
|
|
148
|
+
| `hash PATH` | Print the file's sha256 promise, or the `MISSING` sentinel. Machine-clean on stdout — no colour, no note — so `H=$(cfg-guard hash f)` is exact. | no |
|
|
149
|
+
| `edit PATH` | The only writer. A `-- cmd` filter, `--stdin`, or `$EDITOR`, all under `flock` + the hash promise. Refuses a base that moved (exit `3`). | yes |
|
|
150
|
+
| `watch PATH` | Witness the file: attribute every real change to a live `/proc` fd holder, suppress no-ops by sha256. Linux-only. | no |
|
|
151
|
+
| `selfcheck` | Offline assert suite — lockedit, watch, the exit-code contract, parser wiring, colour policy, editor resolution. **This is what CI runs.** | no |
|
|
152
|
+
|
|
153
|
+
Common options — `edit`: `--expect HASH`, `--stdin`, `--editor ARGV`,
|
|
154
|
+
`--no-backup`, `--timeout S`, `--color {auto,always,never}`, `-q/--quiet`.
|
|
155
|
+
`watch`: `--heartbeat S`, `--max-events N`, `--color {auto,always,never}`.
|
|
156
|
+
|
|
157
|
+
`--expect MISSING` turns a write into a create-only guard: it lands only if
|
|
158
|
+
nobody made the file first.
|
|
159
|
+
|
|
160
|
+
## Exit codes
|
|
161
|
+
|
|
162
|
+
A cron contract. `3` is a finding, not noise.
|
|
163
|
+
|
|
164
|
+
| Code | Meaning |
|
|
165
|
+
|:----:|---------|
|
|
166
|
+
| `0` | Done. A refused stale write is **not** this — see `3`. |
|
|
167
|
+
| `2` | Usage error, unreadable file, a failed filter command, or `watch` off Linux. |
|
|
168
|
+
| `3` | Stale promise: the bytes moved since you read them. Nothing was written — the clobber was prevented. |
|
|
169
|
+
| `4` | Lock busy: another writer held the flock past `--timeout`. |
|
|
170
|
+
| `130` | Interrupted. |
|
|
171
|
+
|
|
172
|
+
## How it holds
|
|
173
|
+
|
|
174
|
+
**`lockedit` — the lock and the promise.** `flock` on a sidecar `<file>.lock`
|
|
175
|
+
serializes every writer polite enough to take the lock. A content-hash
|
|
176
|
+
compare-and-swap catches the impolite ones: you snapshot the hash at read time
|
|
177
|
+
and hand it back as `--expect`; if the bytes moved in between, the write is
|
|
178
|
+
refused and nothing is touched. Writes are atomic (a temp file plus
|
|
179
|
+
`os.replace`). Correctness never rests on the lock alone — on a platform without
|
|
180
|
+
`flock` the lock is a no-op and the promise still holds.
|
|
181
|
+
|
|
182
|
+
**`watch` — the witness.** `inotify` hands you a mask and a filename but never a
|
|
183
|
+
PID, so on each event the watcher snapshots `/proc/*/fd` *first* — before hashing,
|
|
184
|
+
before logging — because a fast writer closes its descriptor in milliseconds and
|
|
185
|
+
any delay loses the trail. It then separates real content changes from no-ops by
|
|
186
|
+
sha256, and reads `/proc/*/fdinfo` to name the other inotify watchers sharing the
|
|
187
|
+
file (an agent that auto-saves a config usually watches it too).
|
|
188
|
+
|
|
189
|
+
Three sidecar files, all next to the target:
|
|
190
|
+
|
|
191
|
+
| Suffix | What it is |
|
|
192
|
+
|--------|------------|
|
|
193
|
+
| `<file>.lock` | The `flock` sidecar. Not the config itself, so an atomic temp+rename that replaces the inode never drops the lock. |
|
|
194
|
+
| `<file>.bak` | A copy of the pre-write bytes, kept unless `--no-backup`. |
|
|
195
|
+
| `<file>.tmp` | The atomic-write scratch, renamed over the target then unlinked. |
|
|
196
|
+
|
|
197
|
+
## Platform
|
|
198
|
+
|
|
199
|
+
`flock` is POSIX; `inotify` + `/proc` are Linux. The two cores degrade
|
|
200
|
+
independently and honestly:
|
|
201
|
+
|
|
202
|
+
- **Linux** — everything: serialized writes, the hash promise, and a witness that
|
|
203
|
+
names the writer.
|
|
204
|
+
- **Other POSIX (macOS/BSD)** — `hash` and `edit` work in full; the lock is a
|
|
205
|
+
no-op, so the content-hash promise is what prevents the clobber. `watch`
|
|
206
|
+
reports itself unavailable.
|
|
207
|
+
- **Windows** — same: `hash`/`edit` ride on the promise, `watch` exits `2` with a
|
|
208
|
+
plain "this platform has neither inotify nor /proc" rather than a fake watch.
|
|
209
|
+
|
|
210
|
+
## Library use
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
from cfgguard import file_hash, guarded_write, StaleConfig
|
|
214
|
+
|
|
215
|
+
seen = file_hash("gateway.yaml") # snapshot at read time
|
|
216
|
+
try:
|
|
217
|
+
guarded_write("gateway.yaml", new_bytes, expect=seen)
|
|
218
|
+
except StaleConfig:
|
|
219
|
+
... # someone moved it; re-read and redo
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The witness is a library too: `Witness`, `fd_holders`, `inotify_watchers`, and
|
|
223
|
+
`watch_available` are all re-exported from the package root. `transform()` runs a
|
|
224
|
+
stdin→stdout filter under the same lock + promise; `edit_inplace()` serializes an
|
|
225
|
+
interactive editor.
|
|
226
|
+
|
|
227
|
+
## Development
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
python -m cfgguard selfcheck # the whole offline contract; CI runs exactly this
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`selfcheck` needs no network, no config file and no state on disk. On Linux it
|
|
234
|
+
drives a real inotify round-trip — write a file, catch the attributed event,
|
|
235
|
+
assert `CONTENT CHANGED`. If it passes, the lock, the promise, the witness, the
|
|
236
|
+
exit-code contract and the parser wiring are all behaving.
|
|
237
|
+
|
|
238
|
+
## License
|
|
239
|
+
|
|
240
|
+
MIT — see [LICENSE](LICENSE).
|
|
241
|
+
|
|
242
|
+
[cliproxy]: https://github.com/router-for-me/CLIProxyAPI
|