nthlayer-common 2.1.2__tar.gz → 3.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/PKG-INFO +1 -1
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/pyproject.toml +1 -1
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/__init__.py +8 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/openslo/parser.py +77 -4
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/parser/v1.py +23 -1
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/parser/v2.py +25 -20
- nthlayer_common-3.0.0/src/nthlayer_common/manifest/scan.py +375 -0
- nthlayer_common-3.0.0/src/nthlayer_common/manifest/target_validation.py +362 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/v1_compat.py +66 -34
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common.egg-info/PKG-INFO +1 -1
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common.egg-info/SOURCES.txt +2 -0
- nthlayer_common-3.0.0/tests/test_judgment_targets_against_spec_examples.py +167 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_manifest_models.py +16 -2
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_manifest_parser.py +128 -24
- nthlayer_common-3.0.0/tests/test_manifest_scan.py +862 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_manifest_service_type.py +2 -2
- nthlayer_common-3.0.0/tests/test_target_unit_equivalence.py +839 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_v1_to_v2_migration.py +28 -1
- nthlayer_common-2.1.2/src/nthlayer_common/manifest/scan.py +0 -177
- nthlayer_common-2.1.2/src/nthlayer_common/manifest/target_validation.py +0 -78
- nthlayer_common-2.1.2/tests/test_manifest_scan.py +0 -301
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/README.md +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/setup.cfg +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/__init__.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/api_client.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/clients/__init__.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/clients/base.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/clients/cortex.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/clients/mimir.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/clients/pagerduty.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/clients/slack.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/cloudevents.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/config.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/dependency_models.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/domain_models.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/errors.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/explanation.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/gate_models.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/governance_bridge/__init__.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/governance_bridge/emitter.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/governance_bridge/models.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/__init__.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/models.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/normalizer.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/ownership.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/ownership_providers/__init__.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/ownership_providers/backstage.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/ownership_providers/base.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/ownership_providers/kubernetes.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/ownership_providers/pagerduty.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/identity/resolver.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/llm.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/llm_structured.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/llm_stub.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/models.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/openslo/__init__.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/parser/__init__.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/parser/_shared.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/parser/loader.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/metrics.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/outcomes.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/overrides/__init__.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/overrides/ingestion.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/overrides/models.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/parsing.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/prompts.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/providers/__init__.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/providers/base.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/providers/grafana.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/providers/lock.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/providers/mimir.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/providers/pagerduty.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/providers/prometheus.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/providers/registry.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/py.typed +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/records/__init__.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/records/errors.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/records/hashing.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/records/models.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/records/sqlite_store.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/records/store.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/records/verdict_bridge.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/records/verification.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/slack.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/slack_web.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/slo_models.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/telemetry.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/tiers.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/verdicts/__init__.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/verdicts/core.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/verdicts/models.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/verdicts/serialise.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/verdicts/sqlite_store.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/verdicts/store.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common.egg-info/dependency_links.txt +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common.egg-info/requires.txt +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common.egg-info/top_level.txt +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_api_client.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_cloudevents.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_config.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_errors.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_explanation.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_governance_bridge.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_identity.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_llm.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_llm_structured.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_llm_stub.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_manifest_real_specs.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_manifest_v2_archetypes.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_metrics.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_models.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_outcomes.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_overrides.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_prompts.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_providers.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_slack.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_slack_web.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_target_validation.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_telemetry.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_tier_errors.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_tiers.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_verdicts_core.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_verdicts_models.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_verdicts_serialise.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_verdicts_sqlite_concurrency.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_verdicts_store.py +0 -0
- {nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/tests/test_verdicts_v15_fields.py +0 -0
|
@@ -75,8 +75,12 @@ from nthlayer_common.manifest.parser.v1 import OpenSRMParseError
|
|
|
75
75
|
from nthlayer_common.manifest.parser.v2 import OpenSRMV2ParseError
|
|
76
76
|
from nthlayer_common.manifest.scan import (
|
|
77
77
|
MANIFEST_SUFFIXES,
|
|
78
|
+
ManifestCollisionWarning,
|
|
79
|
+
ManifestScan,
|
|
80
|
+
SuffixCollision,
|
|
78
81
|
foreign_yaml_reason,
|
|
79
82
|
iter_manifest_files,
|
|
83
|
+
scan_manifest_files,
|
|
80
84
|
)
|
|
81
85
|
|
|
82
86
|
__all__ = [
|
|
@@ -102,8 +106,12 @@ __all__ = [
|
|
|
102
106
|
"valid_service_types_phrase",
|
|
103
107
|
# Directory scanning
|
|
104
108
|
"MANIFEST_SUFFIXES",
|
|
109
|
+
"ManifestCollisionWarning",
|
|
110
|
+
"ManifestScan",
|
|
111
|
+
"SuffixCollision",
|
|
105
112
|
"foreign_yaml_reason",
|
|
106
113
|
"iter_manifest_files",
|
|
114
|
+
"scan_manifest_files",
|
|
107
115
|
# Enums
|
|
108
116
|
"DependencyCriticality",
|
|
109
117
|
"SourceFormat",
|
{nthlayer_common-2.1.2 → nthlayer_common-3.0.0}/src/nthlayer_common/manifest/openslo/parser.py
RENAMED
|
@@ -16,6 +16,10 @@ from typing import Any
|
|
|
16
16
|
import yaml
|
|
17
17
|
|
|
18
18
|
from nthlayer_common.manifest.models import SLODefinition
|
|
19
|
+
from nthlayer_common.manifest.target_validation import (
|
|
20
|
+
check_finite,
|
|
21
|
+
require_number,
|
|
22
|
+
)
|
|
19
23
|
|
|
20
24
|
|
|
21
25
|
class OpenSLOParseError(Exception):
|
|
@@ -114,9 +118,7 @@ def _parse_openslo_document(data: dict[str, Any]) -> SLODefinition:
|
|
|
114
118
|
raise OpenSLOParseError(f"OpenSLO '{name}' has no objectives")
|
|
115
119
|
|
|
116
120
|
objective = objectives[0]
|
|
117
|
-
target = objective
|
|
118
|
-
if target is None:
|
|
119
|
-
raise OpenSLOParseError(f"OpenSLO '{name}' objective missing target")
|
|
121
|
+
target = _objective_target_percent(objective, name)
|
|
120
122
|
|
|
121
123
|
# Parse indicator
|
|
122
124
|
indicator = spec.get("indicator", {})
|
|
@@ -182,8 +184,11 @@ def _parse_openslo_document(data: dict[str, Any]) -> SLODefinition:
|
|
|
182
184
|
description = objective.get("displayName") or metadata.get("displayName")
|
|
183
185
|
|
|
184
186
|
return SLODefinition(
|
|
187
|
+
# No float(): _objective_target_percent already returns one, having gone
|
|
188
|
+
# through require_number. The redundant cast was a leftover from when
|
|
189
|
+
# this read objective["target"] directly [opensrm-ocvu].
|
|
185
190
|
name=name,
|
|
186
|
-
target=
|
|
191
|
+
target=target,
|
|
187
192
|
slo_type=slo_type,
|
|
188
193
|
window=window,
|
|
189
194
|
unit=unit,
|
|
@@ -195,6 +200,74 @@ def _parse_openslo_document(data: dict[str, Any]) -> SLODefinition:
|
|
|
195
200
|
)
|
|
196
201
|
|
|
197
202
|
|
|
203
|
+
def _objective_target_percent(objective: dict[str, Any], name: str) -> float:
|
|
204
|
+
"""An OpenSLO objective's target, as the canonical 0-100 percentage.
|
|
205
|
+
|
|
206
|
+
OpenSLO accepts EITHER ``target`` (a fraction in [0,1)) OR ``targetPercent``
|
|
207
|
+
(0-100), exactly one of them. NthLayer's internal convention is 0-100 for
|
|
208
|
+
every consumer (nthlayer-common CLAUDE.md hard rule 1), so a fraction is
|
|
209
|
+
converted here — at the inbound boundary — and a percentage is taken as-is.
|
|
210
|
+
|
|
211
|
+
This boundary was missing entirely [opensrm-ocvu]. The outbound one has
|
|
212
|
+
always existed (nthlayer_generate/slos/pipeline.py divides by 100), so a
|
|
213
|
+
ratio reached SLODefinition.target unconverted and every consumer comparing
|
|
214
|
+
a target to a measured value got an answer 100x out, silently, depending
|
|
215
|
+
only on which format the manifest happened to be written in.
|
|
216
|
+
|
|
217
|
+
It also broke the documented v1 -> v2 migration: v1_compat converts a v1
|
|
218
|
+
percentage to a ratio for the OpenSLO document it emits, and reading that
|
|
219
|
+
back without converting turned a 99.9 target into 0.999. The module
|
|
220
|
+
promised the output "round-trips through parse_opensrm_v2"; it did not.
|
|
221
|
+
"""
|
|
222
|
+
has_target = "target" in objective and objective["target"] is not None
|
|
223
|
+
has_percent = (
|
|
224
|
+
"targetPercent" in objective and objective["targetPercent"] is not None
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if has_target and has_percent:
|
|
228
|
+
# OpenSLO requires exactly one. Picking a winner here would let two
|
|
229
|
+
# disagreeing values sit in a manifest with only one taking effect.
|
|
230
|
+
raise OpenSLOParseError(
|
|
231
|
+
f"OpenSLO '{name}' objective sets both target and targetPercent; "
|
|
232
|
+
f"OpenSLO permits exactly one"
|
|
233
|
+
)
|
|
234
|
+
# Both branches go through require_number + check_finite, the same guards
|
|
235
|
+
# the JUDGMENT boundary uses, re-raised as this parser's own error type
|
|
236
|
+
# [opensrm-ocvu]. Without them this function — which this bead added — had
|
|
237
|
+
# the two defects the judgment side had already been fixed for:
|
|
238
|
+
#
|
|
239
|
+
# target: .nan -> SLODefinition.target = nan, and with a contract
|
|
240
|
+
# promise validate_contracts() returned [], a breach
|
|
241
|
+
# reporting CLEAN. Identical outcome, identical field.
|
|
242
|
+
# target: {} -> bare TypeError out of float(), which is not a type
|
|
243
|
+
# this parser declares.
|
|
244
|
+
#
|
|
245
|
+
# RANGE is deliberately NOT checked here. `targetPercent: 50` meaning "50%"
|
|
246
|
+
# versus `target: 50` meaning a 5000% floor is a real ambiguity, but
|
|
247
|
+
# rejecting it would also reject `target` values this parser accepts today,
|
|
248
|
+
# and plain `target` has been unranged since long before this bead. That is
|
|
249
|
+
# a scoped decision, not a fix to make inside one.
|
|
250
|
+
try:
|
|
251
|
+
if has_percent:
|
|
252
|
+
value = require_number(
|
|
253
|
+
"targetPercent", objective["targetPercent"],
|
|
254
|
+
what="objective target",
|
|
255
|
+
)
|
|
256
|
+
check_finite("targetPercent", value, what="objective target")
|
|
257
|
+
return value
|
|
258
|
+
if has_target:
|
|
259
|
+
value = require_number(
|
|
260
|
+
"target", objective["target"], what="objective target",
|
|
261
|
+
)
|
|
262
|
+
check_finite("target", value, what="objective target")
|
|
263
|
+
return value * 100.0
|
|
264
|
+
except ValueError as exc:
|
|
265
|
+
raise OpenSLOParseError(f"OpenSLO '{name}': {exc}") from exc
|
|
266
|
+
raise OpenSLOParseError(
|
|
267
|
+
f"OpenSLO '{name}' objective missing target (or targetPercent)"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
198
271
|
def _extract_query(metric_source: dict[str, Any]) -> str | None:
|
|
199
272
|
"""Extract PromQL query from an OpenSLO metricSource."""
|
|
200
273
|
ms_type = metric_source.get("type", "")
|
|
@@ -42,6 +42,10 @@ from nthlayer_common.manifest.models import (
|
|
|
42
42
|
TelemetryEvent,
|
|
43
43
|
)
|
|
44
44
|
from nthlayer_common.manifest.parser._shared import parse_observability
|
|
45
|
+
from nthlayer_common.manifest.target_validation import (
|
|
46
|
+
check_finite,
|
|
47
|
+
require_number,
|
|
48
|
+
)
|
|
45
49
|
from nthlayer_common.manifest.v1_compat import (
|
|
46
50
|
convert_v1_contract,
|
|
47
51
|
default_measurement,
|
|
@@ -287,9 +291,27 @@ def _parse_slos(
|
|
|
287
291
|
measurement = default_measurement(judgment_type, window)
|
|
288
292
|
statistical_requirements = default_statistical_requirements(judgment_type)
|
|
289
293
|
|
|
294
|
+
# Guarded the same way as the v2 classical path [opensrm-ocvu]. v1 was
|
|
295
|
+
# the LAST unguarded writer of SLODefinition.target, and leaving it so
|
|
296
|
+
# would have recreated this bead's own subject one level down: v2
|
|
297
|
+
# classical rejecting a NaN target while v1 classical accepted it.
|
|
298
|
+
# Measured before the guard — `slos: {availability: {target: .nan}}`
|
|
299
|
+
# with a contract gave target=nan and validate_contracts() -> [], a
|
|
300
|
+
# breach reporting CLEAN; `target: {}` gave a bare TypeError.
|
|
301
|
+
#
|
|
302
|
+
# RANGE is not checked, matching the v2 classical path exactly: plain
|
|
303
|
+
# targets have been unranged in both formats since long before this
|
|
304
|
+
# bead, and rejecting out-of-range would reject manifests that parse
|
|
305
|
+
# today. That remains a scoped decision.
|
|
306
|
+
try:
|
|
307
|
+
numeric_target = require_number(name, target, what="SLO target")
|
|
308
|
+
check_finite(name, numeric_target, what="SLO target")
|
|
309
|
+
except ValueError as exc:
|
|
310
|
+
raise OpenSRMParseError(f"SLO '{name}': {exc}") from exc
|
|
311
|
+
|
|
290
312
|
slo = SLODefinition(
|
|
291
313
|
name=name,
|
|
292
|
-
target=
|
|
314
|
+
target=numeric_target,
|
|
293
315
|
slo_type=slo_type,
|
|
294
316
|
window=config.get("window", "30d"),
|
|
295
317
|
unit=config.get("unit"),
|
|
@@ -28,7 +28,6 @@ from nthlayer_common.manifest.models import (
|
|
|
28
28
|
FallbackDeclaration,
|
|
29
29
|
Instrumentation,
|
|
30
30
|
JudgmentMeasurement,
|
|
31
|
-
JudgmentPromise,
|
|
32
31
|
Outcomes,
|
|
33
32
|
Ownership,
|
|
34
33
|
ProbeConfig,
|
|
@@ -53,6 +52,11 @@ from nthlayer_common.manifest.openslo.parser import (
|
|
|
53
52
|
parse_openslo_slos,
|
|
54
53
|
)
|
|
55
54
|
from nthlayer_common.manifest.parser._shared import parse_observability
|
|
55
|
+
from nthlayer_common.manifest.target_validation import (
|
|
56
|
+
JUDGMENT_TARGET_FIELDS,
|
|
57
|
+
judgment_promise,
|
|
58
|
+
judgment_target_percent,
|
|
59
|
+
)
|
|
56
60
|
|
|
57
61
|
logger = structlog.get_logger()
|
|
58
62
|
|
|
@@ -410,19 +414,7 @@ def _extract_judgment_target(
|
|
|
410
414
|
|
|
411
415
|
Each judgment type has a different target field name in the spec.
|
|
412
416
|
"""
|
|
413
|
-
|
|
414
|
-
target_fields: dict[str, str] = {
|
|
415
|
-
"reversal_rate": "maximum_reversal_rate",
|
|
416
|
-
"high_confidence_failure": "maximum_failure_rate",
|
|
417
|
-
"audit_sampling": "audit_completion_rate",
|
|
418
|
-
"outcomes": "desired_outcome_rate",
|
|
419
|
-
"escalation": "maximum_escalation_rate",
|
|
420
|
-
"segments": "maximum_variance_from_overall",
|
|
421
|
-
"stability": "maximum_drift",
|
|
422
|
-
"calibration": "maximum_brier_score",
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
field_name = target_fields.get(judgment_type)
|
|
417
|
+
field_name = JUDGMENT_TARGET_FIELDS.get(judgment_type)
|
|
426
418
|
if not field_name:
|
|
427
419
|
raise OpenSRMV2ParseError(
|
|
428
420
|
f"No target field mapping for judgment_type '{judgment_type}'"
|
|
@@ -434,7 +426,15 @@ def _extract_judgment_target(
|
|
|
434
426
|
f"Judgment SLO '{name}' missing target.{field_name}"
|
|
435
427
|
)
|
|
436
428
|
|
|
437
|
-
|
|
429
|
+
# No float() here — judgment_target_percent validates the type itself and
|
|
430
|
+
# raises ValueError, whereas float() raised TypeError for None / dict /
|
|
431
|
+
# list, a type this parser does not declare. Re-raised as this parser's own
|
|
432
|
+
# error so a malformed target is indistinguishable in KIND from a missing
|
|
433
|
+
# one, which is raised above [opensrm-ocvu].
|
|
434
|
+
try:
|
|
435
|
+
return judgment_target_percent(field_name, value)
|
|
436
|
+
except ValueError as exc:
|
|
437
|
+
raise OpenSRMV2ParseError(f"Judgment SLO '{name}': {exc}") from exc
|
|
438
438
|
|
|
439
439
|
|
|
440
440
|
def _parse_judgment_measurement(
|
|
@@ -556,11 +556,16 @@ def _parse_contracts(contracts_data: list[dict[str, Any]]) -> list[ReliabilityCo
|
|
|
556
556
|
promise_data = c_data.get("promise", {})
|
|
557
557
|
judgment_promises = []
|
|
558
558
|
for jtype, threshold in promise_data.get("judgment", {}).items():
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
559
|
+
# Threshold and direction decided together, in one place shared with
|
|
560
|
+
# the v1 path — see judgment_promise() [opensrm-ocvu]. Re-raised as
|
|
561
|
+
# this parser's own error type; a `judgment: {reversal_rate:}` typo
|
|
562
|
+
# otherwise escaped as a bare TypeError.
|
|
563
|
+
try:
|
|
564
|
+
judgment_promises.append(judgment_promise(jtype, threshold))
|
|
565
|
+
except ValueError as exc:
|
|
566
|
+
raise OpenSRMV2ParseError(
|
|
567
|
+
f"Contract '{name}' promise: {exc}"
|
|
568
|
+
) from exc
|
|
564
569
|
|
|
565
570
|
promise = ContractPromise(
|
|
566
571
|
availability=promise_data.get("availability"),
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Scanning a directory that holds manifests alongside other YAML.
|
|
2
|
+
|
|
3
|
+
``load_manifest`` raises ``ManifestLoadError`` for any YAML it cannot parse
|
|
4
|
+
as a manifest, including files that never claimed to be one. A caller
|
|
5
|
+
walking a specs directory therefore has to answer a question the parser
|
|
6
|
+
does not: was this file *aiming* to be a manifest and failing, or is it
|
|
7
|
+
something else sharing the directory?
|
|
8
|
+
|
|
9
|
+
Both wrong answers are real bugs, and both have happened:
|
|
10
|
+
|
|
11
|
+
- Treat everything as a broken manifest, and a "computed over a subset"
|
|
12
|
+
caveat fires on every mixed-directory run. A caveat that always fires
|
|
13
|
+
stops being read.
|
|
14
|
+
- Treat nothing as one, and a broken manifest drops out of a financial
|
|
15
|
+
figure or a set of measured SLOs without a word (opensrm-oh27,
|
|
16
|
+
opensrm-3470).
|
|
17
|
+
|
|
18
|
+
Promoted here from ``nthlayer_workers.learn.retrospective`` once a third
|
|
19
|
+
consumer appeared, so the call sites stop reaching into one another
|
|
20
|
+
(opensrm-3470).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import unicodedata
|
|
26
|
+
import warnings
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import NamedTuple
|
|
29
|
+
|
|
30
|
+
import yaml
|
|
31
|
+
|
|
32
|
+
from nthlayer_common.manifest.models import is_valid_service_type
|
|
33
|
+
from nthlayer_common.manifest.parser.v1 import is_srm_v1_format
|
|
34
|
+
from nthlayer_common.manifest.parser.v2 import is_opensrm_v2_format
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ManifestCollisionWarning(UserWarning):
|
|
38
|
+
"""Two manifest files shared a stem, so one was not loaded [opensrm-xvwt].
|
|
39
|
+
|
|
40
|
+
A warning rather than a structlog event, because this is a LIBRARY.
|
|
41
|
+
nthlayer-common never calls structlog.configure, so the default
|
|
42
|
+
PrintLogger writes to STDOUT — measured — and a consumer CLI emitting
|
|
43
|
+
machine-readable output on stdout would have a human-formatted log line
|
|
44
|
+
interleaved into it. warnings go to stderr, are filterable by category,
|
|
45
|
+
and are the pattern this package already uses for an authoring mistake it
|
|
46
|
+
should flag loudly without rejecting: see TargetConventionWarning in
|
|
47
|
+
target_validation.py.
|
|
48
|
+
|
|
49
|
+
The STRUCTURED form of the same information is scan_manifest_files()'
|
|
50
|
+
return value, which is what a caller that wants to act on it should use.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# One home for the wording, and it names the actual risk rather than the
|
|
55
|
+
# mechanism: an operator does not care that two stems matched, they care that a
|
|
56
|
+
# service they declared is not being measured.
|
|
57
|
+
_COLLISION_HINT = (
|
|
58
|
+
"If they declare DIFFERENT services, rename one: the dropped file is not "
|
|
59
|
+
"being measured."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Suffixes a manifest may carry. Both, always: `.yml` invisibility is the
|
|
63
|
+
# same silent-subset failure as an uncounted parse error, reached by file
|
|
64
|
+
# extension instead — the manifest is dropped and nothing says so.
|
|
65
|
+
MANIFEST_SUFFIXES = (".yaml", ".yml")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _resolve_collision(group: list[Path]) -> tuple[Path, list[Path]]:
|
|
69
|
+
"""Which of several same-stem manifests to keep, and which to report.
|
|
70
|
+
|
|
71
|
+
A separate named function so the rule can be exercised WITHOUT a
|
|
72
|
+
filesystem. The three-way case needs `svc.yaml` and `svc.YAML` to coexist,
|
|
73
|
+
which a case-insensitive filesystem cannot do — so a temp-directory
|
|
74
|
+
fixture skipped on macOS and ran in Linux CI, and the skip hid an
|
|
75
|
+
assertion that was wrong. See
|
|
76
|
+
tests/test_manifest_scan.py::test_collision_precedence_is_deterministic.
|
|
77
|
+
|
|
78
|
+
Three ranks, in order:
|
|
79
|
+
|
|
80
|
+
1. MANIFEST_SUFFIXES order IS the precedence, so the rule and the tuple
|
|
81
|
+
cannot drift apart. ``.yaml`` is what the ecosystem writes, and that is
|
|
82
|
+
counted rather than asserted: across nthlayer/demo and opensrm/spec,
|
|
83
|
+
46 manifests are ``.yaml`` and 0 are ``.yml`` or a case variant.
|
|
84
|
+
2. The EXACTLY-lowercase spelling beats a case variant. Without this rank
|
|
85
|
+
the name tiebreak kept ``svc.YAML`` over ``svc.yaml``, because
|
|
86
|
+
``"svc.YAML" < "svc.yaml"`` in ASCII.
|
|
87
|
+
3. Name, so the choice is never filesystem-order dependent.
|
|
88
|
+
|
|
89
|
+
PRECONDITION: every path's case-folded suffix is in MANIFEST_SUFFIXES.
|
|
90
|
+
scan_manifest_files() guarantees it by filtering on the same key; a direct
|
|
91
|
+
caller that does not will get ValueError from .index(), which is the right
|
|
92
|
+
outcome for a programming error but is not a guard.
|
|
93
|
+
"""
|
|
94
|
+
kept, *dropped = sorted(
|
|
95
|
+
group,
|
|
96
|
+
key=lambda p: (
|
|
97
|
+
MANIFEST_SUFFIXES.index(p.suffix.lower()),
|
|
98
|
+
0 if p.suffix == p.suffix.lower() else 1,
|
|
99
|
+
p.name,
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
return kept, dropped
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SuffixCollision(NamedTuple):
|
|
106
|
+
"""One stem declared twice, once per manifest suffix [opensrm-xvwt].
|
|
107
|
+
|
|
108
|
+
NamedTuple rather than the @dataclass this package uses for models: these
|
|
109
|
+
two are a return shape, not a domain object — no validation, no behaviour,
|
|
110
|
+
and callers unpack them positionally.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
stem: str
|
|
114
|
+
kept: Path
|
|
115
|
+
dropped: Path
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ManifestScan(NamedTuple):
|
|
119
|
+
"""What a directory scan found, and what it had to choose between."""
|
|
120
|
+
|
|
121
|
+
files: list[Path]
|
|
122
|
+
suffix_collisions: list[SuffixCollision]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def scan_manifest_files(specs_dir: str | Path) -> ManifestScan:
|
|
126
|
+
"""Manifest files under *specs_dir*, with same-stem collisions resolved.
|
|
127
|
+
|
|
128
|
+
A directory holding BOTH ``svc.yaml`` and ``svc.yml`` — the ordinary
|
|
129
|
+
result of an editor default changing, or two people adding the same
|
|
130
|
+
service — previously yielded both [opensrm-xvwt]. Neither is foreign
|
|
131
|
+
YAML, so foreign_yaml_reason() passes them and both parse, and the
|
|
132
|
+
consequences were silent in two places:
|
|
133
|
+
|
|
134
|
+
- measure's load_specs appended the service's SLOs twice, so
|
|
135
|
+
evaluate_slos wrote two verdicts per window for one service/SLO. Since
|
|
136
|
+
count_consecutive_breaches counts VERDICTS rather than windows, a
|
|
137
|
+
3-window hysteresis threshold was reached in 2 real windows or fewer.
|
|
138
|
+
A judgment SLO firing early is the same class of wrong as one that
|
|
139
|
+
never fires.
|
|
140
|
+
- observe's per-service SLO counts, and any budget arithmetic derived
|
|
141
|
+
from them, were inflated.
|
|
142
|
+
|
|
143
|
+
Nothing errored and nothing warned, because both files are valid
|
|
144
|
+
manifests.
|
|
145
|
+
|
|
146
|
+
``.yaml`` wins, deterministically — it is the suffix the ecosystem
|
|
147
|
+
writes — with the name as tiebreak so the choice cannot vary by
|
|
148
|
+
filesystem order. The dropped path is RETURNED rather than only logged,
|
|
149
|
+
which is the same reason opensrm-3470 added a parse-failure report: this
|
|
150
|
+
is a partial view of the directory, and the operator reading SLO output
|
|
151
|
+
is who needs to know a manifest was set aside.
|
|
152
|
+
|
|
153
|
+
STEM, not parsed service name. Two files can declare the same service
|
|
154
|
+
under unrelated names, and scan.py deliberately does not parse — that is
|
|
155
|
+
what foreign_yaml_reason() exists to avoid. So this catches the common
|
|
156
|
+
shape the bead describes and not every possible duplicate; a caller that
|
|
157
|
+
needs the stronger property has to compare parsed names itself.
|
|
158
|
+
"""
|
|
159
|
+
path = Path(specs_dir)
|
|
160
|
+
if not path.is_dir():
|
|
161
|
+
return ManifestScan([], [])
|
|
162
|
+
|
|
163
|
+
# `not p.is_dir()` rather than `p.is_file()`: excluding DIRECTORIES was
|
|
164
|
+
# the point — a directory named `foo.yaml` would be counted as a broken
|
|
165
|
+
# manifest by every caller. is_file() also excludes a dangling symlink,
|
|
166
|
+
# which silently drops a stale overlay link that previously reached
|
|
167
|
+
# load_manifest and was counted.
|
|
168
|
+
#
|
|
169
|
+
# Suffix compared case-folded: `.YAML` was excluded outright — never
|
|
170
|
+
# loaded, never counted, never logged — which is the silent-subset-by-
|
|
171
|
+
# extension failure MANIFEST_SUFFIXES exists to prevent, one case-fold
|
|
172
|
+
# away.
|
|
173
|
+
# This sort is now BELT-AND-BRACES, kept rather than removed. Both return
|
|
174
|
+
# values are re-sorted below and _resolve_collision sorts its own group, so
|
|
175
|
+
# removing it changes nothing — proven, not assumed: a differential over
|
|
176
|
+
# 4,000 random directory shapes x 3 orderings found zero output
|
|
177
|
+
# differences. It stays because iterdir order is arbitrary and a future
|
|
178
|
+
# reader should not have to re-derive that the rest of this function is
|
|
179
|
+
# order-independent.
|
|
180
|
+
candidates = sorted(
|
|
181
|
+
p
|
|
182
|
+
for p in path.iterdir()
|
|
183
|
+
if not p.is_dir() and p.suffix.lower() in MANIFEST_SUFFIXES
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
by_stem: dict[str, list[Path]] = {}
|
|
187
|
+
for candidate in candidates:
|
|
188
|
+
# NFC-normalised, for the same reason the suffix is case-folded: two
|
|
189
|
+
# spellings of one name must not become two services. APFS PRESERVES
|
|
190
|
+
# normalisation rather than enforcing it, so `café.yaml` written NFC
|
|
191
|
+
# and `café.yml` written NFD are two directory entries with different
|
|
192
|
+
# byte stems — visually one stem. Measured before this: both were
|
|
193
|
+
# returned and no collision reported, so the double-count this function
|
|
194
|
+
# exists to stop survived for any non-ASCII filename.
|
|
195
|
+
# The stem is NOT case-folded, deliberately, while the suffix is. A
|
|
196
|
+
# suffix is a format marker, so `.YAML` and `.yaml` mean the same
|
|
197
|
+
# thing; a stem is a name, and `SVC.yaml` beside `svc.yml` is two
|
|
198
|
+
# names an operator can tell apart. Both of the bead's shapes — an
|
|
199
|
+
# editor default changing, two people adding the same service — produce
|
|
200
|
+
# an IDENTICAL stem, so folding case here would merge files that were
|
|
201
|
+
# never one service to catch a case that does not occur.
|
|
202
|
+
by_stem.setdefault(
|
|
203
|
+
unicodedata.normalize("NFC", candidate.stem), []
|
|
204
|
+
).append(candidate)
|
|
205
|
+
|
|
206
|
+
files: list[Path] = []
|
|
207
|
+
collisions: list[SuffixCollision] = []
|
|
208
|
+
for stem, group in by_stem.items():
|
|
209
|
+
if len(group) == 1:
|
|
210
|
+
files.append(group[0])
|
|
211
|
+
continue
|
|
212
|
+
kept, dropped = _resolve_collision(group)
|
|
213
|
+
files.append(kept)
|
|
214
|
+
collisions.extend(SuffixCollision(stem, kept, d) for d in dropped)
|
|
215
|
+
|
|
216
|
+
return ManifestScan(
|
|
217
|
+
sorted(files),
|
|
218
|
+
sorted(collisions, key=lambda c: (c.stem, c.dropped.name)),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def iter_manifest_files(specs_dir: str | Path) -> list[Path]:
|
|
223
|
+
"""Manifest files under ``specs_dir``, sorted, ONE PER STEM.
|
|
224
|
+
|
|
225
|
+
A lossy view of scan_manifest_files(): same files, collisions raised as a
|
|
226
|
+
ManifestCollisionWarning rather than returned. Its signature is fixed — three consumers in
|
|
227
|
+
nthlayer-workers call it — so the de-duplication had to arrive without one,
|
|
228
|
+
and that is why this exists alongside the fuller function rather than
|
|
229
|
+
instead of it. A caller that should SURFACE a set-aside file to an operator
|
|
230
|
+
wants scan_manifest_files().
|
|
231
|
+
|
|
232
|
+
IT WARNS ONCE PER PROCESS, NOT ONCE PER SCAN. Python's default filter
|
|
233
|
+
dedupes on (message, category, module, lineno), and with stacklevel=2 the
|
|
234
|
+
key is the CALLER's line — so a worker re-scanning the same directory every
|
|
235
|
+
window warns on the first window and is silent thereafter. Quieter, but an
|
|
236
|
+
operator who misses the first one never sees it again, which is why the
|
|
237
|
+
three consumers should move to scan_manifest_files() and surface the
|
|
238
|
+
collision themselves: opensrm-j9wq.
|
|
239
|
+
|
|
240
|
+
NOTHING DROPS SILENTLY, and that is not belt-and-braces: grouping is by
|
|
241
|
+
stem, so a collision can be two DIFFERENT services rather than a duplicate,
|
|
242
|
+
and dropping one of those without a trace is the silent-subset failure this
|
|
243
|
+
module exists to prevent (opensrm-oh27, opensrm-3470). See
|
|
244
|
+
scan_manifest_files() for the full argument.
|
|
245
|
+
|
|
246
|
+
Sorted so the order is stable across machines. That matters to any caller
|
|
247
|
+
that dedupes first-wins — learn/retrospective does; observe's spec_loader
|
|
248
|
+
does not, and whether it should is opensrm-3470's follow-up rather than a
|
|
249
|
+
property this function can assume.
|
|
250
|
+
|
|
251
|
+
Returns empty for a path that is not a directory rather than raising, so a
|
|
252
|
+
caller that wants its own error message for that case keeps the decision.
|
|
253
|
+
"""
|
|
254
|
+
scan = scan_manifest_files(specs_dir)
|
|
255
|
+
for collision in scan.suffix_collisions:
|
|
256
|
+
warnings.warn(
|
|
257
|
+
f"{collision.dropped} was not loaded: it shares the stem "
|
|
258
|
+
f"'{collision.stem}' with {collision.kept.name}. "
|
|
259
|
+
f"{_COLLISION_HINT}",
|
|
260
|
+
ManifestCollisionWarning,
|
|
261
|
+
# 2 = this function's caller. Measured, not copied: a deeper value
|
|
262
|
+
# names scan.py itself, which tells the reader nothing about which
|
|
263
|
+
# directory scan produced it.
|
|
264
|
+
stacklevel=2,
|
|
265
|
+
)
|
|
266
|
+
return scan.files
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def foreign_yaml_reason(spec_file: str | Path) -> str | None:
|
|
270
|
+
"""Why a file that failed to load should NOT count as a broken manifest.
|
|
271
|
+
|
|
272
|
+
Returns ``None`` when the file was aiming at being a manifest — so the
|
|
273
|
+
caller counts it — or a short reason when it plainly was not, so the
|
|
274
|
+
caller can drop it and log the reason rather than dropping it silently.
|
|
275
|
+
|
|
276
|
+
Evidence of intent is checked in three widening steps: the canonical
|
|
277
|
+
v1/v2 format predicates, so this cannot drift from what the parser
|
|
278
|
+
accepts; then a near miss on those headers, since a typo'd kind or a
|
|
279
|
+
drifted API group is an ordinary way a real manifest breaks; then the
|
|
280
|
+
body, since a bad merge or a mis-indent can strip the header while
|
|
281
|
+
leaving ``spec.service`` (as a *mapping*), ``spec.slos`` or
|
|
282
|
+
``spec.outcomes`` intact.
|
|
283
|
+
|
|
284
|
+
``spec.service`` as a mapping is what separates a headerless manifest
|
|
285
|
+
from a headerless OpenSLO document, which writes the same key as a
|
|
286
|
+
plain string. Anything unreadable or empty counts too — a syntax error
|
|
287
|
+
or a truncated write inside a specs directory is a deployment error
|
|
288
|
+
either way.
|
|
289
|
+
|
|
290
|
+
LIMIT, stated rather than deferred: this recovers intent only while
|
|
291
|
+
evidence of intent survives. A file that has lost both its header and
|
|
292
|
+
its body is indistinguishable in principle from any other headerless
|
|
293
|
+
YAML mapping — both are dicts with no apiVersion, no kind, no
|
|
294
|
+
``spec.service``. No content heuristic separates those, and adding more
|
|
295
|
+
markers would not change it. For such a file this returns a reason, so
|
|
296
|
+
the caller can record it rather than drop it without trace — which is
|
|
297
|
+
what lets an investigator chasing a number that looks wrong pick the
|
|
298
|
+
trail back up.
|
|
299
|
+
"""
|
|
300
|
+
path = Path(spec_file)
|
|
301
|
+
try:
|
|
302
|
+
data = yaml.safe_load(path.read_text())
|
|
303
|
+
except (OSError, ValueError, yaml.YAMLError):
|
|
304
|
+
# ValueError covers UnicodeDecodeError on non-UTF-8 bytes. Too
|
|
305
|
+
# malformed to inspect, so it counts. A read failing here where
|
|
306
|
+
# load_manifest's read succeeded also means the file moved
|
|
307
|
+
# underneath us, which is not evidence it was foreign.
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
if not data:
|
|
311
|
+
# None (zero bytes, whitespace, comments) or an empty container. A
|
|
312
|
+
# truncated or interrupted write is precisely what the count exists
|
|
313
|
+
# for.
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
if not isinstance(data, dict):
|
|
317
|
+
return f"top-level YAML is {type(data).__name__}, not a mapping"
|
|
318
|
+
|
|
319
|
+
# "service" at the top level is the legacy pre-apiVersion shape that
|
|
320
|
+
# load_manifest still accepts.
|
|
321
|
+
if is_opensrm_v2_format(data) or is_srm_v1_format(data) or "service" in data:
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
api_version = data.get("apiVersion")
|
|
325
|
+
kind = data.get("kind")
|
|
326
|
+
near_miss = (
|
|
327
|
+
isinstance(api_version, str)
|
|
328
|
+
and (api_version.startswith("srm/") or "opensrm" in api_version)
|
|
329
|
+
) or (
|
|
330
|
+
isinstance(kind, str)
|
|
331
|
+
and kind.startswith(("ServiceManifest", "ServiceReliabilityManifest"))
|
|
332
|
+
)
|
|
333
|
+
if near_miss:
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
# Body recovery: a bad merge or a mis-indent can strip the header while
|
|
337
|
+
# leaving the body intact, and that is the common corruption.
|
|
338
|
+
#
|
|
339
|
+
# The key names differ by format, and getting them wrong makes this whole
|
|
340
|
+
# branch inert — which it was until opensrm-3470's edge-cases pass:
|
|
341
|
+
# v1 spec.slos a MAPPING of name -> config (parser/v1 does
|
|
342
|
+
# slos_data.items()), not a list
|
|
343
|
+
# v2 spec.slo a list of OpenSLO documents
|
|
344
|
+
# spec.judgment_slo a list
|
|
345
|
+
# spec.service a MAPPING; OpenSLO writes the same key as a
|
|
346
|
+
# plain string, which is what keeps OpenSLO out
|
|
347
|
+
# spec.outcomes a mapping
|
|
348
|
+
# v1 spec.type REQUIRED by parse_srm_v1 (v1.py:91), and
|
|
349
|
+
# checked against is_valid_service_type rather
|
|
350
|
+
# than "is a string": a Backstage catalog-info
|
|
351
|
+
# carries spec.type too (`service`, `library`),
|
|
352
|
+
# and counting those makes every Backstage
|
|
353
|
+
# Component in the directory a broken manifest
|
|
354
|
+
# v2 spec.owner REQUIRED by parse_opensrm_v2 (v2.py:104)
|
|
355
|
+
# spec.slos is accepted as either shape: v1 writes a mapping, and a
|
|
356
|
+
# partially-converted file is exactly the kind of thing that lands here.
|
|
357
|
+
#
|
|
358
|
+
# The required keys matter most: a file carrying the one key its format
|
|
359
|
+
# cannot be parsed without makes a stronger claim to being a manifest
|
|
360
|
+
# than any of the optional ones above. Checking only the optional keys
|
|
361
|
+
# was the same mistake as testing spec.slos for the wrong type —
|
|
362
|
+
# reasoning about shapes without asking what the parsers demand.
|
|
363
|
+
spec = data.get("spec")
|
|
364
|
+
if isinstance(spec, dict) and (
|
|
365
|
+
isinstance(spec.get("service"), dict)
|
|
366
|
+
or isinstance(spec.get("slos"), (dict, list))
|
|
367
|
+
or isinstance(spec.get("slo"), list)
|
|
368
|
+
or isinstance(spec.get("judgment_slo"), list)
|
|
369
|
+
or isinstance(spec.get("outcomes"), dict)
|
|
370
|
+
or is_valid_service_type(spec.get("type"))
|
|
371
|
+
or isinstance(spec.get("owner"), dict)
|
|
372
|
+
):
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
return f"no manifest markers (apiVersion={api_version!r}, kind={kind!r})"
|