statusdoc 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.
- statusdoc-0.1.0/LICENSE +21 -0
- statusdoc-0.1.0/PKG-INFO +172 -0
- statusdoc-0.1.0/README.md +155 -0
- statusdoc-0.1.0/pyproject.toml +36 -0
- statusdoc-0.1.0/setup.cfg +4 -0
- statusdoc-0.1.0/statusdoc/__init__.py +26 -0
- statusdoc-0.1.0/statusdoc/__main__.py +3 -0
- statusdoc-0.1.0/statusdoc/adapters.py +237 -0
- statusdoc-0.1.0/statusdoc/cli.py +221 -0
- statusdoc-0.1.0/statusdoc/discover.py +129 -0
- statusdoc-0.1.0/statusdoc/fetch.py +74 -0
- statusdoc-0.1.0/statusdoc/model.py +230 -0
- statusdoc-0.1.0/statusdoc/py.typed +0 -0
- statusdoc-0.1.0/statusdoc/registry.json +245 -0
- statusdoc-0.1.0/statusdoc/validate.py +237 -0
- statusdoc-0.1.0/statusdoc.egg-info/PKG-INFO +172 -0
- statusdoc-0.1.0/statusdoc.egg-info/SOURCES.txt +23 -0
- statusdoc-0.1.0/statusdoc.egg-info/dependency_links.txt +1 -0
- statusdoc-0.1.0/statusdoc.egg-info/entry_points.txt +2 -0
- statusdoc-0.1.0/statusdoc.egg-info/top_level.txt +1 -0
- statusdoc-0.1.0/tests/test_adapters.py +163 -0
- statusdoc-0.1.0/tests/test_cli.py +102 -0
- statusdoc-0.1.0/tests/test_discover.py +153 -0
- statusdoc-0.1.0/tests/test_model.py +65 -0
- statusdoc-0.1.0/tests/test_validate.py +92 -0
statusdoc-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrea Acanfora
|
|
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.
|
statusdoc-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: statusdoc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Open Status Document: a spec and tools to discover, read and validate service status pages
|
|
5
|
+
Author: Andrea Acanfora
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/AndreaAcanfora/statusdoc
|
|
8
|
+
Project-URL: Specification, https://github.com/AndreaAcanfora/statusdoc/blob/master/SPEC.md
|
|
9
|
+
Keywords: status page,statuspage,incidents,monitoring,sre,uptime
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: System :: Monitoring
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# statusdoc: Open Status Document
|
|
19
|
+
|
|
20
|
+
[](https://github.com/AndreaAcanfora/statusdoc/actions/workflows/ci.yml)
|
|
21
|
+
|
|
22
|
+
A specification and a Python tool to **find, read and validate the status
|
|
23
|
+
pages of the services you depend on**, starting from nothing but their
|
|
24
|
+
domain.
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
- **Specification:** [SPEC.md](SPEC.md), Open Status Document (OSD) 0.1, draft.
|
|
29
|
+
- **Compatible with what is deployed:** OSD specifies the `summary.json`
|
|
30
|
+
shape that Atlassian Statuspage made common and other providers mirror, so
|
|
31
|
+
thousands of status pages already serve conforming documents.
|
|
32
|
+
- **Adds discovery:** `https://{domain}/.well-known/status` tells programs
|
|
33
|
+
where a service's status document lives.
|
|
34
|
+
- **Zero dependencies**, Python 3.10+.
|
|
35
|
+
|
|
36
|
+
## Why
|
|
37
|
+
|
|
38
|
+
Status pages are for people. Programs need the same information: a CI
|
|
39
|
+
pipeline that should hold a deploy while the cloud provider has an outage, a
|
|
40
|
+
dashboard of upstream dependencies, a support bot that explains an error.
|
|
41
|
+
Today that means knowing that GitHub's status lives on `githubstatus.com`
|
|
42
|
+
and Stripe's on `stripestatus.com`, and writing a parser for every format.
|
|
43
|
+
Commercial aggregators maintain thousands of such scrapers.
|
|
44
|
+
|
|
45
|
+
OSD turns the de facto format into a written spec, and lets each service
|
|
46
|
+
announce its document on its own domain.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
pip install git+https://github.com/AndreaAcanfora/statusdoc
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Command line
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
statusdoc check github.com npmjs.com # exit 1 if any is degraded
|
|
58
|
+
statusdoc check --fail-on major vercel.com # only major and critical fail
|
|
59
|
+
statusdoc check --json -v github.com # machine-readable, with components
|
|
60
|
+
statusdoc discover fly.io # where is the status document?
|
|
61
|
+
statusdoc validate status.example.com # is it conforming?
|
|
62
|
+
statusdoc convert status.betterstack.com # any supported format → OSD JSON
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Targets are domains (`github.com`), status page URLs or document URLs.
|
|
66
|
+
Exit codes of `check`: `0` all fine, `1` at least one service at or above
|
|
67
|
+
`--fail-on` (default `minor`), `3` a service could not be reached.
|
|
68
|
+
|
|
69
|
+
### GitHub Action: hold a deploy during an upstream outage
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
- uses: AndreaAcanfora/statusdoc@v0
|
|
73
|
+
with:
|
|
74
|
+
services: github.com npmjs.com vercel.com
|
|
75
|
+
fail-on: major # minor, major, critical or never
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The step fails when a service is at or above `fail-on`, adds a table to the
|
|
79
|
+
job summary and annotations to the run, and sets the outputs `worst-level`,
|
|
80
|
+
`degraded`, `unreachable` and `report` (JSON) for later steps. A status page
|
|
81
|
+
that cannot be read only produces a warning, unless
|
|
82
|
+
`fail-on-unreachable: true`. It runs on Linux, macOS and Windows runners with
|
|
83
|
+
any Python 3.10+, and installs nothing.
|
|
84
|
+
|
|
85
|
+
Or from any CI:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
pip install git+https://github.com/AndreaAcanfora/statusdoc
|
|
89
|
+
statusdoc check --fail-on major github.com npmjs.com vercel.com
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## How documents are found
|
|
93
|
+
|
|
94
|
+
1. `https://{domain}/.well-known/status`, the discovery URI defined by OSD.
|
|
95
|
+
2. A list of known services bundled with the tool (60 services, each verified
|
|
96
|
+
by hand), checked weekly by CI.
|
|
97
|
+
3. Conventions: `status.{domain}`, `{name}status.com`, `www.{name}status.com`,
|
|
98
|
+
at `/api/v2/summary.json` (Statuspage shape) or `/index.json` (Better Stack).
|
|
99
|
+
|
|
100
|
+
Better Stack pages and the Slack Status API are converted to OSD on the fly.
|
|
101
|
+
Other formats can be added as adapters.
|
|
102
|
+
|
|
103
|
+
## Library
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from statusdoc import discover, validate_document
|
|
107
|
+
|
|
108
|
+
found = discover("github.com")
|
|
109
|
+
doc = found.document
|
|
110
|
+
print(doc.name, doc.level) # GitHub operational
|
|
111
|
+
for incident in doc.open_incidents:
|
|
112
|
+
print(incident.name, incident.impact, incident.shortlink)
|
|
113
|
+
for c in doc.components:
|
|
114
|
+
if c.status != "operational":
|
|
115
|
+
print(c.name, c.status)
|
|
116
|
+
|
|
117
|
+
issues = validate_document(found.response.json())
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`doc.level` is one of `operational`, `maintenance`, `minor`, `major`,
|
|
121
|
+
`critical`: the publisher's indicator, raised by open incidents and active
|
|
122
|
+
maintenance.
|
|
123
|
+
|
|
124
|
+
## Publishing a conforming status page
|
|
125
|
+
|
|
126
|
+
If your status page runs on Statuspage or a compatible provider, it most
|
|
127
|
+
likely conforms already. Two small additions complete it:
|
|
128
|
+
|
|
129
|
+
1. **Announce it on your own domain.** Serve this at
|
|
130
|
+
`https://example.com/.well-known/status`, with
|
|
131
|
+
`Content-Type: application/json` and `Access-Control-Allow-Origin: *`:
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{
|
|
135
|
+
"osd_version": "0.1",
|
|
136
|
+
"document": "https://status.example.com/api/v2/summary.json",
|
|
137
|
+
"page": "https://status.example.com"
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
With nginx:
|
|
142
|
+
|
|
143
|
+
```nginx
|
|
144
|
+
location = /.well-known/status {
|
|
145
|
+
default_type application/json;
|
|
146
|
+
add_header Access-Control-Allow-Origin *;
|
|
147
|
+
return 200 '{"osd_version":"0.1","document":"https://status.example.com/api/v2/summary.json","page":"https://status.example.com"}';
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
2. **Run the validator:** `statusdoc validate example.com`.
|
|
152
|
+
|
|
153
|
+
If you build your own status page, `Document.to_dict()` produces a
|
|
154
|
+
conforming document, and SPEC.md lists every field.
|
|
155
|
+
|
|
156
|
+
## Status
|
|
157
|
+
|
|
158
|
+
The spec is a 0.1 draft. The most useful contributions right now are
|
|
159
|
+
feedback on the spec, new verified entries for the service list and adapters
|
|
160
|
+
for other status page providers.
|
|
161
|
+
|
|
162
|
+
## Related work
|
|
163
|
+
|
|
164
|
+
- Atlassian Statuspage's public API, whose `summary.json` shape OSD specifies.
|
|
165
|
+
- The expired IETF draft *Service Status Resource Format for Web Services*,
|
|
166
|
+
which proposed a new format instead.
|
|
167
|
+
- Aggregators such as StatusGator (commercial) and Statusphere (open source),
|
|
168
|
+
which scrape status pages with one parser per provider.
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
MIT
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# statusdoc: Open Status Document
|
|
2
|
+
|
|
3
|
+
[](https://github.com/AndreaAcanfora/statusdoc/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
A specification and a Python tool to **find, read and validate the status
|
|
6
|
+
pages of the services you depend on**, starting from nothing but their
|
|
7
|
+
domain.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
- **Specification:** [SPEC.md](SPEC.md), Open Status Document (OSD) 0.1, draft.
|
|
12
|
+
- **Compatible with what is deployed:** OSD specifies the `summary.json`
|
|
13
|
+
shape that Atlassian Statuspage made common and other providers mirror, so
|
|
14
|
+
thousands of status pages already serve conforming documents.
|
|
15
|
+
- **Adds discovery:** `https://{domain}/.well-known/status` tells programs
|
|
16
|
+
where a service's status document lives.
|
|
17
|
+
- **Zero dependencies**, Python 3.10+.
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
Status pages are for people. Programs need the same information: a CI
|
|
22
|
+
pipeline that should hold a deploy while the cloud provider has an outage, a
|
|
23
|
+
dashboard of upstream dependencies, a support bot that explains an error.
|
|
24
|
+
Today that means knowing that GitHub's status lives on `githubstatus.com`
|
|
25
|
+
and Stripe's on `stripestatus.com`, and writing a parser for every format.
|
|
26
|
+
Commercial aggregators maintain thousands of such scrapers.
|
|
27
|
+
|
|
28
|
+
OSD turns the de facto format into a written spec, and lets each service
|
|
29
|
+
announce its document on its own domain.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pip install git+https://github.com/AndreaAcanfora/statusdoc
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Command line
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
statusdoc check github.com npmjs.com # exit 1 if any is degraded
|
|
41
|
+
statusdoc check --fail-on major vercel.com # only major and critical fail
|
|
42
|
+
statusdoc check --json -v github.com # machine-readable, with components
|
|
43
|
+
statusdoc discover fly.io # where is the status document?
|
|
44
|
+
statusdoc validate status.example.com # is it conforming?
|
|
45
|
+
statusdoc convert status.betterstack.com # any supported format → OSD JSON
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Targets are domains (`github.com`), status page URLs or document URLs.
|
|
49
|
+
Exit codes of `check`: `0` all fine, `1` at least one service at or above
|
|
50
|
+
`--fail-on` (default `minor`), `3` a service could not be reached.
|
|
51
|
+
|
|
52
|
+
### GitHub Action: hold a deploy during an upstream outage
|
|
53
|
+
|
|
54
|
+
```yaml
|
|
55
|
+
- uses: AndreaAcanfora/statusdoc@v0
|
|
56
|
+
with:
|
|
57
|
+
services: github.com npmjs.com vercel.com
|
|
58
|
+
fail-on: major # minor, major, critical or never
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The step fails when a service is at or above `fail-on`, adds a table to the
|
|
62
|
+
job summary and annotations to the run, and sets the outputs `worst-level`,
|
|
63
|
+
`degraded`, `unreachable` and `report` (JSON) for later steps. A status page
|
|
64
|
+
that cannot be read only produces a warning, unless
|
|
65
|
+
`fail-on-unreachable: true`. It runs on Linux, macOS and Windows runners with
|
|
66
|
+
any Python 3.10+, and installs nothing.
|
|
67
|
+
|
|
68
|
+
Or from any CI:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
pip install git+https://github.com/AndreaAcanfora/statusdoc
|
|
72
|
+
statusdoc check --fail-on major github.com npmjs.com vercel.com
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## How documents are found
|
|
76
|
+
|
|
77
|
+
1. `https://{domain}/.well-known/status`, the discovery URI defined by OSD.
|
|
78
|
+
2. A list of known services bundled with the tool (60 services, each verified
|
|
79
|
+
by hand), checked weekly by CI.
|
|
80
|
+
3. Conventions: `status.{domain}`, `{name}status.com`, `www.{name}status.com`,
|
|
81
|
+
at `/api/v2/summary.json` (Statuspage shape) or `/index.json` (Better Stack).
|
|
82
|
+
|
|
83
|
+
Better Stack pages and the Slack Status API are converted to OSD on the fly.
|
|
84
|
+
Other formats can be added as adapters.
|
|
85
|
+
|
|
86
|
+
## Library
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from statusdoc import discover, validate_document
|
|
90
|
+
|
|
91
|
+
found = discover("github.com")
|
|
92
|
+
doc = found.document
|
|
93
|
+
print(doc.name, doc.level) # GitHub operational
|
|
94
|
+
for incident in doc.open_incidents:
|
|
95
|
+
print(incident.name, incident.impact, incident.shortlink)
|
|
96
|
+
for c in doc.components:
|
|
97
|
+
if c.status != "operational":
|
|
98
|
+
print(c.name, c.status)
|
|
99
|
+
|
|
100
|
+
issues = validate_document(found.response.json())
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`doc.level` is one of `operational`, `maintenance`, `minor`, `major`,
|
|
104
|
+
`critical`: the publisher's indicator, raised by open incidents and active
|
|
105
|
+
maintenance.
|
|
106
|
+
|
|
107
|
+
## Publishing a conforming status page
|
|
108
|
+
|
|
109
|
+
If your status page runs on Statuspage or a compatible provider, it most
|
|
110
|
+
likely conforms already. Two small additions complete it:
|
|
111
|
+
|
|
112
|
+
1. **Announce it on your own domain.** Serve this at
|
|
113
|
+
`https://example.com/.well-known/status`, with
|
|
114
|
+
`Content-Type: application/json` and `Access-Control-Allow-Origin: *`:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"osd_version": "0.1",
|
|
119
|
+
"document": "https://status.example.com/api/v2/summary.json",
|
|
120
|
+
"page": "https://status.example.com"
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
With nginx:
|
|
125
|
+
|
|
126
|
+
```nginx
|
|
127
|
+
location = /.well-known/status {
|
|
128
|
+
default_type application/json;
|
|
129
|
+
add_header Access-Control-Allow-Origin *;
|
|
130
|
+
return 200 '{"osd_version":"0.1","document":"https://status.example.com/api/v2/summary.json","page":"https://status.example.com"}';
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
2. **Run the validator:** `statusdoc validate example.com`.
|
|
135
|
+
|
|
136
|
+
If you build your own status page, `Document.to_dict()` produces a
|
|
137
|
+
conforming document, and SPEC.md lists every field.
|
|
138
|
+
|
|
139
|
+
## Status
|
|
140
|
+
|
|
141
|
+
The spec is a 0.1 draft. The most useful contributions right now are
|
|
142
|
+
feedback on the spec, new verified entries for the service list and adapters
|
|
143
|
+
for other status page providers.
|
|
144
|
+
|
|
145
|
+
## Related work
|
|
146
|
+
|
|
147
|
+
- Atlassian Statuspage's public API, whose `summary.json` shape OSD specifies.
|
|
148
|
+
- The expired IETF draft *Service Status Resource Format for Web Services*,
|
|
149
|
+
which proposed a new format instead.
|
|
150
|
+
- Aggregators such as StatusGator (commercial) and Statusphere (open source),
|
|
151
|
+
which scrape status pages with one parser per provider.
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "statusdoc"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Open Status Document: a spec and tools to discover, read and validate service status pages"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "Andrea Acanfora" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["status page", "statuspage", "incidents", "monitoring", "sre", "uptime"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: System :: Monitoring",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/AndreaAcanfora/statusdoc"
|
|
23
|
+
Specification = "https://github.com/AndreaAcanfora/statusdoc/blob/master/SPEC.md"
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
statusdoc = "statusdoc.cli:main"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools]
|
|
29
|
+
packages = ["statusdoc"]
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.package-data]
|
|
32
|
+
statusdoc = ["registry.json", "py.typed"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
line-length = 160
|
|
36
|
+
target-version = "py310"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Open Status Document: read, discover and validate service status pages."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .adapters import detect, from_betterstack, load
|
|
6
|
+
from .discover import Discovery, DiscoveryError, discover
|
|
7
|
+
from .model import Component, Document, FormatError, Incident, Update, parse
|
|
8
|
+
from .validate import Issue, validate_document, validate_http
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Component",
|
|
12
|
+
"Discovery",
|
|
13
|
+
"DiscoveryError",
|
|
14
|
+
"Document",
|
|
15
|
+
"FormatError",
|
|
16
|
+
"Incident",
|
|
17
|
+
"Issue",
|
|
18
|
+
"Update",
|
|
19
|
+
"detect",
|
|
20
|
+
"discover",
|
|
21
|
+
"from_betterstack",
|
|
22
|
+
"load",
|
|
23
|
+
"parse",
|
|
24
|
+
"validate_document",
|
|
25
|
+
"validate_http",
|
|
26
|
+
]
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Readers for status formats that are not OSD yet."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .model import Component, Document, FormatError, Incident, Update, parse
|
|
8
|
+
|
|
9
|
+
# Better Stack resource states → OSD component statuses.
|
|
10
|
+
_BETTERSTACK_STATUS = {
|
|
11
|
+
"operational": "operational",
|
|
12
|
+
"maintenance": "under_maintenance",
|
|
13
|
+
"degraded": "degraded_performance",
|
|
14
|
+
"downtime": "major_outage",
|
|
15
|
+
}
|
|
16
|
+
_BETTERSTACK_SEVERITY = {"operational": 0, "under_maintenance": 1, "degraded_performance": 2, "partial_outage": 3, "major_outage": 4}
|
|
17
|
+
_IMPACT_OF = {"degraded_performance": "minor", "partial_outage": "major", "major_outage": "critical"}
|
|
18
|
+
_INDICATOR_OF = {0: "none", 1: "maintenance", 2: "minor", 3: "major", 4: "critical"}
|
|
19
|
+
_DESCRIPTION_OF = {
|
|
20
|
+
0: "All Systems Operational",
|
|
21
|
+
1: "Service Under Maintenance",
|
|
22
|
+
2: "Partially Degraded Service",
|
|
23
|
+
3: "Partial System Outage",
|
|
24
|
+
4: "Major System Outage",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def detect(data: Any) -> str | None:
|
|
29
|
+
"""Returns the format of a parsed JSON document, or None if unknown."""
|
|
30
|
+
if not isinstance(data, dict):
|
|
31
|
+
return None
|
|
32
|
+
if isinstance(data.get("page"), dict) and isinstance(data.get("status"), dict) and isinstance(data.get("components"), list):
|
|
33
|
+
return "osd"
|
|
34
|
+
d = data.get("data")
|
|
35
|
+
if isinstance(d, dict) and d.get("type") == "status_page" and isinstance(data.get("included"), list):
|
|
36
|
+
return "betterstack"
|
|
37
|
+
if isinstance(data.get("status"), str) and isinstance(data.get("active_incidents"), list):
|
|
38
|
+
return "slack"
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load(data: Any) -> Document:
|
|
43
|
+
"""Reads a status document in any supported format."""
|
|
44
|
+
fmt = detect(data)
|
|
45
|
+
if fmt == "osd":
|
|
46
|
+
return parse(data)
|
|
47
|
+
if fmt == "betterstack":
|
|
48
|
+
return from_betterstack(data)
|
|
49
|
+
if fmt == "slack":
|
|
50
|
+
return from_slack(data)
|
|
51
|
+
raise FormatError("not a status document in a known format")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def from_betterstack(data: dict[str, Any]) -> Document:
|
|
55
|
+
"""Converts a Better Stack status page (`/index.json`, JSON:API) to OSD."""
|
|
56
|
+
page = data["data"].get("attributes", {})
|
|
57
|
+
included = [x for x in data["included"] if isinstance(x, dict)]
|
|
58
|
+
|
|
59
|
+
def of_type(t: str) -> list[dict[str, Any]]:
|
|
60
|
+
return [x for x in included if x.get("type") == t]
|
|
61
|
+
|
|
62
|
+
components: dict[str, Component] = {}
|
|
63
|
+
sections = {str(s.get("id")): s.get("attributes", {}) for s in of_type("status_page_section")}
|
|
64
|
+
for s_id, s in sections.items():
|
|
65
|
+
components["section-" + s_id] = Component(
|
|
66
|
+
id="section-" + s_id, name=str(s.get("name", "")).strip(), status="operational", group=True, position=s.get("position")
|
|
67
|
+
)
|
|
68
|
+
for r in of_type("status_page_resource"):
|
|
69
|
+
a = r.get("attributes", {})
|
|
70
|
+
if a.get("status") == "not_monitored":
|
|
71
|
+
# No data, so no claim either way: leave it out.
|
|
72
|
+
continue
|
|
73
|
+
section = a.get("status_page_section_id")
|
|
74
|
+
c = Component(
|
|
75
|
+
id=str(r.get("id")),
|
|
76
|
+
name=str(a.get("public_name", "")),
|
|
77
|
+
status=_BETTERSTACK_STATUS.get(str(a.get("status")), "degraded_performance"),
|
|
78
|
+
description=a.get("explanation") or None,
|
|
79
|
+
group_id=f"section-{section}" if section is not None and str(section) in sections else None,
|
|
80
|
+
position=a.get("position"),
|
|
81
|
+
)
|
|
82
|
+
components[c.id] = c
|
|
83
|
+
# A section is as bad as its worst resource.
|
|
84
|
+
for c in components.values():
|
|
85
|
+
if c.group_id:
|
|
86
|
+
group = components[c.group_id]
|
|
87
|
+
if _BETTERSTACK_SEVERITY[c.status] > _BETTERSTACK_SEVERITY[group.status]:
|
|
88
|
+
group.status = c.status
|
|
89
|
+
|
|
90
|
+
updates = {str(u.get("id")): u.get("attributes", {}) for u in of_type("status_update")}
|
|
91
|
+
incidents: list[Incident] = []
|
|
92
|
+
maintenances: list[Incident] = []
|
|
93
|
+
for report in of_type("status_report"):
|
|
94
|
+
a = report.get("attributes", {})
|
|
95
|
+
resolved = a.get("aggregate_state") == "resolved" or a.get("ends_at") is not None
|
|
96
|
+
affected = [
|
|
97
|
+
components[str(x.get("status_page_resource_id"))] for x in a.get("affected_resources", []) if str(x.get("status_page_resource_id")) in components
|
|
98
|
+
]
|
|
99
|
+
worst = max((_BETTERSTACK_SEVERITY[c.status] for c in affected), default=0)
|
|
100
|
+
rel = report.get("relationships", {}).get("status_updates", {}).get("data", [])
|
|
101
|
+
report_updates = [updates[str(x.get("id"))] for x in rel if str(x.get("id")) in updates]
|
|
102
|
+
report_updates.sort(key=lambda u: str(u.get("published_at", "")), reverse=True)
|
|
103
|
+
is_maintenance = a.get("report_type") == "maintenance"
|
|
104
|
+
item = Incident(
|
|
105
|
+
id=str(report.get("id")),
|
|
106
|
+
name=str(a.get("title", "")),
|
|
107
|
+
status=("completed" if resolved else "in_progress") if is_maintenance else ("resolved" if resolved else "investigating"),
|
|
108
|
+
impact="none" if is_maintenance else _IMPACT_OF.get(_status_of(worst), "minor"),
|
|
109
|
+
created_at=str(a.get("starts_at", "")),
|
|
110
|
+
updates=[
|
|
111
|
+
Update(id=str(i), status="resolved" if resolved else "investigating", body=str(u.get("message", "")), created_at=str(u.get("published_at", "")))
|
|
112
|
+
for i, u in enumerate(report_updates)
|
|
113
|
+
],
|
|
114
|
+
components=affected,
|
|
115
|
+
scheduled_for=a.get("starts_at") if is_maintenance else None,
|
|
116
|
+
scheduled_until=a.get("ends_at") if is_maintenance else None,
|
|
117
|
+
)
|
|
118
|
+
if is_maintenance:
|
|
119
|
+
if not resolved:
|
|
120
|
+
maintenances.append(item)
|
|
121
|
+
elif not resolved:
|
|
122
|
+
incidents.append(item)
|
|
123
|
+
|
|
124
|
+
# The page's own aggregate state is the publisher's judgement (spec section 4.3);
|
|
125
|
+
# fall back to the worst resource when it is missing.
|
|
126
|
+
aggregate = _BETTERSTACK_STATUS.get(str(page.get("aggregate_state")))
|
|
127
|
+
worst = _BETTERSTACK_SEVERITY[aggregate] if aggregate else max((_BETTERSTACK_SEVERITY[c.status] for c in components.values() if not c.group), default=0)
|
|
128
|
+
domain = page.get("custom_domain") or f"{page.get('subdomain', '')}.betteruptime.com"
|
|
129
|
+
return Document(
|
|
130
|
+
name=str(page.get("company_name", "")),
|
|
131
|
+
url=f"https://{domain}",
|
|
132
|
+
updated_at=str(page.get("updated_at", "")),
|
|
133
|
+
indicator=_INDICATOR_OF[worst],
|
|
134
|
+
description=_DESCRIPTION_OF[worst],
|
|
135
|
+
components=sorted(components.values(), key=lambda c: (c.group_id or c.id, not c.group, c.position or 0)),
|
|
136
|
+
incidents=incidents,
|
|
137
|
+
maintenances=maintenances,
|
|
138
|
+
source_format="betterstack",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _status_of(severity: int) -> str:
|
|
143
|
+
for status, s in _BETTERSTACK_SEVERITY.items():
|
|
144
|
+
if s == severity:
|
|
145
|
+
return status
|
|
146
|
+
return "operational"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# Services listed by the Slack Status API documentation for /api/v2.0.0/current.
|
|
150
|
+
SLACK_SERVICES = (
|
|
151
|
+
"Login/SSO",
|
|
152
|
+
"Messaging",
|
|
153
|
+
"Notifications",
|
|
154
|
+
"Search",
|
|
155
|
+
"Workspace/Org Administration",
|
|
156
|
+
"Canvases",
|
|
157
|
+
"Connectivity",
|
|
158
|
+
"Files",
|
|
159
|
+
"Huddles",
|
|
160
|
+
"Apps/Integrations/APIs",
|
|
161
|
+
"Workflows",
|
|
162
|
+
)
|
|
163
|
+
# Slack issue types → (OSD incident impact, status of the affected services).
|
|
164
|
+
_SLACK_TYPES = {"outage": ("major", "major_outage"), "incident": ("minor", "degraded_performance"), "notice": ("none", "operational")}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def from_slack(data: dict[str, Any]) -> Document:
|
|
168
|
+
"""Converts the Slack Status API (`https://slack-status.com/api/v2.0.0/current`) to OSD.
|
|
169
|
+
|
|
170
|
+
Slack reports issues, not component states: affected services take the
|
|
171
|
+
status implied by the issue type (an outage makes them major_outage, an
|
|
172
|
+
incident degraded_performance, a notice leaves them operational).
|
|
173
|
+
"""
|
|
174
|
+
services: dict[str, Component] = {}
|
|
175
|
+
|
|
176
|
+
def service(name: str) -> Component:
|
|
177
|
+
if name not in services:
|
|
178
|
+
slug = "".join(ch if ch.isalnum() else "-" for ch in name.lower()).strip("-")
|
|
179
|
+
services[name] = Component(id=slug, name=name, status="operational", position=len(services))
|
|
180
|
+
return services[name]
|
|
181
|
+
|
|
182
|
+
for name in SLACK_SERVICES:
|
|
183
|
+
service(name)
|
|
184
|
+
|
|
185
|
+
incidents: list[Incident] = []
|
|
186
|
+
maintenances: list[Incident] = []
|
|
187
|
+
worst = 0
|
|
188
|
+
for item in data.get("active_incidents", []):
|
|
189
|
+
if not isinstance(item, dict):
|
|
190
|
+
continue
|
|
191
|
+
status = str(item.get("status", ""))
|
|
192
|
+
kind = str(item.get("type", ""))
|
|
193
|
+
impact, service_status = _SLACK_TYPES.get(kind, ("minor", "degraded_performance"))
|
|
194
|
+
notes = [n for n in item.get("notes", []) if isinstance(n, dict)]
|
|
195
|
+
notes.sort(key=lambda n: str(n.get("date_created", "")), reverse=True)
|
|
196
|
+
affected = [service(str(s)) for s in item.get("services", []) if isinstance(s, str)]
|
|
197
|
+
entry = Incident(
|
|
198
|
+
id=str(item.get("id", "")),
|
|
199
|
+
name=str(item.get("title", "")),
|
|
200
|
+
status="",
|
|
201
|
+
impact=impact,
|
|
202
|
+
created_at=str(item.get("date_created", "")),
|
|
203
|
+
updated_at=item.get("date_updated"),
|
|
204
|
+
shortlink=item.get("url"),
|
|
205
|
+
updates=[
|
|
206
|
+
Update(id=f"{item.get('id', '')}-{n}", status="", body=str(note.get("body", "")), created_at=str(note.get("date_created", "")))
|
|
207
|
+
for n, note in enumerate(notes)
|
|
208
|
+
],
|
|
209
|
+
)
|
|
210
|
+
if status in ("scheduled", "completed", "cancelled"):
|
|
211
|
+
if status == "scheduled":
|
|
212
|
+
entry.status, entry.impact = "scheduled", "none"
|
|
213
|
+
entry.scheduled_for = entry.created_at
|
|
214
|
+
maintenances.append(entry)
|
|
215
|
+
continue
|
|
216
|
+
entry.status = "resolved" if status == "resolved" else "investigating"
|
|
217
|
+
for u in entry.updates:
|
|
218
|
+
u.status = entry.status
|
|
219
|
+
if not entry.resolved:
|
|
220
|
+
for c in affected:
|
|
221
|
+
if _BETTERSTACK_SEVERITY[service_status] > _BETTERSTACK_SEVERITY[c.status]:
|
|
222
|
+
c.status = service_status
|
|
223
|
+
worst = max(worst, {"none": 0, "minor": 2, "major": 3}[impact])
|
|
224
|
+
entry.components = [Component(id=c.id, name=c.name, status=c.status) for c in affected]
|
|
225
|
+
incidents.append(entry)
|
|
226
|
+
|
|
227
|
+
return Document(
|
|
228
|
+
name="Slack",
|
|
229
|
+
url="https://slack-status.com",
|
|
230
|
+
updated_at=str(data.get("date_updated", "")),
|
|
231
|
+
indicator=_INDICATOR_OF[worst],
|
|
232
|
+
description=_DESCRIPTION_OF[worst],
|
|
233
|
+
components=list(services.values()),
|
|
234
|
+
incidents=incidents,
|
|
235
|
+
maintenances=maintenances,
|
|
236
|
+
source_format="slack",
|
|
237
|
+
)
|