Skip to content

Commit 7015dd7

Browse files
authored
SEP-1941: Add a diagnostics-delivery target to the connectivity check (#1441)
Adds a `delivery` target to `POST /api/sep/admin/connectivity-check/`, so an operator can test the configured diagnostics-delivery receiver without sending a bundle. It fans out alongside the existing four targets and is isolated the same way — a delivery failure never fails the response or the other targets. **The probe request is declared on the delivery plan, not derived from its send steps.** Replaying the send steps is not possible three ways over: a resolution step may be a mutating `POST` (`sidecar/settings.yaml:111` declares one), its values may cite the `source_ref` / `case_ref` / `manifest` send inputs that only exist during a real send, and the upload step's values may cite an earlier step's outputs. So `DeliveryPlan` gains an optional `probe` field, sibling to `upload`: - `ProbeStep` carries `path`, `headers` and `query` only — no `name` (so it joins no step namespace and cannot collide with a configured step), no `method` (GET by construction), no `body`, no `outputs`. - Its value maps are typed with `ProbeValue`, a discriminated union narrowed to `literal` and `secret`. The other three sources are refused by pydantic at parse time with `union_tag_invalid`, rather than by a validator — `_check_value` (`app/sep/bundle_upload/plan.py`) branches only on `SecretValue` and `StepOutputValue`, so cross-reference validation alone would let `input` and `manifest_key` through. The two rules that validator does own — a secret must be declared, and no secret in `query` — are reused unchanged. - `ProbeStep.path` is validated as a relative reference. `URL(origin).join()` resolves a `//host/p` or `https://host/p` path *instead of* the endpoint rather than under it, which would send the probe's credentials to a host the plan never named. This is a misconfiguration guard, not an attacker guard: `DIAGNOSTICS_DELIVERY` is settable through the settings file and environment variables only, never the settings API, so anyone who can write a probe path can already repoint `endpoint`. `DeliveryPlanExecutor.probe()` issues that one GET through `get_delivery_executor`, reusing the existing per-call, unpooled transport and `redact_headers(_secret_valued_keys(...))`. It runs no resolution step and no upload, and records no step trail. **It reads no response body either.** `RemoteAPI.request()` parses every body but a `204` before it checks the status, so a receiver whose health route answers `200 text/plain` reaches the caller as a `2xx` `HTTPException` and would have been reported as an upstream error. `upload()` already documents and tolerates that case inline; this promotes its condition to `is_non_json_success()` in `app/core/requests/remote_api.py` and applies it in both callers. A non-JSON *error* status still fails (a `401 text/html` still reports `auth_failed`), and an unfollowed redirect still fails — that branch raises before the body is parsed, so it carries no non-JSON stamp. `_rebased_plan` gains a `probe` branch. It enumerates step kinds by hand, so without it a plan endpoint carrying a path prefix or query would have probed the wrong URL — and only a prefixed-endpoint test can detect that, since the unrebased path is already correct under a root endpoint. `_rebase_query`'s first parameter widens from `dict` to `Mapping` so the probe's narrower value map goes through the same helper; no behaviour changes. `DeliveryPlanResolution` gains a `DeliveryUnavailableCode`, so the route tells an unconfigured deployment from one whose stored inputs have drifted without matching on the resolver's prose; that prose still travels verbatim as the result `detail`. The dataclass invariant now requires the code and reason to be set together. `unavailable()` takes the code as a required argument, and a new `unconfigured()` classmethod names the outcome its three call sites share. Both existing consumers (`app/sep/apps/atw/deps.py`, `app/sep/apps/atw/send.py`) read only `.unavailable_reason` and are unchanged. `ConnectivityStatusEnum` widens by three members — `not_configured`, `inputs_drifted`, `probe_undeclared` — each with a `_DEFAULT_DETAILS` entry, which is required rather than optional: `build_connectivity_result` indexes that dict unguarded. `classify_connectivity_error` is unchanged; the three are only ever passed explicitly. The delivery probe takes its own `EXTERNAL_PROBE_TIMEOUT_SECONDS = 15`, declared beside `PROBE_TIMEOUT_SECONDS`, which stays 5s for the four intra-cluster targets. Because targets are gathered concurrently, this caps the worst-case whole-response wait at 15s rather than lengthening it per target. The Settings → Test connection panel requests the new target and labels all three new statuses. `ALL_TARGETS` and `STATUS_CHIP` are hardcoded in `TestConnectionButton.tsx`, so a backend-only change would have shipped a target no UI ever asks for; a test now asserts the requested target list. The settings e2e spec gains the fifth row and two of the three new statuses — its comment claiming every status is rendered end to end was true for four targets and is now stated accurately, with `inputs_drifted` covered by the component test instead. **Behaviour change on an existing target:** an unconfigured PMM reports `not_configured` instead of `unreachable`. `reachable` stays `false` and the `detail` text is unchanged, so only a consumer branching on the status value is affected. It ships with its own changelog fragment. ## Known limitations - **The side-car's baked probe block is not included.** `sidecar/settings.yaml` points at production ServiceNow, and this branch has no production credential to confirm the connector key may read the probe target. The path is confirmed working against the development instance (`perconadev.service-now.com` returns 200 with a real key, 401 with a bogus one or none), but production authenticates before resolving a table, so a credential-free check there establishes nothing about the read ACL. Shipping an unverified block would let the side-car report `auth_failed` against a receiver that would accept a real send — worse than no probe, because it tells an operator to fix a credential that is fine. The side-car answers `probe_undeclared` until a follow-up supplies a verified path; the ticket's In Scope explicitly provides for this. The owner to ask is the ServiceNow connector owner named on https://perconadev.atlassian.net/browse/PMM-15390 <!-- followup --> - **`ResolutionStep.path` and `UploadStep.path` carry the same off-origin hazard the new `ProbeStep.path` validator closes.** They are not fixed here: adding the validator to them would change validation for every already-deployed plan, and a plan that fails validation resolves to "delivery unavailable", turning delivery off on upgrade. <!-- followup --> - **A connect-phase failure reports `unreachable`, not `timeout`.** `BaseRemoteAPI.__aenter__` sets a client timeout of `total=300, connect=5, sock_connect=5, sock_read=120` in app/core/requests/remote_api.py:357, so a receiver slow to *connect* fails at aiohttp's 5s connector bound before the probe's own 15s bound is reached. This is deliberate and left alone: the probe's timeout profile then matches a real send's, which is what a "test this connection" button should report. Widening `RemoteAPI` would change every caller. - **A misspelled `probe:` key is silently ignored.** `DeliveryPlan` declares no `model_config`, so pydantic's default `extra='ignore'` applies. This is the same property that makes a settings file carrying `probe:` safe to deploy against an older image — it is ignored, not rejected — so it is kept rather than closed.
1 parent 6fc5955 commit 7015dd7

20 files changed

Lines changed: 1191 additions & 47 deletions

File tree

app/core/requests/connectivity.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@
3131
#: timeouts.
3232
PROBE_TIMEOUT_SECONDS = 5
3333

34+
#: Upper bound for a probe that leaves the cluster. Named for the property that
35+
#: justifies the wider bound -- a round trip over the public internet -- rather
36+
#: than for any one caller, so this module stays free of feature knowledge.
37+
#: Probes run concurrently, so this caps the whole fan-out rather than adding to
38+
#: it.
39+
EXTERNAL_PROBE_TIMEOUT_SECONDS = 15
40+
3441
#: HTTP statuses that mean "the server answered, but rejected our credentials".
3542
_AUTH_FAILURE_STATUSES = frozenset(
3643
{status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN}
@@ -46,18 +53,27 @@ class ConnectivityStatusEnum(StrEnum):
4653
UNREACHABLE = "unreachable"
4754
SSL_ERROR = "ssl_error"
4855
TIMEOUT = "timeout"
56+
NOT_CONFIGURED = "not_configured"
57+
INPUTS_DRIFTED = "inputs_drifted"
58+
PROBE_UNDECLARED = "probe_undeclared"
4959

5060

5161
#: Human-readable default ``detail`` per outcome. Deliberately fixed strings so
5262
#: the probe never echoes the configured API key or any credential embedded in
53-
#: an endpoint URL.
63+
#: an endpoint URL. Every member needs an entry: ``build_connectivity_result``
64+
#: indexes this unguarded whenever a caller passes no explicit ``detail``.
5465
_DEFAULT_DETAILS: dict[ConnectivityStatusEnum, str] = {
5566
ConnectivityStatusEnum.REACHABLE: "Reachable.",
5667
ConnectivityStatusEnum.AUTH_FAILED: "Authentication failed.",
5768
ConnectivityStatusEnum.ERROR: "Endpoint returned an error response.",
5869
ConnectivityStatusEnum.UNREACHABLE: "Connection failed.",
5970
ConnectivityStatusEnum.SSL_ERROR: "SSL verification failed.",
6071
ConnectivityStatusEnum.TIMEOUT: "Connection timed out.",
72+
ConnectivityStatusEnum.NOT_CONFIGURED: "Not configured.",
73+
ConnectivityStatusEnum.INPUTS_DRIFTED: (
74+
"Stored inputs no longer match the configured plan."
75+
),
76+
ConnectivityStatusEnum.PROBE_UNDECLARED: "No connectivity probe is declared.",
6177
}
6278

6379

app/core/requests/remote_api.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"BaseRemoteAPI",
2121
"RemoteAPI",
2222
"exception_for_status",
23+
"is_non_json_success",
2324
]
2425

2526
import asyncio
@@ -159,6 +160,24 @@ def exception_for_status(
159160
return exc_class(detail, headers=headers)
160161

161162

163+
def is_non_json_success(exc: HTTPException) -> bool:
164+
"""Return whether ``exc`` reports a successful answer whose body was not JSON.
165+
166+
:meth:`RemoteAPI.request` parses every body but a ``204`` before it checks
167+
the status, so a receiver answering ``200 text/plain`` (an acknowledgement
168+
string, an HTML health page, an empty non-``204`` body) surfaces as a
169+
``2xx`` :class:`fastapi.HTTPException` rather than as the success it is.
170+
Callers that do not need the parsed body use this to tell that case from a
171+
real upstream error.
172+
173+
:param exc: The exception :meth:`RemoteAPI.request` raised.
174+
:return: ``True`` when the status is below 400 and the body was not JSON.
175+
"""
176+
return exc.status_code < status.HTTP_400_BAD_REQUEST and bool(
177+
(exc.headers or {}).get(UPSTREAM_NON_JSON_HEADER)
178+
)
179+
180+
162181
def _sanitize_request_kwargs(
163182
kwargs: dict[str, Any],
164183
*,
@@ -1104,8 +1123,6 @@ async def upload(
11041123
"POST", path, data=payload, headers=headers, **kwargs
11051124
)
11061125
except HTTPException as exc:
1107-
if exc.status_code < status.HTTP_400_BAD_REQUEST and (
1108-
exc.headers or {}
1109-
).get(UPSTREAM_NON_JSON_HEADER):
1126+
if is_non_json_success(exc):
11101127
return None
11111128
raise

app/sep/api/routes/connectivity_check.py

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@
1616
"""Define the admin-only ``/api/sep/admin/connectivity-check`` endpoint.
1717
1818
Expose a single generic ``POST`` that probes the caller-specified external /
19-
inter-service endpoints (PMM, Inventory, Tasks, Nomad) on demand and reports
20-
normalized per-endpoint connectivity status, so an admin can confirm an
21-
endpoint or credential change is reachable and valid before relying on it. The
22-
request must name which services to probe (``targets``, required, no default),
23-
so the settings flow can validate only the endpoint being edited while a full
24-
sweep still names all four.
19+
inter-service endpoints (PMM, Inventory, Tasks, Nomad, and the diagnostics
20+
delivery receiver) on demand and reports normalized per-endpoint connectivity
21+
status, so an admin can confirm an endpoint or credential change is reachable
22+
and valid before relying on it. The request must name which services to probe
23+
(``targets``, required, no default), so the settings flow can validate only the
24+
endpoint being edited while a full sweep still names them all.
2525
2626
The probes are driven by the overridable ``RemoteAPI.check_connectivity``
2727
capability and fan out concurrently. A failure for one endpoint is captured and
@@ -46,9 +46,15 @@
4646
classify_connectivity_error,
4747
ConnectivityResult,
4848
ConnectivityStatusEnum,
49+
EXTERNAL_PROBE_TIMEOUT_SECONDS,
4950
PROBE_TIMEOUT_SECONDS,
5051
)
5152
from app.core.requests.remote_api import UPSTREAM_NON_JSON_HEADER
53+
from app.sep.bundle_upload.factory import get_delivery_executor
54+
from app.sep.bundle_upload.resolver import (
55+
DeliveryUnavailableCode,
56+
resolve_delivery_plan,
57+
)
5258
from app.sep.deps import InventoryAPI, PMMAPIDep, TaskAPI
5359

5460
router = APIRouter()
@@ -61,6 +67,7 @@ class ServiceEnum(StrEnum):
6167
INVENTORY = "inventory"
6268
TASKS = "tasks"
6369
NOMAD = "nomad"
70+
DELIVERY = "delivery"
6471

6572

6673
class ConnectivityCheckRequest(BaseModel):
@@ -92,7 +99,7 @@ async def _probe_pmm(pmm_api: PMMAPIDep) -> ConnectivityResult:
9299
if pmm_api is None:
93100
return build_connectivity_result(
94101
ServiceEnum.PMM,
95-
ConnectivityStatusEnum.UNREACHABLE,
102+
ConnectivityStatusEnum.NOT_CONFIGURED,
96103
detail="PMM is not configured.",
97104
)
98105
return await pmm_api.check_connectivity(ServiceEnum.PMM)
@@ -150,6 +157,59 @@ async def _probe_tasks_and_nomad(
150157
)
151158

152159

160+
#: The connectivity status each unavailability code reports as. Delivery being
161+
#: unconfigured and its stored inputs having drifted are separate outcomes
162+
#: because only the second is fixed by re-supplying the inputs.
163+
_DELIVERY_UNAVAILABLE_STATUS: dict[DeliveryUnavailableCode, ConnectivityStatusEnum] = {
164+
DeliveryUnavailableCode.UNCONFIGURED: ConnectivityStatusEnum.NOT_CONFIGURED,
165+
DeliveryUnavailableCode.DRIFTED_INPUTS: ConnectivityStatusEnum.INPUTS_DRIFTED,
166+
}
167+
168+
169+
async def _probe_delivery() -> ConnectivityResult:
170+
"""Report whether the configured diagnostics-delivery receiver answers.
171+
172+
Issues the delivery plan's own declared probe request rather than replaying
173+
its send steps: a resolution step may mutate state, and its values may cite
174+
inputs that exist only during a real send. A plan that declares no probe is
175+
reported as such instead of being guessed at.
176+
177+
The resolver's prose reason is passed through verbatim as the detail, while
178+
the status carries the same distinction machine-readably.
179+
180+
Branches on the resolution's code rather than its plan, so the unavailable
181+
outcomes map through one table instead of being re-derived from the prose.
182+
That branch narrows nothing for a type checker, so the ``plan is None`` arm
183+
below is what narrows the optional in place of an assertion; the
184+
resolution's own invariant is what makes that arm unreachable.
185+
186+
:return: The delivery connectivity result.
187+
"""
188+
resolution = resolve_delivery_plan()
189+
if (code := resolution.code) is not None:
190+
return build_connectivity_result(
191+
ServiceEnum.DELIVERY,
192+
_DELIVERY_UNAVAILABLE_STATUS[code],
193+
detail=resolution.unavailable_reason,
194+
)
195+
plan = resolution.plan
196+
if plan is None or plan.probe is None:
197+
return build_connectivity_result(
198+
ServiceEnum.DELIVERY, ConnectivityStatusEnum.PROBE_UNDECLARED
199+
)
200+
try:
201+
async with asyncio.timeout(EXTERNAL_PROBE_TIMEOUT_SECONDS):
202+
async with get_delivery_executor(plan) as executor:
203+
await executor.probe()
204+
except Exception as exc: # noqa: BLE001 -- classified, never re-raised
205+
return build_connectivity_result(
206+
ServiceEnum.DELIVERY, classify_connectivity_error(exc)
207+
)
208+
return build_connectivity_result(
209+
ServiceEnum.DELIVERY, ConnectivityStatusEnum.REACHABLE
210+
)
211+
212+
153213
@router.post("/")
154214
async def check_connectivity(
155215
body: ConnectivityCheckRequest,
@@ -188,6 +248,8 @@ async def check_connectivity(
188248
)
189249
if want_tasks or want_nomad:
190250
probes["_tasks_nomad"] = _probe_tasks_and_nomad(tasks_api)
251+
if ServiceEnum.DELIVERY in targets:
252+
probes[ServiceEnum.DELIVERY] = _probe_delivery()
191253

192254
completed = dict(zip(probes, await asyncio.gather(*probes.values()), strict=False))
193255

app/sep/bundle_upload/factory.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
__all__ = ["get_delivery_executor", "split_endpoint"]
2626

27-
from collections.abc import AsyncIterator
27+
from collections.abc import AsyncIterator, Mapping
2828
from contextlib import asynccontextmanager
2929
from urllib.parse import parse_qsl, urlparse
3030

@@ -69,10 +69,14 @@ def _rebase_path(prefix: str, path: str) -> str:
6969

7070

7171
def _rebase_query(
72-
query: dict[str, PlanValue], endpoint_query: dict[str, str]
72+
query: Mapping[str, PlanValue], endpoint_query: dict[str, str]
7373
) -> dict[str, PlanValue]:
7474
"""Merge the endpoint's query pairs into a step's own, without displacing them.
7575
76+
Accepts any value map the plan admits, so a step whose values are drawn
77+
from a narrower set than :data:`~app.sep.bundle_upload.plan.PlanValue`,
78+
as the probe's are, rebases through the same helper.
79+
7680
:param query: The step's configured query map.
7781
:param endpoint_query: The query pairs carried by the configured endpoint.
7882
:return: The merged query map.
@@ -94,6 +98,10 @@ def _rebased_plan(
9498
than shared: the plan handed in is the process-global configured one, and
9599
mutating its steps would corrupt every later send.
96100
101+
Every step kind the plan carries is enumerated here by hand, so a step kind
102+
added to :class:`~app.sep.bundle_upload.plan.DeliveryPlan` without a branch
103+
below is silently carried through unrebased and issued at the wrong URL.
104+
97105
:param plan: The configured plan to rebase.
98106
:param path: The endpoint's path.
99107
:param query: The endpoint's query pairs.
@@ -114,7 +122,19 @@ def _rebased_plan(
114122
"query": _rebase_query(plan.upload.query, query),
115123
}
116124
)
117-
return plan.model_copy(update={"resolution_steps": steps, "upload": upload})
125+
probe = (
126+
None
127+
if plan.probe is None
128+
else plan.probe.model_copy(
129+
update={
130+
"path": _rebase_path(path, plan.probe.path),
131+
"query": _rebase_query(plan.probe.query, query),
132+
}
133+
)
134+
)
135+
return plan.model_copy(
136+
update={"resolution_steps": steps, "upload": upload, "probe": probe}
137+
)
118138

119139

120140
@asynccontextmanager

0 commit comments

Comments
 (0)