nthlayer-core 1.0.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.
- nthlayer_core/__init__.py +1 -0
- nthlayer_core/catalogue.py +183 -0
- nthlayer_core/cli.py +27 -0
- nthlayer_core/server.py +748 -0
- nthlayer_core/store.py +756 -0
- nthlayer_core-1.0.0.dist-info/METADATA +14 -0
- nthlayer_core-1.0.0.dist-info/RECORD +10 -0
- nthlayer_core-1.0.0.dist-info/WHEEL +5 -0
- nthlayer_core-1.0.0.dist-info/entry_points.txt +2 -0
- nthlayer_core-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""NthLayer core — reliability-critical verdict store, case management, and HTTP API."""
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Manifest catalogue.
|
|
2
|
+
|
|
3
|
+
Loads OpenSRM manifests from a directory, caches them in memory, and
|
|
4
|
+
detects changes via polling. Workers read manifests from core's API
|
|
5
|
+
instead of loading YAML files directly.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
catalogue = ManifestCatalogue("/path/to/specs")
|
|
9
|
+
catalogue.load() # initial load
|
|
10
|
+
changed = catalogue.poll() # check for changes, returns list of changed service names
|
|
11
|
+
manifest = catalogue.get("fraud-detect")
|
|
12
|
+
all_manifests = catalogue.list_all()
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import structlog
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from nthlayer_common.manifest import (
|
|
23
|
+
ManifestLoadError,
|
|
24
|
+
load_manifest,
|
|
25
|
+
)
|
|
26
|
+
from nthlayer_common.manifest.models import ReliabilityManifest
|
|
27
|
+
|
|
28
|
+
logger = structlog.get_logger()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ManifestCatalogue:
|
|
32
|
+
"""In-memory manifest catalogue with file-system change detection.
|
|
33
|
+
|
|
34
|
+
Loads all .yaml/.yml files from a directory, parses them as manifests,
|
|
35
|
+
and caches the parsed results keyed by service name. Polling checks
|
|
36
|
+
file mtimes and reloads changed files.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, manifests_dir: str | Path | None = None):
|
|
40
|
+
self._dir: Path | None = None
|
|
41
|
+
if manifests_dir is not None:
|
|
42
|
+
self._dir = Path(manifests_dir).resolve()
|
|
43
|
+
self._manifests: dict[str, ReliabilityManifest] = {}
|
|
44
|
+
self._mtimes: dict[str, float] = {} # path → mtime
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def directory(self) -> Path | None:
|
|
48
|
+
return self._dir
|
|
49
|
+
|
|
50
|
+
def load(self) -> list[str]:
|
|
51
|
+
"""Load all manifests from the directory. Returns list of service names loaded."""
|
|
52
|
+
if self._dir is None or not self._dir.is_dir():
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
loaded = []
|
|
56
|
+
for yaml_file in sorted(self._dir.glob("*.yaml")) + sorted(self._dir.glob("*.yml")):
|
|
57
|
+
name = self._load_file(yaml_file)
|
|
58
|
+
if name:
|
|
59
|
+
loaded.append(name)
|
|
60
|
+
|
|
61
|
+
logger.info("catalogue_loaded", count=len(loaded), directory=str(self._dir))
|
|
62
|
+
return loaded
|
|
63
|
+
|
|
64
|
+
def poll(self) -> list[str]:
|
|
65
|
+
"""Check for changed/new/deleted manifest files. Returns list of changed service names."""
|
|
66
|
+
if self._dir is None or not self._dir.is_dir():
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
changed: list[str] = []
|
|
70
|
+
current_files: set[str] = set()
|
|
71
|
+
|
|
72
|
+
for yaml_file in sorted(self._dir.glob("*.yaml")) + sorted(self._dir.glob("*.yml")):
|
|
73
|
+
file_key = str(yaml_file)
|
|
74
|
+
current_files.add(file_key)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
mtime = yaml_file.stat().st_mtime
|
|
78
|
+
except OSError:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
if file_key not in self._mtimes or self._mtimes[file_key] < mtime:
|
|
82
|
+
name = self._load_file(yaml_file)
|
|
83
|
+
if name:
|
|
84
|
+
changed.append(name)
|
|
85
|
+
|
|
86
|
+
# Detect deleted files
|
|
87
|
+
deleted_files = set(self._mtimes.keys()) - current_files
|
|
88
|
+
for file_key in deleted_files:
|
|
89
|
+
# Find which service this file belonged to
|
|
90
|
+
for svc_name, manifest in list(self._manifests.items()):
|
|
91
|
+
if manifest.source_file == file_key:
|
|
92
|
+
del self._manifests[svc_name]
|
|
93
|
+
changed.append(svc_name)
|
|
94
|
+
break
|
|
95
|
+
del self._mtimes[file_key]
|
|
96
|
+
|
|
97
|
+
if changed:
|
|
98
|
+
logger.info("catalogue_changed", changed=changed)
|
|
99
|
+
|
|
100
|
+
return changed
|
|
101
|
+
|
|
102
|
+
def get(self, service_name: str) -> ReliabilityManifest | None:
|
|
103
|
+
"""Get a manifest by service name."""
|
|
104
|
+
return self._manifests.get(service_name)
|
|
105
|
+
|
|
106
|
+
def list_all(self) -> list[ReliabilityManifest]:
|
|
107
|
+
"""List all loaded manifests."""
|
|
108
|
+
return list(self._manifests.values())
|
|
109
|
+
|
|
110
|
+
def to_dict_list(self) -> list[dict[str, Any]]:
|
|
111
|
+
"""Serialise all manifests to a list of dicts for API responses."""
|
|
112
|
+
return [manifest_to_dict(m) for m in self._manifests.values()]
|
|
113
|
+
|
|
114
|
+
def _load_file(self, yaml_file: Path) -> str | None:
|
|
115
|
+
"""Load a single manifest file. Returns service name or None on failure."""
|
|
116
|
+
try:
|
|
117
|
+
mtime = yaml_file.stat().st_mtime # capture before parse to avoid TOCTOU
|
|
118
|
+
manifest = load_manifest(yaml_file, suppress_deprecation_warning=True)
|
|
119
|
+
self._manifests[manifest.name] = manifest
|
|
120
|
+
self._mtimes[str(yaml_file)] = mtime
|
|
121
|
+
return manifest.name
|
|
122
|
+
except (ManifestLoadError, FileNotFoundError, ValueError, OSError) as e:
|
|
123
|
+
logger.warning("catalogue_load_failed", file=str(yaml_file), error=str(e))
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def manifest_to_dict(m: ReliabilityManifest) -> dict[str, Any]:
|
|
128
|
+
"""Convert a ReliabilityManifest to a JSON-serialisable dict for the API."""
|
|
129
|
+
result: dict[str, Any] = {
|
|
130
|
+
"name": m.name,
|
|
131
|
+
"team": m.team,
|
|
132
|
+
"tier": m.tier,
|
|
133
|
+
"type": m.type,
|
|
134
|
+
"namespace": m.namespace,
|
|
135
|
+
"source_format": m.source_format.value,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if m.description:
|
|
139
|
+
result["description"] = m.description
|
|
140
|
+
if m.labels:
|
|
141
|
+
result["labels"] = m.labels
|
|
142
|
+
|
|
143
|
+
# SLOs
|
|
144
|
+
result["slos"] = []
|
|
145
|
+
for slo in m.slos:
|
|
146
|
+
slo_dict: dict[str, Any] = {
|
|
147
|
+
"name": slo.name,
|
|
148
|
+
"target": slo.target,
|
|
149
|
+
"slo_type": slo.slo_type,
|
|
150
|
+
"window": slo.window,
|
|
151
|
+
}
|
|
152
|
+
if slo.judgment_type:
|
|
153
|
+
slo_dict["judgment_type"] = slo.judgment_type
|
|
154
|
+
if slo.indicator_query:
|
|
155
|
+
slo_dict["indicator_query"] = slo.indicator_query
|
|
156
|
+
if slo.description:
|
|
157
|
+
slo_dict["description"] = slo.description
|
|
158
|
+
result["slos"].append(slo_dict)
|
|
159
|
+
|
|
160
|
+
# Dependencies
|
|
161
|
+
result["dependencies"] = [
|
|
162
|
+
{
|
|
163
|
+
"name": d.name,
|
|
164
|
+
"type": d.type,
|
|
165
|
+
"critical": d.critical,
|
|
166
|
+
}
|
|
167
|
+
for d in m.dependencies
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
# Contracts
|
|
171
|
+
if m.contracts:
|
|
172
|
+
result["contracts"] = [
|
|
173
|
+
{
|
|
174
|
+
"name": c.name,
|
|
175
|
+
"promise": {
|
|
176
|
+
"availability": c.promise.availability,
|
|
177
|
+
"latency_p99": c.promise.latency_p99,
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
for c in m.contracts
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
return result
|
nthlayer_core/cli.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""NthLayer core CLI."""
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main():
|
|
7
|
+
parser = argparse.ArgumentParser(description="NthLayer core")
|
|
8
|
+
parser.add_argument("-V", "--version", action="version", version="%(prog)s 1.5.0a1")
|
|
9
|
+
sub = parser.add_subparsers(dest="command")
|
|
10
|
+
|
|
11
|
+
serve_parser = sub.add_parser("serve", help="Start the core HTTP server")
|
|
12
|
+
serve_parser.add_argument("--host", default="0.0.0.0")
|
|
13
|
+
serve_parser.add_argument("--port", type=int, default=8000)
|
|
14
|
+
|
|
15
|
+
args = parser.parse_args()
|
|
16
|
+
|
|
17
|
+
if args.command == "serve":
|
|
18
|
+
from nthlayer_core.server import run_server
|
|
19
|
+
|
|
20
|
+
run_server(host=args.host, port=args.port)
|
|
21
|
+
else:
|
|
22
|
+
parser.print_help()
|
|
23
|
+
sys.exit(1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if __name__ == "__main__":
|
|
27
|
+
main()
|