escrow-evidence 0.1.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.
- escrow_evidence-0.1.0/LICENSE +21 -0
- escrow_evidence-0.1.0/PKG-INFO +176 -0
- escrow_evidence-0.1.0/README.md +153 -0
- escrow_evidence-0.1.0/escrow/__init__.py +1 -0
- escrow_evidence-0.1.0/escrow/check.py +53 -0
- escrow_evidence-0.1.0/escrow/cli.py +77 -0
- escrow_evidence-0.1.0/escrow/config.py +62 -0
- escrow_evidence-0.1.0/escrow/duration.py +31 -0
- escrow_evidence-0.1.0/escrow/state.py +54 -0
- escrow_evidence-0.1.0/escrow_evidence.egg-info/PKG-INFO +176 -0
- escrow_evidence-0.1.0/escrow_evidence.egg-info/SOURCES.txt +20 -0
- escrow_evidence-0.1.0/escrow_evidence.egg-info/dependency_links.txt +1 -0
- escrow_evidence-0.1.0/escrow_evidence.egg-info/entry_points.txt +2 -0
- escrow_evidence-0.1.0/escrow_evidence.egg-info/requires.txt +1 -0
- escrow_evidence-0.1.0/escrow_evidence.egg-info/top_level.txt +1 -0
- escrow_evidence-0.1.0/pyproject.toml +40 -0
- escrow_evidence-0.1.0/setup.cfg +4 -0
- escrow_evidence-0.1.0/tests/test_check.py +66 -0
- escrow_evidence-0.1.0/tests/test_cli.py +89 -0
- escrow_evidence-0.1.0/tests/test_config.py +94 -0
- escrow_evidence-0.1.0/tests/test_duration.py +55 -0
- escrow_evidence-0.1.0/tests/test_state.py +67 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ritish Saini
|
|
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.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: escrow-evidence
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A dead-man's-switch for cron/scheduled jobs: flags silence, not just failure -- the job that stopped running weeks ago that no alert ever caught.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/MaXiMo000/escrow
|
|
7
|
+
Project-URL: Source, https://github.com/MaXiMo000/escrow
|
|
8
|
+
Project-URL: Issues, https://github.com/MaXiMo000/escrow/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/MaXiMo000/escrow/releases
|
|
10
|
+
Keywords: cron,monitoring,dead-mans-switch,heartbeat,evidence,reliability
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: System :: Monitoring
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: PyYAML>=6.0
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# escrow
|
|
25
|
+
|
|
26
|
+
[](https://github.com/MaXiMo000/escrow/actions/workflows/ci.yml)
|
|
27
|
+
|
|
28
|
+
**A dead-man's-switch for cron jobs, GitHub Actions schedules, and systemd
|
|
29
|
+
timers: flags silence, not just failure.**
|
|
30
|
+
|
|
31
|
+
A scheduled job that throws an exception gets logged, maybe alerted on.
|
|
32
|
+
One that silently *stops running* — a cron entry removed by a bad deploy,
|
|
33
|
+
a systemd timer disabled and forgotten, a token that expired weeks ago —
|
|
34
|
+
produces nothing: no error, no log line, no exit code to check. "No
|
|
35
|
+
alert" and "everything's fine" look identical to anything that only
|
|
36
|
+
watches for failure. `escrow` watches for the third thing: quiet.
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
$ escrow check escrow.yaml
|
|
40
|
+
[!!] 'nightly-backup' last pinged 1d ago, past its 26h interval
|
|
41
|
+
[??] 'weekly-report' has never pinged in -- it may have never run, or is pinging under a different name
|
|
42
|
+
|
|
43
|
+
0/2 ok, 2 need attention
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
(Real output. `nightly-backup` pinged in on time once, then its clock was
|
|
47
|
+
wound forward 30 hours past its 26h interval; `weekly-report` never pinged
|
|
48
|
+
at all.)
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
pip install escrow-evidence # the command it installs is `escrow`
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
(`escrow` was already taken on PyPI -- same story as every sibling in this
|
|
57
|
+
portfolio.)
|
|
58
|
+
|
|
59
|
+
## Use
|
|
60
|
+
|
|
61
|
+
Declare what you're expecting, in `escrow.yaml`:
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
jobs:
|
|
65
|
+
- name: nightly-backup
|
|
66
|
+
interval: 26h # a daily job, with a few hours' slack
|
|
67
|
+
- name: weekly-report
|
|
68
|
+
interval: 8d
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
At the end of the actual job's script, record that it ran:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
escrow ping nightly-backup
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Separately -- on its own schedule, e.g. every 15 minutes -- check every
|
|
78
|
+
declared job against what's actually been recorded:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
escrow check escrow.yaml
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`ping` and `check` share a small JSON state file (`escrow-state.json` by
|
|
85
|
+
default, or `--state PATH`) -- `ping` writes to it, `check` reads it. They
|
|
86
|
+
need to see the *same* file: the common shape is a single host (the job
|
|
87
|
+
and the check both run there, e.g. two cron entries) or a shared
|
|
88
|
+
filesystem mount. This is not a hosted, multi-machine service; see "What
|
|
89
|
+
this does not do."
|
|
90
|
+
|
|
91
|
+
## Three statuses, not two
|
|
92
|
+
|
|
93
|
+
| Status | Meaning |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `ok` | Pinged within its declared interval. |
|
|
96
|
+
| `overdue` | Pinged before, but not recently enough -- **the job that used to run and stopped.** |
|
|
97
|
+
| `never_seen` | Declared in `escrow.yaml`, never once recorded a ping -- **the job that never ran at all**, or a typo between the name in the config and the name in the script. |
|
|
98
|
+
|
|
99
|
+
`overdue` and `never_seen` are deliberately different statuses, not one
|
|
100
|
+
"bad" bucket: an operator debugs "this stopped" and "this never started"
|
|
101
|
+
differently, and collapsing them would hide which one they're looking at.
|
|
102
|
+
Jobs are declared in `escrow.yaml` up front, not discovered from whatever
|
|
103
|
+
happens to have pinged -- a job that's only "known" because it once pinged
|
|
104
|
+
would be exactly as invisible as before the first time it silently
|
|
105
|
+
stopped, or if it never started.
|
|
106
|
+
|
|
107
|
+
Exit code is `1` if anything is `overdue` or `never_seen`, `0` if every
|
|
108
|
+
declared job is `ok`.
|
|
109
|
+
|
|
110
|
+
## What this does NOT do
|
|
111
|
+
|
|
112
|
+
- **No daemon, no background process.** There is no `escrow serve` or
|
|
113
|
+
`escrow watch`. `escrow check` is one CLI invocation that reads the
|
|
114
|
+
state file and exits; *you* supply the periodic trigger -- a cron
|
|
115
|
+
entry, a systemd timer, a scheduled GitHub Actions workflow. escrow
|
|
116
|
+
never runs unless something else runs it.
|
|
117
|
+
- **No email, Slack, or webhook of its own.** The exit code is the
|
|
118
|
+
interface -- same convention [`receipt`](https://github.com/MaXiMo000/receipt),
|
|
119
|
+
[`invariant`](https://github.com/MaXiMo000/invariant), and
|
|
120
|
+
[`carabiner`](https://github.com/MaXiMo000/carabiner) already share.
|
|
121
|
+
Run `escrow check` as a step that fails loudly in whatever you already
|
|
122
|
+
have (a GitHub Actions job, a systemd `OnFailure=` unit, a cron entry
|
|
123
|
+
piped to your existing paging tool) rather than escrow adding its own
|
|
124
|
+
SMTP client or HTTP dependency for a notification path you may not want.
|
|
125
|
+
- **Not a hosted or multi-machine service.** State is one JSON file; `ping`
|
|
126
|
+
and `check` need to see the same one. A fleet of machines all pinging a
|
|
127
|
+
shared endpoint needs a real datastore behind it, not a local file --
|
|
128
|
+
genuinely different scope, not built here.
|
|
129
|
+
- **Not resilient to concurrent writers.** State is written to a temp file
|
|
130
|
+
and atomically renamed (a crash mid-write can't corrupt it), but two
|
|
131
|
+
`ping`s racing on the exact same job name on a network filesystem at the
|
|
132
|
+
same instant can still lose one update. Fine for the target use --one
|
|
133
|
+
job pings once per run-- not designed for high-frequency concurrent
|
|
134
|
+
writes.
|
|
135
|
+
|
|
136
|
+
## Compared to a hosted dead-man's-switch
|
|
137
|
+
|
|
138
|
+
[healthchecks.io](https://healthchecks.io), [Cronitor](https://cronitor.io),
|
|
139
|
+
and [Dead Man's Snitch](https://deadmanssnitch.com) solve the same problem
|
|
140
|
+
as a real, mature, hosted service: your job pings a URL over HTTPS, and
|
|
141
|
+
their infrastructure -- not yours -- watches the clock and sends email/
|
|
142
|
+
Slack/SMS/PagerDuty when a ping is late. If you want alerting that works
|
|
143
|
+
without you also solving alerting, and don't mind a third party knowing
|
|
144
|
+
when your jobs run, one of those is very likely the better choice --
|
|
145
|
+
escrow doesn't compete with that and isn't trying to.
|
|
146
|
+
|
|
147
|
+
escrow's tradeoff runs the other way, matching the same "stays on your
|
|
148
|
+
machine" discipline as the rest of this portfolio: `ping` and `check` never
|
|
149
|
+
leave the filesystem, there's no account, no third party ever learns your
|
|
150
|
+
job names or schedule, and the whole state is one JSON file you can read,
|
|
151
|
+
back up, or delete yourself. The cost of that is everything a hosted
|
|
152
|
+
service gives you for free: no scheduling (see "no daemon," above) and no
|
|
153
|
+
notification path of its own (see above) -- you supply both, from
|
|
154
|
+
infrastructure you already have. Reach for escrow specifically when a
|
|
155
|
+
third-party dependency for "is my cron job still running" is the wrong
|
|
156
|
+
tradeoff for what the job actually does; reach for a hosted switch
|
|
157
|
+
otherwise.
|
|
158
|
+
|
|
159
|
+
## Tests
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
pip install -e .
|
|
163
|
+
python tests/test_duration.py # "26h", "8d" -> seconds
|
|
164
|
+
python tests/test_config.py # escrow.yaml validation
|
|
165
|
+
python tests/test_state.py # the ping record: real files, real temp dirs
|
|
166
|
+
python tests/test_check.py # ok / overdue / never_seen classification
|
|
167
|
+
python tests/test_cli.py # the real CLI entry point, real files, real argv
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
41 tests. Two exist because testing an actual misconfigured `--state`
|
|
171
|
+
(pointed at a directory instead of a file) found a real gap: `load_state`
|
|
172
|
+
only caught `JSONDecodeError`, so `IsADirectoryError` -- also an `OSError`
|
|
173
|
+
-- escaped as a raw traceback instead of the same graceful "nothing
|
|
174
|
+
recorded yet" every other unreadable state file gets.
|
|
175
|
+
|
|
176
|
+
MIT licensed.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# escrow
|
|
2
|
+
|
|
3
|
+
[](https://github.com/MaXiMo000/escrow/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
**A dead-man's-switch for cron jobs, GitHub Actions schedules, and systemd
|
|
6
|
+
timers: flags silence, not just failure.**
|
|
7
|
+
|
|
8
|
+
A scheduled job that throws an exception gets logged, maybe alerted on.
|
|
9
|
+
One that silently *stops running* — a cron entry removed by a bad deploy,
|
|
10
|
+
a systemd timer disabled and forgotten, a token that expired weeks ago —
|
|
11
|
+
produces nothing: no error, no log line, no exit code to check. "No
|
|
12
|
+
alert" and "everything's fine" look identical to anything that only
|
|
13
|
+
watches for failure. `escrow` watches for the third thing: quiet.
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
$ escrow check escrow.yaml
|
|
17
|
+
[!!] 'nightly-backup' last pinged 1d ago, past its 26h interval
|
|
18
|
+
[??] 'weekly-report' has never pinged in -- it may have never run, or is pinging under a different name
|
|
19
|
+
|
|
20
|
+
0/2 ok, 2 need attention
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
(Real output. `nightly-backup` pinged in on time once, then its clock was
|
|
24
|
+
wound forward 30 hours past its 26h interval; `weekly-report` never pinged
|
|
25
|
+
at all.)
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
pip install escrow-evidence # the command it installs is `escrow`
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
(`escrow` was already taken on PyPI -- same story as every sibling in this
|
|
34
|
+
portfolio.)
|
|
35
|
+
|
|
36
|
+
## Use
|
|
37
|
+
|
|
38
|
+
Declare what you're expecting, in `escrow.yaml`:
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
jobs:
|
|
42
|
+
- name: nightly-backup
|
|
43
|
+
interval: 26h # a daily job, with a few hours' slack
|
|
44
|
+
- name: weekly-report
|
|
45
|
+
interval: 8d
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
At the end of the actual job's script, record that it ran:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
escrow ping nightly-backup
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Separately -- on its own schedule, e.g. every 15 minutes -- check every
|
|
55
|
+
declared job against what's actually been recorded:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
escrow check escrow.yaml
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`ping` and `check` share a small JSON state file (`escrow-state.json` by
|
|
62
|
+
default, or `--state PATH`) -- `ping` writes to it, `check` reads it. They
|
|
63
|
+
need to see the *same* file: the common shape is a single host (the job
|
|
64
|
+
and the check both run there, e.g. two cron entries) or a shared
|
|
65
|
+
filesystem mount. This is not a hosted, multi-machine service; see "What
|
|
66
|
+
this does not do."
|
|
67
|
+
|
|
68
|
+
## Three statuses, not two
|
|
69
|
+
|
|
70
|
+
| Status | Meaning |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `ok` | Pinged within its declared interval. |
|
|
73
|
+
| `overdue` | Pinged before, but not recently enough -- **the job that used to run and stopped.** |
|
|
74
|
+
| `never_seen` | Declared in `escrow.yaml`, never once recorded a ping -- **the job that never ran at all**, or a typo between the name in the config and the name in the script. |
|
|
75
|
+
|
|
76
|
+
`overdue` and `never_seen` are deliberately different statuses, not one
|
|
77
|
+
"bad" bucket: an operator debugs "this stopped" and "this never started"
|
|
78
|
+
differently, and collapsing them would hide which one they're looking at.
|
|
79
|
+
Jobs are declared in `escrow.yaml` up front, not discovered from whatever
|
|
80
|
+
happens to have pinged -- a job that's only "known" because it once pinged
|
|
81
|
+
would be exactly as invisible as before the first time it silently
|
|
82
|
+
stopped, or if it never started.
|
|
83
|
+
|
|
84
|
+
Exit code is `1` if anything is `overdue` or `never_seen`, `0` if every
|
|
85
|
+
declared job is `ok`.
|
|
86
|
+
|
|
87
|
+
## What this does NOT do
|
|
88
|
+
|
|
89
|
+
- **No daemon, no background process.** There is no `escrow serve` or
|
|
90
|
+
`escrow watch`. `escrow check` is one CLI invocation that reads the
|
|
91
|
+
state file and exits; *you* supply the periodic trigger -- a cron
|
|
92
|
+
entry, a systemd timer, a scheduled GitHub Actions workflow. escrow
|
|
93
|
+
never runs unless something else runs it.
|
|
94
|
+
- **No email, Slack, or webhook of its own.** The exit code is the
|
|
95
|
+
interface -- same convention [`receipt`](https://github.com/MaXiMo000/receipt),
|
|
96
|
+
[`invariant`](https://github.com/MaXiMo000/invariant), and
|
|
97
|
+
[`carabiner`](https://github.com/MaXiMo000/carabiner) already share.
|
|
98
|
+
Run `escrow check` as a step that fails loudly in whatever you already
|
|
99
|
+
have (a GitHub Actions job, a systemd `OnFailure=` unit, a cron entry
|
|
100
|
+
piped to your existing paging tool) rather than escrow adding its own
|
|
101
|
+
SMTP client or HTTP dependency for a notification path you may not want.
|
|
102
|
+
- **Not a hosted or multi-machine service.** State is one JSON file; `ping`
|
|
103
|
+
and `check` need to see the same one. A fleet of machines all pinging a
|
|
104
|
+
shared endpoint needs a real datastore behind it, not a local file --
|
|
105
|
+
genuinely different scope, not built here.
|
|
106
|
+
- **Not resilient to concurrent writers.** State is written to a temp file
|
|
107
|
+
and atomically renamed (a crash mid-write can't corrupt it), but two
|
|
108
|
+
`ping`s racing on the exact same job name on a network filesystem at the
|
|
109
|
+
same instant can still lose one update. Fine for the target use --one
|
|
110
|
+
job pings once per run-- not designed for high-frequency concurrent
|
|
111
|
+
writes.
|
|
112
|
+
|
|
113
|
+
## Compared to a hosted dead-man's-switch
|
|
114
|
+
|
|
115
|
+
[healthchecks.io](https://healthchecks.io), [Cronitor](https://cronitor.io),
|
|
116
|
+
and [Dead Man's Snitch](https://deadmanssnitch.com) solve the same problem
|
|
117
|
+
as a real, mature, hosted service: your job pings a URL over HTTPS, and
|
|
118
|
+
their infrastructure -- not yours -- watches the clock and sends email/
|
|
119
|
+
Slack/SMS/PagerDuty when a ping is late. If you want alerting that works
|
|
120
|
+
without you also solving alerting, and don't mind a third party knowing
|
|
121
|
+
when your jobs run, one of those is very likely the better choice --
|
|
122
|
+
escrow doesn't compete with that and isn't trying to.
|
|
123
|
+
|
|
124
|
+
escrow's tradeoff runs the other way, matching the same "stays on your
|
|
125
|
+
machine" discipline as the rest of this portfolio: `ping` and `check` never
|
|
126
|
+
leave the filesystem, there's no account, no third party ever learns your
|
|
127
|
+
job names or schedule, and the whole state is one JSON file you can read,
|
|
128
|
+
back up, or delete yourself. The cost of that is everything a hosted
|
|
129
|
+
service gives you for free: no scheduling (see "no daemon," above) and no
|
|
130
|
+
notification path of its own (see above) -- you supply both, from
|
|
131
|
+
infrastructure you already have. Reach for escrow specifically when a
|
|
132
|
+
third-party dependency for "is my cron job still running" is the wrong
|
|
133
|
+
tradeoff for what the job actually does; reach for a hosted switch
|
|
134
|
+
otherwise.
|
|
135
|
+
|
|
136
|
+
## Tests
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
pip install -e .
|
|
140
|
+
python tests/test_duration.py # "26h", "8d" -> seconds
|
|
141
|
+
python tests/test_config.py # escrow.yaml validation
|
|
142
|
+
python tests/test_state.py # the ping record: real files, real temp dirs
|
|
143
|
+
python tests/test_check.py # ok / overdue / never_seen classification
|
|
144
|
+
python tests/test_cli.py # the real CLI entry point, real files, real argv
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
41 tests. Two exist because testing an actual misconfigured `--state`
|
|
148
|
+
(pointed at a directory instead of a file) found a real gap: `load_state`
|
|
149
|
+
only caught `JSONDecodeError`, so `IsADirectoryError` -- also an `OSError`
|
|
150
|
+
-- escaped as a raw traceback instead of the same graceful "nothing
|
|
151
|
+
recorded yet" every other unreadable state file gets.
|
|
152
|
+
|
|
153
|
+
MIT licensed.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Compare declared jobs against recorded pings: ok, overdue, or
|
|
2
|
+
never_seen. Silence gets its own status, distinct from both -- the entire
|
|
3
|
+
reason this tool exists is that "no alert" and "everything's fine" look
|
|
4
|
+
identical to anything that only watches for a failure exit code.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
OK, OVERDUE, NEVER_SEEN = "ok", "overdue", "never_seen"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _fmt(seconds: float) -> str:
|
|
12
|
+
seconds = int(seconds)
|
|
13
|
+
if seconds < 60:
|
|
14
|
+
return f"{seconds}s"
|
|
15
|
+
if seconds < 3600:
|
|
16
|
+
return f"{seconds // 60}m"
|
|
17
|
+
if seconds < 86400:
|
|
18
|
+
return f"{seconds // 3600}h"
|
|
19
|
+
return f"{seconds // 86400}d"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_jobs(jobs: list[dict], state: dict, now: float) -> list[dict]:
|
|
23
|
+
results = []
|
|
24
|
+
for job in jobs:
|
|
25
|
+
name = job["name"]
|
|
26
|
+
record = state.get(name)
|
|
27
|
+
|
|
28
|
+
if record is None:
|
|
29
|
+
results.append({
|
|
30
|
+
"name": name, "status": NEVER_SEEN,
|
|
31
|
+
"detail": (f"'{name}' has never pinged in -- it may have never run, "
|
|
32
|
+
"or is pinging under a different name"),
|
|
33
|
+
"last_seen": None, "overdue_by_seconds": None,
|
|
34
|
+
})
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
last_seen = record["last_seen"]
|
|
38
|
+
age = now - last_seen
|
|
39
|
+
if age > job["interval_seconds"]:
|
|
40
|
+
results.append({
|
|
41
|
+
"name": name, "status": OVERDUE,
|
|
42
|
+
"detail": (f"'{name}' last pinged {_fmt(age)} ago, past its "
|
|
43
|
+
f"{job['interval']} interval"),
|
|
44
|
+
"last_seen": last_seen,
|
|
45
|
+
"overdue_by_seconds": age - job["interval_seconds"],
|
|
46
|
+
})
|
|
47
|
+
else:
|
|
48
|
+
results.append({
|
|
49
|
+
"name": name, "status": OK,
|
|
50
|
+
"detail": f"'{name}' last pinged {_fmt(age)} ago, within its {job['interval']} interval",
|
|
51
|
+
"last_seen": last_seen, "overdue_by_seconds": None,
|
|
52
|
+
})
|
|
53
|
+
return results
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""escrow ping <job-name> [--state PATH]
|
|
2
|
+
escrow check <config.yaml> [--state PATH] [--json]
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from .check import NEVER_SEEN, OK, OVERDUE, check_jobs
|
|
12
|
+
from .config import ConfigError, load_config
|
|
13
|
+
from .state import load_state, record_ping
|
|
14
|
+
|
|
15
|
+
DEFAULT_STATE = "escrow-state.json"
|
|
16
|
+
_TAG = {OK: "OK", OVERDUE: "!!", NEVER_SEEN: "??"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv: list[str] | None = None) -> int:
|
|
20
|
+
parser = argparse.ArgumentParser(prog="escrow")
|
|
21
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
22
|
+
|
|
23
|
+
ping_p = sub.add_parser("ping", help="record that a job ran, right now")
|
|
24
|
+
ping_p.add_argument("job_name")
|
|
25
|
+
ping_p.add_argument("--state", default=DEFAULT_STATE,
|
|
26
|
+
help=f"path to the state file (default: {DEFAULT_STATE})")
|
|
27
|
+
|
|
28
|
+
check_p = sub.add_parser(
|
|
29
|
+
"check", help="check every declared job against its recorded pings")
|
|
30
|
+
check_p.add_argument("config", help="escrow.yaml -- the declared jobs and intervals")
|
|
31
|
+
check_p.add_argument("--state", default=DEFAULT_STATE,
|
|
32
|
+
help=f"path to the state file (default: {DEFAULT_STATE})")
|
|
33
|
+
check_p.add_argument("--json", action="store_true", help="print the full report as JSON")
|
|
34
|
+
|
|
35
|
+
args = parser.parse_args(argv)
|
|
36
|
+
|
|
37
|
+
if args.command == "ping":
|
|
38
|
+
try:
|
|
39
|
+
record_ping(args.state, args.job_name, time.time())
|
|
40
|
+
except OSError as exc:
|
|
41
|
+
# --state pointed at something that can't be written (a
|
|
42
|
+
# directory, a read-only path) -- a wrong argument, not a
|
|
43
|
+
# crash. Silently swallowing this would be worse: it's the one
|
|
44
|
+
# write escrow makes, and its whole job is knowing whether that
|
|
45
|
+
# write actually happened.
|
|
46
|
+
sys.exit(f"escrow: could not record the ping: {exc}")
|
|
47
|
+
print(f"pinged '{args.job_name}'")
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
jobs = load_config(args.config)
|
|
52
|
+
except ConfigError as exc:
|
|
53
|
+
sys.exit(f"escrow: {exc}")
|
|
54
|
+
|
|
55
|
+
state = load_state(args.state)
|
|
56
|
+
results = check_jobs(jobs, state, time.time())
|
|
57
|
+
|
|
58
|
+
if args.json:
|
|
59
|
+
print(json.dumps(results, indent=2))
|
|
60
|
+
else:
|
|
61
|
+
for r in results:
|
|
62
|
+
print(f"[{_TAG[r['status']]}] {r['detail']}")
|
|
63
|
+
n_bad = sum(1 for r in results if r["status"] != OK)
|
|
64
|
+
summary = f"{len(results) - n_bad}/{len(results)} ok"
|
|
65
|
+
if n_bad:
|
|
66
|
+
summary += f", {n_bad} need attention"
|
|
67
|
+
print(f"\n{summary}")
|
|
68
|
+
|
|
69
|
+
# Non-blocking by design: escrow reports, it doesn't page anyone --
|
|
70
|
+
# wire the exit code into whatever alerting already exists (a failing
|
|
71
|
+
# CI step, a systemd OnFailure= unit), the same "exit code is the
|
|
72
|
+
# interface" convention receipt/invariant/carabiner already share.
|
|
73
|
+
return 1 if any(r["status"] != OK for r in results) else 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Load and validate escrow.yaml: the jobs expected to ping in, and how
|
|
2
|
+
often each one is allowed to go quiet before that's a problem.
|
|
3
|
+
|
|
4
|
+
Jobs must be declared up front, in a file `check` reads -- a job that is
|
|
5
|
+
only ever known because it once pinged would be exactly as invisible as
|
|
6
|
+
before the first time it silently stopped running, or if it never started
|
|
7
|
+
in the first place. Declaring first and checking reality against the
|
|
8
|
+
declaration is the same discipline `invariant` already applies to
|
|
9
|
+
database state, applied here to "did this job show up at all."
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from .duration import DurationError, parse_duration
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigError(ValueError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_config(path: str) -> list[dict]:
|
|
23
|
+
try:
|
|
24
|
+
with open(path, encoding="utf-8") as f:
|
|
25
|
+
raw = yaml.safe_load(f)
|
|
26
|
+
except yaml.YAMLError as exc:
|
|
27
|
+
raise ConfigError(f"{path}: not valid YAML ({exc})") from exc
|
|
28
|
+
except OSError as exc:
|
|
29
|
+
raise ConfigError(f"{path}: {exc}") from exc
|
|
30
|
+
|
|
31
|
+
if not isinstance(raw, dict) or "jobs" not in raw:
|
|
32
|
+
raise ConfigError(f"{path}: must be a mapping with a top-level 'jobs' list")
|
|
33
|
+
jobs = raw["jobs"]
|
|
34
|
+
if not isinstance(jobs, list) or not jobs:
|
|
35
|
+
raise ConfigError(f"{path}: 'jobs' must be a non-empty list")
|
|
36
|
+
|
|
37
|
+
parsed = []
|
|
38
|
+
seen = set()
|
|
39
|
+
for i, job in enumerate(jobs):
|
|
40
|
+
if not isinstance(job, dict):
|
|
41
|
+
raise ConfigError(f"{path}: jobs[{i}] must be a mapping")
|
|
42
|
+
name = job.get("name")
|
|
43
|
+
if not name or not isinstance(name, str):
|
|
44
|
+
raise ConfigError(f"{path}: jobs[{i}] is missing a string 'name'")
|
|
45
|
+
if name in seen:
|
|
46
|
+
raise ConfigError(f"{path}: duplicate job name '{name}'")
|
|
47
|
+
seen.add(name)
|
|
48
|
+
|
|
49
|
+
interval = job.get("interval")
|
|
50
|
+
if not interval:
|
|
51
|
+
raise ConfigError(f"{path}: job '{name}' is missing 'interval'")
|
|
52
|
+
try:
|
|
53
|
+
interval_seconds = parse_duration(str(interval))
|
|
54
|
+
except DurationError as exc:
|
|
55
|
+
raise ConfigError(f"{path}: job '{name}': {exc}") from exc
|
|
56
|
+
|
|
57
|
+
parsed.append({
|
|
58
|
+
"name": name,
|
|
59
|
+
"interval": str(interval),
|
|
60
|
+
"interval_seconds": interval_seconds,
|
|
61
|
+
})
|
|
62
|
+
return parsed
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Parse a human-friendly duration string ("26h", "8d", "45m", "2w") into
|
|
2
|
+
seconds.
|
|
3
|
+
|
|
4
|
+
Single unit only for v1 -- "1d12h" isn't supported; write "36h" instead.
|
|
5
|
+
Combined-unit parsing is a real feature and not hard to add, but every
|
|
6
|
+
config in escrow.yaml only ever needs "how long can this job go quiet
|
|
7
|
+
before that's a problem," which a single unit answers just as well and
|
|
8
|
+
without ambiguity about ordering (is "12h1d" valid? "1d 12h"?) that a
|
|
9
|
+
combined format would have to define and test.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
|
|
16
|
+
_PATTERN = re.compile(r'^(\d+(?:\.\d+)?)([smhdw])$')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DurationError(ValueError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_duration(text: str) -> float:
|
|
24
|
+
m = _PATTERN.match(text.strip())
|
|
25
|
+
if not m:
|
|
26
|
+
raise DurationError(
|
|
27
|
+
f"'{text}' is not a duration escrow understands -- use a number "
|
|
28
|
+
f"followed by one unit: s, m, h, d, or w (e.g. '26h', '8d'). "
|
|
29
|
+
f"Combined units like '1d12h' aren't supported; write '36h'.")
|
|
30
|
+
value, unit = m.groups()
|
|
31
|
+
return float(value) * _UNIT_SECONDS[unit]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Where each job's last ping is recorded: one small JSON file.
|
|
2
|
+
|
|
3
|
+
Built for the common case -- a single host (or a shared filesystem both
|
|
4
|
+
the job and the checker can see) -- not for concurrent writers racing on a
|
|
5
|
+
network filesystem. A cron job and a monitoring check on the same box, or
|
|
6
|
+
a NAS-mounted state file, are the target; a distributed fleet of machines
|
|
7
|
+
all pinging the same file needs a real datastore, not this. Said plainly
|
|
8
|
+
in README rather than silently outgrown.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import pathlib
|
|
15
|
+
import tempfile
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_state(path: str) -> dict:
|
|
19
|
+
p = pathlib.Path(path)
|
|
20
|
+
if not p.exists():
|
|
21
|
+
return {}
|
|
22
|
+
try:
|
|
23
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
24
|
+
except (OSError, json.JSONDecodeError):
|
|
25
|
+
# A corrupted state file, or --state pointed at something that
|
|
26
|
+
# isn't a plain file at all (a directory, a broken symlink), reads
|
|
27
|
+
# as "nothing has ever pinged," not a crash -- the next real ping
|
|
28
|
+
# repairs it, and in the meantime every job just reads as
|
|
29
|
+
# never_seen, which is honest: this file is exactly what would
|
|
30
|
+
# tell us otherwise, and it can't right now.
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def save_state(path: str, state: dict) -> None:
|
|
35
|
+
p = pathlib.Path(path)
|
|
36
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
# Write to a temp file in the same directory, then atomically rename --
|
|
38
|
+
# a crash mid-write leaves the previous, valid state file intact
|
|
39
|
+
# instead of a half-written, unparseable one.
|
|
40
|
+
fd, tmp_path = tempfile.mkstemp(dir=p.parent, prefix=".escrow-", suffix=".tmp")
|
|
41
|
+
try:
|
|
42
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
43
|
+
json.dump(state, f, indent=2, sort_keys=True)
|
|
44
|
+
os.replace(tmp_path, p)
|
|
45
|
+
except BaseException:
|
|
46
|
+
if os.path.exists(tmp_path):
|
|
47
|
+
os.unlink(tmp_path)
|
|
48
|
+
raise
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def record_ping(path: str, job_name: str, when: float) -> None:
|
|
52
|
+
state = load_state(path)
|
|
53
|
+
state[job_name] = {"last_seen": when}
|
|
54
|
+
save_state(path, state)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: escrow-evidence
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A dead-man's-switch for cron/scheduled jobs: flags silence, not just failure -- the job that stopped running weeks ago that no alert ever caught.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/MaXiMo000/escrow
|
|
7
|
+
Project-URL: Source, https://github.com/MaXiMo000/escrow
|
|
8
|
+
Project-URL: Issues, https://github.com/MaXiMo000/escrow/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/MaXiMo000/escrow/releases
|
|
10
|
+
Keywords: cron,monitoring,dead-mans-switch,heartbeat,evidence,reliability
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: System :: Monitoring
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: PyYAML>=6.0
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# escrow
|
|
25
|
+
|
|
26
|
+
[](https://github.com/MaXiMo000/escrow/actions/workflows/ci.yml)
|
|
27
|
+
|
|
28
|
+
**A dead-man's-switch for cron jobs, GitHub Actions schedules, and systemd
|
|
29
|
+
timers: flags silence, not just failure.**
|
|
30
|
+
|
|
31
|
+
A scheduled job that throws an exception gets logged, maybe alerted on.
|
|
32
|
+
One that silently *stops running* — a cron entry removed by a bad deploy,
|
|
33
|
+
a systemd timer disabled and forgotten, a token that expired weeks ago —
|
|
34
|
+
produces nothing: no error, no log line, no exit code to check. "No
|
|
35
|
+
alert" and "everything's fine" look identical to anything that only
|
|
36
|
+
watches for failure. `escrow` watches for the third thing: quiet.
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
$ escrow check escrow.yaml
|
|
40
|
+
[!!] 'nightly-backup' last pinged 1d ago, past its 26h interval
|
|
41
|
+
[??] 'weekly-report' has never pinged in -- it may have never run, or is pinging under a different name
|
|
42
|
+
|
|
43
|
+
0/2 ok, 2 need attention
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
(Real output. `nightly-backup` pinged in on time once, then its clock was
|
|
47
|
+
wound forward 30 hours past its 26h interval; `weekly-report` never pinged
|
|
48
|
+
at all.)
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
pip install escrow-evidence # the command it installs is `escrow`
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
(`escrow` was already taken on PyPI -- same story as every sibling in this
|
|
57
|
+
portfolio.)
|
|
58
|
+
|
|
59
|
+
## Use
|
|
60
|
+
|
|
61
|
+
Declare what you're expecting, in `escrow.yaml`:
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
jobs:
|
|
65
|
+
- name: nightly-backup
|
|
66
|
+
interval: 26h # a daily job, with a few hours' slack
|
|
67
|
+
- name: weekly-report
|
|
68
|
+
interval: 8d
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
At the end of the actual job's script, record that it ran:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
escrow ping nightly-backup
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Separately -- on its own schedule, e.g. every 15 minutes -- check every
|
|
78
|
+
declared job against what's actually been recorded:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
escrow check escrow.yaml
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`ping` and `check` share a small JSON state file (`escrow-state.json` by
|
|
85
|
+
default, or `--state PATH`) -- `ping` writes to it, `check` reads it. They
|
|
86
|
+
need to see the *same* file: the common shape is a single host (the job
|
|
87
|
+
and the check both run there, e.g. two cron entries) or a shared
|
|
88
|
+
filesystem mount. This is not a hosted, multi-machine service; see "What
|
|
89
|
+
this does not do."
|
|
90
|
+
|
|
91
|
+
## Three statuses, not two
|
|
92
|
+
|
|
93
|
+
| Status | Meaning |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `ok` | Pinged within its declared interval. |
|
|
96
|
+
| `overdue` | Pinged before, but not recently enough -- **the job that used to run and stopped.** |
|
|
97
|
+
| `never_seen` | Declared in `escrow.yaml`, never once recorded a ping -- **the job that never ran at all**, or a typo between the name in the config and the name in the script. |
|
|
98
|
+
|
|
99
|
+
`overdue` and `never_seen` are deliberately different statuses, not one
|
|
100
|
+
"bad" bucket: an operator debugs "this stopped" and "this never started"
|
|
101
|
+
differently, and collapsing them would hide which one they're looking at.
|
|
102
|
+
Jobs are declared in `escrow.yaml` up front, not discovered from whatever
|
|
103
|
+
happens to have pinged -- a job that's only "known" because it once pinged
|
|
104
|
+
would be exactly as invisible as before the first time it silently
|
|
105
|
+
stopped, or if it never started.
|
|
106
|
+
|
|
107
|
+
Exit code is `1` if anything is `overdue` or `never_seen`, `0` if every
|
|
108
|
+
declared job is `ok`.
|
|
109
|
+
|
|
110
|
+
## What this does NOT do
|
|
111
|
+
|
|
112
|
+
- **No daemon, no background process.** There is no `escrow serve` or
|
|
113
|
+
`escrow watch`. `escrow check` is one CLI invocation that reads the
|
|
114
|
+
state file and exits; *you* supply the periodic trigger -- a cron
|
|
115
|
+
entry, a systemd timer, a scheduled GitHub Actions workflow. escrow
|
|
116
|
+
never runs unless something else runs it.
|
|
117
|
+
- **No email, Slack, or webhook of its own.** The exit code is the
|
|
118
|
+
interface -- same convention [`receipt`](https://github.com/MaXiMo000/receipt),
|
|
119
|
+
[`invariant`](https://github.com/MaXiMo000/invariant), and
|
|
120
|
+
[`carabiner`](https://github.com/MaXiMo000/carabiner) already share.
|
|
121
|
+
Run `escrow check` as a step that fails loudly in whatever you already
|
|
122
|
+
have (a GitHub Actions job, a systemd `OnFailure=` unit, a cron entry
|
|
123
|
+
piped to your existing paging tool) rather than escrow adding its own
|
|
124
|
+
SMTP client or HTTP dependency for a notification path you may not want.
|
|
125
|
+
- **Not a hosted or multi-machine service.** State is one JSON file; `ping`
|
|
126
|
+
and `check` need to see the same one. A fleet of machines all pinging a
|
|
127
|
+
shared endpoint needs a real datastore behind it, not a local file --
|
|
128
|
+
genuinely different scope, not built here.
|
|
129
|
+
- **Not resilient to concurrent writers.** State is written to a temp file
|
|
130
|
+
and atomically renamed (a crash mid-write can't corrupt it), but two
|
|
131
|
+
`ping`s racing on the exact same job name on a network filesystem at the
|
|
132
|
+
same instant can still lose one update. Fine for the target use --one
|
|
133
|
+
job pings once per run-- not designed for high-frequency concurrent
|
|
134
|
+
writes.
|
|
135
|
+
|
|
136
|
+
## Compared to a hosted dead-man's-switch
|
|
137
|
+
|
|
138
|
+
[healthchecks.io](https://healthchecks.io), [Cronitor](https://cronitor.io),
|
|
139
|
+
and [Dead Man's Snitch](https://deadmanssnitch.com) solve the same problem
|
|
140
|
+
as a real, mature, hosted service: your job pings a URL over HTTPS, and
|
|
141
|
+
their infrastructure -- not yours -- watches the clock and sends email/
|
|
142
|
+
Slack/SMS/PagerDuty when a ping is late. If you want alerting that works
|
|
143
|
+
without you also solving alerting, and don't mind a third party knowing
|
|
144
|
+
when your jobs run, one of those is very likely the better choice --
|
|
145
|
+
escrow doesn't compete with that and isn't trying to.
|
|
146
|
+
|
|
147
|
+
escrow's tradeoff runs the other way, matching the same "stays on your
|
|
148
|
+
machine" discipline as the rest of this portfolio: `ping` and `check` never
|
|
149
|
+
leave the filesystem, there's no account, no third party ever learns your
|
|
150
|
+
job names or schedule, and the whole state is one JSON file you can read,
|
|
151
|
+
back up, or delete yourself. The cost of that is everything a hosted
|
|
152
|
+
service gives you for free: no scheduling (see "no daemon," above) and no
|
|
153
|
+
notification path of its own (see above) -- you supply both, from
|
|
154
|
+
infrastructure you already have. Reach for escrow specifically when a
|
|
155
|
+
third-party dependency for "is my cron job still running" is the wrong
|
|
156
|
+
tradeoff for what the job actually does; reach for a hosted switch
|
|
157
|
+
otherwise.
|
|
158
|
+
|
|
159
|
+
## Tests
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
pip install -e .
|
|
163
|
+
python tests/test_duration.py # "26h", "8d" -> seconds
|
|
164
|
+
python tests/test_config.py # escrow.yaml validation
|
|
165
|
+
python tests/test_state.py # the ping record: real files, real temp dirs
|
|
166
|
+
python tests/test_check.py # ok / overdue / never_seen classification
|
|
167
|
+
python tests/test_cli.py # the real CLI entry point, real files, real argv
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
41 tests. Two exist because testing an actual misconfigured `--state`
|
|
171
|
+
(pointed at a directory instead of a file) found a real gap: `load_state`
|
|
172
|
+
only caught `JSONDecodeError`, so `IsADirectoryError` -- also an `OSError`
|
|
173
|
+
-- escaped as a raw traceback instead of the same graceful "nothing
|
|
174
|
+
recorded yet" every other unreadable state file gets.
|
|
175
|
+
|
|
176
|
+
MIT licensed.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
escrow/__init__.py
|
|
5
|
+
escrow/check.py
|
|
6
|
+
escrow/cli.py
|
|
7
|
+
escrow/config.py
|
|
8
|
+
escrow/duration.py
|
|
9
|
+
escrow/state.py
|
|
10
|
+
escrow_evidence.egg-info/PKG-INFO
|
|
11
|
+
escrow_evidence.egg-info/SOURCES.txt
|
|
12
|
+
escrow_evidence.egg-info/dependency_links.txt
|
|
13
|
+
escrow_evidence.egg-info/entry_points.txt
|
|
14
|
+
escrow_evidence.egg-info/requires.txt
|
|
15
|
+
escrow_evidence.egg-info/top_level.txt
|
|
16
|
+
tests/test_check.py
|
|
17
|
+
tests/test_cli.py
|
|
18
|
+
tests/test_config.py
|
|
19
|
+
tests/test_duration.py
|
|
20
|
+
tests/test_state.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
PyYAML>=6.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
escrow
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
# "escrow" was already taken on PyPI, same story as every sibling in this
|
|
3
|
+
# portfolio (checked, not assumed). The installed command stays the short
|
|
4
|
+
# name -- python-dateutil installs `dateutil`, this installs `escrow`.
|
|
5
|
+
name = "escrow-evidence"
|
|
6
|
+
version = "0.1.0"
|
|
7
|
+
description = "A dead-man's-switch for cron/scheduled jobs: flags silence, not just failure -- the job that stopped running weeks ago that no alert ever caught."
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
keywords = ["cron", "monitoring", "dead-mans-switch", "heartbeat", "evidence", "reliability"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Intended Audience :: System Administrators",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: System :: Monitoring",
|
|
21
|
+
]
|
|
22
|
+
# PyYAML for escrow.yaml -- the same real dependency `invariant` already
|
|
23
|
+
# uses elsewhere in this portfolio for its own config file, not a new
|
|
24
|
+
# convention introduced here.
|
|
25
|
+
dependencies = ["PyYAML>=6.0"]
|
|
26
|
+
|
|
27
|
+
urls.Homepage = "https://github.com/MaXiMo000/escrow"
|
|
28
|
+
urls.Source = "https://github.com/MaXiMo000/escrow"
|
|
29
|
+
urls.Issues = "https://github.com/MaXiMo000/escrow/issues"
|
|
30
|
+
urls.Changelog = "https://github.com/MaXiMo000/escrow/releases"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
escrow = "escrow.cli:main"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["setuptools>=77"]
|
|
37
|
+
build-backend = "setuptools.build_meta"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
include = ["escrow*"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Run: python tests/test_check.py"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
|
9
|
+
|
|
10
|
+
from escrow.check import NEVER_SEEN, OK, OVERDUE, check_jobs
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _job(name: str, interval: str, seconds: float) -> dict:
|
|
14
|
+
return {"name": name, "interval": interval, "interval_seconds": seconds}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestCheckJobs(unittest.TestCase):
|
|
18
|
+
def test_recently_pinged_job_is_ok(self):
|
|
19
|
+
jobs = [_job("nightly-backup", "26h", 26 * 3600)]
|
|
20
|
+
state = {"nightly-backup": {"last_seen": 1000.0}}
|
|
21
|
+
results = check_jobs(jobs, state, now=1000.0 + 3600) # 1h ago
|
|
22
|
+
self.assertEqual(results[0]["status"], OK)
|
|
23
|
+
|
|
24
|
+
def test_a_job_past_its_interval_is_overdue(self):
|
|
25
|
+
jobs = [_job("nightly-backup", "26h", 26 * 3600)]
|
|
26
|
+
state = {"nightly-backup": {"last_seen": 1000.0}}
|
|
27
|
+
results = check_jobs(jobs, state, now=1000.0 + 30 * 3600) # 30h ago
|
|
28
|
+
self.assertEqual(results[0]["status"], OVERDUE)
|
|
29
|
+
self.assertGreater(results[0]["overdue_by_seconds"], 0)
|
|
30
|
+
|
|
31
|
+
def test_exactly_at_the_interval_boundary_is_still_ok(self):
|
|
32
|
+
"""The whole point of an interval is 'up to and including this
|
|
33
|
+
long is fine' -- past it, not at it, is what makes something
|
|
34
|
+
overdue."""
|
|
35
|
+
jobs = [_job("x", "1h", 3600)]
|
|
36
|
+
state = {"x": {"last_seen": 1000.0}}
|
|
37
|
+
results = check_jobs(jobs, state, now=1000.0 + 3600)
|
|
38
|
+
self.assertEqual(results[0]["status"], OK)
|
|
39
|
+
|
|
40
|
+
def test_a_job_that_never_pinged_is_never_seen_not_overdue(self):
|
|
41
|
+
"""A different status on purpose: 'stopped running' and 'never
|
|
42
|
+
started' are different facts an operator needs to act on
|
|
43
|
+
differently, and collapsing them into one status would hide that."""
|
|
44
|
+
jobs = [_job("weekly-report", "8d", 8 * 86400)]
|
|
45
|
+
results = check_jobs(jobs, state={}, now=1000.0)
|
|
46
|
+
self.assertEqual(results[0]["status"], NEVER_SEEN)
|
|
47
|
+
self.assertIsNone(results[0]["last_seen"])
|
|
48
|
+
|
|
49
|
+
def test_multiple_jobs_are_each_evaluated_independently(self):
|
|
50
|
+
jobs = [
|
|
51
|
+
_job("a", "1h", 3600),
|
|
52
|
+
_job("b", "1h", 3600),
|
|
53
|
+
_job("c", "1h", 3600),
|
|
54
|
+
]
|
|
55
|
+
state = {
|
|
56
|
+
"a": {"last_seen": 1000.0}, # will be ok
|
|
57
|
+
"b": {"last_seen": 1000.0 - 10000}, # will be overdue
|
|
58
|
+
# "c" never pinged
|
|
59
|
+
}
|
|
60
|
+
results = check_jobs(jobs, state, now=1000.0)
|
|
61
|
+
statuses = {r["name"]: r["status"] for r in results}
|
|
62
|
+
self.assertEqual(statuses, {"a": OK, "b": OVERDUE, "c": NEVER_SEEN})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
unittest.main()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Run: python tests/test_cli.py
|
|
2
|
+
|
|
3
|
+
Exercises the real CLI entry point end to end -- real temp files, real
|
|
4
|
+
argv, real stdout capture.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import pathlib
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
import unittest
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
|
18
|
+
|
|
19
|
+
from escrow.cli import main
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestCli(unittest.TestCase):
|
|
23
|
+
def setUp(self):
|
|
24
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
25
|
+
self.dir = pathlib.Path(self.tmp.name)
|
|
26
|
+
self.config = self.dir / "escrow.yaml"
|
|
27
|
+
self.state = self.dir / "state.json"
|
|
28
|
+
|
|
29
|
+
def tearDown(self):
|
|
30
|
+
self.tmp.cleanup()
|
|
31
|
+
|
|
32
|
+
def test_ping_then_check_reads_ok(self):
|
|
33
|
+
self.config.write_text("jobs:\n - name: nightly-backup\n interval: 26h\n")
|
|
34
|
+
code = main(["ping", "nightly-backup", "--state", str(self.state)])
|
|
35
|
+
self.assertEqual(code, 0)
|
|
36
|
+
|
|
37
|
+
buf = io.StringIO()
|
|
38
|
+
with contextlib.redirect_stdout(buf):
|
|
39
|
+
code = main(["check", str(self.config), "--state", str(self.state)])
|
|
40
|
+
self.assertEqual(code, 0)
|
|
41
|
+
self.assertIn("[OK]", buf.getvalue())
|
|
42
|
+
self.assertIn("1/1 ok", buf.getvalue())
|
|
43
|
+
|
|
44
|
+
def test_never_pinged_job_fails_check(self):
|
|
45
|
+
self.config.write_text("jobs:\n - name: weekly-report\n interval: 8d\n")
|
|
46
|
+
buf = io.StringIO()
|
|
47
|
+
with contextlib.redirect_stdout(buf):
|
|
48
|
+
code = main(["check", str(self.config), "--state", str(self.state)])
|
|
49
|
+
self.assertEqual(code, 1)
|
|
50
|
+
self.assertIn("[??]", buf.getvalue())
|
|
51
|
+
self.assertIn("need attention", buf.getvalue())
|
|
52
|
+
|
|
53
|
+
def test_overdue_job_fails_check(self):
|
|
54
|
+
self.config.write_text("jobs:\n - name: nightly-backup\n interval: 1h\n")
|
|
55
|
+
self.state.write_text(json.dumps({"nightly-backup": {"last_seen": time.time() - 7200}}))
|
|
56
|
+
buf = io.StringIO()
|
|
57
|
+
with contextlib.redirect_stdout(buf):
|
|
58
|
+
code = main(["check", str(self.config), "--state", str(self.state)])
|
|
59
|
+
self.assertEqual(code, 1)
|
|
60
|
+
self.assertIn("[!!]", buf.getvalue())
|
|
61
|
+
|
|
62
|
+
def test_state_path_that_is_a_directory_is_a_clean_error_on_ping(self):
|
|
63
|
+
"""Found by testing an actual misconfigured --state: recording a
|
|
64
|
+
ping into a path that's a directory used to raise IsADirectoryError
|
|
65
|
+
straight out of main() as a traceback."""
|
|
66
|
+
state_dir = self.dir / "a_directory"
|
|
67
|
+
state_dir.mkdir()
|
|
68
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
69
|
+
main(["ping", "x", "--state", str(state_dir)])
|
|
70
|
+
self.assertIn("escrow:", str(ctx.exception))
|
|
71
|
+
|
|
72
|
+
def test_bad_config_is_a_clean_error_not_a_traceback(self):
|
|
73
|
+
self.config.write_text("not: a valid escrow config\n")
|
|
74
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
75
|
+
main(["check", str(self.config), "--state", str(self.state)])
|
|
76
|
+
self.assertIn("escrow:", str(ctx.exception))
|
|
77
|
+
|
|
78
|
+
def test_json_flag_prints_the_full_report(self):
|
|
79
|
+
self.config.write_text("jobs:\n - name: x\n interval: 1h\n")
|
|
80
|
+
main(["ping", "x", "--state", str(self.state)])
|
|
81
|
+
buf = io.StringIO()
|
|
82
|
+
with contextlib.redirect_stdout(buf):
|
|
83
|
+
main(["check", str(self.config), "--state", str(self.state), "--json"])
|
|
84
|
+
report = json.loads(buf.getvalue())
|
|
85
|
+
self.assertEqual(report[0]["status"], "ok")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
unittest.main()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Run: python tests/test_config.py"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
|
10
|
+
|
|
11
|
+
from escrow.config import ConfigError, load_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestLoadConfig(unittest.TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
17
|
+
self.path = pathlib.Path(self.tmp.name) / "escrow.yaml"
|
|
18
|
+
|
|
19
|
+
def tearDown(self):
|
|
20
|
+
self.tmp.cleanup()
|
|
21
|
+
|
|
22
|
+
def _write(self, text: str) -> str:
|
|
23
|
+
self.path.write_text(text, encoding="utf-8")
|
|
24
|
+
return str(self.path)
|
|
25
|
+
|
|
26
|
+
def test_valid_config(self):
|
|
27
|
+
p = self._write("jobs:\n - name: nightly-backup\n interval: 26h\n")
|
|
28
|
+
jobs = load_config(p)
|
|
29
|
+
self.assertEqual(jobs, [{
|
|
30
|
+
"name": "nightly-backup", "interval": "26h", "interval_seconds": 26 * 3600,
|
|
31
|
+
}])
|
|
32
|
+
|
|
33
|
+
def test_multiple_jobs(self):
|
|
34
|
+
p = self._write(
|
|
35
|
+
"jobs:\n"
|
|
36
|
+
" - name: nightly-backup\n interval: 26h\n"
|
|
37
|
+
" - name: weekly-report\n interval: 8d\n"
|
|
38
|
+
)
|
|
39
|
+
jobs = load_config(p)
|
|
40
|
+
self.assertEqual([j["name"] for j in jobs], ["nightly-backup", "weekly-report"])
|
|
41
|
+
|
|
42
|
+
def test_missing_file_is_a_config_error_not_a_crash(self):
|
|
43
|
+
with self.assertRaises(ConfigError) as ctx:
|
|
44
|
+
load_config(str(self.path)) # never written
|
|
45
|
+
self.assertIn(str(self.path), str(ctx.exception))
|
|
46
|
+
|
|
47
|
+
def test_not_yaml_is_a_config_error(self):
|
|
48
|
+
p = self._write("not: valid: yaml: at: all:::")
|
|
49
|
+
with self.assertRaises(ConfigError):
|
|
50
|
+
load_config(p)
|
|
51
|
+
|
|
52
|
+
def test_missing_jobs_key_is_rejected(self):
|
|
53
|
+
p = self._write("something_else: true\n")
|
|
54
|
+
with self.assertRaises(ConfigError) as ctx:
|
|
55
|
+
load_config(p)
|
|
56
|
+
self.assertIn("jobs", str(ctx.exception))
|
|
57
|
+
|
|
58
|
+
def test_empty_jobs_list_is_rejected(self):
|
|
59
|
+
p = self._write("jobs: []\n")
|
|
60
|
+
with self.assertRaises(ConfigError):
|
|
61
|
+
load_config(p)
|
|
62
|
+
|
|
63
|
+
def test_job_missing_name_is_rejected(self):
|
|
64
|
+
p = self._write("jobs:\n - interval: 1h\n")
|
|
65
|
+
with self.assertRaises(ConfigError) as ctx:
|
|
66
|
+
load_config(p)
|
|
67
|
+
self.assertIn("name", str(ctx.exception))
|
|
68
|
+
|
|
69
|
+
def test_job_missing_interval_is_rejected(self):
|
|
70
|
+
p = self._write("jobs:\n - name: x\n")
|
|
71
|
+
with self.assertRaises(ConfigError) as ctx:
|
|
72
|
+
load_config(p)
|
|
73
|
+
self.assertIn("interval", str(ctx.exception))
|
|
74
|
+
|
|
75
|
+
def test_duplicate_job_names_are_rejected(self):
|
|
76
|
+
p = self._write("jobs:\n - name: x\n interval: 1h\n - name: x\n interval: 2h\n")
|
|
77
|
+
with self.assertRaises(ConfigError) as ctx:
|
|
78
|
+
load_config(p)
|
|
79
|
+
self.assertIn("duplicate", str(ctx.exception))
|
|
80
|
+
|
|
81
|
+
def test_invalid_interval_reports_which_job(self):
|
|
82
|
+
p = self._write("jobs:\n - name: x\n interval: not-a-duration\n")
|
|
83
|
+
with self.assertRaises(ConfigError) as ctx:
|
|
84
|
+
load_config(p)
|
|
85
|
+
self.assertIn("'x'", str(ctx.exception))
|
|
86
|
+
|
|
87
|
+
def test_top_level_list_instead_of_mapping_is_rejected(self):
|
|
88
|
+
p = self._write("- just\n- a\n- list\n")
|
|
89
|
+
with self.assertRaises(ConfigError):
|
|
90
|
+
load_config(p)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
unittest.main()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Run: python tests/test_duration.py"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
|
9
|
+
|
|
10
|
+
from escrow.duration import DurationError, parse_duration
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestParseDuration(unittest.TestCase):
|
|
14
|
+
def test_seconds(self):
|
|
15
|
+
self.assertEqual(parse_duration("30s"), 30)
|
|
16
|
+
|
|
17
|
+
def test_minutes(self):
|
|
18
|
+
self.assertEqual(parse_duration("45m"), 45 * 60)
|
|
19
|
+
|
|
20
|
+
def test_hours(self):
|
|
21
|
+
self.assertEqual(parse_duration("26h"), 26 * 3600)
|
|
22
|
+
|
|
23
|
+
def test_days(self):
|
|
24
|
+
self.assertEqual(parse_duration("8d"), 8 * 86400)
|
|
25
|
+
|
|
26
|
+
def test_weeks(self):
|
|
27
|
+
self.assertEqual(parse_duration("2w"), 2 * 604800)
|
|
28
|
+
|
|
29
|
+
def test_decimal_value(self):
|
|
30
|
+
self.assertEqual(parse_duration("1.5h"), 1.5 * 3600)
|
|
31
|
+
|
|
32
|
+
def test_whitespace_is_stripped(self):
|
|
33
|
+
self.assertEqual(parse_duration(" 26h "), 26 * 3600)
|
|
34
|
+
|
|
35
|
+
def test_combined_units_are_rejected_with_a_clear_message(self):
|
|
36
|
+
with self.assertRaises(DurationError) as ctx:
|
|
37
|
+
parse_duration("1d12h")
|
|
38
|
+
self.assertIn("not a duration", str(ctx.exception))
|
|
39
|
+
self.assertIn("36h", str(ctx.exception)) # the suggested workaround
|
|
40
|
+
|
|
41
|
+
def test_missing_unit_is_rejected(self):
|
|
42
|
+
with self.assertRaises(DurationError):
|
|
43
|
+
parse_duration("26")
|
|
44
|
+
|
|
45
|
+
def test_unknown_unit_is_rejected(self):
|
|
46
|
+
with self.assertRaises(DurationError):
|
|
47
|
+
parse_duration("26x")
|
|
48
|
+
|
|
49
|
+
def test_empty_string_is_rejected(self):
|
|
50
|
+
with self.assertRaises(DurationError):
|
|
51
|
+
parse_duration("")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
unittest.main()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Run: python tests/test_state.py"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
|
10
|
+
|
|
11
|
+
from escrow.state import load_state, record_ping, save_state
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestState(unittest.TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
17
|
+
self.path = str(pathlib.Path(self.tmp.name) / "state.json")
|
|
18
|
+
|
|
19
|
+
def tearDown(self):
|
|
20
|
+
self.tmp.cleanup()
|
|
21
|
+
|
|
22
|
+
def test_missing_state_file_is_empty_not_a_crash(self):
|
|
23
|
+
self.assertEqual(load_state(self.path), {})
|
|
24
|
+
|
|
25
|
+
def test_record_ping_creates_the_file(self):
|
|
26
|
+
record_ping(self.path, "nightly-backup", 1000.0)
|
|
27
|
+
state = load_state(self.path)
|
|
28
|
+
self.assertEqual(state["nightly-backup"]["last_seen"], 1000.0)
|
|
29
|
+
|
|
30
|
+
def test_second_ping_updates_the_same_job(self):
|
|
31
|
+
record_ping(self.path, "nightly-backup", 1000.0)
|
|
32
|
+
record_ping(self.path, "nightly-backup", 2000.0)
|
|
33
|
+
state = load_state(self.path)
|
|
34
|
+
self.assertEqual(len(state), 1)
|
|
35
|
+
self.assertEqual(state["nightly-backup"]["last_seen"], 2000.0)
|
|
36
|
+
|
|
37
|
+
def test_pinging_different_jobs_keeps_both(self):
|
|
38
|
+
record_ping(self.path, "a", 1000.0)
|
|
39
|
+
record_ping(self.path, "b", 2000.0)
|
|
40
|
+
state = load_state(self.path)
|
|
41
|
+
self.assertEqual(set(state), {"a", "b"})
|
|
42
|
+
|
|
43
|
+
def test_corrupted_state_file_reads_as_empty_not_a_crash(self):
|
|
44
|
+
pathlib.Path(self.path).write_text("not valid json {{{", encoding="utf-8")
|
|
45
|
+
self.assertEqual(load_state(self.path), {})
|
|
46
|
+
|
|
47
|
+
def test_state_path_that_is_a_directory_reads_as_empty_not_a_crash(self):
|
|
48
|
+
"""Found by testing an actual misconfigured --state pointed at a
|
|
49
|
+
directory: load_state used to only catch JSONDecodeError, and
|
|
50
|
+
IsADirectoryError (also an OSError) escaped uncaught."""
|
|
51
|
+
dir_path = str(pathlib.Path(self.tmp.name) / "a_directory")
|
|
52
|
+
pathlib.Path(dir_path).mkdir()
|
|
53
|
+
self.assertEqual(load_state(dir_path), {})
|
|
54
|
+
|
|
55
|
+
def test_save_state_creates_parent_directories(self):
|
|
56
|
+
nested = str(pathlib.Path(self.tmp.name) / "a" / "b" / "state.json")
|
|
57
|
+
save_state(nested, {"x": {"last_seen": 1.0}})
|
|
58
|
+
self.assertEqual(load_state(nested), {"x": {"last_seen": 1.0}})
|
|
59
|
+
|
|
60
|
+
def test_no_leftover_temp_file_after_a_normal_save(self):
|
|
61
|
+
save_state(self.path, {"x": {"last_seen": 1.0}})
|
|
62
|
+
leftovers = list(pathlib.Path(self.tmp.name).glob(".escrow-*"))
|
|
63
|
+
self.assertEqual(leftovers, [])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
unittest.main()
|