statusdoc 0.1.0__py3-none-any.whl
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/__init__.py +26 -0
- statusdoc/__main__.py +3 -0
- statusdoc/adapters.py +237 -0
- statusdoc/cli.py +221 -0
- statusdoc/discover.py +129 -0
- statusdoc/fetch.py +74 -0
- statusdoc/model.py +230 -0
- statusdoc/py.typed +0 -0
- statusdoc/registry.json +245 -0
- statusdoc/validate.py +237 -0
- statusdoc-0.1.0.dist-info/METADATA +172 -0
- statusdoc-0.1.0.dist-info/RECORD +16 -0
- statusdoc-0.1.0.dist-info/WHEEL +5 -0
- statusdoc-0.1.0.dist-info/entry_points.txt +2 -0
- statusdoc-0.1.0.dist-info/licenses/LICENSE +21 -0
- statusdoc-0.1.0.dist-info/top_level.txt +1 -0
statusdoc/__init__.py
ADDED
|
@@ -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
|
+
]
|
statusdoc/__main__.py
ADDED
statusdoc/adapters.py
ADDED
|
@@ -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
|
+
)
|
statusdoc/cli.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Command line: check, discover, validate and convert status documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
9
|
+
from typing import Any, TextIO
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .discover import Discovery, DiscoveryError, discover
|
|
13
|
+
from .fetch import Fetcher, FetchError, get
|
|
14
|
+
from .model import LEVELS, Document
|
|
15
|
+
from .validate import Issue, has_errors, validate_document, validate_http
|
|
16
|
+
|
|
17
|
+
# Exit codes of `statusdoc check`.
|
|
18
|
+
EXIT_OK, EXIT_DEGRADED, EXIT_USAGE, EXIT_UNREACHABLE = 0, 1, 2, 3
|
|
19
|
+
|
|
20
|
+
_SYMBOL = {"operational": "✓", "maintenance": "◷", "minor": "!", "major": "✗", "critical": "✗"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(argv: list[str] | None = None, *, out: TextIO = sys.stdout, fetch: Fetcher = get, registry: dict[str, dict[str, str]] | None = None) -> int:
|
|
24
|
+
parser = argparse.ArgumentParser(prog="statusdoc", description="Read service status pages in the Open Status Document format.")
|
|
25
|
+
parser.add_argument("--version", action="version", version=f"statusdoc {__version__}")
|
|
26
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
27
|
+
|
|
28
|
+
p_check = sub.add_parser("check", help="show the status of services (exit 1 if one is degraded)")
|
|
29
|
+
p_check.add_argument("targets", nargs="+", metavar="DOMAIN|URL")
|
|
30
|
+
p_check.add_argument("--json", action="store_true", help="print JSON")
|
|
31
|
+
p_check.add_argument("--github", action="store_true", help="in GitHub Actions: write a job summary, step outputs and annotations")
|
|
32
|
+
p_check.add_argument("-v", "--verbose", action="store_true", help="list components that are not operational")
|
|
33
|
+
p_check.add_argument(
|
|
34
|
+
"--fail-on", choices=("minor", "major", "critical", "never"), default="minor", help="lowest level that makes the exit code 1 (default: minor)"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
p_disc = sub.add_parser("discover", help="find the status document of services")
|
|
38
|
+
p_disc.add_argument("targets", nargs="+", metavar="DOMAIN|URL")
|
|
39
|
+
|
|
40
|
+
p_val = sub.add_parser("validate", help="check a status document against the spec")
|
|
41
|
+
p_val.add_argument("target", metavar="DOMAIN|URL")
|
|
42
|
+
|
|
43
|
+
p_conv = sub.add_parser("convert", help="print a status document in OSD form")
|
|
44
|
+
p_conv.add_argument("target", metavar="DOMAIN|URL")
|
|
45
|
+
|
|
46
|
+
args = parser.parse_args(argv)
|
|
47
|
+
if out is sys.stdout and hasattr(out, "reconfigure"):
|
|
48
|
+
# Windows consoles and pipes may not encode the status symbols.
|
|
49
|
+
out.reconfigure(errors="replace")
|
|
50
|
+
find = lambda t: discover(t, fetch=fetch, registry=registry)
|
|
51
|
+
|
|
52
|
+
if args.command == "check":
|
|
53
|
+
return _check(args, find, out)
|
|
54
|
+
if args.command == "discover":
|
|
55
|
+
status = EXIT_OK
|
|
56
|
+
for target, result in _all(args.targets, find):
|
|
57
|
+
if isinstance(result, Exception):
|
|
58
|
+
print(f"{target}: {result}", file=out)
|
|
59
|
+
status = EXIT_UNREACHABLE
|
|
60
|
+
else:
|
|
61
|
+
print(f"{target}: {result.document_url} (via {result.method}, {result.document.source_format})", file=out)
|
|
62
|
+
return status
|
|
63
|
+
if args.command == "validate":
|
|
64
|
+
return _validate(args.target, find, fetch, out)
|
|
65
|
+
if args.command == "convert":
|
|
66
|
+
try:
|
|
67
|
+
d = find(args.target)
|
|
68
|
+
except (DiscoveryError, FetchError, ValueError) as e:
|
|
69
|
+
print(f"statusdoc: {e}", file=sys.stderr)
|
|
70
|
+
return EXIT_UNREACHABLE
|
|
71
|
+
json.dump(d.document.to_dict(), out, indent=2, ensure_ascii=False)
|
|
72
|
+
print(file=out)
|
|
73
|
+
return EXIT_OK
|
|
74
|
+
return EXIT_USAGE
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _all(targets: list[str], find: Any) -> list[tuple[str, Discovery | Exception]]:
|
|
78
|
+
def one(t: str) -> Discovery | Exception:
|
|
79
|
+
try:
|
|
80
|
+
return find(t)
|
|
81
|
+
except (DiscoveryError, FetchError, ValueError) as e:
|
|
82
|
+
return e
|
|
83
|
+
|
|
84
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
85
|
+
return list(zip(targets, pool.map(one, targets)))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _check(args: argparse.Namespace, find: Any, out: TextIO) -> int:
|
|
89
|
+
results = _all(args.targets, find)
|
|
90
|
+
threshold = len(LEVELS) if args.fail_on == "never" else LEVELS.index(args.fail_on)
|
|
91
|
+
degraded = unreachable = False
|
|
92
|
+
report = []
|
|
93
|
+
for target, r in results:
|
|
94
|
+
if isinstance(r, Exception):
|
|
95
|
+
unreachable = True
|
|
96
|
+
report.append({"target": target, "error": str(r)})
|
|
97
|
+
continue
|
|
98
|
+
d = r.document
|
|
99
|
+
degraded |= d.severity >= threshold
|
|
100
|
+
report.append(_summary(target, r, d))
|
|
101
|
+
|
|
102
|
+
if args.json:
|
|
103
|
+
json.dump(report, out, indent=2, ensure_ascii=False)
|
|
104
|
+
print(file=out)
|
|
105
|
+
else:
|
|
106
|
+
width = max(len(t) for t in args.targets)
|
|
107
|
+
for item in report:
|
|
108
|
+
if "error" in item:
|
|
109
|
+
print(f"? {item['target']:<{width}} unreachable: {item['error']}", file=out)
|
|
110
|
+
continue
|
|
111
|
+
print(f"{_SYMBOL[item['level']]} {item['target']:<{width}} {item['level']:<11} {item['description']}", file=out)
|
|
112
|
+
for inc in item["incidents"]:
|
|
113
|
+
print(f" {'':<{width}} • {inc['name']} ({inc['impact']}, {inc['status']})", file=out)
|
|
114
|
+
for m in item["maintenance"]:
|
|
115
|
+
print(f" {'':<{width}} ◷ {m['name']} (until {m['until'] or 'unknown'})", file=out)
|
|
116
|
+
if args.verbose:
|
|
117
|
+
for comp in item["components"]:
|
|
118
|
+
print(f" {'':<{width}} - {comp['name']}: {comp['status']}", file=out)
|
|
119
|
+
if args.github:
|
|
120
|
+
_github(report, threshold, out)
|
|
121
|
+
if degraded:
|
|
122
|
+
return EXIT_DEGRADED
|
|
123
|
+
return EXIT_UNREACHABLE if unreachable else EXIT_OK
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _short(error: str) -> str:
|
|
127
|
+
"""Shortens a discovery error that lists every address tried."""
|
|
128
|
+
head, sep, tried = error.partition(": tried ")
|
|
129
|
+
return f"{head} (tried {tried.count('; ') + 1} addresses)" if sep else error
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_EMOJI = {"operational": "✅", "maintenance": "🛠️", "minor": "⚠️", "major": "❌", "critical": "🔥"}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def markdown(report: list[dict[str, Any]]) -> str:
|
|
136
|
+
"""Renders a check report as a Markdown table."""
|
|
137
|
+
lines = ["### Upstream status", "", "| | Service | Status | Details |", "|---|---|---|---|"]
|
|
138
|
+
|
|
139
|
+
def cell(text: str) -> str:
|
|
140
|
+
return text.replace("|", "\\|").replace("\n", " ")
|
|
141
|
+
|
|
142
|
+
for item in report:
|
|
143
|
+
if "error" in item:
|
|
144
|
+
lines.append(f"| ❓ | {cell(item['target'])} | unreachable | {cell(_short(item['error']))} |")
|
|
145
|
+
continue
|
|
146
|
+
details = [cell(item["description"])]
|
|
147
|
+
for inc in item["incidents"]:
|
|
148
|
+
name = cell(inc["name"])
|
|
149
|
+
details.append(f"[{name}]({inc['url']})" if inc.get("url") else name)
|
|
150
|
+
for m in item["maintenance"]:
|
|
151
|
+
details.append("Maintenance: " + cell(m["name"]))
|
|
152
|
+
service = f"[{cell(item['target'])}]({item['page']})" if item.get("page") else cell(item["target"])
|
|
153
|
+
lines.append(f"| {_EMOJI[item['level']]} | {service} | {item['level']} | {'<br>'.join(details)} |")
|
|
154
|
+
return "\n".join(lines) + "\n"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _github(report: list[dict[str, Any]], threshold: int, out: TextIO) -> None:
|
|
158
|
+
"""Job summary, step outputs and annotations for GitHub Actions."""
|
|
159
|
+
import os
|
|
160
|
+
|
|
161
|
+
if path := os.environ.get("GITHUB_STEP_SUMMARY"):
|
|
162
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
163
|
+
f.write(markdown(report))
|
|
164
|
+
levels = [LEVELS.index(i["level"]) for i in report if "error" not in i]
|
|
165
|
+
worst = LEVELS[max(levels)] if levels else "unknown"
|
|
166
|
+
if path := os.environ.get("GITHUB_OUTPUT"):
|
|
167
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
168
|
+
f.write(f"worst-level={worst}\n")
|
|
169
|
+
f.write(f"degraded={'true' if levels and max(levels) >= threshold else 'false'}\n")
|
|
170
|
+
f.write(f"unreachable={'true' if any('error' in i for i in report) else 'false'}\n")
|
|
171
|
+
f.write("report=" + json.dumps(report, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
172
|
+
for item in report:
|
|
173
|
+
if "error" in item:
|
|
174
|
+
print(f"::warning title={item['target']} unreachable::{_short(item['error'])}", file=out)
|
|
175
|
+
elif item["level"] != "operational":
|
|
176
|
+
kind = "error" if LEVELS.index(item["level"]) >= threshold else "warning"
|
|
177
|
+
incidents = "; ".join(i["name"] for i in item["incidents"])
|
|
178
|
+
print(f"::{kind} title={item['target']} is {item['level']}::{item['description']}{': ' + incidents if incidents else ''}", file=out)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _summary(target: str, r: Discovery, d: Document) -> dict[str, Any]:
|
|
182
|
+
return {
|
|
183
|
+
"target": target,
|
|
184
|
+
"name": d.name,
|
|
185
|
+
"level": d.level,
|
|
186
|
+
"indicator": d.indicator,
|
|
187
|
+
"description": d.description,
|
|
188
|
+
"page": d.url,
|
|
189
|
+
"document": r.document_url,
|
|
190
|
+
"discovered_via": r.method,
|
|
191
|
+
"incidents": [{"name": i.name, "status": i.status, "impact": i.impact, "url": i.shortlink} for i in d.open_incidents],
|
|
192
|
+
"maintenance": [{"name": m.name, "status": m.status, "until": m.scheduled_until, "url": m.shortlink} for m in d.active_maintenances],
|
|
193
|
+
"components": [{"name": c.name, "status": c.status} for c in d.components if not c.group and c.status != "operational"],
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _validate(target: str, find: Any, fetch: Fetcher, out: TextIO) -> int:
|
|
198
|
+
try:
|
|
199
|
+
r = find(target)
|
|
200
|
+
except (DiscoveryError, FetchError, ValueError) as e:
|
|
201
|
+
print(f"statusdoc: {e}", file=out)
|
|
202
|
+
return EXIT_UNREACHABLE
|
|
203
|
+
print(f"document: {r.document_url} (found via {r.method})", file=out)
|
|
204
|
+
if r.document.source_format != "osd":
|
|
205
|
+
print(f"MUST $: the document is in the {r.document.source_format} format, not OSD; `statusdoc convert` shows the OSD equivalent", file=out)
|
|
206
|
+
return EXIT_DEGRADED
|
|
207
|
+
issues: list[Issue] = []
|
|
208
|
+
if r.method != "well-known" and "://" not in target:
|
|
209
|
+
issues.append(Issue("SHOULD", "discovery", f"https://{target}/.well-known/status does not point to the document (section 3.1)"))
|
|
210
|
+
resp = r.response or fetch(r.document_url)
|
|
211
|
+
issues += validate_http(resp) + validate_document(resp.json())
|
|
212
|
+
for issue in issues:
|
|
213
|
+
print(issue, file=out)
|
|
214
|
+
errors = sum(i.level == "MUST" for i in issues)
|
|
215
|
+
warnings = len(issues) - errors
|
|
216
|
+
print(f"\n{'not conforming' if has_errors(issues) else 'conforming'} to OSD 0.1: {errors} errors, {warnings} recommendations", file=out)
|
|
217
|
+
return EXIT_DEGRADED if has_errors(issues) else EXIT_OK
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
raise SystemExit(main())
|
statusdoc/discover.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Finding the status document of a service (SPEC.md, section 3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
from .adapters import detect, load
|
|
13
|
+
from .fetch import Fetcher, FetchError, Response, get
|
|
14
|
+
from .model import Document
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Discovery:
|
|
19
|
+
target: str
|
|
20
|
+
document_url: str
|
|
21
|
+
#: How the document was found: well-known, registry, convention or url.
|
|
22
|
+
method: str
|
|
23
|
+
document: Document
|
|
24
|
+
page_url: str | None = None
|
|
25
|
+
response: Response | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DiscoveryError(Exception):
|
|
29
|
+
def __init__(self, target: str, attempts: list[tuple[str, str]]):
|
|
30
|
+
self.target = target
|
|
31
|
+
self.attempts = attempts
|
|
32
|
+
tried = "; ".join(f"{url} ({why})" for url, why in attempts)
|
|
33
|
+
super().__init__(f"no status document found for {target}: tried {tried}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_registry() -> dict[str, dict[str, str]]:
|
|
37
|
+
data = json.loads(resources.files("statusdoc").joinpath("registry.json").read_text("utf-8"))
|
|
38
|
+
return data["services"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_DOMAIN = re.compile(r"^[A-Za-z0-9.-]+(:\d+)?$")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def discover(target: str, *, fetch: Fetcher = get, registry: dict[str, dict[str, str]] | None = None, scheme: str = "https") -> Discovery:
|
|
45
|
+
"""Finds and reads the status document of a domain, a status page URL or a document URL."""
|
|
46
|
+
if "://" in target:
|
|
47
|
+
return _from_url(target, fetch)
|
|
48
|
+
domain = target.strip().lower().rstrip(".")
|
|
49
|
+
if not _DOMAIN.match(domain):
|
|
50
|
+
raise ValueError(f"not a domain or URL: {target!r}")
|
|
51
|
+
domain = domain.removeprefix("www.")
|
|
52
|
+
attempts: list[tuple[str, str]] = []
|
|
53
|
+
|
|
54
|
+
# 1. The service's own well-known URI.
|
|
55
|
+
well_known = f"{scheme}://{domain}/.well-known/status"
|
|
56
|
+
data, resp = _try_json(well_known, fetch, attempts)
|
|
57
|
+
if isinstance(data, dict):
|
|
58
|
+
if detect(data):
|
|
59
|
+
return Discovery(target, resp.url, "well-known", load(data), response=resp)
|
|
60
|
+
doc_url = data.get("document")
|
|
61
|
+
if isinstance(doc_url, str) and doc_url.startswith(("https://", f"{scheme}://")):
|
|
62
|
+
found = _try_document(doc_url, fetch, attempts)
|
|
63
|
+
if found:
|
|
64
|
+
page = data.get("page") if isinstance(data.get("page"), str) else None
|
|
65
|
+
return Discovery(target, found[1].url, "well-known", found[0], page_url=page, response=found[1])
|
|
66
|
+
else:
|
|
67
|
+
attempts.append((well_known, "no document URL"))
|
|
68
|
+
|
|
69
|
+
# 2. The list of known services, for the domain and its parents.
|
|
70
|
+
reg = load_registry() if registry is None else registry
|
|
71
|
+
parts = domain.split(".")
|
|
72
|
+
for i in range(len(parts) - 1):
|
|
73
|
+
entry = reg.get(".".join(parts[i:]))
|
|
74
|
+
if entry:
|
|
75
|
+
found = _try_document(entry["document"], fetch, attempts)
|
|
76
|
+
if found:
|
|
77
|
+
return Discovery(target, found[1].url, "registry", found[0], page_url=entry.get("page"), response=found[1])
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
# 3. Common conventions.
|
|
81
|
+
host = domain.split(":")[0]
|
|
82
|
+
label = host.split(".")[-2] if host.count(".") >= 1 else host
|
|
83
|
+
for page in (f"{scheme}://status.{domain}", f"{scheme}://{label}status.com", f"{scheme}://www.{label}status.com"):
|
|
84
|
+
for path in ("/api/v2/summary.json", "/index.json"):
|
|
85
|
+
found = _try_document(page + path, fetch, attempts)
|
|
86
|
+
if found:
|
|
87
|
+
return Discovery(target, found[1].url, "convention", found[0], page_url=page, response=found[1])
|
|
88
|
+
raise DiscoveryError(target, attempts)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _from_url(url: str, fetch: Fetcher) -> Discovery:
|
|
92
|
+
attempts: list[tuple[str, str]] = []
|
|
93
|
+
found = _try_document(url, fetch, attempts)
|
|
94
|
+
if found:
|
|
95
|
+
return Discovery(url, found[1].url, "url", found[0], response=found[1])
|
|
96
|
+
# A status page URL: try the usual document paths.
|
|
97
|
+
parts = urlsplit(url)
|
|
98
|
+
base = f"{parts.scheme}://{parts.netloc}"
|
|
99
|
+
for path in ("/api/v2/summary.json", "/index.json"):
|
|
100
|
+
found = _try_document(base + path, fetch, attempts)
|
|
101
|
+
if found:
|
|
102
|
+
return Discovery(url, found[1].url, "convention", found[0], page_url=url, response=found[1])
|
|
103
|
+
raise DiscoveryError(url, attempts)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _try_json(url: str, fetch: Fetcher, attempts: list[tuple[str, str]]) -> tuple[Any, Response]:
|
|
107
|
+
try:
|
|
108
|
+
resp = fetch(url)
|
|
109
|
+
except FetchError as e:
|
|
110
|
+
attempts.append((url, str(e).removeprefix(url + ": ")))
|
|
111
|
+
return None, Response(url, 0)
|
|
112
|
+
if resp.status != 200:
|
|
113
|
+
attempts.append((url, f"HTTP {resp.status}"))
|
|
114
|
+
return None, resp
|
|
115
|
+
try:
|
|
116
|
+
return resp.json(), resp
|
|
117
|
+
except (ValueError, UnicodeDecodeError):
|
|
118
|
+
attempts.append((url, "not JSON"))
|
|
119
|
+
return None, resp
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _try_document(url: str, fetch: Fetcher, attempts: list[tuple[str, str]]) -> tuple[Document, Response] | None:
|
|
123
|
+
data, resp = _try_json(url, fetch, attempts)
|
|
124
|
+
if data is None:
|
|
125
|
+
return None
|
|
126
|
+
if not detect(data):
|
|
127
|
+
attempts.append((url, "JSON, but not a status document"))
|
|
128
|
+
return None
|
|
129
|
+
return load(data), resp
|