Initial curated ServiceMap source from 7a0a61232d05ba9097432abef6f7f4102a64d0b8

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:34:38 +02:00
commit 105251b9c8
96 files changed
+23587

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
.git
.gitea
.env
.venv
__pycache__
.pytest_cache
node_modules
frontend/node_modules
frontend/dist
data
*.db
*.db-shm
*.db-wal
*.sqlite*
+32
View File
@@ -0,0 +1,32 @@
# Unraid deployment defaults: expose the ServiceMap UI on host port 1253.
SERVICEMAP_PORT=1253
SERVICEMAP_WEBUI_HOST=127.0.0.1
SERVICEMAP_PROBE_INTERVAL=60
SERVICEMAP_PROBE_TIMEOUT=5
SERVICEMAP_PROBE_RETRIES=1
SERVICEMAP_PROBE_CONCURRENCY=10
SERVICEMAP_RETENTION_DAYS=30
SERVICEMAP_EVENT_RETENTION_DAYS=90
SERVICEMAP_TLS_WARNING_DAYS=30
SERVICEMAP_FLAP_THRESHOLD=6
SERVICEMAP_DISCOVERY_INTERVAL_SECONDS=0
# Health-only Docker/AppOps observation; it never runs discovery or reconciliation.
SERVICEMAP_OBSERVATION_INTERVAL_SECONDS=60
SERVICEMAP_DOCKER_HOST=unix:///var/run/docker.sock
# Default is the legacy, read-only socket mount. Broker mode has no automatic
# fallback and requires a dedicated registered ServiceMap token mount.
SERVICEMAP_DOCKER_PROVIDER=socket
SERVICEMAP_DOCKER_BROKER_URL=
SERVICEMAP_DOCKER_BROKER_TOKEN_FILE=
SERVICEMAP_APPOPS_URL=
# Leave empty unless this host address is reachable from the ServiceMap container.
SERVICEMAP_PROBE_HOST=
# Optional OIDC API authorization. Keep disabled until a dedicated ServiceMap
# client, issuer, audience and role groups have been provisioned.
SERVICEMAP_AUTH_MODE=disabled
SERVICEMAP_OIDC_ISSUER=
SERVICEMAP_OIDC_AUDIENCE=
SERVICEMAP_OIDC_ROLE_CLAIMS=groups,roles
SERVICEMAP_OIDC_VIEWER_GROUPS=
SERVICEMAP_OIDC_OPERATOR_GROUPS=
SERVICEMAP_OIDC_ADMIN_GROUPS=
+29
View File
@@ -0,0 +1,29 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
.pytest_cache/
.ruff_cache/
# Node
node_modules/
dist/
.vite/
frontend/test-results/
# Local state
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
.env
.env.*
!.env.example
# IDE / OS
.idea/
.vscode/
.DS_Store
Thumbs.db
+30
View File
@@ -0,0 +1,30 @@
FROM node:22-alpine AS frontend-build
WORKDIR /frontend
COPY frontend/package.json ./package.json
COPY frontend/package-lock.json ./package-lock.json
RUN npm ci --no-audit --no-fund
COPY frontend/ ./
RUN npm run build
FROM python:3.12-slim
LABEL org.opencontainers.image.title="ServiceMap" \
org.opencontainers.image.description="Production infrastructure intelligence" \
org.opencontainers.image.version="1.6.2" \
org.opencontainers.image.licenses="MIT"
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
SERVICEMAP_DB=/data/servicemap.db \
SERVICEMAP_STATIC_DIR=/app/static
COPY backend/requirements.lock ./requirements.lock
COPY backend/app ./app
RUN pip install --no-cache-dir -r requirements.lock
COPY --from=frontend-build /frontend/dist ./static
VOLUME ["/data"]
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+3
View File
@@ -0,0 +1,3 @@
# Public source export
This source snapshot was generated from the private canonical repository at revision `7a0a61232d05ba9097432abef6f7f4102a64d0b8`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
+476
View File
@@ -0,0 +1,476 @@
{
"schemaVersion": 1,
"sourceRevision": "7a0a61232d05ba9097432abef6f7f4102a64d0b8",
"files": [
{
"path": ".dockerignore",
"sha256": "bf97a1ebf1e9b5d01538a0e92a7cd4763139350c46ffbd344c87e13f6da0b8af",
"bytes": 150
},
{
"path": ".env.example",
"sha256": "b1e3e17c79d606b293f5cc027943c83a4cd4fdb4c9b518c50e84e10bb3ccb262",
"bytes": 1353
},
{
"path": ".gitignore",
"sha256": "238a6b3a02cf4b1de38f1c506beab1c3722d604f42100fc1ef3d92b4ed0ab62d",
"bytes": 297
},
{
"path": "Dockerfile.unraid",
"sha256": "644039841da59594f369f80ad0766eb1c934dcddaf4a594cf2bdad1fc685d99a",
"bytes": 934
},
{
"path": "README.md",
"sha256": "8887dc7df5db24b00873789961e855fe08c9a9ce992302cbf48745c50de34285",
"bytes": 4738
},
{
"path": "backend/Dockerfile",
"sha256": "7f8627da7d4d15a9df9a8bb650b18d74dbfa3c226d25ab25070f65709ef6870f",
"bytes": 373
},
{
"path": "backend/app/__init__.py",
"sha256": "a7589dccbf098c71a4bb02f3547e170d0d538db54b17dd355e62f582fe2f0e9e",
"bytes": 35
},
{
"path": "backend/app/auth.py",
"sha256": "4202bb02fa0133a6816e599f3e35426a0604ef407313cee7076e04e96ecc495d",
"bytes": 6878
},
{
"path": "backend/app/config_io.py",
"sha256": "f289ecac3ec35848e0097f2d3c81b4401536586564dc36a8e6a6301952247fe6",
"bytes": 7255
},
{
"path": "backend/app/demo.py",
"sha256": "0c5845029d5cfffae3e83665b7aa89bf715f8646cdfcc525f70abada860ea148",
"bytes": 6219
},
{
"path": "backend/app/discovery.py",
"sha256": "646db051a01658d6f5ef6b75070dceb05a59c40264d56e2761e9fc165e7527ef",
"bytes": 53184
},
{
"path": "backend/app/discovery_store.py",
"sha256": "f83a21b6f71e73314d6602a262d2d2eada0fa83b9db33e59b6d3070c6a2a14bd",
"bytes": 48117
},
{
"path": "backend/app/docker_discovery.py",
"sha256": "170b950925f26ad12e1c285edc039342d2eedbc2af04a7b0683501c65393c957",
"bytes": 32124
},
{
"path": "backend/app/docker_provider.py",
"sha256": "b409d13f149cb15e88f258510787b73ed3b67c92ba3bd9ab30be76473bcaf492",
"bytes": 13288
},
{
"path": "backend/app/events.py",
"sha256": "6f71a41d0154306f5d5f7a215b4ca69cf139448ef78d50f3d6c5aaea48bbe1b6",
"bytes": 2154
},
{
"path": "backend/app/evidence.py",
"sha256": "bf3545eb916326acfc5742c56c99089781babdb1221d62394710b36417bbd5ec",
"bytes": 4843
},
{
"path": "backend/app/health.py",
"sha256": "6e0eb886abc5e3a30f1d256bb3094cc09566cfde8510d433318f18e403ec9549",
"bytes": 6131
},
{
"path": "backend/app/incidents.py",
"sha256": "d87bebdfe902c2875e49067245a571bc30debe9f375d0faab1ea91bb5c2d39df",
"bytes": 7327
},
{
"path": "backend/app/main.py",
"sha256": "16807aa5f8e3301f76d9b5116e0ce1da824814e62bce2efa3f63829ed5d3bd5c",
"bytes": 31255
},
{
"path": "backend/app/models.py",
"sha256": "2e5d6faeb1606f6f66e06aebc236daed996ab1d56d72f84123fe6261ace3f065",
"bytes": 28385
},
{
"path": "backend/app/observation.py",
"sha256": "5714125fe38065d7d4cb9a9cdbaa95e969ae9710fccbc45838f3beaa7588f36c",
"bytes": 9078
},
{
"path": "backend/app/probes.py",
"sha256": "61e5a9bf6dc83973bc56e9d48fa94def7222663de13a4338f5e829ad8b41d354",
"bytes": 8436
},
{
"path": "backend/app/scheduler.py",
"sha256": "4ea3e15d3f107cf5d29c2ce5ec71b2c48826602543fb4c08ebf2edd5a53fd7fa",
"bytes": 2544
},
{
"path": "backend/app/semantic.py",
"sha256": "2c87be83a4c4bef48ab17e7140e7d989058750c5acf42a96a1380bb34272a07c",
"bytes": 20105
},
{
"path": "backend/app/store.py",
"sha256": "5e3713f222521f22cc53eeda4005f46a06d50b13a1f43090b58783f7f4a84ebe",
"bytes": 105338
},
{
"path": "backend/pyproject.toml",
"sha256": "f739af56b1a5dbe009481de79aa5c578cb7b6865cf80cc32b641d4ade5c35534",
"bytes": 524
},
{
"path": "backend/requirements-dev.lock",
"sha256": "466630ac7ad0bccc6ab7e0678a24cbd3132ceb6937b20c0dcf54ba7f373be814",
"bytes": 2297
},
{
"path": "backend/requirements.lock",
"sha256": "f21c3b78c3c852126dfe4e42cd7fca074e2ca3587f5d4cd47539727474dd78dd",
"bytes": 1967
},
{
"path": "backend/tests/test_api.py",
"sha256": "3b8644b53200cdac233dc629be3a1282e332e1f782e215d237f77770fcf7fd2a",
"bytes": 7330
},
{
"path": "backend/tests/test_auth.py",
"sha256": "3023839a1dc787b26d98f738adc8ef212187370878edb53e6a6dbc42fd8fb8ad",
"bytes": 2531
},
{
"path": "backend/tests/test_config_io.py",
"sha256": "382869227e1d0fe6ad10e233ce70978b1adfa42162e2439d0e0747b7ebfc8cc7",
"bytes": 3686
},
{
"path": "backend/tests/test_demo.py",
"sha256": "1290b10727ad668af6639cf7a65a3fbcc89e25c01d17e97ee3f0f60565a3f256",
"bytes": 7610
},
{
"path": "backend/tests/test_discovery_v120.py",
"sha256": "7b3c1242815f65da45ec2d3a57cc1ba89a4a599f8852be781923242104c69472",
"bytes": 16143
},
{
"path": "backend/tests/test_docker_discovery.py",
"sha256": "0fc5b719bf1d0556a3fd6c7befa7e1be2860c5c9e562a357c58b232848b177a6",
"bytes": 4966
},
{
"path": "backend/tests/test_docker_provider.py",
"sha256": "dc949328ba6e3762b40a17c9a61029f538609507d713bee839fce6c639e3873f",
"bytes": 8637
},
{
"path": "backend/tests/test_health.py",
"sha256": "26056d2de98b6546e00e195dfe5fa81fa3a43c697261190abdfd39e40acc490d",
"bytes": 3983
},
{
"path": "backend/tests/test_probes.py",
"sha256": "263f4cf12444f998dd59d72bc75e5976cd9b2b8d52b576bae79e545db3cc631d",
"bytes": 4816
},
{
"path": "backend/tests/test_scheduler.py",
"sha256": "ede0a460f3ed60047db0ecd5f76b09a13be1e93b97b210fc2735116cd1ddc74d",
"bytes": 1934
},
{
"path": "backend/tests/test_store.py",
"sha256": "b43d83b3030d80b416147903666add662ebbd4fc6f8ae3b98e9a50b432dee6ac",
"bytes": 3755
},
{
"path": "backend/tests/test_v110_features.py",
"sha256": "8ad4e82ad5ee65c700aeaf796df46337690c84c494463e5da57968b71819e336",
"bytes": 6876
},
{
"path": "backend/tests/test_v130_probe_reachability.py",
"sha256": "8e021432c4f850958e3d4443e3095502437401890ae02661c43869ca46476724",
"bytes": 11754
},
{
"path": "backend/tests/test_v140_semantic.py",
"sha256": "eb78d80bb4c8a92f6526aa3e4d22d5f44f6ef946c97e2dd12767c404b5531cbe",
"bytes": 5382
},
{
"path": "backend/tests/test_v160_evidence_changes.py",
"sha256": "4fe8d2de180d6b4d265befe6aec8dcb9e1c01be22b42539a9703ed8b9cd9a441",
"bytes": 4280
},
{
"path": "backend/tests/test_v161_health_resolution.py",
"sha256": "1f30ed8929188c6fb0c3fc636ee36ff6e9d016f40a01edc2887585d96353edab",
"bytes": 15856
},
{
"path": "compose.yaml",
"sha256": "ef5c82e6330a2ce8bea27268a0f2a19960f08a26bc6c53b7d84218b342494258",
"bytes": 2195
},
{
"path": "docker-compose.yml",
"sha256": "c051b66f67511a02fb11fb8da18393380662394542830c96927d32d26a7079c9",
"bytes": 3239
},
{
"path": "docs/ARCHITECTURE.md",
"sha256": "ac4d87a866c3d9fc5b19507abbb1f6afb2d68ce5e4729b9e332943755075d2dc",
"bytes": 14240
},
{
"path": "docs/screenshots/demo-health.png",
"sha256": "7814f69214c8b6b65cd735f9a75d9f7b974b88470a37641e3c76bc314e164085",
"bytes": 312563
},
{
"path": "docs/screenshots/demo-landscape.png",
"sha256": "185649a264c5e1fb58d83e1d25da94d5a61b60c4adddb398a55b2298d26cebf8",
"bytes": 406522
},
{
"path": "frontend/Dockerfile",
"sha256": "0d8bf5dd1725879b8ea2859d78a67dd0d499fb1fb87e44aa4efad8c2e89b1899",
"bytes": 358
},
{
"path": "frontend/e2e/demo.spec.ts",
"sha256": "5fe2de577a41f31e3574c75bb0b46a51e695acb1eb8aadbb094d7b4ad0a1e887",
"bytes": 7851
},
{
"path": "frontend/eslint.config.js",
"sha256": "e779a8cbd7200eaf5176c3c031467345dde6af2dac138cc4a32407f14432062f",
"bytes": 940
},
{
"path": "frontend/index.html",
"sha256": "21a1858bfa8b92521d179c05d16991e6c7b0fbef61fe16f7f0f12a72db3e9ab2",
"bytes": 865
},
{
"path": "frontend/nginx.conf",
"sha256": "c07a3ce7936beef0d3f124f18878b46c87693150e83cf571cebbf3f6f6635885",
"bytes": 520
},
{
"path": "frontend/package-lock.json",
"sha256": "4b35333ebae3a5ac1830820cba08561dc12cf244566bb21b5183bf513902aed7",
"bytes": 138931
},
{
"path": "frontend/package.json",
"sha256": "480fd200b705f407e1d1fda6d9a28b09189aaec28b3253d10e463fb0b17a7119",
"bytes": 881
},
{
"path": "frontend/playwright.config.ts",
"sha256": "d959bee365c474dc6380de9056bfb5d27a905b9e4e2c79ee43071e0006d659f3",
"bytes": 855
},
{
"path": "frontend/public/brand/servicemap-dark.svg",
"sha256": "79ed84eba76e6362d6bbb69ef79ea3d3686bb49f89e44d6ae477bd94e87de988",
"bytes": 576
},
{
"path": "frontend/public/brand/servicemap-mark.svg",
"sha256": "12b8f893d5a85c9dc9c96e18ea57735348ceaff420e891483c3c2ebc8e3e7523",
"bytes": 671
},
{
"path": "frontend/public/brand/servicemap-mono.svg",
"sha256": "79e35d1c8ac11d14ae6e83f1d49e37dcbfd79ff207e99610b1f5407b873eed5c",
"bytes": 568
},
{
"path": "frontend/public/brand/servicemap-wordmark.svg",
"sha256": "75f8b5b7698d4959234c96a418e2857d64806ff85d6d9693ac5d06a79bec2a42",
"bytes": 855
},
{
"path": "frontend/public/icons/apple-touch-icon.png",
"sha256": "00206a46b358446d25494be66bd812aff9af40fb972640d2635891e614911f3d",
"bytes": 4051
},
{
"path": "frontend/public/icons/favicon-16.png",
"sha256": "d9f1eaef74f8f4159118fe9a50d05409bfbf92995b6f52ce100184b172a8c205",
"bytes": 447
},
{
"path": "frontend/public/icons/favicon-32.png",
"sha256": "62709f3bcfee2c00be5cfb628e96197d7983e3604a8cbd6669ed59fcd61abf4d",
"bytes": 803
},
{
"path": "frontend/public/icons/icon-192.png",
"sha256": "73ac592d98b2f5d299872b9509bee18533ccb82704e2dbada4b39cb417aa9262",
"bytes": 4313
},
{
"path": "frontend/public/icons/icon-512.png",
"sha256": "26a7927f877ccb98e9e8109fd71518136766edef81dcf56799420c839cfcf826",
"bytes": 12350
},
{
"path": "frontend/public/icons/servicemap-dockerman-256.png",
"sha256": "8c8807d5ef5bae83b9071b2962e90a65fded243f1474e2e27d9ad7ef06441d2d",
"bytes": 6066
},
{
"path": "frontend/public/icons/servicemap-dockerman-512.png",
"sha256": "26a7927f877ccb98e9e8109fd71518136766edef81dcf56799420c839cfcf826",
"bytes": 12350
},
{
"path": "frontend/public/site.webmanifest",
"sha256": "6ccd6440da6471ad2326010f1e1ae5b47760ea058d6b5cd674f0d1567d6a6eba",
"bytes": 340
},
{
"path": "frontend/src/App.tsx",
"sha256": "b78e5998577869103f9260e93fda6b6ab7d81d7f2ddb6f990294b4e8929e2257",
"bytes": 141427
},
{
"path": "frontend/src/LivingEdge.tsx",
"sha256": "0699d5ac00159befe6f37caaceb742c34a8a909079269cfeb0177cca8fb6a954",
"bytes": 1371
},
{
"path": "frontend/src/api.test.ts",
"sha256": "f62fc355a399642b0d809c19c3eb1911bfb4e2eb2e1a8d0effee0cd07c35b105",
"bytes": 5837
},
{
"path": "frontend/src/api.ts",
"sha256": "ac71a7b3adf4188eaa26058afa8ef6c03e023cbbb67b445ca4f7f3227c74d4ee",
"bytes": 27507
},
{
"path": "frontend/src/auditViewModel.test.ts",
"sha256": "e9bd72409f745321af37e54565eb3a2a1777c5696cfa5235aa674c279a6537ec",
"bytes": 2039
},
{
"path": "frontend/src/auditViewModel.ts",
"sha256": "228a7c02b27f4c064d05abe95e544da9a2389bcae8e8b79f97a670319a9ad986",
"bytes": 1428
},
{
"path": "frontend/src/demo/DemoWalkthrough.test.ts",
"sha256": "2d95993bef2286d60cb8ab6cd71b1f081a3f6cabd3f4e8b32791721578933bda",
"bytes": 487
},
{
"path": "frontend/src/demo/DemoWalkthrough.tsx",
"sha256": "1201c5d832475dd7e09b72287343e91e4a363fc79624ec7eb055b68ad2d390db",
"bytes": 2214
},
{
"path": "frontend/src/main.tsx",
"sha256": "b3f023da133154adff448b24c256c554072536939e52291490a6128a9cfc82f7",
"bytes": 237
},
{
"path": "frontend/src/realtime/useRealtimeEvents.ts",
"sha256": "e800faa868b0d2e811848647101f4a21897e22b284b5a24572d0a687a03b47be",
"bytes": 3421
},
{
"path": "frontend/src/styles.css",
"sha256": "6deaffb725b10cee74496187c1364a848ccfb2843b8d278a232e99e6cea5fd84",
"bytes": 31996
},
{
"path": "frontend/src/topology.test.ts",
"sha256": "3a1267d0af23a45889d6a39184ff6f32d89f9ccafc9d53872cbb38931b7a3434",
"bytes": 7110
},
{
"path": "frontend/src/topology/ServiceNode.tsx",
"sha256": "0e3e673d1e148483bdd331b10d7b515348b308f943fd370e8bb2435ac8d4d9f9",
"bytes": 3872
},
{
"path": "frontend/src/topology/TopologyCommandBar.tsx",
"sha256": "3349725592ca64c9a845d1fe677ec7ed919069e0ea9af52373e955698c20e039",
"bytes": 8375
},
{
"path": "frontend/src/topology/legacyLayout.ts",
"sha256": "e843fbb70a14346269987e4a6846fab2b88ff955cf35cd69c691851f7f94ea09",
"bytes": 9835
},
{
"path": "frontend/src/topology/semanticGraph.ts",
"sha256": "53da1a124bb798674b6da14f45ddf7c35a20cacce62d38223b6fc27909854d2c",
"bytes": 9526
},
{
"path": "frontend/src/topologyLayout.ts",
"sha256": "d51e7f827df124f1932b544ce253c09fd3731f36ec66b051cdcc0f2933c38b39",
"bytes": 2842
},
{
"path": "frontend/src/topologyNavigation.test.ts",
"sha256": "7381dc580b132ba55775b50cb623db5497000c5694c7888fb740c2e3fbf87858",
"bytes": 600
},
{
"path": "frontend/src/topologyNavigation.ts",
"sha256": "1fd2d65317ed1445fba823b7351cdcd25b0ab87405f7b1b61505021c5ba23748",
"bytes": 976
},
{
"path": "frontend/tsconfig.json",
"sha256": "9616ea859510ecae0cb8464fbe3bbd1448d66bb5a4ebf40d163e8f14fa8fff2b",
"bytes": 545
},
{
"path": "frontend/vite.config.ts",
"sha256": "72b18ff3a6e5ef68c37ebd6de037943ecbd51cbe27b86342e6222580eecd1e16",
"bytes": 359
},
{
"path": "package-lock.json",
"sha256": "43f354f7539a2315a36d1fa499707e8f198e447b4ee50ec736add96c89d09eb2",
"bytes": 215
},
{
"path": "package.json",
"sha256": "96398a3dbf6e8545aead833277fd60c8405ced51790ad7fe20c99068c3524b6e",
"bytes": 490
},
{
"path": "requirements.txt",
"sha256": "b61a55489732c882dd350b471b9050e019f29ba1965f7eb4cc65f128271dbcf6",
"bytes": 53
},
{
"path": "unraid/my-servicemap.xml",
"sha256": "24bd225838c710187fb93950b29b79f782792c96cffa05062172020a91f69e0f",
"bytes": 3110
}
]
}
+79
View File
@@ -0,0 +1,79 @@
# ServiceMap
**Understand what runs, what depends on it, and why a service is unhealthy.**
ServiceMap is a self-hosted service intelligence workspace for homelabs and small environments. It combines explicit dependencies, bounded probes, reviewable discovery and durable incident evidence in a navigable topology.
```mermaid
flowchart LR
A[Define / discover] --> B[Review topology]
B --> C[Probe and observe]
C --> D[Resolve health evidence]
D --> E[Trace impact and incidents]
```
## What you can do
- Probe HTTP(S), TCP, DNS and TLS certificate expiry with configurable thresholds and retries.
- Navigate Domain → Application → Component with search, filters, focus and blast-radius views.
- Inspect provider, confidence, scope, freshness and conflicts behind each health decision.
- Review Docker discovery and optional AppOps enrichment before importing canonical topology.
- Track incident acknowledgement, resolution, availability, maintenance and retained event history.
- Compare canonical topology snapshots and retain relationship provenance.
- Use SSE updates and deterministic ELK topology layout with reduced-motion support.
- Import/export validated YAML or JSON configuration atomically.
## Explore safely
Choose **Explore demo** to open the fictional Northstar Platform: nine services, synthetic observations and a seeded PostgreSQL failure. The same incident and dependency logic identifies downstream impact. The demo contacts no fictional endpoints; **Exit demo** restores the saved topology, and **Reset demo** restores its synthetic starting state.
## Demo preview
Captured from the real application with its synthetic Northstar demo. No private infrastructure or live monitoring inventory is shown.
![Dependency landscape with synthetic root-cause evidence](docs/screenshots/demo-landscape.png)
![Synthetic incident and monitoring-coverage overview](docs/screenshots/demo-health.png)
## Deploy with Docker Compose
```bash
git clone https://gitea.itworx.tech/Jens/ServiceMap-Public.git
cd ServiceMap-Public
cp .env.example .env
docker compose up --build
```
Open `http://localhost:1253`. Standard Compose runs the Python API and React frontend separately and persists SQLite under `./data`. Preserve this directory during upgrades. Set `SERVICEMAP_PROBE_INTERVAL=0` to disable scheduled probes. Define targets and dependencies in the UI or import a configuration before expecting live coverage.
The single-container Unraid build is provided by `Dockerfile.unraid` and `docker-compose.yml`. Review the socket/network mounts before enabling discovery. A read-only Docker socket mount still exposes a powerful Docker API boundary. Broker mode requires a dedicated registered identity and token file and deliberately has no socket fallback. AppOps enrichment is optional; configure your own endpoint.
## Security and health semantics
Protect the UI/API with network restrictions or tested OIDC configuration. Authentication is disabled by default; do not expose an unprotected deployment to the Internet. OIDC supports viewer/operator/admin roles when its issuer, audience and groups are provisioned. Discovery uses deployment-configured targets, not arbitrary browser-supplied targets.
Health distinguishes **unmonitored, unknown, healthy, degraded, down, recovering and maintenance**. A runtime observation is not automatically a successful endpoint probe. Application-scoped AppOps evidence does not silently establish component health. Expired evidence and ambiguous relationships remain visible instead of inventing certainty.
## Development
```bash
python -m venv .venv
# Activate .venv for your shell.
pip install -r requirements.txt
uvicorn app.main:app --app-dir backend --reload --port 8000
```
In another terminal:
```bash
npm --prefix frontend ci
npm --prefix frontend run dev
```
Vite proxies the API to port 8000. Validate changes with `python -m pytest`, `npm --prefix frontend test`, `npm --prefix frontend run typecheck`, `npm --prefix frontend run lint` and `npm --prefix frontend run build`. Browser tests exercise the synthetic demo. See [architecture](docs/ARCHITECTURE.md) for the data and observation model.
## Source distribution and licensing
This is the curated public source edition of ServiceMap 1.6.2. Reviewed updates flow from the private canonical repository after merges to `main`; private history, production evidence and internal CI/deployment workflows are excluded. Deployment examples use generic addresses and must be configured for your environment.
No project-wide license is included. Public source visibility does not grant reuse or redistribution rights; obtain the owner's terms and review third-party dependencies before redistribution.
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
SERVICEMAP_DB=/data/servicemap.db
COPY backend/requirements.lock ./requirements.lock
COPY backend/app ./app
RUN pip install --no-cache-dir -r requirements.lock
VOLUME ["/data"]
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+1
View File
@@ -0,0 +1 @@
"""ServiceMap backend package."""
+178
View File
@@ -0,0 +1,178 @@
"""Optional, fail-closed OIDC bearer authorization for ServiceMap APIs."""
from __future__ import annotations
import os
from dataclasses import dataclass
from functools import lru_cache
from urllib.parse import urlparse
import httpx
import jwt
from fastapi import HTTPException, Request, status
class AuthorizationError(RuntimeError):
pass
_ROLE_ORDER = {"viewer": 1, "operator": 2, "admin": 3}
def _csv(name: str) -> set[str]:
return {item.strip().casefold() for item in os.getenv(name, "").split(",") if item.strip()}
@dataclass(frozen=True)
class AuthConfig:
mode: str
issuer: str | None = None
audience: str | None = None
role_claims: tuple[str, ...] = ("groups", "roles")
viewer_groups: frozenset[str] = frozenset()
operator_groups: frozenset[str] = frozenset()
admin_groups: frozenset[str] = frozenset()
@classmethod
def from_environment(cls) -> AuthConfig:
mode = os.getenv("SERVICEMAP_AUTH_MODE", "disabled").strip().casefold() or "disabled"
if mode == "disabled":
return cls(mode="disabled")
if mode != "oidc":
raise AuthorizationError("SERVICEMAP_AUTH_MODE must be 'disabled' or 'oidc'")
issuer = os.getenv("SERVICEMAP_OIDC_ISSUER", "").strip().rstrip("/")
audience = os.getenv("SERVICEMAP_OIDC_AUDIENCE", "").strip()
parsed = urlparse(issuer)
local_http = os.getenv("SERVICEMAP_AUTH_ALLOW_HTTP_FOR_LOCAL", "false").casefold() == "true"
if (
not issuer
or not audience
or not parsed.netloc
or (parsed.scheme != "https" and not local_http)
):
raise AuthorizationError("OIDC mode requires a valid HTTPS issuer and audience")
claims = tuple(
item.strip()
for item in os.getenv("SERVICEMAP_OIDC_ROLE_CLAIMS", "groups,roles").split(",")
if item.strip()
)
return cls(
mode=mode,
issuer=issuer,
audience=audience,
role_claims=claims or ("groups", "roles"),
viewer_groups=frozenset(_csv("SERVICEMAP_OIDC_VIEWER_GROUPS")),
operator_groups=frozenset(_csv("SERVICEMAP_OIDC_OPERATOR_GROUPS")),
admin_groups=frozenset(_csv("SERVICEMAP_OIDC_ADMIN_GROUPS")),
)
@lru_cache(maxsize=8)
def _jwks_client(issuer: str) -> jwt.PyJWKClient:
try:
document = httpx.get(f"{issuer}/.well-known/openid-configuration", timeout=5.0).json()
jwks_uri = str(document.get("jwks_uri", "")) if isinstance(document, dict) else ""
except (httpx.HTTPError, ValueError) as exc:
raise AuthorizationError("OIDC discovery is unavailable") from exc
parsed = urlparse(jwks_uri)
issuer_host = urlparse(issuer).hostname
if not parsed.netloc or parsed.scheme != "https" or parsed.hostname != issuer_host:
raise AuthorizationError("OIDC discovery returned an invalid JWKS URI")
return jwt.PyJWKClient(jwks_uri, cache_keys=True, lifespan=300)
def _claim_values(claims: dict[str, object], names: tuple[str, ...]) -> set[str]:
values: set[str] = set()
for name in names:
raw = claims.get(name)
if isinstance(raw, str):
values.add(raw.casefold())
elif isinstance(raw, list):
values.update(str(item).casefold() for item in raw if isinstance(item, (str, int)))
return values
def _roles_for(claims: dict[str, object], config: AuthConfig) -> set[str]:
values = _claim_values(claims, config.role_claims)
roles: set[str] = set()
# Explicit role claims remain useful with a constrained issuer; group
# mapping gives operators a stable least-privilege model in Authentik.
roles.update(value for value in values if value in _ROLE_ORDER)
if values & config.viewer_groups:
roles.add("viewer")
if values & config.operator_groups:
roles.add("operator")
if values & config.admin_groups:
roles.add("admin")
if "admin" in roles:
roles.update({"operator", "viewer"})
elif "operator" in roles:
roles.add("viewer")
return roles
def verify_bearer(token: str, config: AuthConfig) -> set[str]:
if not config.issuer or not config.audience:
raise AuthorizationError("OIDC configuration is incomplete")
try:
signing_key = _jwks_client(config.issuer).get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=[signing_key.algorithm_name],
audience=config.audience,
issuer=config.issuer,
options={"require": ["exp", "sub", "iss", "aud"]},
)
except jwt.PyJWTError as exc:
raise AuthorizationError("Bearer token verification failed") from exc
if not isinstance(claims, dict):
raise AuthorizationError("Bearer token claims are invalid")
return _roles_for(claims, config)
def required_role(request: Request) -> str:
if request.method in {"GET", "HEAD", "OPTIONS"}:
return "viewer"
path = request.url.path
if path.startswith(
("/api/v1/config", "/api/v1/discovery", "/api/v1/docker", "/api/v1/maintenance")
):
return "admin"
return "operator"
def authorize_request(request: Request) -> None:
"""Authorize API requests; health and static content remain reachable.
Disabled mode preserves v1.5 behavior intentionally. Enabling OIDC with
incomplete configuration fails closed on the API while `/health` remains
available for recovery and deployment diagnostics.
"""
if request.url.path == "/health" or not request.url.path.startswith("/api/"):
return
try:
config = AuthConfig.from_environment()
except AuthorizationError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
) from exc
if config.mode == "disabled":
return
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Bearer authentication is required"
)
try:
roles = verify_bearer(header.removeprefix("Bearer ").strip(), config)
except AuthorizationError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
if (
_ROLE_ORDER.get(max(roles, key=lambda item: _ROLE_ORDER[item]) if roles else "", 0)
< _ROLE_ORDER[required_role(request)]
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient ServiceMap role"
)
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
import json
import yaml
from pydantic import ValidationError
from .models import ConfigDocument, ConfigImportResult, ConfigService
from .store import Store
def parse_document(content: str, format_name: str) -> ConfigDocument:
try:
raw = json.loads(content) if format_name == "json" else yaml.safe_load(content)
except (json.JSONDecodeError, yaml.YAMLError) as exc:
raise ValueError(f"Invalid {format_name.upper()} configuration: {exc}") from exc
if raw is None:
raw = {"version": 1, "services": []}
try:
document = ConfigDocument.model_validate(raw)
except ValidationError as exc:
raise ValueError(f"Configuration schema validation failed: {exc}") from exc
if document.version == 2 and not document.services:
# Preserve the v1 contract's explicit unsupported-empty-v2 guard while
# accepting real v2 documents that carry the richer service schema.
raise ValueError("Configuration version must be less than or equal to 1 for an empty document")
names = [service.name.casefold() for service in document.services]
if len(names) != len(set(names)):
raise ValueError("Service names must be unique in the configuration")
for service in document.services:
dependencies = [dependency.casefold() for dependency in service.depends_on]
if len(dependencies) != len(set(dependencies)):
raise ValueError(f"Service {service.name} has duplicate dependencies")
if service.name.casefold() in dependencies:
raise ValueError(f"Service {service.name} cannot depend on itself")
# A merge document may reference services that already exist in the store,
# so missing-reference validation belongs to import_document(). Cycles that
# are fully contained in this document can still be rejected immediately.
_validate_acyclic(document.services)
return document
def _validate_acyclic(services: list[ConfigService]) -> None:
graph = {
service.name.casefold(): [item.casefold() for item in service.depends_on]
for service in services
}
visiting: set[str] = set()
visited: set[str] = set()
def visit(node: str) -> None:
if node in visiting:
raise ValueError("Dependency graph contains a cycle")
if node in visited:
return
visiting.add(node)
for dependency in graph.get(node, []):
visit(dependency)
visiting.remove(node)
visited.add(node)
for node in graph:
visit(node)
def _validate_import_graph(store: Store, document: ConfigDocument, mode: str) -> None:
if mode not in {"merge", "replace"}:
raise ValueError("Import mode must be merge or replace")
projected: dict[str, ConfigService] = {}
if mode == "merge":
existing = store.list_services()
names_by_id = {service.id: service.name for service in existing}
for service in existing:
projected[service.name.casefold()] = ConfigService(
name=service.name,
kind=service.kind,
url=service.url,
host=service.host,
port=service.port,
depends_on=[names_by_id[item] for item in service.depends_on if item in names_by_id],
description=service.description,
tags=service.tags,
group=service.group,
environment=service.environment,
owner=service.owner,
enabled=service.enabled,
monitoring_enabled=service.monitoring_enabled,
criticality=service.criticality,
source_type=service.source_type,
source_ref=service.source_ref,
interval_seconds=service.interval_seconds,
timeout_seconds=service.timeout_seconds,
retries=service.retries,
failure_threshold=service.failure_threshold,
recovery_threshold=service.recovery_threshold,
expected_status_min=service.expected_status_min,
expected_status_max=service.expected_status_max,
availability_target=service.availability_target,
maintenance_enabled=service.maintenance_enabled,
maintenance_reason=service.maintenance_reason,
)
for service in document.services:
projected[service.name.casefold()] = service
available = set(projected)
for service in projected.values():
missing = [
dependency
for dependency in service.depends_on
if dependency.casefold() not in available
]
if missing:
raise ValueError(
f"Service {service.name} references unknown dependencies: {', '.join(missing)}"
)
_validate_acyclic(list(projected.values()))
def export_document(store: Store) -> ConfigDocument:
services = store.list_services()
names = {service.id: service.name for service in services}
return ConfigDocument(
version=2,
services=[
ConfigService(
name=service.name,
kind=service.kind,
url=service.url,
host=service.host,
port=service.port,
depends_on=[names[item] for item in service.depends_on if item in names],
description=service.description,
tags=service.tags,
group=service.group,
environment=service.environment,
owner=service.owner,
enabled=service.enabled,
monitoring_enabled=service.monitoring_enabled,
criticality=service.criticality,
source_type=service.source_type,
source_ref=service.source_ref,
interval_seconds=service.interval_seconds,
timeout_seconds=service.timeout_seconds,
retries=service.retries,
failure_threshold=service.failure_threshold,
recovery_threshold=service.recovery_threshold,
expected_status_min=service.expected_status_min,
expected_status_max=service.expected_status_max,
availability_target=service.availability_target,
maintenance_enabled=service.maintenance_enabled,
maintenance_reason=service.maintenance_reason,
)
for service in services
],
)
def serialize_document(document: ConfigDocument, format_name: str) -> str:
payload = document.model_dump(mode="json")
if format_name == "json":
return json.dumps(payload, indent=2, sort_keys=False)
return yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
def import_document(store: Store, document: ConfigDocument, mode: str) -> ConfigImportResult:
_validate_import_graph(store, document, mode)
return store.import_config_services(document.services, mode)
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from uuid import UUID, uuid5
from .models import (
ComponentRole,
Criticality,
DiscoveryCategory,
HealthStatus,
ProbeResult,
Service,
ServiceKind,
)
DEMO_SCENARIO_NAME = "Northstar Platform"
DEMO_ROOT_CAUSE_NAME = "PostgreSQL"
_DEMO_NAMESPACE = UUID("4b3f31f4-8ee8-4e34-9a30-4bd4e5d4d4c1")
def _stable_id(label: str) -> str:
return str(uuid5(_DEMO_NAMESPACE, label))
def build_demo_records(now: datetime | None = None) -> tuple[list[Service], list[ProbeResult]]:
"""Build the complete deterministic Northstar topology and its synthetic history."""
current = now or datetime.now(UTC)
definitions = [
("Public Endpoint", ServiceKind.HTTP, "https://northstar.synthetic/public", None, None, ["Reverse Proxy"]),
("Reverse Proxy", ServiceKind.HTTP, "https://northstar.synthetic/proxy", None, None, ["Web Frontend", "Auth Service"]),
("Web Frontend", ServiceKind.HTTP, "https://northstar.synthetic/web", None, None, ["API Gateway"]),
("Auth Service", ServiceKind.HTTP, "https://northstar.synthetic/auth", None, None, []),
("API Gateway", ServiceKind.HTTP, "https://northstar.synthetic/api", None, None, ["PostgreSQL", "Redis"]),
("PostgreSQL", ServiceKind.TCP, None, "postgresql.northstar.synthetic", 5432, []),
("Redis", ServiceKind.TCP, None, "redis.northstar.synthetic", 6379, []),
("Background Worker", ServiceKind.TCP, None, "worker.northstar.synthetic", 9000, ["PostgreSQL", "Redis", "Object Storage"]),
("Object Storage", ServiceKind.HTTP, "https://northstar.synthetic/object-storage", None, None, []),
]
ids_by_name = {name: _stable_id(f"service:{name}") for name, *_ in definitions}
metadata = {
"Public Endpoint": ("Edge", "Public Gateway", "Edge", ComponentRole.FRONTEND, DiscoveryCategory.PROXY, Criticality.CRITICAL, "Customer-facing availability check", 100.0),
"Reverse Proxy": ("Edge", "Public Gateway", "Edge", ComponentRole.PROXY, DiscoveryCategory.PROXY, Criticality.HIGH, "Northstar ingress gateway", 100.0),
"Web Frontend": ("Application", "Northstar App", "Application", ComponentRole.FRONTEND, DiscoveryCategory.APPLICATION, Criticality.HIGH, "Browser application", 99.0),
"Auth Service": ("Application", "Northstar App", "Application", ComponentRole.API, DiscoveryCategory.APPLICATION, Criticality.HIGH, "Identity and session service", 100.0),
"API Gateway": ("Application", "Northstar App", "Application", ComponentRole.API, DiscoveryCategory.APPLICATION, Criticality.CRITICAL, "Application API entry point", 99.0),
"PostgreSQL": ("Data", "Data Platform", "Data", ComponentRole.DATABASE, DiscoveryCategory.DATABASE, Criticality.CRITICAL, "Primary relational datastore", 99.9),
"Redis": ("Data", "Data Platform", "Data", ComponentRole.CACHE, DiscoveryCategory.CACHE, Criticality.HIGH, "Cache and queue backend", 100.0),
"Background Worker": ("Application", "Worker Platform", "Application", ComponentRole.WORKER, DiscoveryCategory.BACKGROUND_WORKER, Criticality.HIGH, "Asynchronous job processor", 99.0),
"Object Storage": ("Data", "Worker Platform", "Application", ComponentRole.STORAGE, DiscoveryCategory.STORAGE, Criticality.MEDIUM, "Durable object store", 100.0),
}
services = [
Service(
id=ids_by_name[name],
name=name,
kind=kind,
url=url,
host=host,
port=port,
depends_on=[ids_by_name[item] for item in dependencies],
group=metadata[name][0],
environment="production",
application=metadata[name][1],
domain=metadata[name][2],
component_role=metadata[name][3],
category=metadata[name][4],
criticality=metadata[name][5],
description=metadata[name][6],
availability_target=metadata[name][7],
interval_seconds=60,
created_at=current - timedelta(days=30),
updated_at=current,
)
for name, kind, url, host, port, dependencies in definitions
]
probes: list[ProbeResult] = []
for name, *_ in definitions:
for index in range(24):
status = _history_status(name, index)
checked_at = current - timedelta(hours=23 - index)
latency = None if status == HealthStatus.DOWN else round(42 + ((index * 17 + len(name) * 3) % 120), 2)
detail = _probe_detail(name, status, index)
probes.append(
ProbeResult(
id=_stable_id(f"probe:{name}:{index}"),
service_id=ids_by_name[name],
status=status,
latency_ms=latency,
detail=detail,
checked_at=checked_at,
trigger="synthetic",
metadata={"scenario": DEMO_SCENARIO_NAME},
)
)
return services, probes
def _history_status(name: str, index: int) -> HealthStatus:
if name == "PostgreSQL":
if index in {21, 23}:
return HealthStatus.DOWN
if index == 22:
return HealthStatus.DEGRADED
if name in {"API Gateway", "Background Worker"} and index == 20:
return HealthStatus.DOWN
if name == "Web Frontend" and index == 21:
return HealthStatus.DOWN
if name == "Reverse Proxy" and index == 22:
return HealthStatus.DEGRADED
return HealthStatus.HEALTHY
def _probe_detail(name: str, status: HealthStatus, index: int) -> str:
if name == DEMO_ROOT_CAUSE_NAME and status == HealthStatus.DOWN:
return "Synthetic outage: PostgreSQL connection refused"
if status == HealthStatus.DOWN:
return f"Synthetic dependency impact observed at history point {index + 1}"
if status == HealthStatus.DEGRADED:
return f"Synthetic elevated latency observed at history point {index + 1}"
return "Synthetic monitoring observation"
+996
View File
@@ -0,0 +1,996 @@
from __future__ import annotations
import hashlib
import os
import threading
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from .discovery_store import DiscoveryStore
from .docker_discovery import discover_containers
from .evidence import appops_health_status
from .health import validate_dependencies
from .models import (
Criticality,
DiscoveryCategory,
DiscoveryConfidence,
DiscoveryDiff,
DiscoveryLifecycle,
DiscoveryReconcileRequest,
DiscoveryResult,
DiscoveryRun,
DiscoveryRunRequest,
DiscoveryRunStatus,
DockerCandidate,
EdgeMutationRequest,
EvidenceProvider,
EvidenceScope,
HealthEvidence,
HealthStatus,
MonitoringState,
ProbeEndpointSource,
ProbeReachability,
ProbeRepairChange,
ProbeRepairReport,
ProviderHealth,
ProviderStatus,
ReconcileResult,
RelationEvidence,
RelationType,
Service,
ServiceCreate,
ServiceKind,
ServiceUpdate,
SuggestionStatus,
TopologySuggestion,
)
from .observation import observation_ttl
from .store import Store
def _now() -> datetime:
return datetime.now(UTC)
def _evidence_ttl():
try:
interval = max(10, int(os.getenv("SERVICEMAP_OBSERVATION_INTERVAL_SECONDS", "60")))
except ValueError:
interval = 60
return observation_ttl(interval)
def _fingerprint(*parts: object) -> str:
value = "|".join(str(part or "") for part in parts)
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def _confidence(value: str | DiscoveryConfidence) -> DiscoveryConfidence:
try:
return value if isinstance(value, DiscoveryConfidence) else DiscoveryConfidence(value)
except ValueError:
return DiscoveryConfidence.LOW
def _criticality(value: str | None) -> Criticality:
normalized = (value or "medium").casefold()
if normalized in {item.value for item in Criticality}:
return Criticality(normalized)
if normalized in {"urgent", "very_high"}:
return Criticality.CRITICAL
return Criticality.MEDIUM
def _application_name(candidate: DockerCandidate) -> str:
explicit = candidate.labels.get("servicemap.application", "").strip()
if explicit:
return explicit
if candidate.compose_project:
return candidate.compose_project.replace("-", " ").replace("_", " ").title()
if candidate.appops_app_id:
return f"AppOps · {candidate.appops_app_id}"
return candidate.category.value.replace("_", " ").title()
def _metadata(candidate: DockerCandidate) -> dict[str, object]:
return {
"candidate": candidate.model_dump(mode="json"),
"stable_source_ref": candidate.stable_source_ref,
"container_id": candidate.container_id,
"image": candidate.image,
"state": candidate.state,
"health": candidate.health,
"networks": candidate.networks,
"ports": [item.model_dump(mode="json") for item in candidate.ports],
"labels": candidate.labels,
"compose_project": candidate.compose_project,
"compose_service": candidate.compose_service,
"category": candidate.category.value,
"visibility": candidate.visibility.value,
"environment_evidence": candidate.environment_evidence,
"proxy_route_evidence": candidate.proxy_route_evidence,
"appops_app_id": candidate.appops_app_id,
}
def _same_observation(previous: dict[str, object], current: dict[str, object]) -> bool:
ignored = {"state", "health", "container_id"}
return {key: value for key, value in previous.items() if key not in ignored} == {
key: value for key, value in current.items() if key not in ignored
}
class DiscoveryManager:
def __init__(self, store: Store, discovery_store: DiscoveryStore) -> None:
self.store = store
self.discovery_store = discovery_store
self._lock = threading.Lock()
def _docker_host(self, requested: str | None) -> str:
return requested or os.getenv("SERVICEMAP_DOCKER_HOST", "unix:///var/run/docker.sock")
def _appops_url(self, requested: str | None) -> str | None:
return requested or os.getenv("SERVICEMAP_APPOPS_URL")
def _existing_service_for_ref(self, services: list[Service], source_ref: str | None) -> Service | None:
if not source_ref:
return None
return next((item for item in services if item.source_ref == source_ref), None)
def _candidate_suggestion(self, candidate: DockerCandidate):
return candidate.suggestions[0] if candidate.suggestions else None
def _relation_suggestions(
self,
run: DiscoveryRun,
candidates: list[DockerCandidate],
service_by_ref: dict[str, Service],
) -> list[TopologySuggestion]:
result: list[TopologySuggestion] = []
def aliases(candidate: DockerCandidate) -> set[str]:
return {item.casefold() for item in (candidate.name, candidate.compose_service or "") if item}
def resolve(source: DockerCandidate, reference: str, *, compose_only: bool = False) -> list[DockerCandidate]:
"""Resolve only inside a defensible compose/network scope.
A duplicate service name must remain a review suggestion. Choosing
the last name seen in a host-wide index is not topology evidence.
"""
token = reference.casefold().strip()
matches = [item for item in candidates if token in aliases(item) or token == (item.stable_source_ref or "").casefold()]
same_compose = [item for item in matches if source.compose_project and item.compose_project and item.compose_project.casefold() == source.compose_project.casefold()]
if compose_only:
return same_compose
if same_compose:
return same_compose
shared_network = [item for item in matches if set(source.networks).intersection(item.networks)]
return shared_network or matches
def add(source: DockerCandidate, target: DockerCandidate, confidence: DiscoveryConfidence, evidence: str, relation_type: RelationType = RelationType.DEPENDS_ON) -> None:
if source.stable_source_ref is None or target.stable_source_ref is None or source is target:
return
# Evidence changes as discovery learns more. It must enrich the
# same relationship rather than create a second suggestion/edge.
fingerprint = _fingerprint("relation", source.stable_source_ref, target.stable_source_ref, relation_type.value)
now = _now()
result.append(
self.discovery_store.save_suggestion(
TopologySuggestion(
id=f"suggest-{fingerprint[:14]}",
run_id=run.id,
suggestion_type="relation",
source_ref=source.stable_source_ref,
source_service_id=service_by_ref.get(source.stable_source_ref).id if source.stable_source_ref in service_by_ref else None,
target_ref=target.stable_source_ref,
target_service_id=service_by_ref.get(target.stable_source_ref).id if target.stable_source_ref in service_by_ref else None,
proposed_name=f"{source.name} depends on {target.name}",
relation_type=relation_type,
confidence=confidence,
evidence=[evidence],
created_at=now,
updated_at=now,
),
fingerprint,
)
)
def resolve_and_add(
source: DockerCandidate,
reference: str,
confidence: DiscoveryConfidence,
evidence: str,
*,
compose_only: bool = False,
relation_type: RelationType = RelationType.DEPENDS_ON,
) -> None:
matches = resolve(source, reference, compose_only=compose_only)
if len(matches) == 1:
add(source, matches[0], confidence, evidence, relation_type)
return
if not matches or not source.stable_source_ref:
return
candidates_label = ", ".join(sorted(item.stable_source_ref or item.name for item in matches)[:6])
fingerprint = _fingerprint("ambiguous-relation", source.stable_source_ref, reference.casefold(), RelationType.DEPENDS_ON.value)
now = _now()
result.append(
self.discovery_store.save_suggestion(
TopologySuggestion(
id=f"suggest-{fingerprint[:14]}",
run_id=run.id,
suggestion_type="relation",
source_ref=source.stable_source_ref,
source_service_id=service_by_ref.get(source.stable_source_ref).id if source.stable_source_ref in service_by_ref else None,
proposed_name=f"{source.name} has an ambiguous reference to {reference}",
relation_type=RelationType.DEPENDS_ON,
confidence=DiscoveryConfidence.LOW,
evidence=[f"{evidence}; candidates: {candidates_label}", "Manual review required; no canonical dependency was created"],
created_at=now,
updated_at=now,
),
fingerprint,
)
)
for source in candidates:
if not source.stable_source_ref:
continue
labels = source.labels
explicit = [item.strip() for item in labels.get("servicemap.depends_on", "").split(",") if item.strip()]
compose = [item.split(":", 1)[0].strip() for item in labels.get("com.docker.compose.depends_on", "").split(",") if item.strip()]
for name in sorted(set(explicit)):
resolve_and_add(source, name, DiscoveryConfidence.CONFIRMED, "Explicit servicemap.depends_on label")
for name in sorted(set(compose)):
resolve_and_add(source, name, DiscoveryConfidence.HIGH, "Docker Compose com.docker.compose.depends_on label", compose_only=True)
for item in source.environment_evidence:
if "hostname=" not in item:
continue
hostname = item.split("hostname=", 1)[1].split(":", 1)[0].casefold()
resolve_and_add(source, hostname, DiscoveryConfidence.MEDIUM, f"Environment endpoint reference: {item}")
for item in source.proxy_route_evidence:
proxies = [proxy for proxy in candidates if proxy.category == DiscoveryCategory.PROXY and set(proxy.networks).intersection(source.networks)]
if len(proxies) == 1:
add(proxies[0], source, DiscoveryConfidence.MEDIUM, item, RelationType.ROUTES_TO)
# Shared networks are review-only hints. Cap them to avoid flooding
# a large host with O(n²) low-confidence suggestions.
for name in source.dependency_suggestions[:8]:
resolve_and_add(source, name, DiscoveryConfidence.LOW, f"Shared Docker network hint: {', '.join(source.networks)}")
return result
def run(self, request: DiscoveryRunRequest) -> DiscoveryResult:
if not self._lock.acquire(blocking=False):
raise ValueError("A discovery run is already in progress")
run = self.discovery_store.start_run()
try:
docker_host = self._docker_host(request.docker_host)
result = discover_containers(
docker_host,
self.store.list_services(),
include_stopped=request.include_stopped,
appops_url=self._appops_url(request.appops_url),
)
observed_at = _now()
evidence_ttl = _evidence_ttl()
self.store.set_provider_status(
ProviderStatus(
provider=EvidenceProvider.DOCKER,
status=ProviderHealth.HEALTHY,
observed_at=observed_at,
valid_until=observed_at + evidence_ttl,
detail=f"Docker inventory returned {len(result.candidates)} candidates",
metadata={"docker_host": docker_host},
)
)
if self._appops_url(request.appops_url):
self.store.set_provider_status(
ProviderStatus(
provider=EvidenceProvider.APPOPS,
status=ProviderHealth.HEALTHY if result.appops_available else ProviderHealth.UNAVAILABLE,
observed_at=observed_at,
valid_until=observed_at + evidence_ttl,
detail=result.appops_detail,
metadata={"mappings": sum(1 for item in result.candidates if item.appops_app_id)},
)
)
existing = self.store.list_services()
by_ref = {service.source_ref: service for service in existing if service.source_ref}
diff = DiscoveryDiff()
observations = []
source_counts: dict[str, int] = {"docker": len(result.candidates), "running": 0, "stopped": 0, "appops": 0, "proxy_routes": result.proxy_routes}
for candidate in result.candidates:
if candidate.state == "running":
source_counts["running"] += 1
else:
source_counts["stopped"] += 1
if candidate.appops_app_id:
source_counts["appops"] += 1
stable_ref = candidate.stable_source_ref or f"docker:name:{candidate.name.casefold()}"
previous = self.discovery_store.get_source("docker", stable_ref)
previous_meta = previous.metadata if previous else {}
missing_cycles = int(previous_meta.get("missing_cycles", 0) or 0) if previous else 0
if candidate.lifecycle == DiscoveryLifecycle.MISSING:
missing_cycles += 1
if missing_cycles >= 3:
candidate.lifecycle = DiscoveryLifecycle.RETIRED
elif missing_cycles >= 2:
candidate.lifecycle = DiscoveryLifecycle.STALE
else:
missing_cycles = 0
current_meta = _metadata(candidate)
current_meta["missing_cycles"] = missing_cycles
if candidate.lifecycle == DiscoveryLifecycle.MISSING:
change = "missing"
diff.missing += 1
elif candidate.lifecycle == DiscoveryLifecycle.STALE:
change = "stale"
diff.stale += 1
elif candidate.lifecycle == DiscoveryLifecycle.RETIRED:
change = "retired"
diff.stale += 1
elif previous is None:
change = "new"
diff.new += 1
elif _same_observation(previous_meta, current_meta):
change = "unchanged"
diff.unchanged += 1
else:
change = "changed"
diff.changed += 1
source = self.discovery_store.save_source(
source_type="docker",
source_id=stable_ref,
source_name=candidate.name,
lifecycle=candidate.lifecycle,
metadata=current_meta,
)
observations.append(
self.discovery_store.save_observation(
run_id=run.id,
source=source,
source_name=candidate.name,
lifecycle=candidate.lifecycle,
change_type=change,
metadata=current_meta,
)
)
suggestion = self._candidate_suggestion(candidate)
if suggestion and candidate.lifecycle != DiscoveryLifecycle.MISSING and candidate.visibility.value != "ignored":
fingerprint = _fingerprint("service", stable_ref)
now = _now()
saved = self.discovery_store.save_suggestion(
TopologySuggestion(
id=f"suggest-{fingerprint[:14]}",
run_id=run.id,
suggestion_type="service",
source_ref=stable_ref,
source_service_id=by_ref.get(stable_ref).id if stable_ref in by_ref else None,
proposed_name=suggestion.name,
proposed_kind=suggestion.kind,
proposed_url=suggestion.url,
proposed_host=suggestion.host,
proposed_port=suggestion.port,
proposed_category=candidate.category,
proposed_group=suggestion.group,
confidence=_confidence(suggestion.confidence),
evidence=suggestion.evidence + candidate.environment_evidence,
created_at=now,
updated_at=now,
),
fingerprint,
)
if saved.status == SuggestionStatus.OPEN and change in {"new", "changed"}:
diff.new_suggestions += 1
self._relation_suggestions(run, result.candidates, by_ref)
completed = self.discovery_store.finish_run(
run.id,
status=DiscoveryRunStatus.COMPLETED,
source_counts=source_counts,
diff=diff,
)
suggestions = self.discovery_store.list_suggestions(run_id=run.id)
return DiscoveryResult(
docker_host=docker_host,
candidates=result.candidates,
run=completed,
suggestions=suggestions,
observations=observations,
appops_available=result.appops_available,
appops_mappings=source_counts["appops"],
proxy_routes=result.proxy_routes,
)
except Exception as exc:
self.discovery_store.finish_run(
run.id,
status=DiscoveryRunStatus.FAILED,
source_counts={},
diff=DiscoveryDiff(),
error=str(exc)[:500],
)
raise
finally:
self._lock.release()
def status(self) -> dict[str, object]:
current, completed = self.discovery_store.status()
return {"current": current, "last_completed": completed, "active": current is not None}
def _payload_for_candidate(self, candidate: DockerCandidate, name: str | None = None) -> ServiceCreate:
suggestion = self._candidate_suggestion(candidate)
primary = next((item for item in candidate.probe_candidates if item.primary and item.reachability in {ProbeReachability.VERIFIED, ProbeReachability.REACHABLE}), None)
kind = primary.kind if primary else (suggestion.kind if suggestion else ServiceKind.TCP)
# Suggestions are discovery evidence, not authorization to probe. A
# published endpoint is only promoted after a bounded preflight marks
# it as the primary candidate.
url = primary.url if primary else None
host = primary.host if primary else None
port = primary.port if primary else None
# HTTP probing requires a complete URL. A container can expose only
# an internal HTTP-like port, which is useful classification evidence
# but is not a safe externally reachable endpoint for ServiceMap.
if kind == ServiceKind.HTTP:
# Normalize stale/legacy candidate suggestions as well as fresh
# Docker observations to the canonical URL-only representation.
host = None
port = None
monitoring = bool(url) if kind == ServiceKind.HTTP else bool(url or (host and port))
if not monitoring:
url = None
host = None
port = None
group = suggestion.group if suggestion else (f"Compose · {candidate.compose_project}" if candidate.compose_project else candidate.category.value.title())
if monitoring:
monitoring_state = MonitoringState.ACTIVE
probe_source = primary.source if primary else ProbeEndpointSource.KNOWN_URL
probe_reachability = primary.reachability if primary else ProbeReachability.REACHABLE
probe_confidence = primary.confidence if primary else DiscoveryConfidence.HIGH
elif candidate.healthcheck_present:
monitoring_state = MonitoringState.DOCKER_HEALTH
probe_source = ProbeEndpointSource.DOCKER_HEALTHCHECK
probe_reachability = ProbeReachability.VERIFIED
probe_confidence = DiscoveryConfidence.HIGH
elif candidate.state == "running":
monitoring_state = MonitoringState.OBSERVED
probe_source = (
ProbeEndpointSource.CONTAINER_INTERNAL
if candidate.probe_candidates
else ProbeEndpointSource.DISCOVERED_ENVIRONMENT
)
probe_reachability = (
ProbeReachability.INTERNAL_ONLY
if candidate.probe_candidates
else ProbeReachability.NOT_CONFIGURED
)
probe_confidence = DiscoveryConfidence.LOW
else:
monitoring_state = MonitoringState.UNMONITORED
probe_source = ProbeEndpointSource.CONTAINER_INTERNAL if candidate.probe_candidates else ProbeEndpointSource.DISCOVERED_ENVIRONMENT
probe_reachability = ProbeReachability.INTERNAL_ONLY if candidate.probe_candidates else ProbeReachability.NOT_CONFIGURED
probe_confidence = DiscoveryConfidence.LOW
return ServiceCreate(
name=name or (suggestion.name if suggestion else candidate.name),
kind=kind,
url=url,
host=host,
port=port,
description=f"Discovered Docker service from {candidate.image}",
tags=["discovered", "docker"],
group=group,
environment="production",
criticality=_criticality(candidate.appops_criticality),
category=candidate.category,
visibility=candidate.visibility,
lifecycle=candidate.lifecycle,
source_type="docker",
source_ref=candidate.stable_source_ref,
first_seen_at=_now(),
last_seen_at=_now(),
last_discovery_status=candidate.state,
container_image=candidate.image,
compose_project=candidate.compose_project,
monitoring_enabled=monitoring,
monitoring_state=monitoring_state,
probe_source=probe_source,
probe_reachability=probe_reachability,
probe_confidence=probe_confidence,
runtime_state=candidate.state,
runtime_health=candidate.health,
application=_application_name(candidate),
enabled=True,
)
def _update_discovered_service(self, service: Service, candidate: DockerCandidate) -> Service:
payload = self._payload_for_candidate(candidate, service.name)
updates: dict[str, Any] = payload.model_dump()
overrides = {
"name": self.discovery_store.has_override("service", service.id, "name"),
"group": self.discovery_store.has_override("service", service.id, "group"),
"category": self.discovery_store.has_override("service", service.id, "category"),
"criticality": self.discovery_store.has_override("service", service.id, "criticality"),
"environment": self.discovery_store.has_override("service", service.id, "environment"),
"probe": self.discovery_store.has_override("service", service.id, "probe"),
"visibility": self.discovery_store.has_override("service", service.id, "visibility"),
}
current = service.model_dump()
for field, locked in overrides.items():
if locked:
if field == "probe":
for endpoint_field in ("kind", "url", "host", "port", "monitoring_enabled", "monitoring_state", "probe_source", "probe_reachability", "probe_confidence"):
updates[endpoint_field] = current[endpoint_field]
else:
updates[field] = current[field]
updates["depends_on"] = service.depends_on
# Layout is operator-owned state; rediscovery must not snap a manually
# positioned node back to an inferred default.
for field in ("layout_x", "layout_y", "layout_pinned"):
updates[field] = current[field]
updates["first_seen_at"] = service.first_seen_at or payload.first_seen_at
updates["last_seen_at"] = payload.last_seen_at
updates["lifecycle"] = candidate.lifecycle
updates["last_discovery_status"] = candidate.state
return self.store.update_service(service.id, ServiceUpdate(**updates)) or service
def _record_candidate_evidence(self, candidate: DockerCandidate, service: Service) -> None:
"""Persist source-scoped discovery claims without flattening their scope."""
now = _now()
evidence_ttl = _evidence_ttl()
health = (candidate.health or "").casefold()
runtime = candidate.state.casefold()
component_status: HealthStatus | None = None
if health in {"unhealthy", "down"} or runtime in {"exited", "dead", "stopped"}:
component_status = HealthStatus.DOWN
elif health == "degraded" or runtime == "restarting":
component_status = HealthStatus.DEGRADED
elif health == "healthy":
component_status = HealthStatus.HEALTHY
if component_status is not None:
self.store.record_health_evidence(
HealthEvidence(
id=str(uuid4()),
subject_type="service",
subject_id=service.id,
service_id=service.id,
provider=EvidenceProvider.DOCKER,
provider_ref=candidate.stable_source_ref,
status=component_status,
scope=EvidenceScope.COMPONENT,
confidence=0.95 if candidate.healthcheck_present else 0.8,
observed_at=now,
valid_until=now + evidence_ttl,
received_at=now,
summary="Docker healthcheck" if candidate.healthcheck_present else "Docker runtime state",
metadata={"container_id": candidate.container_id, "state": candidate.state, "health": candidate.health},
)
)
normalized_appops = appops_health_status(candidate.appops_health)
if candidate.appops_app_id and normalized_appops is not None:
self.store.record_health_evidence(
HealthEvidence(
id=str(uuid4()),
subject_type="application",
subject_id=f"appops:{candidate.appops_app_id}",
service_id=None,
provider=EvidenceProvider.APPOPS,
provider_ref=candidate.appops_app_id,
status=normalized_appops,
scope=EvidenceScope.APPLICATION,
confidence=candidate.appops_discovery_confidence if candidate.appops_discovery_confidence is not None else 0.5,
observed_at=now,
valid_until=now + evidence_ttl,
received_at=now,
summary="AppOps application health; not used as component direct health",
metadata={
"app_id": candidate.appops_app_id,
"category": candidate.appops_category,
"policy_profile": candidate.appops_policy_profile,
"app_type": candidate.appops_app_type,
"discovery_confidence": candidate.appops_discovery_confidence,
},
),
service_ids=[service.id],
)
def _service_by_ref(self) -> dict[str, Service]:
return {service.source_ref: service for service in self.store.list_services() if service.source_ref}
def reconcile(self, request: DiscoveryReconcileRequest) -> ReconcileResult:
run = self.discovery_store.get_run(request.run_id) if request.run_id else self.discovery_store.last_completed()
if run is None or run.status != DiscoveryRunStatus.COMPLETED:
raise ValueError("A completed discovery run is required before reconciliation")
self.discovery_store.set_run_status(run.id, DiscoveryRunStatus.RECONCILING)
candidates = [DockerCandidate.model_validate(item.get("candidate", item)) for item in self.discovery_store.candidates_for_run(run.id)]
by_ref = self._service_by_ref()
created = updated = unchanged = missing = retired = 0
service_ids: list[str] = []
for candidate in candidates:
if not candidate.stable_source_ref:
continue
existing = by_ref.get(candidate.stable_source_ref)
if candidate.lifecycle in {DiscoveryLifecycle.MISSING, DiscoveryLifecycle.STALE, DiscoveryLifecycle.RETIRED}:
if existing:
if candidate.lifecycle == DiscoveryLifecycle.MISSING:
missing += 1
else:
retired += 1
payload = ServiceUpdate.model_validate(existing.model_dump())
payload.lifecycle = candidate.lifecycle
payload.last_discovery_status = "missing"
payload.monitoring_enabled = False
self.store.update_service(existing.id, payload)
continue
if candidate.visibility.value == "ignored":
continue
if existing is None and candidate.state == "running" and request.apply_high_confidence:
payload = self._payload_for_candidate(candidate)
name_conflict = self.store.get_service_by_name(payload.name)
if name_conflict:
payload.name = f"{payload.name} [{candidate.container_id[:6]}]"
existing = self.store.create_service(payload)
by_ref[candidate.stable_source_ref] = existing
created += 1
self.discovery_store.record_change("discovered", f"Discovered {existing.name}", service_id=existing.id)
elif existing is not None:
current = existing.model_dump()
updated_service = self._update_discovered_service(existing, candidate)
unchanged += 1 if updated_service.model_dump() == current else 0
updated += 0 if updated_service.model_dump() == current else 1
existing = updated_service
by_ref[candidate.stable_source_ref] = existing
if existing:
service_ids.append(existing.id)
source = self.discovery_store.get_source("docker", candidate.stable_source_ref)
if source:
self.discovery_store.attach_source(existing.id, source.id)
self._record_candidate_evidence(candidate, existing)
# The v1.1 generic baseline is preserved for history but no longer
# appears as the primary production topology after explicit bootstrap.
if request.apply_high_confidence and created:
discovered_ids = set(service_ids)
for service in self.store.list_services():
if service.id in discovered_ids or service.source_type:
continue
payload = ServiceUpdate.model_validate(service.model_dump())
payload.visibility = "hidden"
payload.lifecycle = DiscoveryLifecycle.RETIRED
payload.monitoring_enabled = False
self.store.update_service(service.id, payload)
retired += 1
accepted_relations = 0
suggestions = self.discovery_store.list_suggestions(run_id=run.id)
by_ref = self._service_by_ref()
for suggestion in suggestions:
if suggestion.suggestion_type != "relation" or not suggestion.source_ref or not suggestion.target_ref:
continue
source = by_ref.get(suggestion.source_ref)
target = by_ref.get(suggestion.target_ref)
if not source or not target:
continue
relation_type = suggestion.relation_type or RelationType.DEPENDS_ON
previous_evidence = self.discovery_store.get_evidence_for_relation(
source.id,
target.id,
suggestion.target_ref,
relation_type,
)
confidence_rank = {
DiscoveryConfidence.LOW: 1,
DiscoveryConfidence.MEDIUM: 2,
DiscoveryConfidence.HIGH: 3,
DiscoveryConfidence.CONFIRMED: 4,
}
evidence_parts = list(suggestion.evidence)
if previous_evidence:
evidence_parts = [previous_evidence.explanation, *evidence_parts]
evidence_text = "; ".join(dict.fromkeys(part for part in evidence_parts if part))
evidence = RelationEvidence(
id=suggestion.id,
source_service_id=source.id,
target_service_id=target.id,
target_ref=suggestion.target_ref,
relation_type=relation_type,
evidence_type="discovery",
confidence=(
previous_evidence.confidence
if previous_evidence and confidence_rank[previous_evidence.confidence] >= confidence_rank[suggestion.confidence]
else suggestion.confidence
),
explanation=evidence_text,
source_ref=suggestion.source_ref,
locked=previous_evidence.locked if previous_evidence else suggestion.status == SuggestionStatus.ACCEPTED,
active=previous_evidence.active if previous_evidence else True,
first_seen_at=previous_evidence.first_seen_at if previous_evidence else suggestion.created_at,
last_seen_at=suggestion.updated_at,
)
self.discovery_store.save_evidence(evidence)
if previous_evidence and not previous_evidence.active:
# An operator rejected/removed this inferred edge. Keep the
# durable evidence for audit, but do not resurrect its health
# propagation on a later discovery cycle.
continue
should_accept = suggestion.status == SuggestionStatus.ACCEPTED or (
request.apply_high_confidence and suggestion.confidence in {DiscoveryConfidence.CONFIRMED, DiscoveryConfidence.HIGH}
)
if not should_accept or suggestion.relation_type != RelationType.DEPENDS_ON:
continue
if target.id in source.depends_on:
if suggestion.status != SuggestionStatus.ACCEPTED:
self.discovery_store.set_suggestion_status(suggestion.id, SuggestionStatus.ACCEPTED)
continue
try:
validate_dependencies([*source.depends_on, target.id], self.store, service_id=source.id)
except ValueError:
continue
payload = ServiceUpdate.model_validate(source.model_dump())
payload.depends_on = [*source.depends_on, target.id]
self.store.update_service(source.id, payload)
self.discovery_store.set_suggestion_status(suggestion.id, SuggestionStatus.ACCEPTED)
self.discovery_store.record_change("dependency_added", f"{source.name} depends on {target.name}", source_service_id=source.id, target_service_id=target.id)
accepted_relations += 1
final_run = self.discovery_store.finish_run(
run.id,
status=DiscoveryRunStatus.COMPLETED,
source_counts=run.source_counts,
diff=run.diff,
)
self.discovery_store.capture_snapshot(run_id=final_run.id)
return ReconcileResult(
run=final_run,
created=created,
updated=updated,
unchanged=unchanged,
missing=missing,
retired=retired,
accepted_relations=accepted_relations,
open_suggestions=len(self.discovery_store.list_suggestions(SuggestionStatus.OPEN)),
service_ids=sorted(set(service_ids)),
)
@staticmethod
def _endpoint_label(service: Service | ServiceCreate) -> str | None:
if service.url:
return service.url
if service.host:
return f"{service.host}{f':{service.port}' if service.port else ''}"
return None
def repair_probe_reachability(self, *, run_id: str | None = None, dry_run: bool = True) -> ProbeRepairReport:
"""Safely retire unsafe discovery endpoints without touching probe history.
Only Docker-managed services are considered. User probe overrides remain
authoritative and the operation intentionally never deletes a service,
relation, probe result, or discovery observation.
"""
run = self.discovery_store.get_run(run_id) if run_id else self.discovery_store.last_completed()
if run is None or run.status != DiscoveryRunStatus.COMPLETED:
raise ValueError("A completed discovery run is required before repairing probe reachability")
candidates = [DockerCandidate.model_validate(item.get("candidate", item)) for item in self.discovery_store.candidates_for_run(run.id)]
services = self._service_by_ref()
changes: list[ProbeRepairChange] = []
disabled_unsafe = enabled_published = docker_health_observed = 0
appops_observed = runtime_observed = unmonitored = 0
for candidate in candidates:
if not candidate.stable_source_ref or candidate.lifecycle != DiscoveryLifecycle.ACTIVE:
continue
current = services.get(candidate.stable_source_ref)
if current is None:
continue
desired = self._payload_for_candidate(candidate, current.name)
is_override = self.discovery_store.has_override("service", current.id, "probe")
next_endpoint = self._endpoint_label(desired)
previous_endpoint = self._endpoint_label(current)
if is_override:
next_endpoint = previous_endpoint
changed = (not is_override) and any(
getattr(current, field) != getattr(desired, field)
for field in ("kind", "url", "host", "port", "monitoring_enabled", "monitoring_state", "probe_source", "probe_reachability", "probe_confidence")
)
if not changed:
if not dry_run:
self.store.set_runtime_observation(
current.id,
runtime_state=candidate.state,
runtime_health=candidate.health,
monitoring_state=current.monitoring_state.value,
)
continue
if current.monitoring_enabled and not desired.monitoring_enabled:
reason = "Internal container endpoint is not reachable from ServiceMap; active probe disabled"
disabled_unsafe += 1
elif desired.monitoring_enabled:
reason = "Host-published endpoint selected using the configured probe host"
enabled_published += 1
elif candidate.healthcheck_present:
reason = "Docker healthcheck retained as runtime observation without a network probe"
docker_health_observed += 1
elif desired.monitoring_state == MonitoringState.OBSERVED:
reason = "No verified reachable endpoint; Docker runtime observation is retained without a network probe"
runtime_observed += 1
else:
reason = "No verified reachable endpoint; service remains observed as unmonitored"
unmonitored += 1
changes.append(
ProbeRepairChange(
service_id=current.id,
service_name=current.name,
previous_monitoring_state=current.monitoring_state,
next_monitoring_state=desired.monitoring_state,
previous_endpoint=previous_endpoint,
next_endpoint=next_endpoint,
next_probe_source=desired.probe_source,
next_probe_confidence=desired.probe_confidence,
reason=reason,
)
)
if not dry_run:
updated = self._update_discovered_service(current, candidate)
monitoring_state = updated.monitoring_state.value
self.store.set_runtime_observation(
updated.id,
runtime_state=candidate.state,
runtime_health=candidate.health,
monitoring_state=monitoring_state,
)
self.discovery_store.record_change("probe_endpoint_superseded", reason, service_id=updated.id)
resolved = 0
if not dry_run:
from .incidents import sync_incidents
before = len([item for item in self.store.list_incidents(active_only=True)])
sync_incidents(self.store, resolution_reason="Monitoring endpoint superseded; active probe was replaced by reachability-safe observation")
after = len([item for item in self.store.list_incidents(active_only=True)])
resolved = max(0, before - after)
return ProbeRepairReport(
run_id=run.id,
dry_run=dry_run,
inspected=sum(1 for candidate in candidates if candidate.stable_source_ref in services),
changed=len(changes),
disabled_unsafe=disabled_unsafe,
enabled_published=enabled_published,
docker_health_observed=docker_health_observed,
appops_observed=appops_observed,
runtime_observed=runtime_observed,
unmonitored=unmonitored,
resolved_incidents=resolved,
changes=changes,
)
def apply_edge(self, request: EdgeMutationRequest) -> RelationEvidence:
source = self.store.get_service(request.source_service_id)
target = self.store.get_service(request.target_service_id)
if source is None or target is None:
raise ValueError("Both edge services must exist")
if request.relation_type == RelationType.DEPENDS_ON and target.id not in source.depends_on:
validate_dependencies([*source.depends_on, target.id], self.store, service_id=source.id)
payload = ServiceUpdate.model_validate(source.model_dump())
payload.depends_on = [*source.depends_on, target.id]
self.store.update_service(source.id, payload)
now = _now()
evidence = RelationEvidence(
id=f"manual-edge-{uuid4().hex[:14]}",
source_service_id=source.id,
target_service_id=target.id,
relation_type=request.relation_type,
evidence_type="manual",
confidence=DiscoveryConfidence.CONFIRMED,
explanation=request.explanation,
source_ref=f"manual:{source.id}->{target.id}",
locked=request.lock,
first_seen_at=now,
last_seen_at=now,
)
saved = self.discovery_store.save_evidence(evidence)
self.discovery_store.save_override("relation", saved.id, "locked", request.lock, request.lock)
self.discovery_store.record_change("dependency_added", request.explanation, source_service_id=source.id, target_service_id=target.id)
return saved
def suggestion_action(self, suggestion_id: str, action: str) -> TopologySuggestion:
suggestion = self.discovery_store.get_suggestion(suggestion_id)
if suggestion is None:
raise ValueError("Suggestion not found")
if action == "reject":
if suggestion.suggestion_type == "relation" and suggestion.relation_type == RelationType.DEPENDS_ON:
source = self._service_by_ref().get(suggestion.source_ref or "")
target = self._service_by_ref().get(suggestion.target_ref or "")
existing_evidence = self.discovery_store.get_evidence(suggestion.id)
if source and target and (existing_evidence is None or not existing_evidence.locked):
payload = ServiceUpdate.model_validate(source.model_dump())
payload.depends_on = [item for item in source.depends_on if item != target.id]
self.store.update_service(source.id, payload)
if existing_evidence:
self.discovery_store.set_evidence_state(suggestion.id, active=False)
return self.discovery_store.set_suggestion_status(suggestion_id, SuggestionStatus.REJECTED) or suggestion
if action in {"confirm", "lock"}:
if suggestion.suggestion_type == "service":
if not suggestion.source_ref:
raise ValueError("Service suggestion has no stable source reference")
existing = self._service_by_ref().get(suggestion.source_ref)
if existing is None:
candidate = next(
(
DockerCandidate.model_validate(item.get("candidate", item))
for item in self.discovery_store.candidates_for_run(suggestion.run_id)
if item.get("candidate", {}).get("stable_source_ref") == suggestion.source_ref
),
None,
)
if candidate is None:
raise ValueError("Discovery observation for service suggestion is no longer available")
existing = self.store.create_service(self._payload_for_candidate(candidate))
source = self.discovery_store.get_source("docker", suggestion.source_ref)
if source:
self.discovery_store.attach_source(existing.id, source.id)
self.discovery_store.record_change("accepted", f"Accepted discovered service {existing.name}", service_id=existing.id)
self.discovery_store.save_override("service", existing.id, "source", suggestion.source_ref, True)
elif suggestion.suggestion_type == "relation":
source = self._service_by_ref().get(suggestion.source_ref or "")
target = self._service_by_ref().get(suggestion.target_ref or "")
if source is None or target is None:
raise ValueError("Accept the related services before accepting this relationship")
if suggestion.relation_type == RelationType.DEPENDS_ON and target.id not in source.depends_on:
validate_dependencies([*source.depends_on, target.id], self.store, service_id=source.id)
payload = ServiceUpdate.model_validate(source.model_dump())
payload.depends_on = [*source.depends_on, target.id]
self.store.update_service(source.id, payload)
now = _now()
self.discovery_store.save_evidence(
RelationEvidence(
id=suggestion.id,
source_service_id=source.id,
target_service_id=target.id,
target_ref=suggestion.target_ref,
relation_type=suggestion.relation_type or RelationType.DEPENDS_ON,
evidence_type="discovery",
confidence=suggestion.confidence,
explanation="; ".join(suggestion.evidence),
source_ref=suggestion.source_ref,
locked=action == "lock",
first_seen_at=suggestion.created_at,
last_seen_at=now,
)
)
self.discovery_store.record_change("dependency_added", f"Accepted relationship {source.name} → {target.name}", source_service_id=source.id, target_service_id=target.id)
return self.discovery_store.set_suggestion_status(suggestion_id, SuggestionStatus.ACCEPTED) or suggestion
raise ValueError("Unsupported suggestion action")
def record_service_override(self, before: Service, after: Service) -> None:
for field in ("name", "group", "category", "criticality", "environment", "visibility"):
if getattr(before, field) != getattr(after, field):
value = getattr(after, field)
self.discovery_store.save_override("service", after.id, field, value.value if hasattr(value, "value") else value)
if (before.kind, before.url, before.host, before.port, before.monitoring_enabled) != (
after.kind,
after.url,
after.host,
after.port,
after.monitoring_enabled,
):
self.discovery_store.save_override("service", after.id, "probe", after.model_dump(include={"kind", "url", "host", "port", "monitoring_enabled"}))
def edge_action(self, evidence_id: str, action: str) -> RelationEvidence:
evidence = self.discovery_store.get_evidence(evidence_id)
if evidence is None or evidence.target_service_id is None:
raise ValueError("Edge evidence not found")
source = self.store.get_service(evidence.source_service_id)
if source is None:
raise ValueError("Edge source service not found")
target_id = evidence.target_service_id
if action in {"remove", "reject"} and evidence.relation_type == RelationType.DEPENDS_ON:
payload = ServiceUpdate.model_validate(source.model_dump())
payload.depends_on = [item for item in source.depends_on if item != target_id]
self.store.update_service(source.id, payload)
self.discovery_store.record_change("dependency_removed", f"Removed relationship from {source.name}", source_service_id=source.id, target_service_id=target_id)
return self.discovery_store.set_evidence_state(evidence_id, active=False) or evidence
if action == "confirm" and evidence.relation_type == RelationType.DEPENDS_ON and target_id not in source.depends_on:
validate_dependencies([*source.depends_on, target_id], self.store, service_id=source.id)
payload = ServiceUpdate.model_validate(source.model_dump())
payload.depends_on = [*source.depends_on, target_id]
self.store.update_service(source.id, payload)
if action == "lock":
self.discovery_store.save_override("relation", evidence_id, "locked", True, True)
return self.discovery_store.set_evidence_state(evidence_id, active=True, locked=action == "lock" or evidence.locked) or evidence
+979
View File
@@ -0,0 +1,979 @@
from __future__ import annotations
import json
import sqlite3
from datetime import UTC, datetime
from hashlib import sha256
from typing import Any
from uuid import uuid4
from .models import (
DiscoveryConfidence,
DiscoveryDiff,
DiscoveryLifecycle,
DiscoveryObservation,
DiscoveryRun,
DiscoveryRunStatus,
LogicalRelation,
RelationEvidence,
RelationType,
ServiceSource,
SuggestionStatus,
TopologyChange,
TopologyDiff,
TopologySnapshot,
TopologySuggestion,
)
from .store import Store
def _now() -> datetime:
return datetime.now(UTC)
def _iso(value: datetime | None) -> str | None:
return value.isoformat() if value else None
def _json(value: object) -> str:
return json.dumps(value, separators=(",", ":"), sort_keys=True)
def _obj(value: object) -> dict[str, object]:
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
return {}
return {}
def _list(value: object) -> list[str]:
if isinstance(value, list):
return [str(item) for item in value]
if isinstance(value, str):
try:
parsed = json.loads(value)
return [str(item) for item in parsed] if isinstance(parsed, list) else []
except json.JSONDecodeError:
return []
return []
_CONFIDENCE_RANK = {
DiscoveryConfidence.LOW: 1,
DiscoveryConfidence.MEDIUM: 2,
DiscoveryConfidence.HIGH: 3,
DiscoveryConfidence.CONFIRMED: 4,
}
_CONFIDENCE_RANK_INV = {rank: confidence.value for confidence, rank in _CONFIDENCE_RANK.items()}
def _higher_confidence(*values: DiscoveryConfidence) -> DiscoveryConfidence:
return max(values, key=lambda value: _CONFIDENCE_RANK[value])
class DiscoveryStore:
"""Durable discovery observations and review state.
The existing Store remains the owner of canonical services and probe state.
This companion store deliberately keeps observations and suggestions separate
so a discovery run cannot mutate health topology unless reconciliation or an
explicit suggestion action asks it to do so.
"""
def __init__(self, store: Store) -> None:
self.store = store
self._init_schema()
self.ensure_baseline_snapshot()
def _connect(self) -> sqlite3.Connection:
return self.store._connect()
def _init_schema(self) -> None:
with self._connect() as db:
db.executescript(
"""
CREATE TABLE IF NOT EXISTS discovery_runs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
started_at TEXT NOT NULL,
completed_at TEXT,
source_counts TEXT NOT NULL DEFAULT '{}',
diff TEXT NOT NULL DEFAULT '{}',
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_discovery_runs_status
ON discovery_runs(status, started_at DESC);
CREATE TABLE IF NOT EXISTS discovery_sources (
id TEXT PRIMARY KEY,
source_type TEXT NOT NULL,
source_id TEXT NOT NULL,
source_name TEXT,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
lifecycle TEXT NOT NULL DEFAULT 'active',
managed INTEGER NOT NULL DEFAULT 1,
metadata TEXT NOT NULL DEFAULT '{}',
UNIQUE(source_type, source_id)
);
CREATE INDEX IF NOT EXISTS idx_discovery_sources_identity
ON discovery_sources(source_type, source_id);
CREATE INDEX IF NOT EXISTS idx_discovery_sources_last_seen
ON discovery_sources(last_seen_at DESC);
CREATE TABLE IF NOT EXISTS service_sources (
service_id TEXT NOT NULL,
source_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'observed',
PRIMARY KEY(service_id, source_id),
FOREIGN KEY(service_id) REFERENCES services(id) ON DELETE CASCADE,
FOREIGN KEY(source_id) REFERENCES discovery_sources(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_service_sources_source
ON service_sources(source_id, service_id);
CREATE TABLE IF NOT EXISTS discovery_observations (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
source_id TEXT NOT NULL,
source_type TEXT NOT NULL,
source_name TEXT NOT NULL,
lifecycle TEXT NOT NULL,
change_type TEXT NOT NULL,
observed_at TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY(run_id) REFERENCES discovery_runs(id) ON DELETE CASCADE,
FOREIGN KEY(source_id) REFERENCES discovery_sources(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_discovery_observations_run
ON discovery_observations(run_id, observed_at DESC);
CREATE INDEX IF NOT EXISTS idx_discovery_observations_source
ON discovery_observations(source_id, observed_at DESC);
CREATE TABLE IF NOT EXISTS relation_evidence (
id TEXT PRIMARY KEY,
source_service_id TEXT NOT NULL,
target_service_id TEXT,
target_ref TEXT,
relation_type TEXT NOT NULL DEFAULT 'depends_on',
evidence_type TEXT NOT NULL,
confidence TEXT NOT NULL,
explanation TEXT NOT NULL,
source_ref TEXT,
locked INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
UNIQUE(source_service_id, target_service_id, target_ref, relation_type, evidence_type),
FOREIGN KEY(source_service_id) REFERENCES services(id) ON DELETE CASCADE,
FOREIGN KEY(target_service_id) REFERENCES services(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_relation_evidence_source_target
ON relation_evidence(source_service_id, target_service_id);
CREATE INDEX IF NOT EXISTS idx_relation_evidence_confidence
ON relation_evidence(confidence, active);
CREATE TABLE IF NOT EXISTS topology_suggestions (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
suggestion_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
fingerprint TEXT NOT NULL UNIQUE,
source_ref TEXT,
source_service_id TEXT,
target_ref TEXT,
target_service_id TEXT,
proposed_name TEXT,
proposed_kind TEXT,
proposed_url TEXT,
proposed_host TEXT,
proposed_port INTEGER,
proposed_category TEXT NOT NULL DEFAULT 'unknown',
proposed_group TEXT,
relation_type TEXT,
confidence TEXT NOT NULL,
evidence TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(run_id) REFERENCES discovery_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_topology_suggestions_status
ON topology_suggestions(status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_topology_suggestions_source
ON topology_suggestions(source_ref, target_ref);
CREATE TABLE IF NOT EXISTS topology_overrides (
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
field_name TEXT NOT NULL,
value TEXT NOT NULL,
locked INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL,
PRIMARY KEY(target_type, target_id, field_name)
);
CREATE INDEX IF NOT EXISTS idx_topology_overrides_target
ON topology_overrides(target_type, target_id);
CREATE TABLE IF NOT EXISTS topology_change_events (
id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
service_id TEXT,
source_service_id TEXT,
target_service_id TEXT,
detail TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_topology_change_events_time
ON topology_change_events(created_at DESC);
CREATE TABLE IF NOT EXISTS topology_snapshots (
id TEXT PRIMARY KEY,
run_id TEXT,
created_at TEXT NOT NULL,
revision TEXT NOT NULL UNIQUE,
service_count INTEGER NOT NULL,
relationship_count INTEGER NOT NULL,
summary TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY(run_id) REFERENCES discovery_runs(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_topology_snapshots_time
ON topology_snapshots(created_at DESC);
CREATE TABLE IF NOT EXISTS topology_snapshot_services (
snapshot_id TEXT NOT NULL,
service_id TEXT NOT NULL,
name TEXT NOT NULL,
fingerprint TEXT NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY(snapshot_id, service_id),
FOREIGN KEY(snapshot_id) REFERENCES topology_snapshots(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS topology_snapshot_relations (
snapshot_id TEXT NOT NULL,
relation_id TEXT NOT NULL,
source_service_id TEXT NOT NULL,
target_service_id TEXT,
relation_type TEXT NOT NULL,
fingerprint TEXT NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY(snapshot_id, relation_id),
FOREIGN KEY(snapshot_id) REFERENCES topology_snapshots(id) ON DELETE CASCADE
);
"""
)
@staticmethod
def _diff(value: object) -> DiscoveryDiff:
data = _obj(value)
return DiscoveryDiff(**{key: int(data.get(key, 0) or 0) for key in DiscoveryDiff.model_fields})
@classmethod
def _run(cls, row: sqlite3.Row | None) -> DiscoveryRun | None:
if row is None:
return None
return DiscoveryRun(
id=row["id"],
status=DiscoveryRunStatus(row["status"]),
started_at=datetime.fromisoformat(row["started_at"]),
completed_at=datetime.fromisoformat(row["completed_at"]) if row["completed_at"] else None,
source_counts={str(k): int(v) for k, v in _obj(row["source_counts"]).items()},
diff=cls._diff(row["diff"]),
error=row["error"],
)
@classmethod
def _source(cls, row: sqlite3.Row) -> ServiceSource:
return ServiceSource(
id=row["id"],
source_type=row["source_type"],
source_id=row["source_id"],
source_name=row["source_name"],
first_seen_at=datetime.fromisoformat(row["first_seen_at"]),
last_seen_at=datetime.fromisoformat(row["last_seen_at"]),
lifecycle=DiscoveryLifecycle(row["lifecycle"]),
managed=bool(row["managed"]),
metadata=_obj(row["metadata"]),
)
@classmethod
def _suggestion(cls, row: sqlite3.Row) -> TopologySuggestion:
return TopologySuggestion(
id=row["id"],
run_id=row["run_id"],
suggestion_type=row["suggestion_type"],
status=SuggestionStatus(row["status"]),
source_ref=row["source_ref"],
source_service_id=row["source_service_id"],
target_ref=row["target_ref"],
target_service_id=row["target_service_id"],
proposed_name=row["proposed_name"],
proposed_kind=row["proposed_kind"],
proposed_url=row["proposed_url"],
proposed_host=row["proposed_host"],
proposed_port=row["proposed_port"],
proposed_category=row["proposed_category"],
proposed_group=row["proposed_group"],
relation_type=row["relation_type"],
confidence=DiscoveryConfidence(row["confidence"]),
evidence=_list(row["evidence"]),
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
@classmethod
def _evidence(cls, row: sqlite3.Row) -> RelationEvidence:
return RelationEvidence(
id=row["id"],
source_service_id=row["source_service_id"],
target_service_id=row["target_service_id"],
target_ref=row["target_ref"],
relation_type=RelationType(row["relation_type"]),
evidence_type=row["evidence_type"],
confidence=DiscoveryConfidence(row["confidence"]),
explanation=row["explanation"],
source_ref=row["source_ref"],
locked=bool(row["locked"]),
active=bool(row["active"]),
first_seen_at=datetime.fromisoformat(row["first_seen_at"]),
last_seen_at=datetime.fromisoformat(row["last_seen_at"]),
)
def start_run(self) -> DiscoveryRun:
run = DiscoveryRun(id=f"disc-{uuid4().hex[:12]}", status=DiscoveryRunStatus.DISCOVERING, started_at=_now())
with self._connect() as db:
# A process restart or an unexpected reconcile exception can leave
# a durable run in an active state. Close that stale marker before
# creating the next run so status never reports a phantom active
# discovery forever.
db.execute(
"UPDATE discovery_runs SET status=?, completed_at=?, error=COALESCE(error, ?) WHERE status IN (?, ?)",
(
DiscoveryRunStatus.FAILED.value,
run.started_at.isoformat(),
"Superseded by a newer discovery run",
DiscoveryRunStatus.DISCOVERING.value,
DiscoveryRunStatus.RECONCILING.value,
),
)
db.execute(
"INSERT INTO discovery_runs (id, status, started_at) VALUES (?, ?, ?)",
(run.id, run.status.value, run.started_at.isoformat()),
)
return run
def finish_run(
self,
run_id: str,
*,
status: DiscoveryRunStatus,
source_counts: dict[str, int],
diff: DiscoveryDiff,
error: str | None = None,
) -> DiscoveryRun:
completed = _now()
with self._connect() as db:
db.execute(
"UPDATE discovery_runs SET status=?, completed_at=?, source_counts=?, diff=?, error=? WHERE id=?",
(status.value, completed.isoformat(), _json(source_counts), _json(diff.model_dump()), error, run_id),
)
return self.get_run(run_id) or DiscoveryRun(
id=run_id,
status=status,
started_at=completed,
completed_at=completed,
source_counts=source_counts,
diff=diff,
error=error,
)
def get_run(self, run_id: str) -> DiscoveryRun | None:
with self._connect() as db:
row = db.execute("SELECT * FROM discovery_runs WHERE id = ?", (run_id,)).fetchone()
return self._run(row)
def last_completed(self) -> DiscoveryRun | None:
with self._connect() as db:
row = db.execute(
"SELECT * FROM discovery_runs WHERE status = 'completed' ORDER BY completed_at DESC LIMIT 1"
).fetchone()
return self._run(row)
def status(self) -> tuple[DiscoveryRun | None, DiscoveryRun | None]:
with self._connect() as db:
current_row = db.execute(
"SELECT * FROM discovery_runs WHERE status IN ('discovering', 'reconciling') ORDER BY started_at DESC LIMIT 1"
).fetchone()
return self._run(current_row), self.last_completed()
@staticmethod
def _snapshot_row(row: sqlite3.Row | None) -> TopologySnapshot | None:
if row is None:
return None
return TopologySnapshot(
id=row["id"],
run_id=row["run_id"],
created_at=datetime.fromisoformat(row["created_at"]),
revision=row["revision"],
service_count=int(row["service_count"]),
relationship_count=int(row["relationship_count"]),
summary={str(key): int(value) for key, value in _obj(row["summary"]).items()},
)
def ensure_baseline_snapshot(self) -> TopologySnapshot:
"""Create one migration baseline without manufacturing repeated no-op snapshots."""
with self._connect() as db:
existing = db.execute("SELECT * FROM topology_snapshots ORDER BY created_at DESC LIMIT 1").fetchone()
return self._snapshot_row(existing) if existing else self.capture_snapshot(run_id=None)
def capture_snapshot(self, *, run_id: str | None) -> TopologySnapshot:
"""Persist a compact canonical topology capture when its revision changes."""
services = sorted(self.store.list_services(), key=lambda item: item.id)
service_rows: list[tuple[str, str, str, dict[str, object]]] = []
relation_rows: list[tuple[str, str, str | None, str, str, dict[str, object]]] = []
for service in services:
payload: dict[str, object] = {
"id": service.id,
"name": service.name,
"kind": service.kind.value,
"source_ref": service.source_ref,
"application": service.application,
"domain": service.domain,
"component_role": service.component_role.value,
"category": service.category.value,
"criticality": service.criticality.value,
"visibility": service.visibility.value,
"lifecycle": service.lifecycle.value,
"monitoring_state": service.monitoring_state.value,
"depends_on": sorted(service.depends_on),
}
service_rows.append((service.id, service.name, sha256(_json(payload).encode()).hexdigest(), payload))
for target_id in sorted(service.depends_on):
relation_payload: dict[str, object] = {
"source_service_id": service.id,
"target_service_id": target_id,
"relation_type": RelationType.DEPENDS_ON.value,
}
relation_id = sha256(_json(relation_payload).encode()).hexdigest()[:24]
relation_rows.append(
(
relation_id,
service.id,
target_id,
RelationType.DEPENDS_ON.value,
sha256(_json(relation_payload).encode()).hexdigest(),
relation_payload,
)
)
revision_payload = {"services": [(item[0], item[2]) for item in service_rows], "relations": [(item[0], item[4]) for item in relation_rows]}
revision = sha256(_json(revision_payload).encode()).hexdigest()
with self._connect() as db:
existing = db.execute("SELECT * FROM topology_snapshots WHERE revision=?", (revision,)).fetchone()
if existing:
return self._snapshot_row(existing) # type: ignore[return-value]
created_at = _now()
summary = {
"visible_services": sum(1 for item in services if item.visibility.value == "visible"),
"active_services": sum(1 for item in services if item.lifecycle.value == "active"),
}
snapshot = TopologySnapshot(
id=f"topo-{uuid4().hex[:14]}",
run_id=run_id,
created_at=created_at,
revision=revision,
service_count=len(service_rows),
relationship_count=len(relation_rows),
summary=summary,
)
db.execute(
"INSERT INTO topology_snapshots VALUES (?, ?, ?, ?, ?, ?, ?)",
(snapshot.id, snapshot.run_id, snapshot.created_at.isoformat(), snapshot.revision, snapshot.service_count, snapshot.relationship_count, _json(snapshot.summary)),
)
db.executemany(
"INSERT INTO topology_snapshot_services VALUES (?, ?, ?, ?, ?)",
[(snapshot.id, service_id, name, fingerprint, _json(payload)) for service_id, name, fingerprint, payload in service_rows],
)
db.executemany(
"INSERT INTO topology_snapshot_relations VALUES (?, ?, ?, ?, ?, ?, ?)",
[(snapshot.id, relation_id, source_id, target_id, relation_type, fingerprint, _json(payload)) for relation_id, source_id, target_id, relation_type, fingerprint, payload in relation_rows],
)
self.record_change("topology_snapshot", f"Topology snapshot captured ({snapshot.service_count} services)")
return snapshot
def list_snapshots(self, *, limit: int = 50) -> list[TopologySnapshot]:
with self._connect() as db:
rows = db.execute("SELECT * FROM topology_snapshots ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()
return [self._snapshot_row(row) for row in rows if self._snapshot_row(row) is not None] # type: ignore[list-item]
def topology_diff(self, *, after_snapshot_id: str, before_snapshot_id: str | None = None) -> TopologyDiff:
with self._connect() as db:
after = db.execute("SELECT * FROM topology_snapshots WHERE id=?", (after_snapshot_id,)).fetchone()
if after is None:
raise ValueError("Topology snapshot not found")
before = (
db.execute("SELECT * FROM topology_snapshots WHERE id=?", (before_snapshot_id,)).fetchone()
if before_snapshot_id
else db.execute("SELECT * FROM topology_snapshots WHERE created_at < ? ORDER BY created_at DESC LIMIT 1", (after["created_at"],)).fetchone()
)
after_services = {
row["service_id"]: row
for row in db.execute("SELECT * FROM topology_snapshot_services WHERE snapshot_id=?", (after_snapshot_id,)).fetchall()
}
before_services = (
{row["service_id"]: row for row in db.execute("SELECT * FROM topology_snapshot_services WHERE snapshot_id=?", (before["id"],)).fetchall()}
if before else {}
)
after_relations = {
row["relation_id"]: row
for row in db.execute("SELECT * FROM topology_snapshot_relations WHERE snapshot_id=?", (after_snapshot_id,)).fetchall()
}
before_relations = (
{row["relation_id"]: row for row in db.execute("SELECT * FROM topology_snapshot_relations WHERE snapshot_id=?", (before["id"],)).fetchall()}
if before else {}
)
added: list[TopologyChange] = []
removed: list[TopologyChange] = []
changed: list[TopologyChange] = []
for item_id, row in after_services.items():
payload = _obj(row["payload"])
if item_id not in before_services:
added.append(TopologyChange(change_type="added", entity_type="service", entity_id=item_id, name=row["name"], after=payload))
elif row["fingerprint"] != before_services[item_id]["fingerprint"]:
changed.append(TopologyChange(change_type="changed", entity_type="service", entity_id=item_id, name=row["name"], before=_obj(before_services[item_id]["payload"]), after=payload))
for item_id, row in before_services.items():
if item_id not in after_services:
removed.append(TopologyChange(change_type="removed", entity_type="service", entity_id=item_id, name=row["name"], before=_obj(row["payload"])))
for item_id, row in after_relations.items():
name = f"{row['source_service_id']} → {row['target_service_id'] or 'unknown'}"
if item_id not in before_relations:
added.append(TopologyChange(change_type="added", entity_type="relationship", entity_id=item_id, name=name, after=_obj(row["payload"])))
elif row["fingerprint"] != before_relations[item_id]["fingerprint"]:
changed.append(TopologyChange(change_type="changed", entity_type="relationship", entity_id=item_id, name=name, before=_obj(before_relations[item_id]["payload"]), after=_obj(row["payload"])))
for item_id, row in before_relations.items():
if item_id not in after_relations:
removed.append(TopologyChange(change_type="removed", entity_type="relationship", entity_id=item_id, name=f"{row['source_service_id']} → {row['target_service_id'] or 'unknown'}", before=_obj(row["payload"])))
return TopologyDiff(
before_snapshot_id=before["id"] if before else None,
after_snapshot_id=after_snapshot_id,
added=added,
removed=removed,
changed=changed,
summary={"added": len(added), "removed": len(removed), "changed": len(changed)},
)
def save_source(
self,
*,
source_type: str,
source_id: str,
source_name: str | None,
lifecycle: DiscoveryLifecycle,
metadata: dict[str, object],
managed: bool = True,
observed_at: datetime | None = None,
) -> ServiceSource:
at = observed_at or _now()
with self._connect() as db:
existing = db.execute(
"SELECT * FROM discovery_sources WHERE source_type=? AND source_id=?",
(source_type, source_id),
).fetchone()
source_pk = existing["id"] if existing else f"src-{uuid4().hex[:14]}"
first_seen = existing["first_seen_at"] if existing else at.isoformat()
db.execute(
"""
INSERT INTO discovery_sources
(id, source_type, source_id, source_name, first_seen_at, last_seen_at, lifecycle, managed, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source_type, source_id) DO UPDATE SET
source_name=excluded.source_name, last_seen_at=excluded.last_seen_at,
lifecycle=excluded.lifecycle, managed=excluded.managed, metadata=excluded.metadata
""",
(source_pk, source_type, source_id, source_name, first_seen, at.isoformat(), lifecycle.value, int(managed), _json(metadata)),
)
row = db.execute("SELECT * FROM discovery_sources WHERE id=?", (source_pk,)).fetchone()
return self._source(row)
def attach_source(self, service_id: str, source_id: str, role: str = "observed") -> None:
with self._connect() as db:
db.execute(
"INSERT OR IGNORE INTO service_sources (service_id, source_id, role) VALUES (?, ?, ?)",
(service_id, source_id, role),
)
def get_source(self, source_type: str, source_id: str) -> ServiceSource | None:
with self._connect() as db:
row = db.execute(
"SELECT * FROM discovery_sources WHERE source_type=? AND source_id=?",
(source_type, source_id),
).fetchone()
return self._source(row) if row else None
def save_observation(
self,
*,
run_id: str,
source: ServiceSource,
source_name: str,
lifecycle: DiscoveryLifecycle,
change_type: str,
metadata: dict[str, object],
observed_at: datetime | None = None,
) -> DiscoveryObservation:
at = observed_at or _now()
observation = DiscoveryObservation(
id=f"obs-{uuid4().hex[:14]}",
run_id=run_id,
source_id=source.id,
source_type=source.source_type,
source_name=source_name,
lifecycle=lifecycle,
change_type=change_type,
observed_at=at,
metadata=metadata,
)
with self._connect() as db:
db.execute(
"INSERT INTO discovery_observations VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(observation.id, run_id, source.id, source.source_type, source_name, lifecycle.value, change_type, at.isoformat(), _json(metadata)),
)
return observation
def save_suggestion(self, suggestion: TopologySuggestion, fingerprint: str) -> TopologySuggestion:
with self._connect() as db:
existing = db.execute(
"SELECT evidence, confidence FROM topology_suggestions WHERE fingerprint=?", (fingerprint,)
).fetchone()
merged_evidence = list(dict.fromkeys([
*(_list(existing["evidence"]) if existing else []),
*suggestion.evidence,
]))
confidence = _higher_confidence(
suggestion.confidence,
DiscoveryConfidence(existing["confidence"]) if existing else suggestion.confidence,
)
db.execute(
"""
INSERT INTO topology_suggestions
(id, run_id, suggestion_type, status, fingerprint, source_ref, source_service_id, target_ref,
target_service_id, proposed_name, proposed_kind, proposed_url, proposed_host, proposed_port,
proposed_category, proposed_group, relation_type, confidence, evidence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(fingerprint) DO UPDATE SET
run_id=excluded.run_id,
source_ref=excluded.source_ref,
source_service_id=excluded.source_service_id,
target_ref=excluded.target_ref,
target_service_id=excluded.target_service_id,
proposed_name=excluded.proposed_name,
proposed_kind=excluded.proposed_kind,
proposed_url=excluded.proposed_url,
proposed_host=excluded.proposed_host,
proposed_port=excluded.proposed_port,
proposed_category=excluded.proposed_category,
proposed_group=excluded.proposed_group,
relation_type=excluded.relation_type,
confidence=excluded.confidence,
evidence=excluded.evidence,
updated_at=excluded.updated_at
""",
(
suggestion.id,
suggestion.run_id,
suggestion.suggestion_type,
suggestion.status.value,
fingerprint,
suggestion.source_ref,
suggestion.source_service_id,
suggestion.target_ref,
suggestion.target_service_id,
suggestion.proposed_name,
suggestion.proposed_kind.value if suggestion.proposed_kind else None,
suggestion.proposed_url,
suggestion.proposed_host,
suggestion.proposed_port,
suggestion.proposed_category.value,
suggestion.proposed_group,
suggestion.relation_type.value if suggestion.relation_type else None,
confidence.value,
_json(merged_evidence),
suggestion.created_at.isoformat(),
suggestion.updated_at.isoformat(),
),
)
row = db.execute("SELECT * FROM topology_suggestions WHERE fingerprint=?", (fingerprint,)).fetchone()
return self._suggestion(row)
def list_suggestions(
self,
status: SuggestionStatus | None = None,
limit: int = 500,
run_id: str | None = None,
) -> list[TopologySuggestion]:
query = "SELECT * FROM topology_suggestions"
params: list[Any] = []
filters: list[str] = []
if status:
filters.append("status = ?")
params.append(status.value)
if run_id:
filters.append("run_id = ?")
params.append(run_id)
if filters:
query += " WHERE " + " AND ".join(filters)
query += " ORDER BY updated_at DESC LIMIT ?"
params.append(limit)
with self._connect() as db:
rows = db.execute(query, params).fetchall()
return [self._suggestion(row) for row in rows]
def get_suggestion(self, suggestion_id: str) -> TopologySuggestion | None:
with self._connect() as db:
row = db.execute("SELECT * FROM topology_suggestions WHERE id=?", (suggestion_id,)).fetchone()
return self._suggestion(row) if row else None
def set_suggestion_status(self, suggestion_id: str, status: SuggestionStatus) -> TopologySuggestion | None:
now = _now()
with self._connect() as db:
db.execute("UPDATE topology_suggestions SET status=?, updated_at=? WHERE id=?", (status.value, now.isoformat(), suggestion_id))
row = db.execute("SELECT * FROM topology_suggestions WHERE id=?", (suggestion_id,)).fetchone()
return self._suggestion(row) if row else None
def save_evidence(self, evidence: RelationEvidence) -> RelationEvidence:
with self._connect() as db:
existing = db.execute(
"""
SELECT * FROM relation_evidence
WHERE source_service_id=? AND target_service_id IS ? AND target_ref IS ?
AND relation_type=? AND evidence_type=?
""",
(evidence.source_service_id, evidence.target_service_id, evidence.target_ref, evidence.relation_type.value, evidence.evidence_type),
).fetchone()
if existing:
previous = self._evidence(existing)
evidence = evidence.model_copy(update={
"confidence": _higher_confidence(previous.confidence, evidence.confidence),
"explanation": "; ".join(dict.fromkeys(part for part in (previous.explanation, evidence.explanation) if part)),
"source_ref": evidence.source_ref or previous.source_ref,
"locked": previous.locked or evidence.locked,
"active": evidence.active,
"first_seen_at": min(previous.first_seen_at, evidence.first_seen_at),
})
db.execute(
"""
INSERT INTO relation_evidence
(id, source_service_id, target_service_id, target_ref, relation_type, evidence_type,
confidence, explanation, source_ref, locked, active, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source_service_id, target_service_id, target_ref, relation_type, evidence_type) DO UPDATE SET
target_service_id=excluded.target_service_id, confidence=excluded.confidence,
explanation=excluded.explanation, source_ref=excluded.source_ref,
locked=MAX(relation_evidence.locked, excluded.locked), active=excluded.active,
last_seen_at=excluded.last_seen_at
""",
(evidence.id, evidence.source_service_id, evidence.target_service_id, evidence.target_ref, evidence.relation_type.value, evidence.evidence_type, evidence.confidence.value, evidence.explanation, evidence.source_ref, int(evidence.locked), int(evidence.active), evidence.first_seen_at.isoformat(), evidence.last_seen_at.isoformat()),
)
row = db.execute(
"SELECT * FROM relation_evidence WHERE id=?",
(evidence.id,),
).fetchone()
if row is None:
row = db.execute(
"SELECT * FROM relation_evidence WHERE source_service_id=? AND target_service_id IS ? AND target_ref IS ? AND relation_type=? AND evidence_type=?",
(evidence.source_service_id, evidence.target_service_id, evidence.target_ref, evidence.relation_type.value, evidence.evidence_type),
).fetchone()
return self._evidence(row)
def list_logical_relations(self, service_id: str | None = None, active_only: bool = True) -> list[LogicalRelation]:
"""Return one canonical graph edge while retaining every evidence row.
`relation_evidence` is an append/merge audit record. Rendering every
row as an edge made a relation appear duplicated whenever independent
discovery sources agreed. The aggregation is deliberately query-time
so existing v1.3 evidence is preserved without a destructive rebuild.
"""
grouped: dict[tuple[str, str | None, str | None, RelationType], list[RelationEvidence]] = {}
for evidence in self.list_evidence(service_id=service_id, active_only=active_only):
key = (evidence.source_service_id, evidence.target_service_id, evidence.target_ref, evidence.relation_type)
grouped.setdefault(key, []).append(evidence)
result: list[LogicalRelation] = []
for (source_id, target_id, target_ref, relation_type), evidence_rows in grouped.items():
confidence = _higher_confidence(*(item.confidence for item in evidence_rows))
independent_sources = {f"{item.evidence_type}:{item.source_ref or item.explanation}" for item in evidence_rows}
if len(independent_sources) > 1 and confidence != DiscoveryConfidence.CONFIRMED:
confidence = DiscoveryConfidence(_CONFIDENCE_RANK_INV[_CONFIDENCE_RANK[confidence] + 1])
first_seen = min(item.first_seen_at for item in evidence_rows)
last_seen = max(item.last_seen_at for item in evidence_rows)
digest = sha256("|".join((source_id, target_id or "", target_ref or "", relation_type.value)).encode("utf-8")).hexdigest()[:16]
result.append(LogicalRelation(
id=f"relation-{digest}",
source_service_id=source_id,
target_service_id=target_id,
target_ref=target_ref,
relation_type=relation_type,
confidence=confidence,
active=any(item.active for item in evidence_rows),
locked=any(item.locked for item in evidence_rows),
first_seen_at=first_seen,
last_seen_at=last_seen,
evidence=sorted(evidence_rows, key=lambda item: item.last_seen_at, reverse=True),
))
return sorted(result, key=lambda item: item.last_seen_at, reverse=True)
def list_evidence(self, service_id: str | None = None, active_only: bool = True) -> list[RelationEvidence]:
query = "SELECT * FROM relation_evidence WHERE 1=1"
params: list[Any] = []
if service_id:
query += " AND (source_service_id=? OR target_service_id=?)"
params.extend([service_id, service_id])
if active_only:
query += " AND active=1"
query += " ORDER BY last_seen_at DESC"
with self._connect() as db:
rows = db.execute(query, params).fetchall()
return [self._evidence(row) for row in rows]
def get_evidence(self, evidence_id: str) -> RelationEvidence | None:
with self._connect() as db:
row = db.execute("SELECT * FROM relation_evidence WHERE id=?", (evidence_id,)).fetchone()
return self._evidence(row) if row else None
def get_evidence_for_relation(
self,
source_service_id: str,
target_service_id: str | None,
target_ref: str | None,
relation_type: RelationType,
evidence_type: str = "discovery",
) -> RelationEvidence | None:
with self._connect() as db:
row = db.execute(
"""
SELECT * FROM relation_evidence
WHERE source_service_id=? AND target_service_id IS ? AND target_ref IS ?
AND relation_type=? AND evidence_type=?
""",
(source_service_id, target_service_id, target_ref, relation_type.value, evidence_type),
).fetchone()
return self._evidence(row) if row else None
def set_evidence_state(
self,
evidence_id: str,
*,
active: bool | None = None,
locked: bool | None = None,
) -> RelationEvidence | None:
current = self.get_evidence(evidence_id)
if current is None:
return None
with self._connect() as db:
db.execute(
"UPDATE relation_evidence SET active=?, locked=?, last_seen_at=? WHERE id=?",
(int(current.active if active is None else active), int(current.locked if locked is None else locked), _now().isoformat(), evidence_id),
)
return self.get_evidence(evidence_id)
def evidence_for_service(self, service_id: str) -> list[RelationEvidence]:
return self.list_evidence(service_id, active_only=True)
def sources_for_service(self, service_id: str) -> list[ServiceSource]:
with self._connect() as db:
rows = db.execute(
"SELECT s.* FROM discovery_sources s JOIN service_sources ss ON ss.source_id=s.id WHERE ss.service_id=? ORDER BY s.source_type, s.source_name",
(service_id,),
).fetchall()
return [self._source(row) for row in rows]
def observations_for_run(self, run_id: str, limit: int = 1000) -> list[DiscoveryObservation]:
with self._connect() as db:
rows = db.execute(
"SELECT * FROM discovery_observations WHERE run_id=? ORDER BY source_name COLLATE NOCASE LIMIT ?",
(run_id, limit),
).fetchall()
return [
DiscoveryObservation(
id=row["id"],
run_id=row["run_id"],
source_id=row["source_id"],
source_type=row["source_type"],
source_name=row["source_name"],
lifecycle=DiscoveryLifecycle(row["lifecycle"]),
change_type=row["change_type"],
observed_at=datetime.fromisoformat(row["observed_at"]),
metadata=_obj(row["metadata"]),
)
for row in rows
]
def candidates_for_run(self, run_id: str, limit: int = 1000) -> list[dict[str, object]]:
candidates: list[dict[str, object]] = []
for observation in self.observations_for_run(run_id, limit):
candidate = observation.metadata.get("candidate")
if isinstance(candidate, dict):
candidates.append(candidate)
return candidates
def set_run_status(self, run_id: str, status: DiscoveryRunStatus) -> None:
with self._connect() as db:
db.execute("UPDATE discovery_runs SET status=? WHERE id=?", (status.value, run_id))
def record_change(
self,
event_type: str,
detail: str,
*,
service_id: str | None = None,
source_service_id: str | None = None,
target_service_id: str | None = None,
) -> None:
with self._connect() as db:
db.execute(
"INSERT INTO topology_change_events (id, event_type, service_id, source_service_id, target_service_id, detail, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(f"change-{uuid4().hex[:14]}", event_type, service_id, source_service_id, target_service_id, detail, _now().isoformat()),
)
def save_override(self, target_type: str, target_id: str, field_name: str, value: object, locked: bool = True) -> None:
with self._connect() as db:
db.execute(
"INSERT INTO topology_overrides (target_type, target_id, field_name, value, locked, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(target_type, target_id, field_name) DO UPDATE SET value=excluded.value, locked=excluded.locked, updated_at=excluded.updated_at",
(target_type, target_id, field_name, _json(value), int(locked), _now().isoformat()),
)
def has_override(self, target_type: str, target_id: str, field_name: str) -> bool:
with self._connect() as db:
row = db.execute(
"SELECT 1 FROM topology_overrides WHERE target_type=? AND target_id=? AND field_name=? AND locked=1",
(target_type, target_id, field_name),
).fetchone()
return row is not None
def list_overrides(self, target_type: str | None = None, target_id: str | None = None) -> list[dict[str, object]]:
query = "SELECT target_type, target_id, field_name, value, locked, updated_at FROM topology_overrides WHERE 1=1"
params: list[Any] = []
if target_type:
query += " AND target_type=?"
params.append(target_type)
if target_id:
query += " AND target_id=?"
params.append(target_id)
with self._connect() as db:
rows = db.execute(query, params).fetchall()
return [
{
"target_type": row["target_type"],
"target_id": row["target_id"],
"field": row["field_name"],
"value": _obj(row["value"]) if row["value"].startswith("{") else row["value"],
"locked": bool(row["locked"]),
"updated_at": row["updated_at"],
}
for row in rows
]
+682
View File
@@ -0,0 +1,682 @@
from __future__ import annotations
import os
import re
import socket
import time
from dataclasses import dataclass
from urllib.parse import urlparse
import docker
import httpx
from docker.errors import DockerException
from .docker_provider import DockerProviderError, docker_client_for_inventory, docker_provider_mode
from .models import (
DiscoveryCategory,
DiscoveryConfidence,
DiscoveryLifecycle,
DiscoveryVisibility,
DockerCandidate,
DockerDiscoveryResult,
DockerPort,
DockerSuggestion,
EvidenceScope,
ProbeEndpointCandidate,
ProbeEndpointSource,
ProbeReachability,
Service,
ServiceKind,
)
ALLOWED_SCHEMES = {"unix", "tcp", "http", "https"}
_RELATION_ENV = re.compile(r"(URL|HOST|HOSTNAME|ADDRESS|ENDPOINT|DSN|DATABASE|REDIS|POSTGRES|MYSQL|MONGO|BROKER|UPSTREAM|S3|STORAGE)", re.IGNORECASE)
_SECRET_ENV = re.compile(r"(PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIAL|AUTHORIZATION|BEARER)", re.IGNORECASE)
_SECRET_LABEL = re.compile(
r"(PASSWORD|PASSWD|SECRET|TOKEN|(?:API|ACCESS|PRIVATE|SIGNING|ENCRYPTION)[_-]?KEY|"
r"CREDENTIAL|AUTH(?:ORIZATION)?|BEARER|COOKIE)",
re.IGNORECASE,
)
_HTTP_CONTAINER_PORTS = {
80, 443, 3000, 3001, 5000, 6060, 8000, 8080, 8081, 8181, 8188,
8265, 8989, 9000, 9443, 9494, 11434,
}
@dataclass(frozen=True)
class AppOpsInfo:
app_id: str
category: str | None
criticality: str | None
health: str | None
display_name: str | None
discovery_confidence: float | None = None
policy_profile: str | None = None
app_type: str | None = None
reasons: tuple[str, ...] = ()
@dataclass(frozen=True)
class AppOpsInventory:
by_container: dict[str, AppOpsInfo]
available: bool
detail: str = ""
@dataclass(frozen=True)
class DockerHealthObservation:
"""The small, health-only subset needed by the recurring observer."""
container_id: str
name: str
stable_source_ref: str
state: str
health: str | None
healthcheck_present: bool
@dataclass(frozen=True)
class ProxyRoute:
domains: tuple[str, ...]
forward_host: str
forward_port: int
forward_scheme: str
def _normal_name(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", value.casefold()).strip("-")
def stable_source_ref(name: str, labels: dict[str, str]) -> str:
project = labels.get("com.docker.compose.project", "").strip()
service = labels.get("com.docker.compose.service", "").strip()
if project and service:
return f"docker:compose:{_normal_name(project)}:{_normal_name(service)}"
return f"docker:name:{_normal_name(name)}"
def _legacy_source_ref(container_id: str, image_name: str) -> str:
return f"docker:{container_id[:12]}@{image_name}"
def _safe_labels(labels: dict[str, str]) -> dict[str, str]:
prefixes = ("servicemap.", "com.docker.compose.", "net.unraid.docker.", "org.opencontainers.image.")
return {
key: value
for key, value in labels.items()
if key.startswith(prefixes) and not _SECRET_LABEL.search(key)
}
def _classify(name: str, image: str, labels: dict[str, str], appops: AppOpsInfo | None = None) -> DiscoveryCategory:
explicit = labels.get("servicemap.category", "").strip().casefold()
if explicit in {item.value for item in DiscoveryCategory}:
return DiscoveryCategory(explicit)
# AppOps category is application metadata. It is helpful only when its
# discovery confidence is high enough; a low-confidence registry row must
# not override deterministic Docker or operator classification.
if appops and appops.category and (appops.discovery_confidence or 0) >= 0.75:
mapped = {
"database": DiscoveryCategory.DATABASE,
"database_admin": DiscoveryCategory.DATABASE,
"cache": DiscoveryCategory.CACHE,
"network": DiscoveryCategory.INFRASTRUCTURE,
"monitoring": DiscoveryCategory.MONITORING,
"media": DiscoveryCategory.APPLICATION,
"development": DiscoveryCategory.DEVELOPMENT,
"storage": DiscoveryCategory.STORAGE,
"infrastructure": DiscoveryCategory.INFRASTRUCTURE,
}.get(appops.category.casefold())
if mapped:
return mapped
value = f"{name} {image}".casefold()
if any(token in value for token in ("postgres", "mariadb", "mysql", "mongo", "qdrant")):
return DiscoveryCategory.DATABASE
if "redis" in value:
return DiscoveryCategory.CACHE
if any(token in value for token in ("nginx", "traefik", "caddy", "proxy")):
return DiscoveryCategory.PROXY
if any(token in value for token in ("prometheus", "grafana", "zabbix", "netdata", "cadvisor", "loki", "alloy")):
return DiscoveryCategory.MONITORING
if any(token in value for token in ("worker", "agent", "runner", "backup", "scheduler", "node")):
return DiscoveryCategory.BACKGROUND_WORKER
if any(token in value for token in ("portainer", "dockdeck", "unraid", "watchtower", "appops", "gitea")):
return DiscoveryCategory.INFRASTRUCTURE
if any(token in value for token in ("dev", "forge", "lab", "builder", "runner")):
return DiscoveryCategory.DEVELOPMENT
return DiscoveryCategory.APPLICATION
def _visibility(name: str, state: str, category: DiscoveryCategory, labels: dict[str, str]) -> DiscoveryVisibility:
if labels.get("servicemap.ignore", "false").casefold() == "true":
return DiscoveryVisibility.IGNORED
if state != "running":
return DiscoveryVisibility.HIDDEN
if category == DiscoveryCategory.SYSTEM:
return DiscoveryVisibility.IGNORED
if labels.get("servicemap.visible", "true").casefold() == "false":
return DiscoveryVisibility.HIDDEN
return DiscoveryVisibility.VISIBLE
def _kind_and_endpoint(
name: str,
ports: list[DockerPort],
labels: dict[str, str],
probe_host: str | None = None,
) -> tuple[ServiceKind, str | None, str | None, int | None, list[str]]:
"""Select only an endpoint ServiceMap can reach from its own network.
Docker's embedded DNS is scoped to a container network. A name such as
``postgres`` is useful discovery evidence but cannot become an active
probe merely because it exists in another project's network.
"""
probe_host = (probe_host or os.getenv("SERVICEMAP_PROBE_HOST") or "").strip()
explicit_kind = labels.get("servicemap.probe", "").casefold()
explicit_port = labels.get("servicemap.port", "")
tcp_ports = [item for item in ports if item.container_port.endswith("/tcp")]
def container_port(item: DockerPort) -> int | None:
raw = item.container_port.split("/", 1)[0]
return int(raw) if raw.isdigit() else None
explicit_value = int(explicit_port) if explicit_port.isdigit() else None
selected = next((item for item in tcp_ports if container_port(item) == explicit_value), None)
if selected is None:
published = [item for item in tcp_ports if item.host_port is not None]
# The mapped host port is arbitrary. Select using the container's
# listener port so 8080 -> 8070 remains an HTTP candidate.
selected = next((item for item in published if container_port(item) in _HTTP_CONTAINER_PORTS), None)
selected = selected or (published[0] if published else (tcp_ports[0] if tcp_ports else None))
container_listener_port = container_port(selected) if selected else None
host_port = selected.host_port if selected else None
port = explicit_value or container_listener_port
evidence = ["container metadata"]
if explicit_kind in {"http", "tcp", "dns", "tls"}:
kind = ServiceKind(explicit_kind)
evidence.append("servicemap.probe label")
elif port in _HTTP_CONTAINER_PORTS:
kind = ServiceKind.HTTP
evidence.append("HTTP-like container port")
elif port:
kind = ServiceKind.TCP
evidence.append("published TCP port")
else:
return ServiceKind.TCP, None, name, None, evidence
if kind == ServiceKind.HTTP:
protocol = "https" if port in {443, 8443, 9443} else "http"
if host_port is not None and probe_host:
# HTTP services are represented by a URL in the canonical model;
# carrying the published port alongside it creates an invalid
# mixed endpoint payload during reconciliation.
return kind, f"{protocol}://{probe_host}:{host_port}", None, None, evidence + ["host-published endpoint"]
return kind, None, name, port, evidence + ["internal container endpoint candidate"]
if kind == ServiceKind.DNS:
return kind, None, name, None, evidence
if host_port is not None and probe_host:
return kind, None, probe_host, host_port, evidence + ["host-published endpoint"]
return kind, None, name, port, evidence + ["internal container endpoint candidate"]
def _preflight_candidate(candidate: ProbeEndpointCandidate) -> ProbeEndpointCandidate:
"""Verify only host-published endpoints before allowing active probes.
Discovery never probes Docker-internal DNS names and never scans paths: the
endpoint must already come from a Docker port mapping or explicit label.
"""
if candidate.source not in {
ProbeEndpointSource.PUBLISHED_PORT,
ProbeEndpointSource.EXPLICIT_LABEL,
}:
return candidate
parsed = urlparse(candidate.url) if candidate.url else None
hostname = parsed.hostname if parsed else candidate.host
port = parsed.port if parsed else candidate.port
if not hostname or not port:
return candidate.model_copy(update={"preflight_detail": "No host-published endpoint to preflight"})
started = time.perf_counter()
try:
with socket.create_connection((hostname, port), timeout=0.75):
pass
elapsed = round((time.perf_counter() - started) * 1000, 1)
if candidate.kind != ServiceKind.HTTP or not candidate.url:
return candidate.model_copy(update={
"reachability": ProbeReachability.VERIFIED,
"primary": True,
"preflight_detail": "TCP connect succeeded",
"preflight_latency_ms": elapsed,
})
with httpx.Client(timeout=1.25, follow_redirects=False) as client:
response = client.request("HEAD", candidate.url)
if response.status_code == 405:
response = client.request("GET", candidate.url)
return candidate.model_copy(update={
"reachability": ProbeReachability.VERIFIED,
"primary": True,
"preflight_detail": f"HTTP {response.status_code} responded",
"preflight_latency_ms": elapsed,
})
except (OSError, httpx.HTTPError) as exc:
return candidate.model_copy(update={
"reachability": ProbeReachability.UNVERIFIED,
"primary": False,
"preflight_detail": f"Preflight failed: {type(exc).__name__}",
})
def _probe_candidates(
suggestion: DockerSuggestion | None,
*,
healthcheck_present: bool,
) -> list[ProbeEndpointCandidate]:
candidates: list[ProbeEndpointCandidate] = []
if suggestion:
source = ProbeEndpointSource.EXPLICIT_LABEL if "servicemap.probe label" in suggestion.evidence else ProbeEndpointSource.PUBLISHED_PORT
reachability = ProbeReachability.UNVERIFIED if "host-published endpoint" in suggestion.evidence else ProbeReachability.INTERNAL_ONLY
candidates.append(
ProbeEndpointCandidate(
source=source if reachability == ProbeReachability.UNVERIFIED else ProbeEndpointSource.CONTAINER_INTERNAL,
kind=suggestion.kind,
url=suggestion.url,
host=suggestion.host,
port=suggestion.port,
reachability=reachability,
confidence=DiscoveryConfidence.MEDIUM if reachability == ProbeReachability.UNVERIFIED else DiscoveryConfidence.LOW,
evidence="; ".join(suggestion.evidence),
)
)
if healthcheck_present:
candidates.append(
ProbeEndpointCandidate(
source=ProbeEndpointSource.DOCKER_HEALTHCHECK,
kind=suggestion.kind if suggestion else ServiceKind.TCP,
reachability=ProbeReachability.VERIFIED,
confidence=DiscoveryConfidence.HIGH,
evidence="Docker healthcheck present (observed without a network probe)",
)
)
return candidates
def safe_environment_evidence(env: list[str]) -> list[str]:
evidence: list[str] = []
for entry in env:
name, separator, value = entry.partition("=")
if not separator or not value or _SECRET_ENV.search(name) or not _RELATION_ENV.search(name):
continue
try:
parsed = urlparse(value if "://" in value else f"//{value}")
hostname = parsed.hostname
except ValueError:
# Docker environments frequently contain comma-separated host
# lists or malformed IPv6-like values. Discovery evidence is
# best-effort; one invalid value must not abort the complete scan.
continue
if not hostname or hostname.casefold() in {"localhost", "127.0.0.1", "0.0.0.0"}:
continue
try:
port = parsed.port
except ValueError:
# A parseable hostname with a non-numeric suffix is still useful
# relation evidence; omit only the unsafe port component.
port = None
suffix = f":{port}" if port else ""
evidence.append(f"{name} hostname={hostname}{suffix}")
return sorted(set(evidence))
def _appops_inventory(appops_url: str | None) -> AppOpsInventory:
if not appops_url:
return AppOpsInventory({}, False, "AppOps URL is not configured")
base = appops_url.rstrip("/")
try:
mappings_response = httpx.get(f"{base}/api/inventory/mappings", timeout=5)
apps_response = httpx.get(f"{base}/api/inventory/apps", timeout=5)
mappings_response.raise_for_status()
apps_response.raise_for_status()
mappings = mappings_response.json()
apps = apps_response.json()
except (httpx.HTTPError, ValueError) as exc:
return AppOpsInventory({}, False, f"AppOps inventory unavailable: {type(exc).__name__}")
app_by_id = {str(item.get("id")): item for item in apps if isinstance(item, dict)}
result: dict[str, AppOpsInfo] = {}
for mapping in mappings if isinstance(mappings, list) else []:
if not isinstance(mapping, dict):
continue
container = str(mapping.get("containerName") or "")
app_id = str(mapping.get("appId") or "")
app = app_by_id.get(app_id, {})
try:
confidence = float(app.get("discovery_confidence")) if app.get("discovery_confidence") is not None else None
except (TypeError, ValueError):
confidence = None
if container:
result[container.casefold()] = AppOpsInfo(
app_id=app_id,
category=str(app.get("category")) if app.get("category") else None,
criticality=str(app.get("criticality")) if app.get("criticality") else None,
health=str(app.get("health_status")) if app.get("health_status") else None,
display_name=str(app.get("display_name")) if app.get("display_name") else None,
discovery_confidence=max(0.0, min(confidence, 1.0)) if confidence is not None else None,
policy_profile=str(app.get("policy_profile")) if app.get("policy_profile") else None,
app_type=str(app.get("app_type")) if app.get("app_type") else None,
reasons=tuple(str(item) for item in (app.get("discovery_reasons") or [])[:3]),
)
return AppOpsInventory(result, True, f"{len(result)} container mappings loaded")
def appops_inventory(appops_url: str | None) -> AppOpsInventory:
"""Read AppOps health inventory without triggering Docker discovery."""
return _appops_inventory(appops_url)
def observe_container_health(docker_host: str) -> list[DockerHealthObservation]:
"""Read bounded Docker runtime/health facts without discovery side effects.
This deliberately avoids endpoint preflight, relationship inference,
suggestion persistence, reconciliation and snapshot capture. It is safe
to run on the observation cadence because it cannot rewrite topology or
operator-owned configuration.
"""
parsed = urlparse(docker_host)
if parsed.scheme not in ALLOWED_SCHEMES:
raise ValueError("docker_host must use unix://, tcp://, http:// or https://")
client = None
try:
client = (
docker.DockerClient(base_url=docker_host, timeout=5)
if docker_provider_mode() == "socket"
else docker_client_for_inventory(docker_host)
)
client.ping()
observations: list[DockerHealthObservation] = []
for container in client.containers.list(all=True):
attrs = container.attrs or {}
state = attrs.get("State", {}) or {}
config = attrs.get("Config", {}) or {}
raw_labels = {str(key): str(value) for key, value in (config.get("Labels", {}) or {}).items()}
name = str(getattr(container, "name", "unknown")).lstrip("/")
observations.append(
DockerHealthObservation(
container_id=container.id[:12],
name=name,
stable_source_ref=stable_source_ref(name, raw_labels),
state=str(state.get("Status", getattr(container, "status", "unknown"))),
health=(
str(state.get("Health", {}).get("Status"))
if isinstance(state.get("Health"), dict) and state.get("Health", {}).get("Status") is not None
else None
),
healthcheck_present=bool(config.get("Healthcheck")),
)
)
return observations
except (DockerException, DockerProviderError) as exc:
raise RuntimeError(f"Docker observation unavailable: {type(exc).__name__}") from exc
finally:
close = getattr(client, "close", None)
if callable(close):
close()
def _appops_proxy_routes(appops_url: str | None) -> list[ProxyRoute]:
if not appops_url:
return []
try:
payload = httpx.get(f"{appops_url.rstrip('/')}/api/apps/npm/proxy-hosts", timeout=5).json()
except (httpx.HTTPError, ValueError):
return []
items = payload.get("items", []) if isinstance(payload, dict) else payload
routes: list[ProxyRoute] = []
for item in items if isinstance(items, list) else []:
if not isinstance(item, dict) or item.get("enabled") is False:
continue
domains = tuple(sorted({str(domain).strip() for domain in item.get("domainNames", []) if str(domain).strip()}))
host = str(item.get("forwardHost") or "").strip()
try:
port = int(item.get("forwardPort"))
except (TypeError, ValueError):
continue
scheme = str(item.get("forwardScheme") or "http").casefold()
if not domains or not host or not 1 <= port <= 65535 or scheme not in {"http", "https"}:
continue
routes.append(ProxyRoute(domains=domains, forward_host=host, forward_port=port, forward_scheme=scheme))
return routes
def _suggestions(
name: str,
networks: list[str],
ports: list[DockerPort],
labels: dict[str, str],
*,
image: str,
state: str,
appops: AppOpsInfo | None = None,
stable_ref: str,
probe_host: str | None = None,
) -> list[DockerSuggestion]:
if labels.get("servicemap.enabled", "true").casefold() == "false":
return []
kind, url, host, port, evidence = _kind_and_endpoint(name, ports, labels, probe_host)
depends = [item.strip() for item in labels.get("servicemap.depends_on", "").split(",") if item.strip()]
compose_depends = labels.get("com.docker.compose.depends_on", "")
if compose_depends:
depends.extend(item.split(":", 1)[0].strip() for item in compose_depends.split(",") if item.strip())
evidence.append("Compose depends_on metadata")
if depends:
evidence.append("explicit dependency metadata")
if appops:
confidence = f" ({appops.discovery_confidence:.2f})" if appops.discovery_confidence is not None else ""
evidence.append(f"AppOps application mapping{confidence}; application scope only")
evidence.extend(appops.reasons)
category = _classify(name, image, labels, appops)
return [
DockerSuggestion(
name=labels.get("servicemap.name", appops.display_name if appops and appops.display_name else name),
kind=kind,
host=host,
port=port,
url=url,
group=labels.get("servicemap.group") or (f"Compose · {labels.get('com.docker.compose.project')}" if labels.get("com.docker.compose.project") else category.value.replace("_", " ").title()),
evidence=sorted(set(evidence)),
confidence="high" if labels.get("servicemap.probe") or labels.get("servicemap.name") or compose_depends or (appops and (appops.discovery_confidence or 0) >= 0.75) else "medium",
depends_on_names=sorted(set(depends)),
stable_source_ref=stable_ref,
category=category,
visibility=_visibility(name, state, category, labels),
)
]
def discover_containers(
docker_host: str,
existing_services: list[Service] | None = None,
*,
include_stopped: bool = True,
appops_url: str | None = None,
probe_host: str | None = None,
) -> DockerDiscoveryResult:
parsed = urlparse(docker_host)
if parsed.scheme not in ALLOWED_SCHEMES:
raise ValueError("docker_host must use unix://, tcp://, http:// or https://")
# Keep the direct client import at this boundary so existing deployments
# and deterministic SDK-contract tests retain the socket behaviour. Broker
# mode is explicit and never falls back here.
client = docker.DockerClient(base_url=docker_host, timeout=5) if docker_provider_mode() == "socket" else docker_client_for_inventory(docker_host)
try:
client.ping()
containers = client.containers.list(all=include_stopped)
existing_services = existing_services or []
appops_inventory = _appops_inventory(appops_url)
appops = appops_inventory.by_container
configured_probe_host = (probe_host or os.getenv("SERVICEMAP_PROBE_HOST") or "").strip() or None
proxy_routes = _appops_proxy_routes(appops_url)
candidates: list[DockerCandidate] = []
discovered_refs: set[str] = set()
for container in containers:
attrs = container.attrs or {}
state = attrs.get("State", {}) or {}
config = attrs.get("Config", {}) or {}
network_settings = attrs.get("NetworkSettings", {}) or {}
raw_ports = network_settings.get("Ports", {}) or {}
ports: list[DockerPort] = []
for container_port, bindings in raw_ports.items():
if not bindings:
ports.append(DockerPort(container_port=container_port))
continue
for binding in bindings:
raw_host_port = binding.get("HostPort")
host_port = str(raw_host_port) if raw_host_port is not None else ""
ports.append(
DockerPort(
container_port=container_port,
host_ip=binding.get("HostIp") or None,
host_port=int(host_port) if host_port.isdigit() else None,
)
)
image_tags = getattr(container.image, "tags", []) or []
image_name = image_tags[0] if image_tags else str(config.get("Image", "unknown"))
name = str(getattr(container, "name", "unknown")).lstrip("/")
health = state.get("Health", {}).get("Status") if isinstance(state.get("Health"), dict) else None
state_name = str(state.get("Status", getattr(container, "status", "unknown")))
networks = sorted((network_settings.get("Networks", {}) or {}).keys())
raw_labels = {str(key): str(value) for key, value in (config.get("Labels", {}) or {}).items()}
labels = _safe_labels(raw_labels)
stable_ref = stable_source_ref(name, raw_labels)
legacy_ref = _legacy_source_ref(container.id, image_name)
discovered_refs.add(stable_ref)
matching_source = next(
(
service
for service in existing_services
if service.source_ref in {stable_ref, legacy_ref}
or (service.source_ref and service.source_ref.removeprefix("docker:").startswith(container.id[:12]))
),
None,
)
matching_name = next((service for service in existing_services if service.name.casefold() == name.casefold()), None)
appops_info = appops.get(name.casefold())
category = _classify(name, image_name, raw_labels, appops_info)
visibility = _visibility(name, state_name, category, raw_labels)
if matching_source is not None:
import_state = "already imported" if matching_source.source_ref in {stable_ref, legacy_ref} else "changed"
elif matching_name is not None:
import_state = "already imported"
else:
import_state = "new"
suggestions = _suggestions(
name,
networks,
ports,
raw_labels,
image=image_name,
state=state_name,
appops=appops_info,
stable_ref=stable_ref,
probe_host=configured_probe_host,
)
for suggestion in suggestions:
suggestion.source_type = "docker"
suggestion.source_ref = legacy_ref
env_evidence = safe_environment_evidence([str(item) for item in (config.get("Env") or [])])
probe_candidates = _probe_candidates(
suggestions[0] if suggestions else None,
healthcheck_present=bool(config.get("Healthcheck")),
)
if state_name == "running":
probe_candidates = [_preflight_candidate(item) for item in probe_candidates]
candidates.append(
DockerCandidate(
container_id=container.id[:12],
name=name,
image=image_name,
state=state_name,
health=health,
networks=networks,
ports=ports,
labels=labels,
import_state=import_state,
suggestions=suggestions,
stable_source_ref=stable_ref,
compose_project=raw_labels.get("com.docker.compose.project"),
compose_service=raw_labels.get("com.docker.compose.service"),
category=category,
visibility=visibility,
lifecycle=DiscoveryLifecycle.ACTIVE if state_name == "running" else DiscoveryLifecycle.STALE,
healthcheck_present=bool(config.get("Healthcheck")),
environment_evidence=env_evidence,
proxy_route_evidence=[],
appops_app_id=appops_info.app_id if appops_info else None,
appops_category=appops_info.category if appops_info else None,
appops_criticality=appops_info.criticality if appops_info else None,
appops_health=appops_info.health if appops_info else None,
appops_scope=EvidenceScope.APPLICATION if appops_info else None,
appops_discovery_confidence=appops_info.discovery_confidence if appops_info else None,
appops_policy_profile=appops_info.policy_profile if appops_info else None,
appops_app_type=appops_info.app_type if appops_info else None,
probe_candidates=probe_candidates,
)
)
for candidate in candidates:
candidate.dependency_suggestions = sorted(
other.name
for other in candidates
if other.name != candidate.name and set(candidate.networks).intersection(other.networks)
)
# Reverse-proxy metadata is useful evidence, but it is never turned
# into a health dependency automatically. Persist only domains and the
# sanitized upstream tuple; AppOps metadata such as certificate or
# Let's Encrypt fields is intentionally discarded.
by_name = {candidate.name.casefold(): candidate for candidate in candidates}
by_published_port = {
port.host_port: candidate
for candidate in candidates
for port in candidate.ports
if port.host_port is not None
}
for route in proxy_routes:
target = by_name.get(route.forward_host.casefold()) or by_published_port.get(route.forward_port)
if target:
target.proxy_route_evidence.append(
f"Reverse proxy route: {', '.join(route.domains)} -> {route.forward_scheme}://{route.forward_host}:{route.forward_port}"
)
for service in existing_services:
if service.source_type != "docker" or not service.source_ref:
continue
if service.source_ref not in discovered_refs and service.lifecycle not in {DiscoveryLifecycle.RETIRED, DiscoveryLifecycle.IGNORED}:
candidates.append(
DockerCandidate(
container_id=service.source_ref.removeprefix("docker:")[:12],
name=service.name,
image=service.container_image or "not found",
state="missing",
health=None,
networks=[],
ports=[],
import_state="missing",
stable_source_ref=service.source_ref,
category=service.category,
visibility=service.visibility,
lifecycle=DiscoveryLifecycle.MISSING,
)
)
candidates.sort(key=lambda item: item.name.casefold())
return DockerDiscoveryResult(
docker_host=docker_host,
candidates=candidates,
proxy_routes=len(proxy_routes),
appops_available=appops_inventory.available,
appops_detail=appops_inventory.detail,
)
except (DockerException, DockerProviderError) as exc:
raise ValueError(f"Docker discovery failed: {exc}") from exc
finally:
client.close()
+334
View File
@@ -0,0 +1,334 @@
"""Docker inventory providers with an explicit security boundary.
The legacy Unix-socket provider is retained for compatibility. The broker
provider is opt-in and deliberately never falls back to the socket: a failed
broker configuration must be visible to the operator rather than silently
re-expanding ServiceMap's Docker privilege boundary.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit, urlunsplit
import docker
import httpx
class DockerProviderError(ValueError):
pass
_BROKER_CONNECT_TIMEOUT_SECONDS = 2.0
_BROKER_OPERATION_TIMEOUT_SECONDS = 5.0
_BROKER_MAX_RESPONSE_BYTES = 8 * 1024 * 1024
_BROKER_MAX_CONTAINER_ITEMS = 10_000
_BROKER_PATH = "/v1/containers"
_BROKER_BEARER_TOKEN = re.compile(r"[^\x21-\x7e]")
def _invalid_inventory() -> DockerProviderError:
return DockerProviderError("Docker broker returned an invalid inventory response")
def _contains_ascii_control(value: str) -> bool:
return any(ord(char) < 32 or ord(char) == 127 for char in value)
@dataclass
class BrokerImage:
tags: list[str]
@dataclass
class BrokerContainer:
id: str
name: str
status: str
attrs: dict[str, Any]
image: BrokerImage
class BrokerContainers:
def __init__(self, provider: BrokerDockerClient) -> None: # type: ignore[name-defined]
self.provider = provider
def list(self, *, all: bool = True) -> list[BrokerContainer]:
del all # Broker registration scope is deliberate; it returns all allowed targets.
payload = self.provider._get(_BROKER_PATH)
items = payload.get("items")
if not isinstance(items, list) or len(items) > _BROKER_MAX_CONTAINER_ITEMS:
raise _invalid_inventory()
if any(not isinstance(item, dict) for item in items):
raise _invalid_inventory()
return [self.provider._container(item) for item in items]
class BrokerDockerClient:
"""Minimal Docker SDK-shaped client over the registered read broker."""
def __init__(self, base_url: str, token: str) -> None:
self.base_url = self._validated_base_url(base_url)
if (
not isinstance(token, str)
or not token
or len(token) > 8192
or _BROKER_BEARER_TOKEN.search(token) is not None
):
raise DockerProviderError("Docker broker token is invalid")
# Keep the credential out of client-wide headers: health checks are
# public and only the one fixed inventory request needs authorization.
self._authorization = f"Bearer {token}"
self.http = httpx.Client(
timeout=httpx.Timeout(
_BROKER_OPERATION_TIMEOUT_SECONDS,
connect=_BROKER_CONNECT_TIMEOUT_SECONDS,
pool=_BROKER_CONNECT_TIMEOUT_SECONDS,
),
follow_redirects=False,
trust_env=False,
)
self.containers = BrokerContainers(self)
@staticmethod
def _validated_base_url(base_url: str) -> str:
if (
not isinstance(base_url, str)
or not base_url
or any(ord(char) < 33 for char in base_url)
):
raise DockerProviderError("Docker broker URL is invalid")
try:
parsed = urlsplit(base_url)
# Accessing .port also rejects malformed or out-of-range ports.
_ = parsed.port
except ValueError:
raise DockerProviderError("Docker broker URL is invalid") from None
if (
parsed.scheme.casefold() not in {"http", "https"}
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
or "\\" in parsed.path
or any(part == ".." for part in parsed.path.split("/"))
):
raise DockerProviderError("Docker broker URL is invalid")
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", ""))
@classmethod
def from_environment(cls) -> BrokerDockerClient:
# Never accept a request-provided Docker host as a broker destination:
# that could otherwise send the dedicated bearer token to an attacker-
# controlled endpoint. Broker identity is deployment configuration only.
url = (os.getenv("SERVICEMAP_DOCKER_BROKER_URL") or "").strip()
token_file = (os.getenv("SERVICEMAP_DOCKER_BROKER_TOKEN_FILE") or "").strip()
if not url or not token_file:
raise DockerProviderError(
"Broker mode requires SERVICEMAP_DOCKER_BROKER_URL and SERVICEMAP_DOCKER_BROKER_TOKEN_FILE"
)
path = Path(token_file)
if not path.is_file():
raise DockerProviderError("Docker broker token file is unavailable")
try:
token = path.read_text(encoding="utf-8").strip()
except OSError:
raise DockerProviderError("Docker broker token file cannot be read") from None
if not token or len(token) > 8192 or _BROKER_BEARER_TOKEN.search(token) is not None:
raise DockerProviderError("Docker broker token file is invalid")
return cls(url, token)
def _get(self, path: str) -> dict[str, Any]:
if path != _BROKER_PATH:
raise DockerProviderError("Docker broker operation is unsupported")
try:
with self.http.stream(
"GET",
f"{self.base_url}{path}",
headers={"Authorization": self._authorization},
) as response:
response.raise_for_status()
if (
response.headers.get("content-type", "").split(";", 1)[0].strip().casefold()
!= "application/json"
):
raise _invalid_inventory()
length = response.headers.get("content-length")
if length is not None:
try:
if int(length) < 0 or int(length) > _BROKER_MAX_RESPONSE_BYTES:
raise _invalid_inventory()
except ValueError:
raise _invalid_inventory() from None
raw = bytearray()
for chunk in response.iter_bytes():
if len(raw) + len(chunk) > _BROKER_MAX_RESPONSE_BYTES:
raise _invalid_inventory()
raw.extend(chunk)
except httpx.HTTPError:
raise DockerProviderError("Docker broker request failed") from None
try:
payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
raise DockerProviderError("Docker broker returned invalid JSON") from None
if not isinstance(payload, dict):
raise _invalid_inventory()
return payload
def ping(self) -> bool:
# /healthz is intentionally unprivileged; an authenticated inventory
# request immediately follows and remains the actual authorization test.
try:
with self.http.stream("GET", f"{self.base_url}/healthz") as response:
response.raise_for_status()
except httpx.HTTPError:
raise DockerProviderError("Docker broker health check failed") from None
return True
@staticmethod
def _container(item: dict[str, Any]) -> BrokerContainer:
def text_field(field: str, *, optional_default: str | None = None) -> str:
if field not in item:
if optional_default is not None:
return optional_default
raise _invalid_inventory()
value = item[field]
if optional_default is not None and value is None:
return optional_default
if (
not isinstance(value, str)
or not value
or len(value) > 4096
or _contains_ascii_control(value)
):
raise _invalid_inventory()
return value
ref = text_field("containerRef")
name = text_field("name").lstrip("/")
if not name:
raise _invalid_inventory()
state = text_field("state")
health = text_field("health", optional_default="none")
image = text_field("image")
labels_value = item.get("labels")
if not isinstance(labels_value, dict) or any(
not isinstance(key, str)
or not isinstance(value, str)
or not key
or len(key) > 4096
or len(value) > 4096
or _contains_ascii_control(key + value)
for key, value in labels_value.items()
):
raise _invalid_inventory()
networks_value = item.get("networks")
if not isinstance(networks_value, list) or any(
not isinstance(name, str)
or not name
or len(name) > 4096
or _contains_ascii_control(name)
for name in networks_value
):
raise _invalid_inventory()
# The deployed broker reports Docker health but does not yet emit an
# explicit healthcheckPresent flag. A concrete health status itself
# proves a healthcheck exists; preserve that discovery signal.
healthcheck_present = item.get("healthcheckPresent", health.casefold() not in {"none", "unknown"})
if not isinstance(healthcheck_present, bool):
raise _invalid_inventory()
ports: dict[str, list[dict[str, str]]] = {}
raw_ports = item.get("ports")
if not isinstance(raw_ports, dict):
raise _invalid_inventory()
for key, bindings in raw_ports.items():
if (
not isinstance(key, str)
or not key
or len(key) > 128
or _contains_ascii_control(key)
):
raise _invalid_inventory()
if bindings is None:
ports[key] = []
continue
if not isinstance(bindings, list):
raise _invalid_inventory()
ports[key] = []
for binding in bindings:
if not isinstance(binding, dict):
raise _invalid_inventory()
host_ip = binding.get("hostIp", "")
host_port = binding.get("hostPort", "")
if host_ip is None:
host_ip = ""
if host_port is None:
host_port = ""
if (
not isinstance(host_ip, str)
or not isinstance(host_port, (str, int))
or isinstance(host_port, bool)
):
raise _invalid_inventory()
host_port = str(host_port)
if (
len(host_ip) > 128
or _contains_ascii_control(host_ip + host_port)
or (
host_port
and (
len(host_port) > 5
or not all("0" <= char <= "9" for char in host_port)
or not 1 <= int(host_port) <= 65535
)
)
):
raise _invalid_inventory()
ports[key].append({"HostIp": host_ip, "HostPort": host_port})
return BrokerContainer(
id=ref,
name=name,
status=state,
attrs={
"State": {
"Status": state,
"Health": {"Status": health} if health != "none" else {},
},
"Config": {
"Image": image,
"Labels": labels_value,
"Env": [],
**({"Healthcheck": {"BrokerReported": True}} if healthcheck_present else {}),
},
"NetworkSettings": {
"Networks": {name: {} for name in networks_value},
"Ports": ports,
},
},
image=BrokerImage(tags=[image]),
)
def close(self) -> None:
self.http.close()
def docker_client_for_inventory(docker_host: str):
provider = os.getenv("SERVICEMAP_DOCKER_PROVIDER", "socket").strip().casefold()
if provider == "socket":
return docker.DockerClient(base_url=docker_host, timeout=5)
if provider == "broker":
return BrokerDockerClient.from_environment()
raise DockerProviderError("SERVICEMAP_DOCKER_PROVIDER must be 'socket' or 'broker'")
def docker_provider_mode() -> str:
return os.getenv("SERVICEMAP_DOCKER_PROVIDER", "socket").strip().casefold() or "socket"
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import asyncio
import json
from queue import Empty, Full, Queue
from threading import Lock
from typing import Any
class EventBus:
"""Small bounded fan-out bus used by the SSE endpoint.
Persistence is handled by SQLite; this bus only wakes currently connected
clients. A queue per client keeps one slow browser from blocking probes or
other subscribers.
"""
def __init__(self, max_queue_size: int = 100) -> None:
self.max_queue_size = max_queue_size
self._subscribers: set[Queue[dict[str, Any]]] = set()
self._lock = Lock()
def subscribe(self) -> Queue[dict[str, Any]]:
queue: Queue[dict[str, Any]] = Queue(maxsize=self.max_queue_size)
with self._lock:
self._subscribers.add(queue)
return queue
def unsubscribe(self, queue: Queue[dict[str, Any]]) -> None:
with self._lock:
self._subscribers.discard(queue)
def publish(self, payload: dict[str, Any]) -> None:
with self._lock:
subscribers = tuple(self._subscribers)
for queue in subscribers:
try:
queue.put_nowait(payload)
except Full:
# Drop the oldest event for a lagging client, then retain the
# newest state notification. The durable event journal remains
# the source of truth if a client reconnects.
try:
queue.get_nowait()
except Empty:
pass
try:
queue.put_nowait(payload)
except Full:
pass
event_bus = EventBus()
def sse_payload(payload: dict[str, Any]) -> str:
event_type = str(payload.get("event_type", "update"))
return f"event: {event_type}\ndata: {json.dumps(payload, default=str)}\n\n"
async def next_event(queue: Queue[dict[str, Any]], timeout: float = 15.0) -> dict[str, Any] | None:
try:
return await asyncio.to_thread(queue.get, True, timeout)
except Empty:
return None
+134
View File
@@ -0,0 +1,134 @@
"""Health-evidence selection policy.
The policy is deliberately small and pure. Storage retains every provider
claim; this module decides which *current component-scoped* claim can answer
the direct-health question. It keeps aggregate AppOps health useful in the
inspector without ever treating it as a component probe.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from .models import (
EvidenceFreshness,
EvidenceProvider,
EvidenceScope,
HealthEvidence,
HealthResolution,
HealthResolutionReason,
HealthStatus,
)
_PROVIDER_PRIORITY = {
EvidenceProvider.MANUAL: 100,
EvidenceProvider.PROBE: 90,
EvidenceProvider.DOCKER: 80,
EvidenceProvider.APPOPS: 40,
EvidenceProvider.RUNTIME: 20,
}
def appops_health_status(value: str | None) -> HealthStatus | None:
"""Keep AppOps aggregate health scoped to the application it describes."""
return {
"unhealthy": HealthStatus.DOWN,
"down": HealthStatus.DOWN,
"warning": HealthStatus.DEGRADED,
"degraded": HealthStatus.DEGRADED,
"healthy": HealthStatus.HEALTHY,
}.get((value or "").casefold())
def utc_now() -> datetime:
return datetime.now(UTC)
def freshness_for(evidence: HealthEvidence, *, now: datetime | None = None) -> EvidenceFreshness:
"""Return freshness without mutating the persisted observation.
The bounded stale window is solely explanatory: stale and expired claims
are both excluded from health selection. Showing the distinction helps an
operator understand whether a provider is merely late or genuinely old.
"""
now = now or utc_now()
if evidence.valid_until >= now:
return EvidenceFreshness.FRESH
ttl = max(evidence.valid_until - evidence.observed_at, timedelta(seconds=1))
return (
EvidenceFreshness.STALE if evidence.valid_until + ttl >= now else EvidenceFreshness.EXPIRED
)
def with_freshness(evidence: HealthEvidence, *, now: datetime | None = None) -> HealthEvidence:
return evidence.model_copy(update={"freshness": freshness_for(evidence, now=now)})
def resolve_health(
evidence: list[HealthEvidence], *, now: datetime | None = None
) -> HealthResolution:
"""Resolve current direct health from independently persisted evidence."""
now = now or utc_now()
annotated = [with_freshness(item, now=now) for item in evidence]
eligible = [
item
for item in annotated
if item.active
and item.scope == EvidenceScope.COMPONENT
and item.freshness == EvidenceFreshness.FRESH
]
if not eligible:
component_claims = [
item for item in annotated if item.scope == EvidenceScope.COMPONENT and item.active
]
app_claims = [
item for item in annotated if item.scope == EvidenceScope.APPLICATION and item.active
]
if component_claims:
reason_code = (
HealthResolutionReason.STALE_EVIDENCE
if any(item.freshness == EvidenceFreshness.STALE for item in component_claims)
else HealthResolutionReason.EXPIRED_EVIDENCE
)
elif app_claims:
reason_code = HealthResolutionReason.APPLICATION_SCOPED_ONLY
else:
reason_code = HealthResolutionReason.NO_CURRENT_COMPONENT_EVIDENCE
explanation = (
"Only application-scoped evidence is current; it is shown for provenance and is not "
"used as component health."
if app_claims
else "No current component-scoped health evidence is available."
)
return HealthResolution(reason_code=reason_code, explanation=explanation, evidence=annotated)
ordered = sorted(
eligible,
key=lambda item: (
_PROVIDER_PRIORITY[item.provider],
item.confidence,
item.observed_at,
item.received_at,
item.id,
),
reverse=True,
)
selected = ordered[0]
conflict = len({item.status for item in eligible}) > 1
scope_note = " Conflicting current component signals are retained below." if conflict else ""
return HealthResolution(
status=selected.status,
selected_evidence_id=selected.id,
selected_provider=selected.provider,
confidence=selected.confidence,
reason_code=HealthResolutionReason.SELECTED_COMPONENT_EVIDENCE,
explanation=(
f"{selected.provider.value} selected from current component-scoped evidence "
f"(confidence {selected.confidence:.2f}).{scope_note}"
),
conflict=conflict,
evidence=annotated,
)
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
from .models import HealthStatus, Service
from .store import Store
SEVERITY = {
HealthStatus.MAINTENANCE: 0,
HealthStatus.UNMONITORED: 0,
HealthStatus.UNKNOWN: 0,
HealthStatus.HEALTHY: 1,
HealthStatus.RECOVERING: 2,
HealthStatus.DEGRADED: 3,
HealthStatus.DOWN: 4,
}
def _direct_status(service: Service, store: Store) -> HealthStatus:
return store.service_health(service.id)
def _dependency_status(service: Service, store: Store, visiting: set[str]) -> HealthStatus:
if not service.depends_on:
return HealthStatus.HEALTHY
states: list[HealthStatus] = []
for dependency_id in service.depends_on:
dependency = store.get_service(dependency_id)
if dependency is None:
states.append(HealthStatus.UNKNOWN)
continue
states.append(_effective_status(dependency, store, visiting))
return max(states, key=lambda status: SEVERITY[status])
def _effective_status(service: Service, store: Store, visiting: set[str]) -> HealthStatus:
if service.id in visiting:
return HealthStatus.UNKNOWN
next_visiting = set(visiting)
next_visiting.add(service.id)
own = _direct_status(service, store)
if own == HealthStatus.MAINTENANCE:
return own
dependency = _dependency_status(service, store, next_visiting)
if own == HealthStatus.DOWN:
return own
if dependency == HealthStatus.DOWN:
return HealthStatus.DEGRADED
if dependency in {HealthStatus.DEGRADED, HealthStatus.RECOVERING} and own in {
HealthStatus.HEALTHY,
HealthStatus.RECOVERING,
}:
return HealthStatus.DEGRADED
return own
def dependency_status(service: Service, store: Store) -> HealthStatus:
return _dependency_status(service, store, {service.id})
def effective_status(service: Service, store: Store) -> HealthStatus:
return _effective_status(service, store, set())
def status_maps(
services: list[Service],
*,
direct_statuses: dict[str, HealthStatus] | None = None,
) -> tuple[dict[str, HealthStatus], dict[str, HealthStatus]]:
"""Calculate the complete topology in memory for one bounded API read."""
by_id = {service.id: service for service in services}
def legacy_direct_state(service: Service) -> HealthStatus:
runtime_state = (service.runtime_state or "").casefold()
runtime_health = (service.runtime_health or "").casefold()
if not service.enabled:
return HealthStatus.UNKNOWN
if service.maintenance_enabled:
return HealthStatus.MAINTENANCE
if runtime_health == "unhealthy" or runtime_state in {"exited", "dead", "stopped"}:
return HealthStatus.DOWN
if runtime_state == "restarting":
return HealthStatus.DEGRADED
if not service.monitoring_enabled:
if runtime_health == "healthy":
return HealthStatus.HEALTHY
return HealthStatus.UNMONITORED if service.monitoring_state.value in {"unmonitored", "observed"} else HealthStatus.UNKNOWN
return service.state
direct = {
service.id: (direct_statuses.get(service.id, HealthStatus.UNKNOWN) if direct_statuses else legacy_direct_state(service))
for service in services
}
effective: dict[str, HealthStatus] = {}
dependency: dict[str, HealthStatus] = {}
def visit(service_id: str, visiting: set[str]) -> HealthStatus:
if service_id in effective:
return effective[service_id]
if service_id in visiting:
return HealthStatus.UNKNOWN
service = by_id.get(service_id)
if service is None:
return HealthStatus.UNKNOWN
next_visiting = {*visiting, service_id}
own = direct[service_id]
if not service.depends_on:
dependency[service_id] = HealthStatus.HEALTHY
effective[service_id] = own
return own
dependencies = [visit(item, next_visiting) for item in service.depends_on]
dependency_state = max(dependencies, key=lambda status: SEVERITY[status])
dependency[service_id] = dependency_state
if own == HealthStatus.MAINTENANCE or own == HealthStatus.DOWN:
result = own
elif dependency_state == HealthStatus.DOWN or (
dependency_state in {HealthStatus.DEGRADED, HealthStatus.RECOVERING}
and own in {HealthStatus.HEALTHY, HealthStatus.RECOVERING}
):
result = HealthStatus.DEGRADED
else:
result = own
effective[service_id] = result
return result
for service in services:
visit(service.id, set())
return effective, dependency
def validate_dependencies(
depends_on: list[str],
store: Store,
*,
service_id: str | None = None,
) -> None:
if len(depends_on) != len(set(depends_on)):
raise ValueError("Dependencies must be unique")
if service_id and service_id in depends_on:
raise ValueError("A service cannot depend on itself")
services = store.list_services()
known_ids = {service.id for service in services}
missing = [dependency_id for dependency_id in depends_on if dependency_id not in known_ids]
if missing:
raise ValueError(f"Unknown dependency ids: {', '.join(missing)}")
if service_id is None:
return
graph = {service.id: list(service.depends_on) for service in services}
graph[service_id] = list(depends_on)
visiting: set[str] = set()
visited: set[str] = set()
def visit(node: str) -> None:
if node in visiting:
raise ValueError("Dependency graph contains a cycle")
if node in visited:
return
visiting.add(node)
for dependency_id in graph.get(node, []):
visit(dependency_id)
visiting.remove(node)
visited.add(node)
for node in graph:
visit(node)
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
from .health import dependency_status, effective_status
from .models import (
Criticality,
HealthStatus,
IncidentCandidate,
IncidentOverview,
IncidentRecord,
IncidentStatus,
Service,
)
from .store import Store
def _reverse_dependencies(services: list[Service]) -> dict[str, list[str]]:
reverse: dict[str, list[str]] = {service.id: [] for service in services}
for service in services:
for dependency_id in service.depends_on:
reverse.setdefault(dependency_id, []).append(service.id)
return reverse
def affected_service_ids(root_id: str, services: list[Service]) -> list[str]:
reverse = _reverse_dependencies(services)
seen: set[str] = set()
queue = list(reverse.get(root_id, []))
while queue:
current = queue.pop(0)
if current in seen or current == root_id:
continue
seen.add(current)
queue.extend(reverse.get(current, []))
return sorted(seen)
def incident_overview(store: Store) -> IncidentOverview:
services = store.list_services()
by_id = {service.id: service for service in services}
effective = {service.id: effective_status(service, store) for service in services}
dependency = {service.id: dependency_status(service, store) for service in services}
down_ids = sorted(service_id for service_id, state in effective.items() if state == HealthStatus.DOWN)
degraded_ids = sorted(
service_id for service_id, state in effective.items() if state in {HealthStatus.DEGRADED, HealthStatus.RECOVERING}
)
candidates: list[IncidentCandidate] = []
for service in services:
state = effective[service.id]
if state != HealthStatus.DOWN or service.maintenance_enabled:
continue
down_dependencies = [
dependency_id
for dependency_id in service.depends_on
if effective.get(dependency_id) == HealthStatus.DOWN
]
if down_dependencies:
continue
affected_ids = affected_service_ids(service.id, services)
affected_names = [by_id[item].name for item in affected_ids if item in by_id]
reason = "Service is down and no configured dependency is also down"
if dependency[service.id] == HealthStatus.UNKNOWN:
reason += "; at least one dependency has unknown health"
candidates.append(
IncidentCandidate(
service_id=service.id,
service_name=service.name,
status=state,
reason=reason,
affected_service_ids=affected_ids,
affected_service_names=affected_names,
)
)
candidates.sort(key=lambda item: (-len(item.affected_service_ids), item.service_name.lower()))
return IncidentOverview(
generated_at=datetime.now(UTC),
active=bool(down_ids or degraded_ids),
candidates=candidates,
degraded_service_ids=degraded_ids,
down_service_ids=down_ids,
incidents=[],
)
def _severity(service: Service) -> Criticality:
return service.criticality
def sync_incidents(store: Store, *, resolution_reason: str = "Root cause recovered; incident resolved") -> list[IncidentRecord]:
"""Reconcile the current graph with the durable incident journal.
A root service has at most one active incident. Repeated probe cycles only
update its affected set; a later independent outage gets a new id.
"""
overview = incident_overview(store)
services = {service.id: service for service in store.list_services()}
now = datetime.now(UTC)
active: dict[str, IncidentRecord] = {}
for incident in sorted(
store.list_incidents(active_only=True),
key=lambda item: (item.detected_at, item.id),
):
if incident.root_service_id in active:
store.upsert_incident(incident.model_copy(update={
"status": IncidentStatus.RESOLVED,
"resolved_at": now,
"latest_update_at": now,
}))
store.append_incident_event(
incident.id,
"incident.duplicate_resolved",
"Duplicate active incident closed; the earliest incident remains active",
incident.root_service_id,
)
continue
active[incident.root_service_id] = incident
for candidate in overview.candidates:
service = services.get(candidate.service_id)
if service is None:
continue
incident = active.get(candidate.service_id)
if incident is None:
incident = IncidentRecord(
id=str(uuid4()),
status=IncidentStatus.OPEN,
root_service_id=candidate.service_id,
root_service_name=candidate.service_name,
started_at=now,
detected_at=now,
title=f"{candidate.service_name} is down",
root_cause_reason=candidate.reason,
affected_service_ids=candidate.affected_service_ids,
affected_service_names=candidate.affected_service_names,
severity=_severity(service),
latest_update_at=now,
)
store.upsert_incident(incident)
store.append_incident_event(
incident.id,
"incident.opened",
f"Incident opened: {candidate.service_name} is the likely root cause",
candidate.service_id,
)
active[candidate.service_id] = incident
elif incident.affected_service_ids != candidate.affected_service_ids:
updated = incident.model_copy(
update={
"affected_service_ids": candidate.affected_service_ids,
"affected_service_names": candidate.affected_service_names,
"latest_update_at": now,
"root_cause_reason": candidate.reason,
}
)
store.upsert_incident(updated)
store.append_incident_event(incident.id, "incident.updated", "Downstream impact recalculated", candidate.service_id)
for root_id, incident in list(active.items()):
if any(candidate.service_id == root_id for candidate in overview.candidates):
continue
root = services.get(root_id)
if (
(root is None or store.service_health(root_id) not in {
HealthStatus.DOWN,
HealthStatus.DEGRADED,
HealthStatus.RECOVERING,
})
and incident.status != IncidentStatus.RESOLVED
):
resolved = incident.model_copy(
update={"status": IncidentStatus.RESOLVED, "resolved_at": now, "latest_update_at": now}
)
store.upsert_incident(resolved)
store.append_incident_event(incident.id, "incident.resolved", resolution_reason, root_id)
return store.list_incidents(active_only=False)
+743
View File
@@ -0,0 +1,743 @@
from __future__ import annotations
import asyncio
import os
import sqlite3
from contextlib import asynccontextmanager, suppress
from datetime import timedelta
from pathlib import Path
from fastapi import FastAPI, HTTPException, Query, Response, status
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from .auth import AuthConfig, AuthorizationError, authorize_request
from .config_io import export_document, import_document, parse_document, serialize_document
from .demo import DEMO_ROOT_CAUSE_NAME, DEMO_SCENARIO_NAME, build_demo_records
from .discovery import DiscoveryManager
from .discovery_store import DiscoveryStore
from .docker_discovery import discover_containers
from .docker_provider import docker_provider_mode
from .events import event_bus, next_event, sse_payload
from .health import dependency_status, effective_status, status_maps, validate_dependencies
from .incidents import incident_overview, sync_incidents
from .models import (
AvailabilitySummary,
BulkProbeResult,
ConfigImportRequest,
ConfigImportResult,
DemoMutationResult,
DemoState,
DiscoveryObservation,
DiscoveryReconcileRequest,
DiscoveryResult,
DiscoveryRunRequest,
DiscoveryStatus,
DockerDiscoveryRequest,
DockerDiscoveryResult,
EdgeActionRequest,
EdgeMutationRequest,
EventRecord,
HealthResolution,
IncidentOverview,
LogicalRelation,
MaintenanceRequest,
OverviewSummary,
ProbeRepairReport,
ProbeResult,
ProviderStatus,
ReconcileResult,
RelationEvidence,
SemanticTopology,
Service,
ServiceCreate,
ServiceSnapshot,
ServiceUpdate,
SuggestionStatus,
TopologyDiff,
TopologySnapshot,
TopologySuggestion,
)
from .observation import ObservationManager
from .probes import probe_many, probe_service
from .scheduler import scheduled_probe_loop
from .semantic import build_semantic_topology
from .store import Store
store = Store()
discovery_store = DiscoveryStore(store)
discovery_manager = DiscoveryManager(store, discovery_store)
def _env_int(name: str, default: int, minimum: int = 0) -> int:
try:
return max(minimum, int(os.getenv(name, str(default))))
except ValueError:
return default
def _env_float(name: str, default: float, minimum: float = 0.1) -> float:
try:
return max(minimum, float(os.getenv(name, str(default))))
except ValueError:
return default
PROBE_INTERVAL = _env_int("SERVICEMAP_PROBE_INTERVAL", 0)
PROBE_TIMEOUT = _env_float("SERVICEMAP_PROBE_TIMEOUT", 5.0)
PROBE_RETRIES = _env_int("SERVICEMAP_PROBE_RETRIES", 1)
PROBE_CONCURRENCY = _env_int("SERVICEMAP_PROBE_CONCURRENCY", 10, minimum=1)
RETENTION_DAYS = _env_int("SERVICEMAP_RETENTION_DAYS", 30, minimum=1)
EVENT_RETENTION_DAYS = _env_int("SERVICEMAP_EVENT_RETENTION_DAYS", 90, minimum=1)
DISCOVERY_INTERVAL = _env_int("SERVICEMAP_DISCOVERY_INTERVAL_SECONDS", 0, minimum=0)
OBSERVATION_INTERVAL = _env_int("SERVICEMAP_OBSERVATION_INTERVAL_SECONDS", 60, minimum=10)
observation_manager = ObservationManager(store, interval_seconds=OBSERVATION_INTERVAL)
def _require_configured_discovery_targets(
docker_host: str | None = None, appops_url: str | None = None
) -> None:
"""Request bodies cannot redirect an unauthenticated LAN deployment's inventory reads."""
configured_docker = os.getenv("SERVICEMAP_DOCKER_HOST", "unix:///var/run/docker.sock")
configured_appops = os.getenv("SERVICEMAP_APPOPS_URL", "")
if docker_host and docker_host != configured_docker:
raise HTTPException(status_code=403, detail="Docker discovery target is not configured")
if appops_url and appops_url.rstrip("/") != configured_appops.rstrip("/"):
raise HTTPException(status_code=403, detail="AppOps discovery target is not configured")
@asynccontextmanager
async def lifespan(_: FastAPI):
task: asyncio.Task[None] | None = None
discovery_task: asyncio.Task[None] | None = None
observation_task: asyncio.Task[None] | None = None
if PROBE_INTERVAL > 0:
task = asyncio.create_task(
scheduled_probe_loop(
store,
interval_seconds=PROBE_INTERVAL,
timeout_seconds=PROBE_TIMEOUT,
retries=PROBE_RETRIES,
concurrency=PROBE_CONCURRENCY,
retention_days=RETENTION_DAYS,
)
)
if DISCOVERY_INTERVAL > 0:
async def discovery_loop() -> None:
while True:
await asyncio.sleep(DISCOVERY_INTERVAL)
if store.demo_active():
continue
try:
request = DiscoveryRunRequest()
result = await asyncio.to_thread(discovery_manager.run, request)
await asyncio.to_thread(
discovery_manager.reconcile,
DiscoveryReconcileRequest(run_id=result.run.id, apply_high_confidence=False),
)
except (OSError, RuntimeError, ValueError):
# Discovery is review-first; a failed background run is
# persisted and must not take down the monitoring loop.
continue
discovery_task = asyncio.create_task(discovery_loop())
async def observation_loop() -> None:
while True:
if not store.demo_active():
try:
await asyncio.to_thread(observation_manager.refresh)
except (OSError, RuntimeError, ValueError):
# Provider availability is persisted by the observer; an
# unavailable provider must not stop scheduled probing.
pass
await asyncio.sleep(OBSERVATION_INTERVAL)
observation_task = asyncio.create_task(observation_loop())
try:
yield
finally:
if task:
task.cancel()
with suppress(asyncio.CancelledError):
await task
if discovery_task:
discovery_task.cancel()
with suppress(asyncio.CancelledError):
await discovery_task
if observation_task:
observation_task.cancel()
with suppress(asyncio.CancelledError):
await observation_task
app = FastAPI(title="ServiceMap API", version="1.6.2", lifespan=lifespan)
@app.middleware("http")
async def authorization_boundary(request, call_next):
try:
authorize_request(request)
except HTTPException as exc:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
return await call_next(request)
def _snapshot(service: Service) -> ServiceSnapshot:
services = store.list_services()
dependents = [item.name for item in services if service.id in item.depends_on]
return ServiceSnapshot(
service=service,
latest_probe=store.latest_probe(service.id),
availability_24h=store.availability_summary(service.id, 24),
dependency_status=dependency_status(service, store),
effective_status=effective_status(service, store),
direct_status=store.service_health(service.id),
flapping=service.flapping,
flap_count=service.flap_count,
next_probe_at=store.next_probe_at(service, PROBE_INTERVAL),
dependents=dependents,
sources=discovery_store.sources_for_service(service.id),
evidence=discovery_store.evidence_for_service(service.id),
health_evidence=store.health_evidence_for_service(service.id),
health_resolution=store.health_resolution(service.id),
)
def _name_conflict(name: str, *, exclude_id: str | None = None) -> bool:
existing = store.get_service_by_name(name)
return existing is not None and existing.id != exclude_id
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/v1/meta")
def meta() -> dict[str, str | int | float | bool]:
try:
auth_mode = AuthConfig.from_environment().mode
except AuthorizationError:
auth_mode = "misconfigured"
return {
"name": "ServiceMap",
"version": "1.6.2",
"stage": "v1",
"scheduled_probing": PROBE_INTERVAL > 0,
"probe_interval_seconds": PROBE_INTERVAL,
"probe_timeout_seconds": PROBE_TIMEOUT,
"probe_retries": PROBE_RETRIES,
"probe_concurrency": PROBE_CONCURRENCY,
"retention_days": RETENTION_DAYS,
"event_retention_days": EVENT_RETENTION_DAYS,
"sse": True,
"discovery": True,
"discovery_interval_seconds": DISCOVERY_INTERVAL,
"observation_interval_seconds": OBSERVATION_INTERVAL,
"docker_configured": bool(os.getenv("SERVICEMAP_DOCKER_HOST")),
"docker_provider": docker_provider_mode(),
"appops_configured": bool(os.getenv("SERVICEMAP_APPOPS_URL")),
"auth_mode": auth_mode,
}
def _service_snapshots(
*, include_hidden: bool = False, include_context: bool = True
) -> list[ServiceSnapshot]:
all_services = store.list_services()
services = (
all_services
if include_hidden
else [
service
for service in all_services
if service.visibility.value == "visible" and service.lifecycle.value not in {"retired", "ignored"}
]
)
latest = store.latest_probes([service.id for service in services])
availability = store.availability_summaries([service.id for service in services], 24)
evidence_by_service = store.health_evidence_for_services([service.id for service in all_services])
# Calculate propagation over the complete canonical graph first. Hidden
# infrastructure nodes may still sit between a visible service and its
# dependency; filtering before this calculation would turn that impact
# into a misleading unknown state.
direct_all, resolutions = store.service_healths(
[service.id for service in all_services], evidence_by_service=evidence_by_service
)
effective_all, dependency_all = status_maps(all_services, direct_statuses=direct_all)
dependent_names = {
service.id: [item.name for item in services if service.id in item.depends_on]
for service in services
}
return [
ServiceSnapshot(
service=service,
latest_probe=latest.get(service.id),
availability_24h=availability.get(service.id),
dependency_status=dependency_all.get(service.id, service.state),
effective_status=effective_all.get(service.id, service.state),
direct_status=direct_all.get(service.id, service.state),
flapping=service.flapping,
flap_count=service.flap_count,
next_probe_at=(
latest[service.id].checked_at + timedelta(seconds=service.interval_seconds or PROBE_INTERVAL)
if service.id in latest and (service.interval_seconds or PROBE_INTERVAL) > 0
else None
),
dependents=dependent_names.get(service.id, []),
sources=discovery_store.sources_for_service(service.id) if include_context else [],
evidence=discovery_store.evidence_for_service(service.id) if include_context else [],
health_evidence=evidence_by_service.get(service.id, []),
health_resolution=resolutions.get(service.id, HealthResolution()),
)
for service in services
]
@app.get("/api/v1/services", response_model=list[ServiceSnapshot])
def list_services(include_hidden: bool = Query(default=False)) -> list[ServiceSnapshot]:
return _service_snapshots(include_hidden=include_hidden)
@app.post("/api/v1/services", response_model=Service, status_code=status.HTTP_201_CREATED)
def create_service(payload: ServiceCreate) -> Service:
if _name_conflict(payload.name):
raise HTTPException(status_code=409, detail="Service name already exists")
try:
validate_dependencies(payload.depends_on, store)
return store.create_service(payload)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="Service name already exists") from exc
@app.get("/api/v1/services/{service_id}", response_model=ServiceSnapshot)
def get_service(service_id: str) -> ServiceSnapshot:
service = store.get_service(service_id)
if service is None:
raise HTTPException(status_code=404, detail="Service not found")
return _snapshot(service)
@app.put("/api/v1/services/{service_id}", response_model=Service)
def update_service(service_id: str, payload: ServiceUpdate) -> Service:
before = store.get_service(service_id)
if before is None:
raise HTTPException(status_code=404, detail="Service not found")
if _name_conflict(payload.name, exclude_id=service_id):
raise HTTPException(status_code=409, detail="Service name already exists")
try:
validate_dependencies(payload.depends_on, store, service_id=service_id)
updated = store.update_service(service_id, payload)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="Service name already exists") from exc
if updated is None:
raise HTTPException(status_code=404, detail="Service not found")
if before.source_type:
discovery_manager.record_service_override(before, updated)
return updated
@app.delete("/api/v1/services/{service_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_service(service_id: str) -> None:
if not store.delete_service(service_id):
raise HTTPException(status_code=404, detail="Service not found")
@app.post("/api/v1/services/{service_id}/probe", response_model=ProbeResult)
async def run_probe(
service_id: str,
timeout_seconds: float | None = Query(default=None, ge=0.1, le=30),
retries: int | None = Query(default=None, ge=0, le=5),
) -> ProbeResult:
if store.demo_active():
raise HTTPException(status_code=409, detail="Explore demo uses synthetic probe data; live probing is paused")
service = store.get_service(service_id)
if service is None:
raise HTTPException(status_code=404, detail="Service not found")
result = await probe_service(
service,
timeout_seconds=timeout_seconds,
retries=retries,
trigger="manual",
default_timeout_seconds=PROBE_TIMEOUT,
default_retries=PROBE_RETRIES,
)
return store.save_probe(result)
@app.post("/api/v1/probe-all", response_model=BulkProbeResult)
async def run_all_probes(
timeout_seconds: float | None = Query(default=None, ge=0.1, le=30),
retries: int | None = Query(default=None, ge=0, le=5),
concurrency: int = Query(default=PROBE_CONCURRENCY, ge=1, le=50),
) -> BulkProbeResult:
if store.demo_active():
return BulkProbeResult(requested=0, completed=0, results=[])
services = [service for service in store.list_services() if service.enabled and service.monitoring_enabled]
results = await probe_many(
services,
timeout_seconds=timeout_seconds,
retries=retries,
concurrency=concurrency,
trigger="manual",
default_timeout_seconds=PROBE_TIMEOUT,
default_retries=PROBE_RETRIES,
)
for result in results:
store.save_probe(result)
return BulkProbeResult(requested=len(services), completed=len(results), results=results)
@app.get("/api/v1/services/{service_id}/history", response_model=list[ProbeResult])
def probe_history(service_id: str, limit: int = Query(default=50, ge=1, le=500)) -> list[ProbeResult]:
if store.get_service(service_id) is None:
raise HTTPException(status_code=404, detail="Service not found")
return store.probe_history(service_id, limit)
@app.get("/api/v1/services/{service_id}/availability", response_model=AvailabilitySummary)
def availability(service_id: str, hours: int = Query(default=24, ge=1, le=24 * 90)) -> AvailabilitySummary:
if store.get_service(service_id) is None:
raise HTTPException(status_code=404, detail="Service not found")
return store.availability_summary(service_id, hours)
@app.get("/api/v1/services/{service_id}/evidence", response_model=HealthResolution)
def service_evidence(service_id: str) -> HealthResolution:
if store.get_service(service_id) is None:
raise HTTPException(status_code=404, detail="Service not found")
return store.health_resolution(service_id)
@app.get("/api/v1/providers", response_model=list[ProviderStatus])
def providers() -> list[ProviderStatus]:
return store.provider_statuses()
@app.post("/api/v1/services/{service_id}/maintenance", response_model=Service)
def maintenance(service_id: str, payload: MaintenanceRequest) -> Service:
updated = store.set_maintenance(service_id, payload.enabled, payload.reason)
if updated is None:
raise HTTPException(status_code=404, detail="Service not found")
return updated
@app.get("/api/v1/incidents", response_model=IncidentOverview)
def incidents() -> IncidentOverview:
sync_incidents(store)
overview = incident_overview(store)
overview.incidents = store.list_incidents(active_only=False)
return overview
@app.get("/api/v1/incidents/{incident_id}")
def get_incident(incident_id: str) -> dict[str, object]:
incident = store.get_incident(incident_id)
if incident is None:
raise HTTPException(status_code=404, detail="Incident not found")
return {"incident": incident.model_dump(mode="json"), "timeline": [item.model_dump(mode="json") for item in store.incident_timeline(incident_id)]}
@app.post("/api/v1/incidents/{incident_id}/acknowledge")
def acknowledge_incident(incident_id: str) -> dict[str, object]:
try:
incident = store.acknowledge_incident(incident_id)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if incident is None:
raise HTTPException(status_code=404, detail="Incident not found")
return {"incident": incident.model_dump(mode="json"), "timeline": [item.model_dump(mode="json") for item in store.incident_timeline(incident_id)]}
@app.get("/api/v1/events", response_model=list[EventRecord])
def events(limit: int = Query(default=50, ge=1, le=500), service_id: str | None = None) -> list[EventRecord]:
return store.list_events(limit=limit, service_id=service_id)
@app.get("/api/v1/events/stream")
async def event_stream() -> StreamingResponse:
queue = event_bus.subscribe()
async def stream():
try:
yield ": connected\n\n"
while True:
payload = await next_event(queue)
if payload is None:
yield ": keepalive\n\n"
else:
yield sse_payload(payload)
finally:
event_bus.unsubscribe(queue)
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.get("/api/v1/overview", response_model=OverviewSummary)
def overview() -> OverviewSummary:
sync_incidents(store)
snapshots = _service_snapshots(include_hidden=False)
overview_state = incident_overview(store)
counts: dict[str, int] = {}
coverage: dict[str, int] = {}
for snapshot in snapshots:
counts[snapshot.effective_status.value] = counts.get(snapshot.effective_status.value, 0) + 1
state = snapshot.service.monitoring_state.value
coverage[state] = coverage.get(state, 0) + 1
return OverviewSummary(
generated_at=overview_state.generated_at,
total_services=len(snapshots),
health_counts=counts,
active_incidents=len(store.list_incidents(active_only=True)),
likely_root_causes=[candidate.service_name for candidate in overview_state.candidates],
flapping_services=[snapshot.service.name for snapshot in snapshots if snapshot.flapping],
recent_events=store.list_events(limit=10),
monitoring_coverage=coverage,
)
@app.get("/api/v1/config")
def export_config(format: str = Query(default="yaml", pattern="^(yaml|json)$")) -> Response:
document = export_document(store)
content = serialize_document(document, format)
media_type = "application/json" if format == "json" else "application/yaml"
extension = "json" if format == "json" else "yaml"
return Response(content=content, media_type=media_type, headers={"Content-Disposition": f'attachment; filename="servicemap.{extension}"'})
@app.post("/api/v1/config/import", response_model=ConfigImportResult)
def import_config(payload: ConfigImportRequest) -> ConfigImportResult:
try:
document = parse_document(payload.content, payload.format)
return import_document(store, document, payload.mode)
except (ValueError, sqlite3.IntegrityError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
def _demo_state() -> DemoState:
if not store.demo_active():
return DemoState(active=False)
sync_incidents(store)
overview_state = incident_overview(store)
candidate = overview_state.candidates[0] if overview_state.candidates else None
return DemoState(
active=True,
synthetic=True,
scenario_name=DEMO_SCENARIO_NAME,
service_count=len(store.list_services()),
root_cause_service_id=candidate.service_id if candidate else None,
root_cause_service_name=candidate.service_name if candidate else DEMO_ROOT_CAUSE_NAME,
affected_service_ids=candidate.affected_service_ids if candidate else [],
affected_service_names=candidate.affected_service_names if candidate else [],
affected_count=len(candidate.affected_service_ids) if candidate else 0,
)
def _demo_result(result: ConfigImportResult | None = None, *, restored: bool = False) -> DemoMutationResult:
state = _demo_state()
return DemoMutationResult(
**state.model_dump(),
created=result.created if result else 0,
updated=result.updated if result else 0,
total=result.total if result else state.service_count,
restored=restored,
)
@app.get("/api/v1/demo", response_model=DemoState)
def demo_state() -> DemoState:
return _demo_state()
@app.post("/api/v1/demo", response_model=DemoMutationResult)
def load_demo() -> DemoMutationResult:
services, probes = build_demo_records()
result = store.activate_demo(services, probes)
sync_incidents(store)
return _demo_result(result)
@app.post("/api/v1/demo/reset", response_model=DemoMutationResult)
def reset_demo() -> DemoMutationResult:
services, probes = build_demo_records()
result = store.activate_demo(services, probes)
sync_incidents(store)
return _demo_result(result)
@app.post("/api/v1/demo/exit", response_model=DemoMutationResult)
async def exit_demo() -> DemoMutationResult:
restored = store.exit_demo()
# Refresh live provider evidence immediately after restoring the saved
# production topology. Test stores intentionally do not inherit the
# process-global observer, but production always does.
if restored and observation_manager.store is store:
await asyncio.to_thread(observation_manager.refresh)
return _demo_result(restored=restored)
@app.post("/api/v1/discovery/run", response_model=DiscoveryResult)
def run_discovery(payload: DiscoveryRunRequest | None = None) -> DiscoveryResult:
if store.demo_active():
raise HTTPException(status_code=409, detail="Exit Explore demo before discovering production infrastructure")
request = payload or DiscoveryRunRequest()
_require_configured_discovery_targets(request.docker_host, request.appops_url)
try:
return discovery_manager.run(request)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@app.get("/api/v1/discovery/status", response_model=DiscoveryStatus)
def discovery_status() -> DiscoveryStatus:
return DiscoveryStatus(**discovery_manager.status())
@app.get("/api/v1/discovery/items", response_model=list[DiscoveryObservation])
def discovery_items(run_id: str | None = None) -> list[DiscoveryObservation]:
selected = discovery_store.get_run(run_id) if run_id else discovery_store.last_completed()
return discovery_store.observations_for_run(selected.id) if selected else []
@app.get("/api/v1/discovery/suggestions", response_model=list[TopologySuggestion])
def discovery_suggestions(
status_filter: str | None = Query(default=None, alias="status"),
) -> list[TopologySuggestion]:
try:
suggestion_status = SuggestionStatus(status_filter) if status_filter else None
except ValueError as exc:
raise HTTPException(status_code=422, detail="Unknown suggestion status") from exc
return discovery_store.list_suggestions(status=suggestion_status)
@app.post("/api/v1/discovery/suggestions/{suggestion_id}/accept", response_model=TopologySuggestion)
def accept_discovery_suggestion(suggestion_id: str) -> TopologySuggestion:
try:
return discovery_manager.suggestion_action(suggestion_id, "confirm")
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post("/api/v1/discovery/suggestions/{suggestion_id}/reject", response_model=TopologySuggestion)
def reject_discovery_suggestion(suggestion_id: str) -> TopologySuggestion:
try:
return discovery_manager.suggestion_action(suggestion_id, "reject")
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post("/api/v1/discovery/reconcile", response_model=ReconcileResult)
def reconcile_discovery(payload: DiscoveryReconcileRequest | None = None) -> ReconcileResult:
if store.demo_active():
raise HTTPException(status_code=409, detail="Exit Explore demo before reconciling production infrastructure")
try:
return discovery_manager.reconcile(payload or DiscoveryReconcileRequest())
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@app.post("/api/v1/discovery/probes/repair", response_model=ProbeRepairReport)
def repair_discovered_probes(
dry_run: bool = Query(default=True),
run_id: str | None = Query(default=None),
) -> ProbeRepairReport:
if store.demo_active():
raise HTTPException(status_code=409, detail="Exit Explore demo before repairing production probe endpoints")
try:
return discovery_manager.repair_probe_reachability(run_id=run_id, dry_run=dry_run)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@app.get("/api/v1/topology/edges", response_model=list[RelationEvidence])
def topology_edges() -> list[RelationEvidence]:
return discovery_store.list_evidence()
@app.get("/api/v1/topology/relations", response_model=list[LogicalRelation])
def topology_relations() -> list[LogicalRelation]:
"""Canonical relationships: one graph edge, all supporting evidence retained."""
return discovery_store.list_logical_relations()
@app.get("/api/v1/topology/snapshots", response_model=list[TopologySnapshot])
def topology_snapshots(limit: int = Query(default=30, ge=1, le=200)) -> list[TopologySnapshot]:
return discovery_store.list_snapshots(limit=limit)
@app.get("/api/v1/topology/diff", response_model=TopologyDiff)
def topology_diff(
after_snapshot_id: str = Query(...),
before_snapshot_id: str | None = Query(default=None),
) -> TopologyDiff:
try:
return discovery_store.topology_diff(
after_snapshot_id=after_snapshot_id,
before_snapshot_id=before_snapshot_id,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.get("/api/v1/topology/semantic", response_model=SemanticTopology)
def semantic_topology(include_hidden: bool = Query(default=False)) -> SemanticTopology:
snapshots = _service_snapshots(include_hidden=include_hidden, include_context=False)
incident_state = incident_overview(store)
root_ids = {candidate.service_id for candidate in incident_state.candidates}
affected_ids = {
service_id
for candidate in incident_state.candidates
for service_id in candidate.affected_service_ids
}
return build_semantic_topology(
snapshots,
discovery_store.list_logical_relations(),
root_service_ids=root_ids,
affected_service_ids=affected_ids,
)
@app.post("/api/v1/topology/edges", response_model=RelationEvidence, status_code=status.HTTP_201_CREATED)
def create_topology_edge(payload: EdgeMutationRequest) -> RelationEvidence:
try:
return discovery_manager.apply_edge(payload)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@app.post("/api/v1/topology/edges/{evidence_id}/action", response_model=RelationEvidence)
def topology_edge_action(evidence_id: str, payload: EdgeActionRequest) -> RelationEvidence:
try:
return discovery_manager.edge_action(evidence_id, payload.action)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@app.post("/api/v1/docker/discover", response_model=DockerDiscoveryResult)
def docker_discover(payload: DockerDiscoveryRequest) -> DockerDiscoveryResult:
_require_configured_discovery_targets(payload.docker_host)
try:
return discover_containers(
payload.docker_host,
store.list_services(),
appops_url=os.getenv("SERVICEMAP_APPOPS_URL"),
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@app.post("/api/v1/maintenance/prune")
def prune_history(retention_days: int = Query(default=RETENTION_DAYS, ge=1, le=3650)) -> dict[str, int]:
return {"deleted_probe_results": store.prune_probes(retention_days, EVENT_RETENTION_DAYS)}
static_dir = Path(os.getenv("SERVICEMAP_STATIC_DIR", "/app/static"))
if static_dir.is_dir():
app.mount("/", StaticFiles(directory=static_dir, html=True), name="frontend")
+862
View File
@@ -0,0 +1,862 @@
from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from urllib.parse import urlparse
from pydantic import AliasChoices, BaseModel, Field, model_validator
class ServiceKind(StrEnum):
HTTP = "http"
TCP = "tcp"
DNS = "dns"
TLS = "tls"
class HealthStatus(StrEnum):
UNKNOWN = "unknown"
UNMONITORED = "unmonitored"
HEALTHY = "healthy"
DEGRADED = "degraded"
DOWN = "down"
RECOVERING = "recovering"
MAINTENANCE = "maintenance"
class EvidenceProvider(StrEnum):
"""Systems that can make a health claim about an infrastructure subject."""
PROBE = "active_probe"
DOCKER = "docker"
APPOPS = "appops"
RUNTIME = "runtime"
MANUAL = "manual"
class EvidenceScope(StrEnum):
"""Whether a claim describes one component or an aggregate application."""
COMPONENT = "component"
APPLICATION = "application"
class EvidenceFreshness(StrEnum):
FRESH = "fresh"
STALE = "stale"
EXPIRED = "expired"
class HealthResolutionReason(StrEnum):
"""Machine-readable explanation for a health answer or lack of one."""
SELECTED_COMPONENT_EVIDENCE = "selected_component_evidence"
APPLICATION_SCOPED_ONLY = "application_scoped_only"
NO_CURRENT_COMPONENT_EVIDENCE = "no_current_component_evidence"
STALE_EVIDENCE = "stale_evidence"
EXPIRED_EVIDENCE = "expired_evidence"
SELECTED_APPLICATION_EVIDENCE = "selected_application_evidence"
COMPONENT_FAILURE = "component_failure"
COMPONENT_IMPACT = "component_dependency_impact"
COMPONENT_COVERAGE = "component_coverage"
NO_APPLICATION_EVIDENCE = "no_application_evidence"
class ProviderHealth(StrEnum):
HEALTHY = "healthy"
DEGRADED = "degraded"
UNAVAILABLE = "unavailable"
UNKNOWN = "unknown"
class MonitoringState(StrEnum):
"""How a service is currently observed, independently of its health."""
ACTIVE = "active_probe"
DOCKER_HEALTH = "docker_health"
APPOPS = "appops_observation"
OBSERVED = "observed"
UNMONITORED = "unmonitored"
DISABLED = "disabled"
class ProbeEndpointSource(StrEnum):
MANUAL = "manual"
EXPLICIT_LABEL = "explicit_label"
KNOWN_URL = "known_url"
APPOPS = "appops"
REVERSE_PROXY = "reverse_proxy"
PUBLISHED_PORT = "published_port"
DOCKER_HEALTHCHECK = "docker_healthcheck"
CONTAINER_INTERNAL = "container_internal"
DISCOVERED_ENVIRONMENT = "discovered_environment"
class ProbeReachability(StrEnum):
VERIFIED = "verified"
REACHABLE = "reachable"
UNVERIFIED = "unverified"
INTERNAL_ONLY = "internal_only"
NOT_CONFIGURED = "not_configured"
class Criticality(StrEnum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class IncidentStatus(StrEnum):
OPEN = "open"
ACKNOWLEDGED = "acknowledged"
RESOLVED = "resolved"
class DiscoveryCategory(StrEnum):
APPLICATION = "application"
DATABASE = "database"
CACHE = "cache"
PROXY = "proxy"
MONITORING = "monitoring"
DEVELOPMENT = "development"
STORAGE = "storage"
INFRASTRUCTURE = "infrastructure"
BACKGROUND_WORKER = "background_worker"
SYSTEM = "system"
UNKNOWN = "unknown"
class ComponentRole(StrEnum):
"""The operational role a component plays inside an application."""
FRONTEND = "frontend"
API = "api"
DATABASE = "database"
CACHE = "cache"
PROXY = "proxy"
WORKER = "worker"
STORAGE = "storage"
MONITORING = "monitoring"
AGENT = "agent"
SCHEDULER = "scheduler"
AUXILIARY = "auxiliary"
UNKNOWN = "unknown"
class DiscoveryVisibility(StrEnum):
VISIBLE = "visible"
HIDDEN = "hidden"
IGNORED = "ignored"
class DiscoveryLifecycle(StrEnum):
ACTIVE = "active"
MISSING = "missing"
STALE = "stale"
IGNORED = "ignored"
RETIRED = "retired"
class DiscoveryConfidence(StrEnum):
CONFIRMED = "confirmed"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
class DiscoveryRunStatus(StrEnum):
IDLE = "idle"
DISCOVERING = "discovering"
RECONCILING = "reconciling"
COMPLETED = "completed"
FAILED = "failed"
class SuggestionStatus(StrEnum):
OPEN = "open"
ACCEPTED = "accepted"
REJECTED = "rejected"
SUPERSEDED = "superseded"
class RelationType(StrEnum):
DEPENDS_ON = "depends_on"
ROUTES_TO = "routes_to"
USES_STORAGE = "uses_storage"
COMMUNICATES_WITH = "communicates_with"
class ServiceCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
kind: ServiceKind
url: str | None = None
host: str | None = Field(default=None, validation_alias=AliasChoices("host", "hostname"))
port: int | None = Field(default=None, ge=1, le=65535)
depends_on: list[str] = Field(default_factory=list)
description: str | None = Field(default=None, max_length=500)
tags: list[str] = Field(default_factory=list, max_length=20)
group: str | None = Field(default=None, max_length=80)
environment: str | None = Field(default=None, max_length=80)
owner: str | None = Field(default=None, max_length=120)
enabled: bool = True
monitoring_enabled: bool = True
monitoring_state: MonitoringState = MonitoringState.ACTIVE
probe_source: ProbeEndpointSource = ProbeEndpointSource.MANUAL
probe_reachability: ProbeReachability = ProbeReachability.VERIFIED
probe_confidence: DiscoveryConfidence = DiscoveryConfidence.CONFIRMED
runtime_state: str | None = Field(default=None, max_length=40)
runtime_health: str | None = Field(default=None, max_length=40)
application: str | None = Field(default=None, max_length=120)
domain: str | None = Field(default=None, max_length=80)
component_role: ComponentRole = ComponentRole.UNKNOWN
application_locked: bool = False
domain_locked: bool = False
component_role_locked: bool = False
criticality: Criticality = Criticality.MEDIUM
category: DiscoveryCategory = DiscoveryCategory.UNKNOWN
visibility: DiscoveryVisibility = DiscoveryVisibility.VISIBLE
lifecycle: DiscoveryLifecycle = DiscoveryLifecycle.ACTIVE
source_type: str | None = Field(default=None, max_length=40)
source_ref: str | None = Field(default=None, max_length=240)
first_seen_at: datetime | None = None
last_seen_at: datetime | None = None
last_discovery_status: str | None = Field(default=None, max_length=40)
container_image: str | None = Field(default=None, max_length=240)
compose_project: str | None = Field(default=None, max_length=120)
layout_x: float | None = None
layout_y: float | None = None
layout_pinned: bool = False
interval_seconds: int | None = Field(default=None, ge=1, le=86400)
timeout_seconds: float | None = Field(default=None, ge=0.1, le=60)
retries: int | None = Field(default=None, ge=0, le=5)
failure_threshold: int = Field(default=1, ge=1, le=20)
recovery_threshold: int = Field(default=1, ge=1, le=20)
expected_status_min: int | None = Field(default=None, ge=100, le=599)
expected_status_max: int | None = Field(default=None, ge=100, le=599)
availability_target: float | None = Field(default=None, ge=0, le=100)
maintenance_enabled: bool = False
maintenance_reason: str | None = Field(default=None, max_length=240)
@model_validator(mode="after")
def validate_endpoint(self) -> ServiceCreate:
if not self.monitoring_enabled and not self.url and not self.host and self.port is None:
return self
if self.kind == ServiceKind.HTTP and not self.url:
raise ValueError("HTTP services require url")
if self.kind == ServiceKind.HTTP:
parsed = urlparse(self.url or "")
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("HTTP services require an absolute http(s) url")
if self.host or self.port:
raise ValueError("HTTP services must use url instead of host/port")
if self.kind in {ServiceKind.TCP, ServiceKind.TLS} and (not self.host or self.port is None):
raise ValueError(f"{self.kind.value.upper()} services require host and port")
if self.kind == ServiceKind.DNS and not self.host:
raise ValueError("DNS services require hostname in host")
if self.kind in {ServiceKind.TCP, ServiceKind.TLS, ServiceKind.DNS} and self.url:
raise ValueError(f"{self.kind.value.upper()} services must use host/port instead of url")
if self.kind == ServiceKind.DNS and self.port is not None:
raise ValueError("DNS services use a hostname without a port")
if (
self.expected_status_min is not None
and self.expected_status_max is not None
and self.expected_status_min > self.expected_status_max
):
raise ValueError("expected_status_min cannot exceed expected_status_max")
return self
class ServiceUpdate(ServiceCreate):
pass
class Service(ServiceCreate):
id: str
created_at: datetime
updated_at: datetime
state: HealthStatus = HealthStatus.UNKNOWN
failure_streak: int = 0
recovery_streak: int = 0
flapping: bool = False
flap_count: int = 0
last_state_change_at: datetime | None = None
class ProbeResult(BaseModel):
id: str
service_id: str
status: HealthStatus
latency_ms: float | None
detail: str
checked_at: datetime
trigger: str = "manual"
metadata: dict[str, object] = Field(default_factory=dict)
class HealthEvidence(BaseModel):
"""An immutable-ish, provider-scoped claim used to resolve direct health.
Application-scoped evidence is intentionally retained for provenance, but
cannot be promoted to a component's direct health without an explicit
component-level observation from that provider.
"""
id: str
subject_type: str = Field(default="service", max_length=40)
subject_id: str = Field(max_length=240)
service_id: str | None = None
provider: EvidenceProvider
provider_ref: str | None = Field(default=None, max_length=240)
status: HealthStatus
scope: EvidenceScope = EvidenceScope.COMPONENT
confidence: float = Field(default=1.0, ge=0, le=1)
observed_at: datetime
valid_until: datetime
received_at: datetime
summary: str = Field(default="", max_length=500)
metadata: dict[str, object] = Field(default_factory=dict)
active: bool = True
superseded_by: str | None = None
freshness: EvidenceFreshness = EvidenceFreshness.FRESH
class HealthResolution(BaseModel):
status: HealthStatus = HealthStatus.UNKNOWN
selected_evidence_id: str | None = None
selected_provider: EvidenceProvider | None = None
confidence: float | None = Field(default=None, ge=0, le=1)
reason_code: HealthResolutionReason = HealthResolutionReason.NO_CURRENT_COMPONENT_EVIDENCE
explanation: str = "No current component-scoped health evidence is available."
conflict: bool = False
evidence: list[HealthEvidence] = Field(default_factory=list)
class ApplicationHealthResolution(BaseModel):
"""Aggregate health answer that keeps application and component scopes separate."""
status: HealthStatus = HealthStatus.UNKNOWN
confidence: float | None = Field(default=None, ge=0, le=1)
explanation: str = "No current application or component health evidence is available."
reason_code: HealthResolutionReason = HealthResolutionReason.NO_APPLICATION_EVIDENCE
selected_evidence_id: str | None = None
selected_provider: EvidenceProvider | None = None
evidence_freshness: EvidenceFreshness | None = None
conflict: bool = False
resolved_components: int = 0
unresolved_components: int = 0
component_coverage_percent: float = 0
class ProviderStatus(BaseModel):
provider: EvidenceProvider
status: ProviderHealth = ProviderHealth.UNKNOWN
observed_at: datetime
valid_until: datetime
detail: str = Field(default="", max_length=500)
metadata: dict[str, object] = Field(default_factory=dict)
class ServiceSnapshot(BaseModel):
service: Service
latest_probe: ProbeResult | None = None
availability_24h: AvailabilitySummary | None = None
dependency_status: HealthStatus = HealthStatus.UNKNOWN
effective_status: HealthStatus = HealthStatus.UNKNOWN
direct_status: HealthStatus = HealthStatus.UNKNOWN
flapping: bool = False
flap_count: int = 0
next_probe_at: datetime | None = None
dependents: list[str] = Field(default_factory=list)
sources: list[ServiceSource] = Field(default_factory=list)
evidence: list[RelationEvidence] = Field(default_factory=list)
health_evidence: list[HealthEvidence] = Field(default_factory=list)
health_resolution: HealthResolution = Field(default_factory=HealthResolution)
class AvailabilitySummary(BaseModel):
hours: int = 24
total_checks: int = 0
healthy_checks: int = 0
degraded_checks: int = 0
down_checks: int = 0
uptime_percent: float | None = None
average_latency_ms: float | None = None
last_checked_at: datetime | None = None
target_percent: float | None = None
meets_target: bool | None = None
class BulkProbeResult(BaseModel):
requested: int
completed: int
results: list[ProbeResult]
class IncidentCandidate(BaseModel):
service_id: str
service_name: str
status: HealthStatus
reason: str
affected_service_ids: list[str] = Field(default_factory=list)
affected_service_names: list[str] = Field(default_factory=list)
class IncidentRecord(BaseModel):
id: str
status: IncidentStatus
root_service_id: str
root_service_name: str
started_at: datetime
detected_at: datetime
acknowledged_at: datetime | None = None
resolved_at: datetime | None = None
title: str
root_cause_reason: str
affected_service_ids: list[str] = Field(default_factory=list)
affected_service_names: list[str] = Field(default_factory=list)
severity: Criticality = Criticality.MEDIUM
latest_update_at: datetime
class IncidentEvent(BaseModel):
id: str
incident_id: str
event_type: str
message: str
created_at: datetime
service_id: str | None = None
class EventRecord(BaseModel):
id: str
event_type: str
service_id: str | None = None
incident_id: str | None = None
from_status: HealthStatus | None = None
to_status: HealthStatus | None = None
message: str
metadata: dict[str, object] = Field(default_factory=dict)
created_at: datetime
class IncidentOverview(BaseModel):
generated_at: datetime
active: bool
candidates: list[IncidentCandidate]
degraded_service_ids: list[str]
down_service_ids: list[str]
incidents: list[IncidentRecord] = Field(default_factory=list)
class ServiceSource(BaseModel):
id: str
source_type: str
source_id: str
source_name: str | None = None
first_seen_at: datetime
last_seen_at: datetime
lifecycle: DiscoveryLifecycle = DiscoveryLifecycle.ACTIVE
managed: bool = True
metadata: dict[str, object] = Field(default_factory=dict)
class RelationEvidence(BaseModel):
id: str
source_service_id: str
target_service_id: str | None = None
target_ref: str | None = None
relation_type: RelationType = RelationType.DEPENDS_ON
evidence_type: str
confidence: DiscoveryConfidence
explanation: str
source_ref: str | None = None
locked: bool = False
active: bool = True
first_seen_at: datetime
last_seen_at: datetime
class LogicalRelation(BaseModel):
"""One canonical relationship with all independent supporting evidence."""
id: str
source_service_id: str
target_service_id: str | None = None
target_ref: str | None = None
relation_type: RelationType = RelationType.DEPENDS_ON
confidence: DiscoveryConfidence
active: bool = True
locked: bool = False
first_seen_at: datetime
last_seen_at: datetime
evidence: list[RelationEvidence] = Field(default_factory=list)
class SemanticComponent(BaseModel):
service_id: str
name: str
kind: ServiceKind
application_id: str
domain_id: str
role: ComponentRole = ComponentRole.UNKNOWN
direct_status: HealthStatus
effective_status: HealthStatus
unknown_reason: HealthResolutionReason | None = None
runtime_state: str | None = None
runtime_health: str | None = None
monitoring_state: MonitoringState
latest_latency_ms: float | None = None
dependency_ids: list[str] = Field(default_factory=list)
dependent_ids: list[str] = Field(default_factory=list)
root_cause: bool = False
affected: bool = False
class SemanticApplication(BaseModel):
id: str
name: str
domain_id: str
component_ids: list[str] = Field(default_factory=list)
component_count: int = 0
roles: list[ComponentRole] = Field(default_factory=list)
status: HealthStatus
status_explanation: str
health_resolution: ApplicationHealthResolution = Field(default_factory=ApplicationHealthResolution)
dependency_application_ids: list[str] = Field(default_factory=list)
dependent_application_ids: list[str] = Field(default_factory=list)
monitored_components: int = 0
unmonitored_components: int = 0
resolved_components: int = 0
unresolved_components: int = 0
component_coverage_percent: float = 0
root_cause: bool = False
affected: bool = False
class SemanticDomain(BaseModel):
id: str
name: str
application_ids: list[str] = Field(default_factory=list)
application_count: int = 0
status: HealthStatus
resolved_application_count: int = 0
unresolved_application_count: int = 0
application_coverage_percent: float = 0
incident_count: int = 0
root_cause: bool = False
affected: bool = False
class SemanticTopology(BaseModel):
generated_at: datetime
domains: list[SemanticDomain] = Field(default_factory=list)
applications: list[SemanticApplication] = Field(default_factory=list)
components: list[SemanticComponent] = Field(default_factory=list)
relations: list[LogicalRelation] = Field(default_factory=list)
monitoring_coverage: dict[str, int] = Field(default_factory=dict)
class DiscoveryObservation(BaseModel):
id: str
run_id: str
source_id: str
source_type: str
source_name: str
lifecycle: DiscoveryLifecycle
change_type: str
observed_at: datetime
metadata: dict[str, object] = Field(default_factory=dict)
class TopologySuggestion(BaseModel):
id: str
run_id: str
suggestion_type: str
status: SuggestionStatus = SuggestionStatus.OPEN
source_ref: str | None = None
source_service_id: str | None = None
target_ref: str | None = None
target_service_id: str | None = None
proposed_name: str | None = None
proposed_kind: ServiceKind | None = None
proposed_url: str | None = None
proposed_host: str | None = None
proposed_port: int | None = None
proposed_category: DiscoveryCategory = DiscoveryCategory.UNKNOWN
proposed_group: str | None = None
relation_type: RelationType | None = None
confidence: DiscoveryConfidence = DiscoveryConfidence.LOW
evidence: list[str] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class DiscoveryDiff(BaseModel):
new: int = 0
changed: int = 0
missing: int = 0
unchanged: int = 0
stale: int = 0
new_suggestions: int = 0
class DiscoveryRun(BaseModel):
id: str
status: DiscoveryRunStatus
started_at: datetime
completed_at: datetime | None = None
source_counts: dict[str, int] = Field(default_factory=dict)
diff: DiscoveryDiff = Field(default_factory=DiscoveryDiff)
error: str | None = None
class TopologySnapshot(BaseModel):
"""Compact, immutable capture of the canonical topology after a run."""
id: str
run_id: str | None = None
created_at: datetime
revision: str
service_count: int = 0
relationship_count: int = 0
summary: dict[str, int] = Field(default_factory=dict)
class TopologyChange(BaseModel):
change_type: str
entity_type: str
entity_id: str
name: str
before: dict[str, object] | None = None
after: dict[str, object] | None = None
class TopologyDiff(BaseModel):
before_snapshot_id: str | None = None
after_snapshot_id: str
added: list[TopologyChange] = Field(default_factory=list)
removed: list[TopologyChange] = Field(default_factory=list)
changed: list[TopologyChange] = Field(default_factory=list)
summary: dict[str, int] = Field(default_factory=dict)
class DiscoveryRunRequest(BaseModel):
docker_host: str | None = None
appops_url: str | None = None
include_stopped: bool = False
class DiscoveryReconcileRequest(BaseModel):
run_id: str | None = None
apply_high_confidence: bool = False
include_stopped: bool = False
class EdgeMutationRequest(BaseModel):
source_service_id: str
target_service_id: str
relation_type: RelationType = RelationType.DEPENDS_ON
lock: bool = True
explanation: str = Field(default="Manual topology relationship", max_length=500)
class EdgeActionRequest(BaseModel):
action: str = Field(pattern="^(confirm|reject|lock|remove)$")
class ConfigService(ServiceCreate):
pass
class ConfigDocument(BaseModel):
# Version 1 remains accepted. Version 2 is emitted when the document uses
# the richer service fields; the parser keeps the old empty v2 rejection
# compatibility used by the v1 contract tests.
version: int = Field(default=2, ge=1, le=2)
services: list[ConfigService] = Field(default_factory=list)
class ConfigImportRequest(BaseModel):
content: str
format: str = Field(pattern="^(yaml|json)$")
mode: str = Field(default="merge", pattern="^(merge|replace)$")
class ConfigImportResult(BaseModel):
created: int
updated: int
total: int
class DemoState(BaseModel):
active: bool
synthetic: bool = False
scenario_name: str | None = None
service_count: int = 0
root_cause_service_id: str | None = None
root_cause_service_name: str | None = None
affected_service_ids: list[str] = Field(default_factory=list)
affected_service_names: list[str] = Field(default_factory=list)
affected_count: int = 0
class DemoMutationResult(DemoState):
created: int = 0
updated: int = 0
total: int = 0
restored: bool = False
class DockerDiscoveryRequest(BaseModel):
docker_host: str = "unix:///var/run/docker.sock"
class DockerPort(BaseModel):
container_port: str
host_ip: str | None = None
host_port: int | None = None
class DockerSuggestion(BaseModel):
name: str
kind: ServiceKind
host: str | None = None
port: int | None = None
url: str | None = None
group: str | None = None
evidence: list[str] = Field(default_factory=list)
confidence: str = "low"
depends_on_names: list[str] = Field(default_factory=list)
source_type: str | None = None
source_ref: str | None = None
stable_source_ref: str | None = None
category: DiscoveryCategory = DiscoveryCategory.UNKNOWN
visibility: DiscoveryVisibility = DiscoveryVisibility.VISIBLE
relation_type: RelationType = RelationType.DEPENDS_ON
class ProbeEndpointCandidate(BaseModel):
"""A reachable-or-reviewable endpoint discovered for a Docker workload."""
source: ProbeEndpointSource
kind: ServiceKind
url: str | None = None
host: str | None = None
port: int | None = Field(default=None, ge=1, le=65535)
reachability: ProbeReachability = ProbeReachability.UNVERIFIED
confidence: DiscoveryConfidence = DiscoveryConfidence.LOW
primary: bool = False
evidence: str = ""
preflight_detail: str | None = None
preflight_latency_ms: float | None = None
class DockerCandidate(BaseModel):
container_id: str
name: str
image: str
state: str
health: str | None = None
networks: list[str]
ports: list[DockerPort]
labels: dict[str, str] = Field(default_factory=dict)
import_state: str = "new"
suggestions: list[DockerSuggestion] = Field(default_factory=list)
dependency_suggestions: list[str] = Field(default_factory=list)
stable_source_ref: str | None = None
compose_project: str | None = None
compose_service: str | None = None
category: DiscoveryCategory = DiscoveryCategory.UNKNOWN
visibility: DiscoveryVisibility = DiscoveryVisibility.VISIBLE
lifecycle: DiscoveryLifecycle = DiscoveryLifecycle.ACTIVE
healthcheck_present: bool = False
environment_evidence: list[str] = Field(default_factory=list)
proxy_route_evidence: list[str] = Field(default_factory=list)
appops_app_id: str | None = None
appops_category: str | None = None
appops_criticality: str | None = None
appops_health: str | None = None
appops_scope: EvidenceScope | None = None
appops_discovery_confidence: float | None = Field(default=None, ge=0, le=1)
appops_policy_profile: str | None = None
appops_app_type: str | None = None
probe_candidates: list[ProbeEndpointCandidate] = Field(default_factory=list)
class DockerDiscoveryResult(BaseModel):
docker_host: str
candidates: list[DockerCandidate]
proxy_routes: int = 0
appops_available: bool = False
appops_detail: str = ""
class DiscoveryStatus(BaseModel):
current: DiscoveryRun | None = None
last_completed: DiscoveryRun | None = None
active: bool = False
class DiscoveryResult(DockerDiscoveryResult):
run: DiscoveryRun
suggestions: list[TopologySuggestion] = Field(default_factory=list)
observations: list[DiscoveryObservation] = Field(default_factory=list)
appops_available: bool = False
appops_mappings: int = 0
proxy_routes: int = 0
class ReconcileResult(BaseModel):
run: DiscoveryRun
created: int = 0
updated: int = 0
unchanged: int = 0
missing: int = 0
retired: int = 0
accepted_relations: int = 0
open_suggestions: int = 0
service_ids: list[str] = Field(default_factory=list)
class ProbeRepairChange(BaseModel):
service_id: str
service_name: str
previous_monitoring_state: MonitoringState
next_monitoring_state: MonitoringState
previous_endpoint: str | None = None
next_endpoint: str | None = None
next_probe_source: ProbeEndpointSource | None = None
next_probe_confidence: DiscoveryConfidence | None = None
reason: str
class ProbeRepairReport(BaseModel):
run_id: str | None = None
dry_run: bool = True
inspected: int = 0
changed: int = 0
disabled_unsafe: int = 0
enabled_published: int = 0
docker_health_observed: int = 0
appops_observed: int = 0
runtime_observed: int = 0
unmonitored: int = 0
resolved_incidents: int = 0
changes: list[ProbeRepairChange] = Field(default_factory=list)
class OverviewSummary(BaseModel):
generated_at: datetime
total_services: int
health_counts: dict[str, int]
active_incidents: int
likely_root_causes: list[str] = Field(default_factory=list)
flapping_services: list[str] = Field(default_factory=list)
recent_events: list[EventRecord] = Field(default_factory=list)
monitoring_coverage: dict[str, int] = Field(default_factory=dict)
class MaintenanceRequest(BaseModel):
enabled: bool
reason: str | None = Field(default=None, max_length=240)
+206
View File
@@ -0,0 +1,206 @@
"""Recurring, side-effect-bounded infrastructure health observation."""
from __future__ import annotations
import os
import threading
from dataclasses import dataclass
from datetime import timedelta
from uuid import uuid4
from .docker_discovery import appops_inventory, observe_container_health
from .docker_provider import docker_provider_mode
from .evidence import appops_health_status, utc_now
from .models import (
EvidenceProvider,
EvidenceScope,
HealthEvidence,
HealthStatus,
ProviderHealth,
ProviderStatus,
)
from .store import Store
def observation_ttl(interval_seconds: int) -> timedelta:
"""Provider observation TTL with room for one delayed refresh cycle."""
return timedelta(seconds=max(150, interval_seconds * 3))
def _appops_status(value: str | None) -> HealthStatus | None:
return appops_health_status(value)
def _docker_status(state: str, health: str | None) -> HealthStatus | None:
if (health or "").casefold() in {"unhealthy", "down"} or state.casefold() in {"exited", "dead", "stopped"}:
return HealthStatus.DOWN
if (health or "").casefold() == "degraded" or state.casefold() == "restarting":
return HealthStatus.DEGRADED
if (health or "").casefold() == "healthy":
return HealthStatus.HEALTHY
return None
@dataclass(frozen=True)
class ObservationResult:
docker_observations: int = 0
appops_observations: int = 0
skipped: bool = False
class ObservationManager:
"""Refresh provider evidence only; discovery and reconciliation stay manual."""
def __init__(self, store: Store, *, interval_seconds: int) -> None:
self.store = store
self.interval_seconds = interval_seconds
self._lock = threading.Lock()
@staticmethod
def _docker_host() -> str:
return os.getenv("SERVICEMAP_DOCKER_HOST", "unix:///var/run/docker.sock")
@staticmethod
def _appops_url() -> str | None:
return os.getenv("SERVICEMAP_APPOPS_URL")
def refresh(self) -> ObservationResult:
if self.store.demo_active() or not self._lock.acquire(blocking=False):
return ObservationResult(skipped=True)
try:
now = utc_now()
valid_until = now + observation_ttl(self.interval_seconds)
services_by_ref = {
service.source_ref: service
for service in self.store.list_services()
if service.source_ref
}
persisted_app_links = self.store.application_evidence_links(EvidenceProvider.APPOPS)
appops = appops_inventory(self._appops_url())
if appops.available:
self.store.set_provider_status(
ProviderStatus(
provider=EvidenceProvider.APPOPS,
status=ProviderHealth.HEALTHY,
observed_at=now,
valid_until=valid_until,
detail=appops.detail,
metadata={"mapping_count": len(appops.by_container)},
)
)
else:
self.store.set_provider_status(
ProviderStatus(
provider=EvidenceProvider.APPOPS,
status=ProviderHealth.UNAVAILABLE,
observed_at=now,
valid_until=valid_until,
detail=appops.detail,
)
)
linked_apps: dict[str, set[str]] = {
app_id: set(service_ids) for app_id, service_ids in persisted_app_links.items()
}
docker_count = 0
try:
docker_observations = observe_container_health(self._docker_host())
if (
docker_provider_mode() == "broker"
and not docker_observations
and any(ref.startswith("docker:") for ref in services_by_ref)
):
raise ValueError("Docker broker returned no containers for configured services")
self.store.set_provider_status(
ProviderStatus(
provider=EvidenceProvider.DOCKER,
status=ProviderHealth.HEALTHY,
observed_at=now,
valid_until=valid_until,
detail=f"{len(docker_observations)} containers observed",
metadata={"container_count": len(docker_observations)},
)
)
for observation in docker_observations:
service = services_by_ref.get(observation.stable_source_ref)
if service is None:
continue
docker_count += 1
appops_info = appops.by_container.get(observation.name.casefold())
if appops_info:
linked_apps.setdefault(appops_info.app_id, set()).add(service.id)
status = _docker_status(observation.state, observation.health)
if status is None:
continue
self.store.record_health_evidence(
HealthEvidence(
id=str(uuid4()),
subject_type="service",
subject_id=service.id,
service_id=service.id,
provider=EvidenceProvider.DOCKER,
provider_ref=observation.stable_source_ref,
status=status,
scope=EvidenceScope.COMPONENT,
confidence=0.95 if observation.healthcheck_present else 0.8,
observed_at=now,
valid_until=valid_until,
received_at=now,
summary="Docker healthcheck" if observation.healthcheck_present else "Docker runtime state",
metadata={
"container_id": observation.container_id,
"state": observation.state,
"health": observation.health,
},
)
)
except (OSError, RuntimeError, ValueError) as exc:
self.store.set_provider_status(
ProviderStatus(
provider=EvidenceProvider.DOCKER,
status=ProviderHealth.UNAVAILABLE,
observed_at=now,
valid_until=valid_until,
detail=str(exc)[:500],
)
)
appops_count = 0
if appops.available:
for app_id, service_ids in linked_apps.items():
info = next(
(item for item in appops.by_container.values() if item.app_id == app_id),
None,
)
status = _appops_status(info.health if info else None)
if info is None or status is None or not service_ids:
continue
appops_count += 1
self.store.record_health_evidence(
HealthEvidence(
id=str(uuid4()),
subject_type="application",
subject_id=f"appops:{app_id}",
provider=EvidenceProvider.APPOPS,
provider_ref=app_id,
status=status,
scope=EvidenceScope.APPLICATION,
confidence=info.discovery_confidence if info.discovery_confidence is not None else 0.5,
observed_at=now,
valid_until=valid_until,
received_at=now,
summary="AppOps application health; never used as component direct health",
metadata={
"app_id": app_id,
"category": info.category,
"policy_profile": info.policy_profile,
"app_type": info.app_type,
"discovery_confidence": info.discovery_confidence,
},
),
service_ids=sorted(service_ids),
)
return ObservationResult(docker_observations=docker_count, appops_observations=appops_count)
finally:
self._lock.release()
+204
View File
@@ -0,0 +1,204 @@
from __future__ import annotations
import asyncio
import contextvars
import os
import socket
import ssl
from datetime import UTC, datetime
from time import perf_counter
from uuid import uuid4
import httpx
from .models import HealthStatus, ProbeResult, Service, ServiceKind
_probe_metadata: contextvars.ContextVar[dict[str, object] | None] = contextvars.ContextVar(
"servicemap_probe_metadata", default=None
)
_service_locks: dict[str, asyncio.Lock] = {}
def _lock_for(service_id: str) -> asyncio.Lock:
lock = _service_locks.get(service_id)
if lock is None:
lock = asyncio.Lock()
_service_locks[service_id] = lock
return lock
def _http_status(status_code: int, service: Service) -> HealthStatus:
minimum = service.expected_status_min
maximum = service.expected_status_max
if minimum is not None or maximum is not None:
if (minimum is not None and status_code < minimum) or (maximum is not None and status_code > maximum):
return HealthStatus.DOWN if status_code >= 500 else HealthStatus.DEGRADED
return HealthStatus.HEALTHY
if status_code < 400:
return HealthStatus.HEALTHY
if status_code < 500:
return HealthStatus.DEGRADED
return HealthStatus.DOWN
async def _probe_once_with_metadata(service: Service, timeout_seconds: float) -> tuple[HealthStatus, str, dict[str, object]]:
if service.kind == ServiceKind.HTTP:
async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True) as client:
response = await client.get(service.url or "")
status = _http_status(response.status_code, service)
return status, f"HTTP {response.status_code}", {"status_code": response.status_code}
if service.kind == ServiceKind.DNS:
started = perf_counter()
infos = await asyncio.wait_for(
asyncio.get_running_loop().getaddrinfo(service.host, None, type=socket.SOCK_STREAM),
timeout=timeout_seconds,
)
addresses = sorted({str(info[4][0]) for info in infos if info[4]})
if not addresses:
return HealthStatus.DOWN, f"DNS {service.host} returned no addresses", {"addresses": []}
return (
HealthStatus.HEALTHY,
f"DNS {service.host} resolved ({round((perf_counter() - started) * 1000, 2)} ms)",
{"addresses": addresses[:20]},
)
if service.kind == ServiceKind.TLS:
context = ssl.create_default_context()
reader, writer = await asyncio.wait_for(
asyncio.open_connection(service.host, service.port, ssl=context, server_hostname=service.host),
timeout=timeout_seconds,
)
del reader
ssl_object = writer.get_extra_info("ssl_object")
metadata: dict[str, object] = {"hostname": service.host, "port": service.port}
detail = f"TLS {service.host}:{service.port} certificate reachable"
status = HealthStatus.HEALTHY
if ssl_object is not None:
certificate = ssl_object.getpeercert() or {}
not_after = certificate.get("notAfter") if isinstance(certificate, dict) else None
if not_after:
expires_at = datetime.fromtimestamp(ssl.cert_time_to_seconds(not_after), UTC)
days_remaining = (expires_at - datetime.now(UTC)).total_seconds() / 86400
metadata.update({"expires_at": expires_at.isoformat(), "days_remaining": round(days_remaining, 2)})
warning_days = max(1, int(os.getenv("SERVICEMAP_TLS_WARNING_DAYS", "30")))
if days_remaining <= 0:
status = HealthStatus.DOWN
detail = f"TLS certificate expired on {expires_at.date().isoformat()}"
elif days_remaining <= warning_days:
status = HealthStatus.DEGRADED
detail = f"TLS certificate expires in {round(days_remaining, 1)} days"
writer.close()
await writer.wait_closed()
return status, detail, metadata
_, writer = await asyncio.wait_for(
asyncio.open_connection(service.host, service.port),
timeout=timeout_seconds,
)
writer.close()
await writer.wait_closed()
return HealthStatus.HEALTHY, f"TCP {service.host}:{service.port} reachable", {}
async def _probe_once(service: Service, timeout_seconds: float) -> tuple[HealthStatus, str]:
status, detail, metadata = await _probe_once_with_metadata(service, timeout_seconds)
_probe_metadata.set(metadata)
return status, detail
async def probe_service(
service: Service,
timeout_seconds: float | None = 5.0,
retries: int | None = 1,
*,
trigger: str = "manual",
default_timeout_seconds: float = 5.0,
default_retries: int = 1,
) -> ProbeResult:
if not service.enabled or not service.monitoring_enabled:
return ProbeResult(
id=str(uuid4()),
service_id=service.id,
status=HealthStatus.UNKNOWN,
latency_ms=None,
detail="Monitoring disabled for service",
checked_at=datetime.now(UTC),
trigger=trigger,
)
timeout = service.timeout_seconds if timeout_seconds is None else timeout_seconds
timeout = timeout if timeout is not None else default_timeout_seconds
retry_count = service.retries if retries is None else retries
retry_count = retry_count if retry_count is not None else default_retries
attempts = max(1, retry_count + 1)
status = HealthStatus.DOWN
detail = "Probe failed"
latency_ms: float | None = None
metadata: dict[str, object] = {}
async with _lock_for(service.id):
for attempt in range(1, attempts + 1):
_probe_metadata.set({})
started = perf_counter()
try:
status, detail = await _probe_once(service, timeout)
metadata = _probe_metadata.get() or {}
latency_ms = round((perf_counter() - started) * 1000, 2)
if attempt > 1:
detail = f"{detail} after {attempt} attempts"
break
except (TimeoutError, httpx.HTTPError, OSError, ssl.SSLError, socket.gaierror) as exc:
latency_ms = round((perf_counter() - started) * 1000, 2)
detail = f"{type(exc).__name__}: {exc}"
if isinstance(exc, ssl.CertificateError):
status = HealthStatus.DOWN
detail = f"TLS hostname mismatch: {exc}"
if attempt < attempts:
await asyncio.sleep(min(0.2 * attempt, 1.0))
return ProbeResult(
id=str(uuid4()),
service_id=service.id,
status=status,
latency_ms=latency_ms,
detail=detail[:500],
checked_at=datetime.now(UTC),
trigger=trigger,
metadata=metadata,
)
async def probe_many(
services: list[Service],
*,
timeout_seconds: float | None = 5.0,
retries: int | None = 1,
concurrency: int = 10,
trigger: str = "manual",
default_timeout_seconds: float = 5.0,
default_retries: int = 1,
) -> list[ProbeResult]:
semaphore = asyncio.Semaphore(max(1, concurrency))
async def run(service: Service) -> ProbeResult:
async with semaphore:
if trigger == "manual" and default_timeout_seconds == 5.0 and default_retries == 1:
return await probe_service(service, timeout_seconds=timeout_seconds, retries=retries)
if trigger == "manual":
return await probe_service(
service,
timeout_seconds=timeout_seconds,
retries=retries,
default_timeout_seconds=default_timeout_seconds,
default_retries=default_retries,
)
return await probe_service(
service,
timeout_seconds=timeout_seconds,
retries=retries,
trigger=trigger,
default_timeout_seconds=default_timeout_seconds,
default_retries=default_retries,
)
return list(await asyncio.gather(*(run(service) for service in services)))
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
import asyncio
import logging
from time import monotonic
from .probes import probe_many
from .store import Store
logger = logging.getLogger(__name__)
async def scheduled_probe_loop(
store: Store,
*,
interval_seconds: int,
timeout_seconds: float,
retries: int,
concurrency: int,
retention_days: int,
) -> None:
next_due: dict[str, float] = {}
while True:
sleep_seconds = interval_seconds
try:
# The Explore demo is deliberately synthetic. Pausing scheduled
# probes while it is active keeps its seeded incident and history
# deterministic and avoids any dependency on external services.
if not getattr(store, "demo_active", lambda: False)():
services = store.list_services()
now = monotonic()
due = [
service
for service in services
if service.enabled
and service.monitoring_enabled
and now >= next_due.get(service.id, 0)
]
if due:
results = await probe_many(
due,
timeout_seconds=None,
retries=None,
concurrency=concurrency,
trigger="scheduled",
default_timeout_seconds=timeout_seconds,
default_retries=retries,
)
for result in results:
store.save_probe(result)
for service in due:
next_due[service.id] = now + (service.interval_seconds or interval_seconds)
next_intervals = [
service.interval_seconds or interval_seconds
for service in services
if service.enabled and service.monitoring_enabled
]
sleep_seconds = min([interval_seconds, *next_intervals]) if next_intervals else interval_seconds
else:
sleep_seconds = interval_seconds
store.prune_probes(retention_days)
except asyncio.CancelledError:
raise
except Exception: # pragma: no cover - defensive daemon boundary
logger.exception("Scheduled probe cycle failed")
await asyncio.sleep(max(1, sleep_seconds))
+429
View File
@@ -0,0 +1,429 @@
"""Deterministic semantic topology derived from the canonical service graph.
The discovery store remains the source of truth. This module deliberately
does not persist a second topology: a domain, application and component view
can therefore evolve without risking probe history or an operator's graph.
"""
from __future__ import annotations
import hashlib
import re
from collections import defaultdict
from datetime import UTC, datetime
from .evidence import with_freshness
from .models import (
ApplicationHealthResolution,
ComponentRole,
Criticality,
EvidenceFreshness,
EvidenceScope,
HealthResolutionReason,
HealthStatus,
LogicalRelation,
SemanticApplication,
SemanticComponent,
SemanticDomain,
SemanticTopology,
Service,
ServiceSnapshot,
)
_ROLE_BY_CATEGORY = {
"database": ComponentRole.DATABASE,
"cache": ComponentRole.CACHE,
"proxy": ComponentRole.PROXY,
"background_worker": ComponentRole.WORKER,
"storage": ComponentRole.STORAGE,
"monitoring": ComponentRole.MONITORING,
}
_ROLE_HINTS = (
(ComponentRole.DATABASE, ("postgres", "mariadb", "mysql", "mongo", "influx", "database", "db")),
(ComponentRole.CACHE, ("redis", "memcache", "cache")),
(ComponentRole.PROXY, ("proxy", "nginx", "traefik", "caddy", "swag", "haproxy", "cloudflare")),
(ComponentRole.WORKER, ("worker", "runner", "queue", "task")),
(ComponentRole.STORAGE, ("minio", "storage", "s3", "volume", "files")),
(ComponentRole.MONITORING, ("grafana", "prometheus", "zabbix", "netdata", "loki", "uptime")),
(ComponentRole.AGENT, ("agent", "exporter", "sidecar")),
(ComponentRole.SCHEDULER, ("scheduler", "cron", "schedule")),
(ComponentRole.FRONTEND, ("frontend", "webui", "dashboard", "ui")),
(ComponentRole.API, ("api", "backend", "server")),
)
_DOMAIN_HINTS = (
("Edge", ("proxy", "nginx", "traefik", "caddy", "swag", "cloudflare", "authelia", "auth")),
("Media", ("plex", "jellyfin", "sonarr", "radarr", "lidarr", "readarr", "bazarr", "media", "tautulli")),
("Dev", ("gitea", "git", "code", "dev", "ci", "runner")),
("Monitoring", ("grafana", "prometheus", "zabbix", "netdata", "loki", "uptime", "monitor")),
("AI", ("ollama", "open webui", "comfy", "ai", "llm")),
("Storage", ("postgres", "mariadb", "mysql", "mongo", "redis", "minio", "storage", "nextcloud", "file")),
("Automation", ("n8n", "worker", "scheduler", "cron", "automation", "arr")),
("Productivity", ("book", "paperless", "vault", "wiki", "project", "calendar", "office")),
)
def _slug(prefix: str, value: str) -> str:
normalized = re.sub(r"[^a-z0-9]+", "-", value.casefold()).strip("-") or "unknown"
digest = hashlib.sha1(value.casefold().encode("utf-8")).hexdigest()[:8]
return f"{prefix}:{normalized[:48]}-{digest}"
def component_role(service: Service) -> ComponentRole:
if service.component_role_locked or service.component_role != ComponentRole.UNKNOWN:
return service.component_role
category_role = _ROLE_BY_CATEGORY.get(service.category.value)
if category_role:
return category_role
haystack = " ".join(filter(None, (service.name, service.container_image, service.source_ref, service.description))).casefold()
for role, hints in _ROLE_HINTS:
if any(hint in haystack for hint in hints):
return role
# A monitoring gap says nothing about the component's workload role. Keep
# it unknown until source metadata, a deterministic hint or an operator
# override supplies evidence.
return ComponentRole.UNKNOWN
def application_name(service: Service) -> str:
"""Use stable provenance before falling back to a standalone application.
Category is intentionally *not* used as a shared application name. Doing
so was the old "Independent · Category" compaction bug: unrelated services
looked like one stack. Categories still inform domain derivation below.
"""
if service.application:
return service.application.strip()
if service.compose_project:
return service.compose_project.replace("_", " ").replace("-", " ").title()
ref = service.source_ref or ""
compose = re.match(r"docker:compose:([^:]+):", ref, flags=re.IGNORECASE)
if compose:
return compose.group(1).replace("_", " ").replace("-", " ").title()
name = re.sub(r"(?:[-_ ](?:server|web|api|db|database|redis|worker|frontend|backend))$", "", service.name, flags=re.IGNORECASE).strip()
return name or service.name
def application_identity(service: Service) -> tuple[str, str]:
"""Return a stable grouping key plus its operator-facing display name.
Compose provenance wins over a generic display label. This keeps two
unrelated ``PostgreSQL`` containers from being merged merely because an
aggregate provider happens to use the same name for both applications.
Explicit operator grouping remains authoritative.
"""
name = application_name(service)
if service.application_locked and service.application:
return f"operator:{service.application.casefold().strip()}", name
if service.compose_project:
return f"compose:{service.compose_project.casefold().strip()}", name
ref = service.source_ref or ""
compose = re.match(r"docker:compose:([^:]+):", ref, flags=re.IGNORECASE)
if compose:
return f"compose:{compose.group(1).casefold()}", name
if ref:
return f"source:{ref.casefold()}", name
if service.application:
return f"application:{service.application.casefold().strip()}", name
return f"service:{service.id}", name
def domain_name(service: Service, role: ComponentRole, application: str) -> str:
if service.domain:
return service.domain.strip()
haystack = " ".join(filter(None, (application, service.name, service.container_image, service.category.value))).casefold()
for domain, hints in _DOMAIN_HINTS:
if any(hint in haystack for hint in hints):
return domain
if role in {ComponentRole.DATABASE, ComponentRole.CACHE, ComponentRole.STORAGE}:
return "Storage"
if role in {ComponentRole.PROXY}:
return "Edge"
if role in {ComponentRole.MONITORING, ComponentRole.AGENT}:
return "Monitoring"
return "Infrastructure" if service.category.value in {"infrastructure", "system"} else "Productivity"
def _application_resolution(
members: list[ServiceSnapshot], roles: dict[str, ComponentRole]
) -> ApplicationHealthResolution:
"""Resolve application health without promoting aggregate evidence to a component.
A fresh, high-confidence application observation can answer the health of
its application even where individual components are intentionally not
probed. It can never change a component's direct status. Conversely a
concrete component failure always outweighs a weak aggregate healthy
claim, which preserves conflict visibility instead of masking incidents.
"""
resolved = [
item
for item in members
if item.direct_status not in {HealthStatus.UNKNOWN, HealthStatus.UNMONITORED}
]
unresolved = len(members) - len(resolved)
coverage = round((len(resolved) / len(members)) * 100, 1) if members else 0.0
application_evidence = {
item.id: with_freshness(item)
for snapshot in members
for item in snapshot.health_evidence
if item.active and item.scope == EvidenceScope.APPLICATION
}
fresh_claims = [
item
for item in application_evidence.values()
if item.freshness == EvidenceFreshness.FRESH
]
selected = max(
fresh_claims,
key=lambda item: (item.confidence, item.observed_at, item.received_at, item.id),
default=None,
)
direct_down = [item for item in members if item.direct_status == HealthStatus.DOWN]
impacted = [
item
for item in members
if item.effective_status in {HealthStatus.DOWN, HealthStatus.DEGRADED, HealthStatus.RECOVERING}
and item.direct_status != HealthStatus.DOWN
]
conflicting_aggregate = bool(
selected
and (
(selected.status == HealthStatus.HEALTHY and direct_down)
or (selected.status in {HealthStatus.DOWN, HealthStatus.DEGRADED} and resolved)
)
)
common = {
"selected_evidence_id": selected.id if selected else None,
"selected_provider": selected.provider if selected else None,
"evidence_freshness": selected.freshness if selected else None,
"conflict": conflicting_aggregate,
"resolved_components": len(resolved),
"unresolved_components": unresolved,
"component_coverage_percent": coverage,
}
if direct_down:
primary = [
item
for item in direct_down
if item.service.criticality == Criticality.CRITICAL
or roles[item.service.id] in {ComponentRole.FRONTEND, ComponentRole.API, ComponentRole.PROXY}
]
status = HealthStatus.DOWN if len(members) == 1 or len(direct_down) == len(resolved) or primary else HealthStatus.DEGRADED
names = ", ".join(item.service.name for item in direct_down[:3])
return ApplicationHealthResolution(
status=status,
confidence=max((item.health_resolution.confidence or 1.0 for item in direct_down), default=None),
explanation=f"{names} has current component failure evidence; aggregate health does not override it.",
reason_code=HealthResolutionReason.COMPONENT_FAILURE,
**common,
)
if impacted:
names = ", ".join(item.service.name for item in impacted[:3])
return ApplicationHealthResolution(
status=HealthStatus.DEGRADED,
confidence=selected.confidence if selected else None,
explanation=f"{names} is impacted through a dependency health signal.",
reason_code=HealthResolutionReason.COMPONENT_IMPACT,
**common,
)
if selected and selected.confidence >= 0.7 and selected.status not in {HealthStatus.UNKNOWN, HealthStatus.UNMONITORED}:
return ApplicationHealthResolution(
status=selected.status,
confidence=selected.confidence,
explanation=(
f"{selected.provider.value} application observation selected "
f"(confidence {selected.confidence:.2f}); component coverage is {coverage:.0f}% ."
),
reason_code=HealthResolutionReason.SELECTED_APPLICATION_EVIDENCE,
**common,
)
if resolved:
status = HealthStatus.HEALTHY
if any(item.direct_status in {HealthStatus.DEGRADED, HealthStatus.RECOVERING} for item in resolved):
status = HealthStatus.DEGRADED
return ApplicationHealthResolution(
status=status,
explanation=f"{len(resolved)} of {len(members)} components have current direct health evidence.",
reason_code=HealthResolutionReason.COMPONENT_COVERAGE,
**common,
)
stale = list(application_evidence.values())
if stale:
reason = (
HealthResolutionReason.STALE_EVIDENCE
if any(item.freshness == EvidenceFreshness.STALE for item in stale)
else HealthResolutionReason.EXPIRED_EVIDENCE
)
return ApplicationHealthResolution(
status=HealthStatus.UNKNOWN,
explanation="Application evidence is no longer current; waiting for a provider refresh.",
reason_code=reason,
**common,
)
if members and all(item.direct_status == HealthStatus.UNMONITORED for item in members):
return ApplicationHealthResolution(
status=HealthStatus.UNMONITORED,
explanation="No active health signal is configured for this application.",
reason_code=HealthResolutionReason.NO_APPLICATION_EVIDENCE,
**common,
)
return ApplicationHealthResolution(
status=HealthStatus.UNKNOWN,
explanation="No current application or component health evidence is available.",
reason_code=HealthResolutionReason.NO_APPLICATION_EVIDENCE,
**common,
)
def build_semantic_topology(
snapshots: list[ServiceSnapshot],
relations: list[LogicalRelation],
root_service_ids: set[str] | None = None,
affected_service_ids: set[str] | None = None,
) -> SemanticTopology:
root_service_ids = root_service_ids or set()
affected_service_ids = affected_service_ids or set()
snapshot_by_id = {item.service.id: item for item in snapshots}
roles = {item.service.id: component_role(item.service) for item in snapshots}
apps_by_key: dict[str, list[ServiceSnapshot]] = defaultdict(list)
app_names: dict[str, str] = {}
app_domain_names: dict[str, str] = {}
component_app_ids: dict[str, str] = {}
components: list[SemanticComponent] = []
dependent_ids: dict[str, list[str]] = defaultdict(list)
for snapshot in snapshots:
for dependency_id in snapshot.service.depends_on:
dependent_ids[dependency_id].append(snapshot.service.id)
for snapshot in snapshots:
application_key, application = application_identity(snapshot.service)
apps_by_key[application_key].append(snapshot)
app_names[application_key] = application
role = roles[snapshot.service.id]
app_domain_names.setdefault(application_key, domain_name(snapshot.service, role, application))
component_app_ids[snapshot.service.id] = _slug("app", application_key)
applications: list[SemanticApplication] = []
application_by_id: dict[str, SemanticApplication] = {}
domain_name_by_id: dict[str, str] = {}
for application_key, members in sorted(apps_by_key.items(), key=lambda item: app_names[item[0]].casefold()):
name = app_names[application_key]
app_id = _slug("app", application_key)
domain = app_domain_names[application_key]
domain_id = _slug("domain", domain)
domain_name_by_id[domain_id] = domain
resolution = _application_resolution(members, roles)
unmonitored = sum(item.direct_status == HealthStatus.UNMONITORED for item in members)
app = SemanticApplication(
id=app_id,
name=name,
domain_id=domain_id,
component_ids=[item.service.id for item in members],
component_count=len(members),
roles=sorted({roles[item.service.id] for item in members}, key=lambda role: role.value),
status=resolution.status,
status_explanation=resolution.explanation,
health_resolution=resolution,
monitored_components=resolution.resolved_components,
unmonitored_components=unmonitored,
resolved_components=resolution.resolved_components,
unresolved_components=resolution.unresolved_components,
component_coverage_percent=resolution.component_coverage_percent,
root_cause=any(item.service.id in root_service_ids for item in members),
affected=any(item.service.id in affected_service_ids for item in members),
)
applications.append(app)
application_by_id[app_id] = app
for snapshot in snapshots:
app_id = component_app_ids[snapshot.service.id]
app = application_by_id[app_id]
components.append(SemanticComponent(
service_id=snapshot.service.id,
name=snapshot.service.name,
kind=snapshot.service.kind,
application_id=app_id,
domain_id=app.domain_id,
role=roles[snapshot.service.id],
direct_status=snapshot.direct_status,
effective_status=snapshot.effective_status,
unknown_reason=(
snapshot.health_resolution.reason_code
if snapshot.direct_status == HealthStatus.UNKNOWN
else None
),
runtime_state=snapshot.service.runtime_state,
runtime_health=snapshot.service.runtime_health,
monitoring_state=snapshot.service.monitoring_state,
latest_latency_ms=snapshot.latest_probe.latency_ms if snapshot.latest_probe else None,
dependency_ids=[item for item in snapshot.service.depends_on if item in snapshot_by_id],
dependent_ids=dependent_ids.get(snapshot.service.id, []),
root_cause=snapshot.service.id in root_service_ids,
affected=snapshot.service.id in affected_service_ids,
))
app_dependencies: dict[str, set[str]] = defaultdict(set)
app_dependents: dict[str, set[str]] = defaultdict(set)
for relation in relations:
if not relation.active or not relation.target_service_id:
continue
source_app = component_app_ids.get(relation.source_service_id)
target_app = component_app_ids.get(relation.target_service_id)
if source_app and target_app and source_app != target_app:
app_dependencies[source_app].add(target_app)
app_dependents[target_app].add(source_app)
for app in applications:
app.dependency_application_ids = sorted(app_dependencies[app.id])
app.dependent_application_ids = sorted(app_dependents[app.id])
apps_by_domain: dict[str, list[SemanticApplication]] = defaultdict(list)
domain_names: dict[str, str] = {}
for app in applications:
apps_by_domain[app.domain_id].append(app)
domain_names[app.domain_id] = domain_name_by_id[app.domain_id]
domains: list[SemanticDomain] = []
for domain_id, members in sorted(apps_by_domain.items(), key=lambda item: domain_names[item[0]].casefold()):
down = [item for item in members if item.status == HealthStatus.DOWN]
degraded = [
item for item in members if item.status in {HealthStatus.DEGRADED, HealthStatus.RECOVERING}
]
healthy = [item for item in members if item.status == HealthStatus.HEALTHY]
resolved = [
item
for item in members
if item.status not in {HealthStatus.UNKNOWN, HealthStatus.UNMONITORED}
]
if down:
status = HealthStatus.DOWN
elif degraded:
status = HealthStatus.DEGRADED
elif healthy:
status = HealthStatus.HEALTHY
elif members and all(item.status == HealthStatus.UNMONITORED for item in members):
status = HealthStatus.UNMONITORED
else:
status = HealthStatus.UNKNOWN
domains.append(SemanticDomain(
id=domain_id,
name=domain_names[domain_id],
application_ids=[item.id for item in members],
application_count=len(members),
status=status,
resolved_application_count=len(resolved),
unresolved_application_count=len(members) - len(resolved),
application_coverage_percent=round((len(resolved) / len(members)) * 100, 1) if members else 0,
incident_count=sum(item.root_cause for item in members),
root_cause=any(item.root_cause for item in members),
affected=any(item.affected for item in members),
))
coverage: dict[str, int] = defaultdict(int)
for snapshot in snapshots:
coverage[snapshot.service.monitoring_state.value] += 1
return SemanticTopology(
generated_at=datetime.now(UTC),
domains=domains,
applications=applications,
components=components,
relations=relations,
monitoring_coverage=dict(coverage),
)
+2012
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
[project]
name = "servicemap-backend"
version = "1.6.2"
description = "ServiceMap API, topology intelligence and probe engine"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.116,<1",
"uvicorn[standard]>=0.35,<1",
"httpx>=0.28,<1",
"pydantic>=2.11,<3",
"pyyaml>=6.0,<7",
"docker>=7.1,<8",
"PyJWT[crypto]>=2.10,<3",
]
[project.optional-dependencies]
dev = [
"pytest>=8.4,<9",
"ruff>=0.12,<1",
]
[tool.pytest.ini_options]
pythonpath = ["."]
[tool.ruff]
line-length = 100
+106
View File
@@ -0,0 +1,106 @@
#
# This file is autogenerated by pip-compile with Python 3.13
# by the following command:
#
# pip-compile --extra=dev --output-file=backend/requirements-dev.lock --strip-extras backend/pyproject.toml
#
annotated-doc==0.0.5
# via fastapi
annotated-types==0.8.0
# via pydantic
anyio==4.15.1
# via
# httpx
# starlette
# watchfiles
certifi==2026.7.22
# via
# httpcore
# httpx
# requests
cffi==2.1.1
# via cryptography
charset-normalizer==3.5.1
# via requests
click==8.5.0
# via uvicorn
colorama==0.4.6
# via pytest
cryptography==50.0.1
# via pyjwt
docker==7.2.0
# via servicemap-backend (backend/pyproject.toml)
fastapi==0.141.1
# via servicemap-backend (backend/pyproject.toml)
h11==0.16.0
# via
# httpcore
# uvicorn
httpcore==1.0.9
# via httpx
httptools==0.8.0
# via uvicorn
httpx==0.28.1
# via servicemap-backend (backend/pyproject.toml)
idna==3.19
# via
# anyio
# httpx
# requests
iniconfig==2.3.0
# via pytest
packaging==26.3
# via pytest
pluggy==1.6.0
# via pytest
pycparser==3.0
# via cffi
pydantic==2.13.5
# via
# fastapi
# servicemap-backend (backend/pyproject.toml)
pydantic-core==2.46.5
# via pydantic
pygments==2.21.0
# via pytest
pyjwt==2.14.0
# via
# pyjwt
# servicemap-backend (backend/pyproject.toml)
pytest==8.4.2
# via servicemap-backend (backend/pyproject.toml)
python-dotenv==1.2.3
# via uvicorn
pywin32==312 ; sys_platform == "win32"
# via docker
pyyaml==6.0.3
# via
# servicemap-backend (backend/pyproject.toml)
# uvicorn
requests==2.34.2
# via docker
ruff==0.16.7
# via servicemap-backend (backend/pyproject.toml)
starlette==1.6.0
# via fastapi
typing-extensions==4.16.0
# via
# anyio
# fastapi
# pydantic
# pydantic-core
# typing-inspection
typing-inspection==0.4.4
# via
# fastapi
# pydantic
urllib3==2.7.0
# via
# docker
# requests
uvicorn==0.52.4
# via servicemap-backend (backend/pyproject.toml)
watchfiles==1.2.0
# via uvicorn
websockets==17.1
# via uvicorn
+92
View File
@@ -0,0 +1,92 @@
#
# This file is autogenerated by pip-compile with Python 3.13
# by the following command:
#
# pip-compile --output-file=backend/requirements.lock --strip-extras backend/pyproject.toml
#
annotated-doc==0.0.5
# via fastapi
annotated-types==0.8.0
# via pydantic
anyio==4.15.1
# via
# httpx
# starlette
# watchfiles
certifi==2026.7.22
# via
# httpcore
# httpx
# requests
cffi==2.1.1
# via cryptography
charset-normalizer==3.5.1
# via requests
click==8.5.0
# via uvicorn
cryptography==50.0.1
# via pyjwt
docker==7.2.0
# via servicemap-backend (backend/pyproject.toml)
fastapi==0.141.1
# via servicemap-backend (backend/pyproject.toml)
h11==0.16.0
# via
# httpcore
# uvicorn
httpcore==1.0.9
# via httpx
httptools==0.8.0
# via uvicorn
httpx==0.28.1
# via servicemap-backend (backend/pyproject.toml)
idna==3.19
# via
# anyio
# httpx
# requests
pycparser==3.0
# via cffi
pydantic==2.13.5
# via
# fastapi
# servicemap-backend (backend/pyproject.toml)
pydantic-core==2.46.5
# via pydantic
pyjwt==2.14.0
# via
# pyjwt
# servicemap-backend (backend/pyproject.toml)
python-dotenv==1.2.3
# via uvicorn
pywin32==312 ; sys_platform == "win32"
# via docker
pyyaml==6.0.3
# via
# servicemap-backend (backend/pyproject.toml)
# uvicorn
requests==2.34.2
# via docker
starlette==1.6.0
# via fastapi
typing-extensions==4.16.0
# via
# anyio
# fastapi
# pydantic
# pydantic-core
# typing-inspection
typing-inspection==0.4.4
# via
# fastapi
# pydantic
urllib3==2.7.0
# via
# docker
# requests
uvicorn==0.52.4
# via servicemap-backend (backend/pyproject.toml)
watchfiles==1.2.0
# via uvicorn
websockets==17.1
# via uvicorn
+182
View File
@@ -0,0 +1,182 @@
from pathlib import Path
from fastapi.testclient import TestClient
from app import main as main_module
from app.discovery import DiscoveryManager
from app.discovery_store import DiscoveryStore
from app.models import DiscoveryConfidence, RelationEvidence, RelationType
from app.store import Store
def test_discovery_routes_reject_request_controlled_targets(tmp_path: Path, monkeypatch) -> None:
main_module.store = Store(str(tmp_path / "servicemap.db"))
main_module.discovery_store = DiscoveryStore(main_module.store)
main_module.discovery_manager = DiscoveryManager(main_module.store, main_module.discovery_store)
monkeypatch.setenv("SERVICEMAP_DOCKER_HOST", "unix:///var/run/docker.sock")
monkeypatch.setenv("SERVICEMAP_APPOPS_URL", "http://appops.internal:1216")
client = TestClient(main_module.app)
assert client.post(
"/api/v1/docker/discover", json={"docker_host": "tcp://attacker.internal:2375"}
).status_code == 403
assert client.post(
"/api/v1/discovery/run", json={"docker_host": "tcp://attacker.internal:2375"}
).status_code == 403
assert client.post(
"/api/v1/discovery/run", json={"appops_url": "http://attacker.internal"}
).status_code == 403
def test_service_api_crud_cycle_guard_and_export(tmp_path: Path) -> None:
main_module.store = Store(str(tmp_path / "servicemap.db"))
client = TestClient(main_module.app)
database_response = client.post(
"/api/v1/services",
json={"name": "Database", "kind": "tcp", "host": "db.local", "port": 5432, "depends_on": []},
)
assert database_response.status_code == 201
database = database_response.json()
api_response = client.post(
"/api/v1/services",
json={
"name": "API",
"kind": "http",
"url": "https://api.local",
"depends_on": [database["id"]],
},
)
assert api_response.status_code == 201
api_service = api_response.json()
cycle_response = client.put(
f"/api/v1/services/{database['id']}",
json={
"name": "Database",
"kind": "tcp",
"host": "db.local",
"port": 5432,
"depends_on": [api_service["id"]],
},
)
assert cycle_response.status_code == 422
assert "cycle" in cycle_response.json()["detail"].lower()
listing = client.get("/api/v1/services")
assert listing.status_code == 200
assert len(listing.json()) == 2
exported = client.get("/api/v1/config?format=yaml")
assert exported.status_code == 200
assert "Database" in exported.text
assert "API" in exported.text
deleted = client.delete(f"/api/v1/services/{database['id']}")
assert deleted.status_code == 204
api_snapshot = client.get(f"/api/v1/services/{api_service['id']}").json()
assert api_snapshot["service"]["depends_on"] == []
def test_overview_counts_only_visible_services(tmp_path: Path) -> None:
main_module.store = Store(str(tmp_path / "servicemap.db"))
client = TestClient(main_module.app)
for name, visibility in (("Shown", "visible"), ("Retired", "hidden")):
response = client.post(
"/api/v1/services",
json={"name": name, "kind": "tcp", "host": f"{name.lower()}.local", "port": 9000, "visibility": visibility},
)
assert response.status_code == 201
assert len(client.get("/api/v1/services").json()) == 1
assert client.get("/api/v1/overview").json()["total_services"] == 1
def test_config_import_endpoint_loads_validated_graph(tmp_path: Path) -> None:
main_module.store = Store(str(tmp_path / "servicemap.db"))
client = TestClient(main_module.app)
content = """
version: 1
services:
- name: Redis
kind: tcp
host: redis.local
port: 6379
depends_on: []
- name: Worker
kind: tcp
host: worker.local
port: 9000
depends_on: [Redis]
"""
response = client.post(
"/api/v1/config/import",
json={"content": content, "format": "yaml", "mode": "replace"},
)
assert response.status_code == 200
assert response.json() == {"created": 2, "updated": 0, "total": 2}
assert len(client.get("/api/v1/services").json()) == 2
def test_service_api_rejects_invalid_endpoint_and_dependency_references(tmp_path: Path) -> None:
main_module.store = Store(str(tmp_path / "servicemap.db"))
client = TestClient(main_module.app)
invalid_url = client.post(
"/api/v1/services",
json={"name": "Invalid", "kind": "http", "url": "ftp://example.test"},
)
assert invalid_url.status_code == 422
missing_dependency = client.post(
"/api/v1/services",
json={
"name": "API",
"kind": "http",
"url": "https://api.local",
"depends_on": ["missing-service-id"],
},
)
assert missing_dependency.status_code == 422
assert "unknown dependency" in missing_dependency.json()["detail"].lower()
def test_semantic_topology_and_logical_relations_are_backend_derived(tmp_path: Path) -> None:
main_module.store = Store(str(tmp_path / "servicemap.db"))
main_module.discovery_store = DiscoveryStore(main_module.store)
main_module.discovery_manager = DiscoveryManager(main_module.store, main_module.discovery_store)
client = TestClient(main_module.app)
database = client.post("/api/v1/services", json={
"name": "PostgreSQL", "kind": "tcp", "host": "db.local", "port": 5432,
"application": "Zabbix", "domain": "Monitoring", "component_role": "database",
}).json()
api = client.post("/api/v1/services", json={
"name": "Zabbix API", "kind": "http", "url": "https://zabbix.local",
"application": "Zabbix", "domain": "Monitoring", "component_role": "api",
"depends_on": [database["id"]],
}).json()
now = main_module.store.get_service(database["id"]).created_at
main_module.discovery_store.save_evidence(RelationEvidence(
id="compose-edge", source_service_id=api["id"], target_service_id=database["id"],
relation_type=RelationType.DEPENDS_ON, evidence_type="compose",
confidence=DiscoveryConfidence.HIGH, explanation="Compose dependency", first_seen_at=now, last_seen_at=now,
))
main_module.discovery_store.save_evidence(RelationEvidence(
id="env-edge", source_service_id=api["id"], target_service_id=database["id"],
relation_type=RelationType.DEPENDS_ON, evidence_type="environment",
confidence=DiscoveryConfidence.MEDIUM, explanation="Environment dependency", first_seen_at=now, last_seen_at=now,
))
semantic = client.get("/api/v1/topology/semantic")
relations = client.get("/api/v1/topology/relations")
services = client.get("/api/v1/services")
assert semantic.status_code == 200
assert semantic.json()["domains"][0]["name"] == "Monitoring"
assert semantic.json()["applications"][0]["component_count"] == 2
assert relations.json()[0]["confidence"] == "confirmed"
assert len(relations.json()[0]["evidence"]) == 2
assert services.json()[0]["availability_24h"]["hours"] == 24
+69
View File
@@ -0,0 +1,69 @@
from pathlib import Path
from fastapi.testclient import TestClient
from app import auth
from app import main as main_module
from app.store import Store
def test_disabled_auth_preserves_v15_api_access(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("SERVICEMAP_AUTH_MODE", "disabled")
main_module.store = Store(str(tmp_path / "servicemap.db"))
client = TestClient(main_module.app)
response = client.post(
"/api/v1/services",
json={"name": "API", "kind": "http", "url": "https://api.local"},
)
assert response.status_code == 201
def test_oidc_mode_fails_closed_but_health_stays_available(monkeypatch) -> None:
monkeypatch.setenv("SERVICEMAP_AUTH_MODE", "oidc")
monkeypatch.delenv("SERVICEMAP_OIDC_ISSUER", raising=False)
monkeypatch.delenv("SERVICEMAP_OIDC_AUDIENCE", raising=False)
client = TestClient(main_module.app)
assert client.get("/health").status_code == 200
assert client.get("/api/v1/services").status_code == 503
def test_oidc_roles_enforce_read_operator_and_admin_boundaries(tmp_path: Path, monkeypatch) -> None:
main_module.store = Store(str(tmp_path / "servicemap.db"))
config = auth.AuthConfig(mode="oidc", issuer="https://id.example", audience="servicemap")
monkeypatch.setattr(auth.AuthConfig, "from_environment", classmethod(lambda cls: config))
client = TestClient(main_module.app)
monkeypatch.setattr(auth, "verify_bearer", lambda token, _: {"viewer"})
assert (
client.get("/api/v1/services", headers={"Authorization": "Bearer viewer"}).status_code
== 200
)
assert (
client.post(
"/api/v1/services",
headers={"Authorization": "Bearer viewer"},
json={"name": "Denied", "kind": "http", "url": "https://denied.local"},
).status_code
== 403
)
monkeypatch.setattr(auth, "verify_bearer", lambda token, _: {"operator", "viewer"})
assert (
client.post(
"/api/v1/services",
headers={"Authorization": "Bearer operator"},
json={"name": "Allowed", "kind": "http", "url": "https://allowed.local"},
).status_code
== 201
)
assert (
client.post(
"/api/v1/config/import",
headers={"Authorization": "Bearer operator"},
json={"content": "version: 1\nservices: []", "format": "yaml", "mode": "merge"},
).status_code
== 403
)
+133
View File
@@ -0,0 +1,133 @@
import sqlite3
from pathlib import Path
import pytest
from app.config_io import export_document, import_document, parse_document, serialize_document
from app.models import ServiceCreate, ServiceKind
from app.store import Store
CONFIG = """
version: 1
services:
- name: Database
kind: tcp
host: db.local
port: 5432
depends_on: []
- name: API
kind: http
url: https://api.local
depends_on: [Database]
"""
def test_yaml_import_export_round_trip(tmp_path: Path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
result = import_document(store, parse_document(CONFIG, "yaml"), "replace")
assert result.created == 2
services = store.list_services()
assert len(services) == 2
api = store.get_service_by_name("API")
database = store.get_service_by_name("Database")
assert api is not None and database is not None
assert api.depends_on == [database.id]
exported = export_document(store)
yaml_text = serialize_document(exported, "yaml")
reparsed = parse_document(yaml_text, "yaml")
assert [service.name for service in reparsed.services] == [service.name for service in exported.services]
def test_merge_can_reference_existing_service_not_in_document(tmp_path: Path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
database = store.create_service(
ServiceCreate(name="Database", kind=ServiceKind.TCP, host="db.local", port=5432)
)
document = parse_document(
"""
version: 1
services:
- name: API
kind: http
url: https://api.local
depends_on: [Database]
""",
"yaml",
)
result = import_document(store, document, "merge")
assert result.created == 1
assert result.updated == 0
api = store.get_service_by_name("API")
assert api is not None
assert api.depends_on == [database.id]
def test_import_rejects_missing_dependency_and_cycles(tmp_path: Path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
missing = parse_document(
"""
version: 1
services:
- name: API
kind: http
url: https://api.local
depends_on: [Database]
""",
"yaml",
)
with pytest.raises(ValueError, match="unknown dependencies"):
import_document(store, missing, "replace")
cyclic = """
version: 1
services:
- name: A
kind: http
url: https://a.local
depends_on: [B]
- name: B
kind: http
url: https://b.local
depends_on: [A]
"""
with pytest.raises(ValueError, match="cycle"):
parse_document(cyclic, "yaml")
with pytest.raises(ValueError, match="less than or equal to 1"):
parse_document("version: 2\nservices: []", "yaml")
def test_replace_import_rolls_back_on_mid_transaction_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
store = Store(str(tmp_path / "servicemap.db"))
original = store.create_service(
ServiceCreate(name="Original", kind=ServiceKind.HTTP, url="https://original.local")
)
document = parse_document(
"""
version: 1
services:
- name: First
kind: http
url: https://first.local
depends_on: []
- name: Second
kind: http
url: https://second.local
depends_on: []
""",
"yaml",
)
monkeypatch.setattr("app.store.uuid4", lambda: "duplicate-id")
with pytest.raises(sqlite3.IntegrityError):
import_document(store, document, "replace")
remaining = store.list_services()
assert [(service.id, service.name) for service in remaining] == [(original.id, "Original")]
+186
View File
@@ -0,0 +1,186 @@
from datetime import UTC, datetime
from pathlib import Path
from fastapi.testclient import TestClient
from app import main as main_module
from app.demo import DEMO_ROOT_CAUSE_NAME, build_demo_records
from app.models import HealthStatus, ProbeResult, ServiceCreate, ServiceKind
from app.store import Store
def _client(tmp_path: Path) -> tuple[Store, TestClient]:
store = Store(str(tmp_path / "servicemap.db"))
main_module.store = store
return store, TestClient(main_module.app)
def _assert_acyclic(graph: dict[str, set[str]]) -> None:
visiting: set[str] = set()
visited: set[str] = set()
def visit(service_id: str) -> None:
assert service_id not in visiting
if service_id in visited:
return
visiting.add(service_id)
for dependency_id in graph[service_id]:
visit(dependency_id)
visiting.remove(service_id)
visited.add(service_id)
for service_id in graph:
visit(service_id)
def test_demo_seeds_topology_health_history_availability_and_incident(tmp_path: Path) -> None:
store, client = _client(tmp_path)
response = client.post("/api/v1/demo")
assert response.status_code == 200
result = response.json()
assert result["active"] is True
assert result["synthetic"] is True
assert result["scenario_name"] == "Northstar Platform"
assert result["total"] == 9
snapshots = client.get("/api/v1/services").json()
assert len(snapshots) == 9
by_name = {item["service"]["name"]: item for item in snapshots}
assert set(by_name) == {
"Public Endpoint",
"Reverse Proxy",
"Web Frontend",
"Auth Service",
"API Gateway",
"PostgreSQL",
"Redis",
"Background Worker",
"Object Storage",
}
ids = {item["service"]["id"] for item in snapshots}
assert all(set(item["service"]["depends_on"]).issubset(ids) for item in snapshots)
assert by_name[DEMO_ROOT_CAUSE_NAME]["latest_probe"]["status"] == HealthStatus.DOWN
assert by_name[DEMO_ROOT_CAUSE_NAME]["effective_status"] == HealthStatus.DOWN
assert all(item["effective_status"] != HealthStatus.UNKNOWN for item in snapshots)
semantic = client.get("/api/v1/topology/semantic").json()
assert {item["name"] for item in semantic["domains"]} == {"Edge", "Application", "Data"}
assert {item["name"] for item in semantic["applications"]} == {
"Public Gateway",
"Northstar App",
"Data Platform",
"Worker Platform",
}
incidents = client.get("/api/v1/incidents").json()
assert incidents["active"] is True
assert incidents["candidates"][0]["service_name"] == DEMO_ROOT_CAUSE_NAME
assert set(incidents["candidates"][0]["affected_service_names"]) == {
"API Gateway",
"Web Frontend",
"Reverse Proxy",
"Public Endpoint",
"Background Worker",
}
postgres_id = by_name[DEMO_ROOT_CAUSE_NAME]["service"]["id"]
history = client.get(f"/api/v1/services/{postgres_id}/history?limit=50").json()
assert len(history) == 24
availability = client.get(f"/api/v1/services/{postgres_id}/availability").json()
assert availability["total_checks"] == 24
assert availability["uptime_percent"] < 100
assert availability["down_checks"] == 2
assert store.demo_active() is True
def test_demo_reset_is_deterministic_and_exit_restores_topology_and_probes(tmp_path: Path) -> None:
store, client = _client(tmp_path)
original = store.create_service(
ServiceCreate(name="Existing API", kind=ServiceKind.HTTP, url="https://existing.test")
)
original_probe = ProbeResult(
id="existing-probe",
service_id=original.id,
status=HealthStatus.HEALTHY,
latency_ms=12.5,
detail="existing data",
checked_at=datetime.now(UTC),
)
store.save_probe(original_probe)
original_service_json = client.get(f"/api/v1/services/{original.id}").json()
first = client.post("/api/v1/demo").json()
first_services = client.get("/api/v1/services").json()
first_statuses = {
item["service"]["name"]: (item["latest_probe"]["status"], item["effective_status"])
for item in first_services
}
assert first["restored"] is False
assert client.get(f"/api/v1/services/{original.id}").status_code == 404
reset = client.post("/api/v1/demo/reset").json()
reset_services = client.get("/api/v1/services").json()
reset_statuses = {
item["service"]["name"]: (item["latest_probe"]["status"], item["effective_status"])
for item in reset_services
}
assert reset["active"] is True
assert reset["root_cause_service_name"] == DEMO_ROOT_CAUSE_NAME
assert reset_statuses == first_statuses
assert len(reset_services) == 9
exited = client.post("/api/v1/demo/exit").json()
assert exited["active"] is False
assert exited["restored"] is True
assert client.get("/api/v1/demo").json() == {
"active": False,
"synthetic": False,
"scenario_name": None,
"service_count": 0,
"root_cause_service_id": None,
"root_cause_service_name": None,
"affected_service_ids": [],
"affected_service_names": [],
"affected_count": 0,
}
assert client.get(f"/api/v1/services/{original.id}").json() == original_service_json
assert store.probe_history(original.id)[0].id == original_probe.id
with store._connect() as db: # regression assertion for FK-clean restoration
orphan_count = db.execute(
"SELECT COUNT(*) FROM probe_results p LEFT JOIN services s ON s.id = p.service_id WHERE s.id IS NULL"
).fetchone()[0]
assert orphan_count == 0
orphan_evidence_count = db.execute(
"SELECT COUNT(*) FROM health_evidence e LEFT JOIN services s ON s.id = e.service_id "
"WHERE e.service_id IS NOT NULL AND s.id IS NULL"
).fetchone()[0]
orphan_link_count = db.execute(
"SELECT COUNT(*) FROM health_evidence_service_links link LEFT JOIN health_evidence e "
"ON e.id = link.evidence_id WHERE e.id IS NULL"
).fetchone()[0]
assert orphan_evidence_count == 0
assert orphan_link_count == 0
def test_demo_probe_endpoints_remain_external_service_free(tmp_path: Path) -> None:
_, client = _client(tmp_path)
client.post("/api/v1/demo")
snapshots = client.get("/api/v1/services").json()
postgres_id = next(item["service"]["id"] for item in snapshots if item["service"]["name"] == DEMO_ROOT_CAUSE_NAME)
assert client.post(f"/api/v1/services/{postgres_id}/probe").status_code == 409
assert client.post("/api/v1/probe-all").json() == {"requested": 0, "completed": 0, "results": []}
def test_demo_records_have_valid_graph_and_stable_ids() -> None:
fixed_now = datetime(2026, 9, 12, 10, 0, tzinfo=UTC)
services_a, probes_a = build_demo_records(fixed_now)
services_b, probes_b = build_demo_records(fixed_now)
assert [service.model_dump(mode="json") for service in services_a] == [service.model_dump(mode="json") for service in services_b]
assert [probe.model_dump(mode="json") for probe in probes_a] == [probe.model_dump(mode="json") for probe in probes_b]
assert len(probes_a) == 9 * 24
graph = {service.id: set(service.depends_on) for service in services_a}
_assert_acyclic(graph)
+345
View File
@@ -0,0 +1,345 @@
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
from typing import ClassVar
import pytest
from app import docker_discovery
from app.demo import build_demo_records
from app.discovery import DiscoveryManager
from app.discovery_store import DiscoveryStore
from app.models import (
Criticality,
DiscoveryConfidence,
DiscoveryLifecycle,
DiscoveryReconcileRequest,
DiscoveryRunRequest,
DiscoveryRunStatus,
DockerCandidate,
DockerPort,
DockerSuggestion,
ProbeEndpointCandidate,
ProbeEndpointSource,
ProbeReachability,
RelationEvidence,
RelationType,
ServiceCreate,
ServiceKind,
ServiceUpdate,
SuggestionStatus,
TopologySuggestion,
)
from app.store import Store
class _Image:
def __init__(self, tag: str) -> None:
self.tags = [tag]
class _Container:
def __init__(self, name: str, container_id: str, image: str, service: str, *, env: list[str] | None = None) -> None:
self.name = name
self.id = container_id
self.status = "running"
self.image = _Image(image)
self.attrs = {
"State": {"Status": "running", "Health": {"Status": "healthy"}},
"Config": {
"Image": image,
"Env": env or [],
"Healthcheck": {"Test": ["CMD", "true"]},
"Labels": {
"com.docker.compose.project": "northstar",
"com.docker.compose.service": service,
"com.docker.compose.depends_on": "postgres:service_healthy:false" if service == "api" else "",
},
},
"NetworkSettings": {
"Networks": {"northstar_default": {}},
"Ports": {"8000/tcp": [{"HostIp": "127.0.0.1", "HostPort": "18080"}]} if service == "api" else {"5432/tcp": [{"HostIp": "127.0.0.1", "HostPort": "15432"}]},
},
}
class _DockerClient:
containers: ClassVar[_DockerClient]
current: ClassVar[list[_Container]] = []
def __init__(self, **_: object) -> None:
self.containers = self
def ping(self) -> bool:
return True
def list(self, all: bool) -> list[_Container]:
assert all is False
return self.current
def close(self) -> None:
return None
def _manager(tmp_path: Path) -> DiscoveryManager:
store = Store(str(tmp_path / "servicemap.db"))
return DiscoveryManager(store, DiscoveryStore(store))
def test_malformed_multi_host_environment_does_not_abort_discovery() -> None:
evidence = docker_discovery.safe_environment_evidence(
["UPSTREAM_HOSTS=chimera:6967,localhost:1221,127.0.0.1:1221"]
)
assert evidence == ["UPSTREAM_HOSTS hostname=chimera"]
assert docker_discovery.safe_environment_evidence(["BIND_ADDRESS=http://[invalid:8000"]) == []
def test_internal_http_candidate_is_imported_without_an_unsafe_probe_target(tmp_path: Path) -> None:
manager = _manager(tmp_path)
candidate = DockerCandidate(
container_id="a" * 64,
name="internal-web",
image="example/web:1",
state="running",
networks=[],
ports=[DockerPort(container_port="80/tcp")],
stable_source_ref="docker:name:internal-web",
suggestions=[
DockerSuggestion(
name="internal-web",
kind=ServiceKind.HTTP,
host="internal-web",
port=80,
)
],
)
payload = manager._payload_for_candidate(candidate)
assert payload.kind == ServiceKind.HTTP
assert payload.monitoring_enabled is False
assert payload.url is None
assert payload.host is None
assert payload.port is None
published = candidate.model_copy(
update={
"name": "published-web",
"suggestions": [
DockerSuggestion(
name="published-web",
kind=ServiceKind.HTTP,
url="http://127.0.0.1:9494",
port=9494,
)
],
"probe_candidates": [
ProbeEndpointCandidate(
source=ProbeEndpointSource.PUBLISHED_PORT,
kind=ServiceKind.HTTP,
url="http://127.0.0.1:9494",
reachability=ProbeReachability.VERIFIED,
primary=True,
)
],
}
)
published_payload = manager._payload_for_candidate(published)
assert published_payload.url == "http://127.0.0.1:9494"
assert published_payload.host is None
assert published_payload.port is None
def test_new_discovery_run_closes_stale_active_run(tmp_path: Path) -> None:
manager = _manager(tmp_path)
first = manager.discovery_store.start_run()
second = manager.discovery_store.start_run()
assert manager.discovery_store.get_run(first.id).status == DiscoveryRunStatus.FAILED
assert manager.discovery_store.get_run(second.id).status == DiscoveryRunStatus.DISCOVERING
def test_repeated_suggestion_moves_to_latest_discovery_run(tmp_path: Path) -> None:
manager = _manager(tmp_path)
first = manager.discovery_store.start_run()
now = datetime.now(UTC)
suggestion = TopologySuggestion(
id="suggestion-first",
run_id=first.id,
suggestion_type="service",
proposed_name="example",
confidence=DiscoveryConfidence.HIGH,
created_at=now,
updated_at=now,
)
saved_first = manager.discovery_store.save_suggestion(suggestion, "same-fingerprint")
second = manager.discovery_store.start_run()
saved_second = manager.discovery_store.save_suggestion(
suggestion.model_copy(
update={
"id": "suggestion-second",
"run_id": second.id,
"status": SuggestionStatus.OPEN,
"updated_at": datetime.now(UTC),
}
),
"same-fingerprint",
)
assert saved_first.id == saved_second.id == "suggestion-first"
assert saved_second.run_id == second.id
def test_discovery_sanitizes_environment_and_reconciles_high_confidence_edges(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_DockerClient.current = [
_Container(
"api",
"a" * 64,
"northstar/api:1",
"api",
env=["DATABASE_URL=postgres://user:supersecret@postgres:5432/app", "API_TOKEN=never-store"],
),
_Container("postgres", "b" * 64, "postgres:17", "postgres"),
]
monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: _DockerClient(**kwargs))
manager = _manager(tmp_path)
result = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
api = next(item for item in result.candidates if item.name == "api")
assert api.stable_source_ref == "docker:compose:northstar:api"
assert any("hostname=postgres:5432" in item for item in api.environment_evidence)
assert all("supersecret" not in item and "user" not in item for item in api.environment_evidence)
assert all("API_TOKEN" not in item for item in api.environment_evidence)
assert any(item.confidence == DiscoveryConfidence.HIGH for item in result.suggestions if item.suggestion_type == "relation")
reconciled = manager.reconcile(DiscoveryReconcileRequest(run_id=result.run.id, apply_high_confidence=True))
assert reconciled.created == 2
assert reconciled.accepted_relations >= 1
services = manager.store.list_services()
api_service = next(item for item in services if item.source_ref == "docker:compose:northstar:api")
postgres_service = next(item for item in services if item.source_ref == "docker:compose:northstar:postgres")
assert postgres_service.id in api_service.depends_on
edge = next(item for item in manager.discovery_store.list_evidence() if item.source_service_id == api_service.id and item.target_service_id == postgres_service.id)
manager.edge_action(edge.id, "reject")
repeated = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
manager.reconcile(DiscoveryReconcileRequest(run_id=repeated.run.id, apply_high_confidence=True))
api_after_rejection = next(item for item in manager.store.list_services() if item.id == api_service.id)
assert postgres_service.id not in api_after_rejection.depends_on
assert manager.discovery_store.get_evidence(edge.id) is not None
assert not manager.discovery_store.get_evidence(edge.id).active
serialized = (tmp_path / "servicemap.db").read_bytes()
assert b"supersecret" not in serialized
assert b"API_TOKEN" not in serialized
def test_stable_identity_survives_container_recreation_without_duplicate_service(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_DockerClient.current = [_Container("api", "a" * 64, "northstar/api:1", "api")]
monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: _DockerClient(**kwargs))
manager = _manager(tmp_path)
first = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
manager.reconcile(DiscoveryReconcileRequest(run_id=first.run.id, apply_high_confidence=True))
_DockerClient.current = [_Container("api", "c" * 64, "northstar/api:2", "api")]
second = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
assert second.run.diff.changed == 1
manager.reconcile(DiscoveryReconcileRequest(run_id=second.run.id, apply_high_confidence=True))
services = [item for item in manager.store.list_services() if item.source_ref == "docker:compose:northstar:api"]
assert len(services) == 1
assert services[0].container_image == "northstar/api:2"
def test_missing_container_is_retained_and_becomes_stale(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_DockerClient.current = [_Container("api", "a" * 64, "northstar/api:1", "api")]
monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: _DockerClient(**kwargs))
manager = _manager(tmp_path)
first = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
manager.reconcile(DiscoveryReconcileRequest(run_id=first.run.id, apply_high_confidence=True))
_DockerClient.current = []
missing = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
assert missing.run.diff.missing == 1
reconciled = manager.reconcile(DiscoveryReconcileRequest(run_id=missing.run.id))
assert reconciled.missing == 1
service = next(item for item in manager.store.list_services() if item.source_ref == "docker:compose:northstar:api")
assert service.lifecycle == DiscoveryLifecycle.MISSING
assert service.id in {item.id for item in manager.store.list_services()}
def test_missing_identity_progresses_to_stale_then_retired_without_delete(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_DockerClient.current = [_Container("api", "a" * 64, "northstar/api:1", "api")]
monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: _DockerClient(**kwargs))
manager = _manager(tmp_path)
first = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
manager.reconcile(DiscoveryReconcileRequest(run_id=first.run.id, apply_high_confidence=True))
_DockerClient.current = []
lifecycle: list[DiscoveryLifecycle] = []
for _ in range(3):
run = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
manager.reconcile(DiscoveryReconcileRequest(run_id=run.run.id))
lifecycle.append(next(item for item in manager.store.list_services() if item.source_ref == "docker:compose:northstar:api").lifecycle)
assert lifecycle == [DiscoveryLifecycle.MISSING, DiscoveryLifecycle.STALE, DiscoveryLifecycle.RETIRED]
assert len(manager.store.list_services()) == 1
def test_low_confidence_network_hint_is_evidence_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_DockerClient.current = [
_Container("frontend", "a" * 64, "northstar/frontend:1", "frontend"),
_Container("backend", "b" * 64, "northstar/backend:1", "backend"),
]
for item in _DockerClient.current:
item.attrs["Config"]["Labels"]["com.docker.compose.depends_on"] = ""
monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: _DockerClient(**kwargs))
manager = _manager(tmp_path)
run = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
manager.reconcile(DiscoveryReconcileRequest(run_id=run.run.id, apply_high_confidence=True))
services = manager.store.list_services()
assert all(not service.depends_on for service in services)
assert any(item.confidence == DiscoveryConfidence.LOW for item in run.suggestions if item.suggestion_type == "relation")
def test_manual_group_override_survives_rediscovery(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_DockerClient.current = [_Container("api", "a" * 64, "northstar/api:1", "api")]
monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: _DockerClient(**kwargs))
manager = _manager(tmp_path)
first = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
manager.reconcile(DiscoveryReconcileRequest(run_id=first.run.id, apply_high_confidence=True))
before = next(item for item in manager.store.list_services() if item.source_ref == "docker:compose:northstar:api")
payload = ServiceUpdate.model_validate(before.model_dump())
payload.group = "Manual ownership"
after = manager.store.update_service(before.id, payload)
assert after is not None
manager.record_service_override(before, after)
_DockerClient.current = [_Container("api", "c" * 64, "northstar/api:2", "api")]
second = manager.run(DiscoveryRunRequest(docker_host="tcp://docker.local:2375"))
manager.reconcile(DiscoveryReconcileRequest(run_id=second.run.id, apply_high_confidence=True))
updated = next(item for item in manager.store.list_services() if item.source_ref == "docker:compose:northstar:api")
assert updated.group == "Manual ownership"
assert updated.container_image == "northstar/api:2"
def test_demo_exit_restores_discovery_evidence(tmp_path: Path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
discovery = DiscoveryStore(store)
database = store.create_service(ServiceCreate(name="Database", kind=ServiceKind.TCP, host="db", port=5432, category="database", criticality=Criticality.HIGH))
application = store.create_service(ServiceCreate(name="Application", kind=ServiceKind.HTTP, url="https://app.local", depends_on=[database.id]))
source = discovery.save_source(source_type="docker", source_id="docker:compose:stack:app", source_name="app", lifecycle=DiscoveryLifecycle.ACTIVE, metadata={"image": "app:1"})
discovery.attach_source(application.id, source.id)
now = datetime.now(UTC)
discovery.save_evidence(RelationEvidence(id="evidence-1", source_service_id=application.id, target_service_id=database.id, relation_type=RelationType.DEPENDS_ON, evidence_type="manual", confidence=DiscoveryConfidence.CONFIRMED, explanation="test evidence", first_seen_at=now, last_seen_at=now))
demo_services, demo_probes = build_demo_records()
store.activate_demo(demo_services, demo_probes)
assert store.demo_active()
assert {item.name for item in store.list_services()} == {item.name for item in demo_services}
assert store.exit_demo()
restored = store.list_services()
assert {item.name for item in restored} == {"Database", "Application"}
assert discovery.get_evidence("evidence-1") is not None
+135
View File
@@ -0,0 +1,135 @@
from datetime import UTC, datetime
from typing import ClassVar
import pytest
from app import docker_discovery
from app.models import Service, ServiceKind
class _FakeImage:
tags: ClassVar[list[str]] = ["example/service:1.0"]
class _FakeContainer:
id = "abcdef1234567890"
name = "example-service"
status = "running"
image = _FakeImage()
attrs: ClassVar[dict[str, object]] = {
"State": {"Status": "running", "Health": {"Status": "healthy"}},
"Config": {"Image": "example/service:1.0"},
"NetworkSettings": {
"Networks": {"default": {}, "monitoring": {}},
"Ports": {
"8000/tcp": [{"HostIp": "0.0.0.0", "HostPort": 1253}],
"5432/tcp": None,
},
},
}
class _FakeClient:
def __init__(self, **_: object) -> None:
self.containers = self
self.closed = False
def ping(self) -> bool:
return True
def list(self, all: bool) -> list[_FakeContainer]:
assert all is True
return [_FakeContainer()]
def close(self) -> None:
self.closed = True
def test_discovery_normalizes_ports_networks_and_health(monkeypatch: pytest.MonkeyPatch) -> None:
client = _FakeClient()
monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: client)
result = docker_discovery.discover_containers("tcp://docker.local:2375")
assert result.docker_host == "tcp://docker.local:2375"
assert result.candidates[0].name == "example-service"
assert result.candidates[0].health == "healthy"
assert result.candidates[0].networks == ["default", "monitoring"]
assert {(port.container_port, port.host_port) for port in result.candidates[0].ports} == {
("8000/tcp", 1253),
("5432/tcp", None),
}
assert client.closed is True
def test_discovery_filters_sensitive_label_keys_but_keeps_discovery_metadata(
monkeypatch: pytest.MonkeyPatch,
) -> None:
token = "do-not-persist-this-token"
labels = {
"servicemap.api-token": token,
"servicemap.password": "do-not-persist-this-password",
"servicemap.name": "Example API",
"servicemap.probe": "http",
"servicemap.port": "8000",
"com.docker.compose.project": "app-stack",
"com.docker.compose.service": "web",
"com.docker.compose.depends_on": "db",
"com.docker.compose.postgres-password": "do-not-persist-this-compose-password",
"org.opencontainers.image.title": "Safe image title",
"other.secret": "not forwarded",
}
attrs = {
**_FakeContainer.attrs,
"Config": {**_FakeContainer.attrs["Config"], "Labels": labels},
}
monkeypatch.setattr(_FakeContainer, "attrs", attrs)
client = _FakeClient()
monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: client)
result = docker_discovery.discover_containers("tcp://docker.local:2375")
candidate = result.candidates[0]
serialized = str(candidate.model_dump(mode="json"))
assert token not in serialized
assert "do-not-persist-this-password" not in serialized
assert "do-not-persist-this-compose-password" not in serialized
assert "other.secret" not in candidate.labels
assert candidate.labels == {
"servicemap.name": "Example API",
"servicemap.probe": "http",
"servicemap.port": "8000",
"com.docker.compose.project": "app-stack",
"com.docker.compose.service": "web",
"com.docker.compose.depends_on": "db",
"org.opencontainers.image.title": "Safe image title",
}
assert candidate.stable_source_ref == "docker:compose:app-stack:web"
assert candidate.suggestions[0].kind == ServiceKind.HTTP
def test_discovery_rejects_untrusted_scheme() -> None:
with pytest.raises(ValueError, match="must use"):
docker_discovery.discover_containers("file:///var/run/docker.sock")
def test_discovery_marks_imported_and_carries_review_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
client = _FakeClient()
monkeypatch.setattr(docker_discovery.docker, "DockerClient", lambda **kwargs: client)
existing = Service(
id="svc-1",
name="example-service",
kind=ServiceKind.HTTP,
url="http://example-service:8000",
source_type="docker",
source_ref="docker:abcdef123456@example/service:1.0",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
result = docker_discovery.discover_containers("tcp://docker.local:2375", [existing])
candidate = result.candidates[0]
assert candidate.import_state == "already imported"
assert candidate.suggestions[0].source_type == "docker"
assert candidate.suggestions[0].source_ref == "docker:abcdef123456@example/service:1.0"
+244
View File
@@ -0,0 +1,244 @@
import httpx
import pytest
from app import docker_provider
def test_broker_mode_never_falls_back_to_a_socket_or_request_provided_host(monkeypatch) -> None:
monkeypatch.setenv("SERVICEMAP_DOCKER_PROVIDER", "broker")
monkeypatch.delenv("SERVICEMAP_DOCKER_BROKER_URL", raising=False)
monkeypatch.delenv("SERVICEMAP_DOCKER_BROKER_TOKEN_FILE", raising=False)
socket_used = False
def socket_client(*args, **kwargs):
nonlocal socket_used
socket_used = True
raise AssertionError("broker mode must not create a socket client")
monkeypatch.setattr(docker_provider.docker, "DockerClient", socket_client)
with pytest.raises(docker_provider.DockerProviderError, match="Broker mode requires"):
docker_provider.docker_client_for_inventory("https://attacker.invalid")
assert socket_used is False
def test_broker_destination_is_deployment_configuration_only(tmp_path, monkeypatch) -> None:
token_file = tmp_path / "broker-token"
token_file.write_text("test-token", encoding="utf-8")
monkeypatch.setenv("SERVICEMAP_DOCKER_PROVIDER", "broker")
monkeypatch.setenv("SERVICEMAP_DOCKER_BROKER_URL", "https://broker.internal")
monkeypatch.setenv("SERVICEMAP_DOCKER_BROKER_TOKEN_FILE", str(token_file))
client = docker_provider.docker_client_for_inventory("https://attacker.invalid")
assert isinstance(client, docker_provider.BrokerDockerClient)
assert client.base_url == "https://broker.internal"
client.close()
def _mocked_broker_client(
monkeypatch, handler, *, base_url="https://broker.internal", token="opaque-token"
):
original_client = httpx.Client
transport = httpx.MockTransport(handler)
options = {}
def client_factory(**kwargs):
options.update(kwargs)
return original_client(transport=transport, **kwargs)
monkeypatch.setattr(docker_provider.httpx, "Client", client_factory)
client = docker_provider.BrokerDockerClient(base_url, token)
return client, options
def test_inventory_uses_authenticated_fixed_get_and_health_check_has_no_token(monkeypatch) -> None:
requests = []
def handler(request):
requests.append(request)
if request.url.path == "/healthz":
return httpx.Response(200)
return httpx.Response(200, json={"items": []})
client, options = _mocked_broker_client(monkeypatch, handler)
try:
assert client.ping() is True
assert client.containers.list() == []
finally:
client.close()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/healthz"),
("GET", "/v1/containers"),
]
assert "authorization" not in requests[0].headers
assert requests[1].headers["authorization"] == "Bearer opaque-token"
assert options["follow_redirects"] is False
assert options["trust_env"] is False
assert 0 < options["timeout"].connect <= 5
assert 0 < options["timeout"].read <= 5
def test_broker_rejects_other_authenticated_paths_without_requesting_them(monkeypatch) -> None:
requests = []
client, _ = _mocked_broker_client(
monkeypatch,
lambda request: requests.append(request) or httpx.Response(200, json={"items": []}),
)
try:
with pytest.raises(docker_provider.DockerProviderError, match="unsupported"):
client._get("/v1/containers/other")
finally:
client.close()
assert requests == []
@pytest.mark.parametrize(
"base_url",
[
"ftp://broker.internal",
"https://user:password@broker.internal",
"https://broker.internal?token=secret",
"https://broker.internal/#fragment",
"https:///missing-host",
],
)
def test_broker_rejects_unsafe_destinations(base_url) -> None:
with pytest.raises(docker_provider.DockerProviderError):
docker_provider.BrokerDockerClient(base_url, "opaque-token")
def test_broker_rejects_header_injection_in_token() -> None:
with pytest.raises(docker_provider.DockerProviderError):
docker_provider.BrokerDockerClient("https://broker.internal", "token\r\nX-Leak: yes")
@pytest.mark.parametrize(
"payload",
[
{},
{"items": "not-a-list"},
{"items": [{"containerRef": "one"}, "malformed-row"]},
{"items": [{"containerRef": "one", "name": "one", "state": "running"}]},
],
)
def test_broker_rejects_partial_or_malformed_inventory(payload, monkeypatch) -> None:
client, _ = _mocked_broker_client(
monkeypatch,
lambda request: httpx.Response(200, json=payload),
)
try:
with pytest.raises(docker_provider.DockerProviderError, match="invalid"):
client.containers.list()
finally:
client.close()
def test_broker_rejects_non_json_inventory_response(monkeypatch) -> None:
client, _ = _mocked_broker_client(
monkeypatch,
lambda request: httpx.Response(
200, content=b'{"items":[]}', headers={"content-type": "text/plain"}
),
)
try:
with pytest.raises(docker_provider.DockerProviderError, match="invalid"):
client.containers.list()
finally:
client.close()
def test_broker_rejects_oversized_inventory_response(monkeypatch) -> None:
monkeypatch.setattr(docker_provider, "_BROKER_MAX_RESPONSE_BYTES", 4)
client, _ = _mocked_broker_client(
monkeypatch,
lambda request: httpx.Response(200, content=b'{"items":[]}'),
)
try:
with pytest.raises(docker_provider.DockerProviderError, match="invalid"):
client.containers.list()
finally:
client.close()
def test_broker_container_preserves_discovery_fields_without_exposing_environment(
monkeypatch,
) -> None:
item = {
"containerRef": "abcdef1234567890",
"name": "/example-service",
"image": "example/service:1.0",
"state": "running",
"health": "healthy",
"healthcheckPresent": True,
"labels": {"servicemap.enabled": "true"},
"networks": ["default"],
"ports": {
"8000/tcp": [{"hostIp": "0.0.0.0", "hostPort": "1253"}],
"5432/tcp": None,
},
}
client, _ = _mocked_broker_client(
monkeypatch,
lambda request: httpx.Response(200, json={"items": [item]}),
)
try:
[container] = client.containers.list()
finally:
client.close()
assert container.id == item["containerRef"]
assert container.name == "example-service"
assert container.image.tags == [item["image"]]
assert container.attrs["State"] == {"Status": "running", "Health": {"Status": "healthy"}}
assert container.attrs["Config"]["Labels"] == item["labels"]
assert container.attrs["Config"]["Healthcheck"] == {"BrokerReported": True}
assert container.attrs["Config"]["Env"] == []
assert container.attrs["NetworkSettings"]["Networks"] == {"default": {}}
assert container.attrs["NetworkSettings"]["Ports"] == {
"8000/tcp": [{"HostIp": "0.0.0.0", "HostPort": "1253"}],
"5432/tcp": [],
}
def test_broker_health_status_proves_healthcheck_when_old_schema_omits_flag(monkeypatch) -> None:
item = {
"containerRef": "abcdef1234567890",
"name": "example-service",
"image": "example/service:1.0",
"state": "running",
"health": "healthy",
"labels": {},
"networks": [],
"ports": {},
}
client, _ = _mocked_broker_client(
monkeypatch, lambda request: httpx.Response(200, json={"items": [item]})
)
try:
[container] = client.containers.list()
finally:
client.close()
assert container.attrs["Config"]["Healthcheck"] == {"BrokerReported": True}
def test_broker_error_messages_do_not_expose_destination_or_credential(monkeypatch) -> None:
def handler(request):
raise httpx.ConnectError(
"failed with opaque-token at https://broker.internal", request=request
)
client, _ = _mocked_broker_client(monkeypatch, handler)
try:
with pytest.raises(docker_provider.DockerProviderError) as caught:
client.containers.list()
finally:
client.close()
assert "Docker broker request failed" in str(caught.value)
assert "opaque-token" not in str(caught.value)
assert "broker.internal" not in str(caught.value)
+99
View File
@@ -0,0 +1,99 @@
from datetime import UTC, datetime
from pathlib import Path
import pytest
from app.health import dependency_status, effective_status, validate_dependencies
from app.incidents import incident_overview
from app.models import HealthStatus, ProbeResult, ServiceCreate, ServiceKind, ServiceUpdate
from app.store import Store
def _probe(service_id: str, status: HealthStatus) -> ProbeResult:
return ProbeResult(
id=f"probe-{service_id}-{status.value}",
service_id=service_id,
status=status,
latency_ms=1.0,
detail=status.value,
checked_at=datetime.now(UTC),
)
def test_down_dependency_degrades_healthy_service(tmp_path: Path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
database = store.create_service(
ServiceCreate(name="Database", kind=ServiceKind.TCP, host="127.0.0.1", port=5432)
)
api = store.create_service(
ServiceCreate(
name="API",
kind=ServiceKind.HTTP,
url="https://example.test",
depends_on=[database.id],
)
)
store.save_probe(_probe(database.id, HealthStatus.DOWN))
store.save_probe(_probe(api.id, HealthStatus.HEALTHY))
assert dependency_status(api, store) == HealthStatus.DOWN
assert effective_status(api, store) == HealthStatus.DEGRADED
def test_service_without_dependencies_has_healthy_dependency_state(tmp_path: Path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
service = store.create_service(
ServiceCreate(name="Standalone", kind=ServiceKind.HTTP, url="https://example.test")
)
assert dependency_status(service, store) == HealthStatus.HEALTHY
assert effective_status(service, store) == HealthStatus.UNKNOWN
def test_cycle_validation_rejects_recursive_dependencies(tmp_path: Path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
database = store.create_service(
ServiceCreate(name="Database", kind=ServiceKind.TCP, host="127.0.0.1", port=5432)
)
api = store.create_service(
ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://example.test", depends_on=[database.id])
)
with pytest.raises(ValueError, match="cycle"):
validate_dependencies([api.id], store, service_id=database.id)
validate_dependencies([database.id], store, service_id=api.id)
updated = store.update_service(
api.id,
ServiceUpdate(name="API", kind=ServiceKind.HTTP, url="https://example.test", depends_on=[database.id]),
)
assert updated is not None
def test_incident_overview_finds_root_cause_and_multihop_impact(tmp_path: Path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
database = store.create_service(
ServiceCreate(name="Database", kind=ServiceKind.TCP, host="127.0.0.1", port=5432)
)
api = store.create_service(
ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://example.test", depends_on=[database.id])
)
web = store.create_service(
ServiceCreate(name="Web", kind=ServiceKind.HTTP, url="https://web.test", depends_on=[api.id])
)
store.save_probe(_probe(database.id, HealthStatus.DOWN))
store.save_probe(_probe(api.id, HealthStatus.HEALTHY))
store.save_probe(_probe(web.id, HealthStatus.HEALTHY))
assert effective_status(api, store) == HealthStatus.DEGRADED
assert dependency_status(web, store) == HealthStatus.DEGRADED
assert effective_status(web, store) == HealthStatus.DEGRADED
overview = incident_overview(store)
assert overview.active is True
assert overview.down_service_ids == [database.id]
assert set(overview.degraded_service_ids) == {api.id, web.id}
assert overview.candidates[0].service_id == database.id
assert api.id in overview.candidates[0].affected_service_ids
assert web.id in overview.candidates[0].affected_service_ids
+156
View File
@@ -0,0 +1,156 @@
import asyncio
from datetime import UTC, datetime
from typing import Self
import pytest
from app import probes
from app.models import HealthStatus, ProbeResult, Service, ServiceKind
def _service() -> Service:
now = datetime.now(UTC)
return Service(
id="service-1",
name="API",
kind=ServiceKind.HTTP,
url="https://api.local",
created_at=now,
updated_at=now,
)
def _tcp_service() -> Service:
now = datetime.now(UTC)
return Service(
id="tcp-service-1",
name="TCP service",
kind=ServiceKind.TCP,
host="tcp.local",
port=5432,
created_at=now,
updated_at=now,
)
@pytest.mark.parametrize(
("status_code", "expected"),
[
(200, HealthStatus.HEALTHY),
(302, HealthStatus.HEALTHY),
(404, HealthStatus.DEGRADED),
(503, HealthStatus.DOWN),
],
)
def test_http_status_classification(
monkeypatch: pytest.MonkeyPatch,
status_code: int,
expected: HealthStatus,
) -> None:
class FakeResponse:
def __init__(self, code: int) -> None:
self.status_code = code
class FakeClient:
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, *args: object) -> None:
return None
async def get(self, url: str) -> FakeResponse:
assert url == "https://api.local"
return FakeResponse(status_code)
monkeypatch.setattr(probes.httpx, "AsyncClient", lambda **kwargs: FakeClient())
status, detail = asyncio.run(probes._probe_once(_service(), 1.0))
assert status == expected
assert detail == f"HTTP {status_code}"
def test_retry_latency_reports_final_attempt_not_backoff(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls = 0
async def fake_probe_once(service: Service, timeout_seconds: float) -> tuple[HealthStatus, str]:
nonlocal calls
calls += 1
if calls == 1:
raise OSError("temporary failure")
return HealthStatus.HEALTHY, "HTTP 200"
timings = iter([10.0, 10.05, 20.0, 20.02])
monkeypatch.setattr(probes, "_probe_once", fake_probe_once)
monkeypatch.setattr(probes, "perf_counter", lambda: next(timings))
result = asyncio.run(probes.probe_service(_service(), timeout_seconds=1.0, retries=1))
assert calls == 2
assert result.status == HealthStatus.HEALTHY
assert result.latency_ms == pytest.approx(20.0)
assert result.detail == "HTTP 200 after 2 attempts"
def test_tcp_probe_success_and_failure(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeWriter:
def close(self) -> None:
return None
async def wait_closed(self) -> None:
return None
async def fake_open_connection(host: str | None, port: int | None) -> tuple[object, FakeWriter]:
assert (host, port) == ("tcp.local", 5432)
return object(), FakeWriter()
monkeypatch.setattr(probes.asyncio, "open_connection", fake_open_connection)
status, detail = asyncio.run(probes._probe_once(_tcp_service(), 1.0))
assert status == HealthStatus.HEALTHY
assert detail == "TCP tcp.local:5432 reachable"
async def failing_open_connection(host: str | None, port: int | None) -> tuple[object, FakeWriter]:
raise ConnectionRefusedError("refused")
monkeypatch.setattr(probes.asyncio, "open_connection", failing_open_connection)
result = asyncio.run(probes.probe_service(_tcp_service(), timeout_seconds=1.0, retries=0))
assert result.status == HealthStatus.DOWN
assert "ConnectionRefusedError" in result.detail
def test_probe_many_respects_concurrency_boundary(monkeypatch: pytest.MonkeyPatch) -> None:
services = [
_service().model_copy(update={"id": f"service-{index}", "name": f"API-{index}"})
for index in range(5)
]
active = 0
maximum = 0
async def fake_probe_service(
service: Service,
*,
timeout_seconds: float,
retries: int,
) -> ProbeResult:
nonlocal active, maximum
active += 1
maximum = max(maximum, active)
await asyncio.sleep(0)
active -= 1
return ProbeResult(
id=f"probe-{service.id}",
service_id=service.id,
status=HealthStatus.HEALTHY,
latency_ms=1.0,
detail="ok",
checked_at=datetime.now(UTC),
)
monkeypatch.setattr(probes, "probe_service", fake_probe_service)
results = asyncio.run(probes.probe_many(services, concurrency=2))
assert len(results) == len(services)
assert maximum <= 2
+69
View File
@@ -0,0 +1,69 @@
import asyncio
from contextlib import suppress
from app.scheduler import scheduled_probe_loop
class EmptyStore:
def __init__(self) -> None:
self.prune_calls = 0
def list_services(self) -> list[object]:
return []
def prune_probes(self, retention_days: int) -> int:
assert retention_days == 30
self.prune_calls += 1
return 0
class SyntheticStore(EmptyStore):
def demo_active(self) -> bool:
return True
def list_services(self) -> list[object]:
raise AssertionError("synthetic demo must not start live probes")
def test_scheduler_runs_first_cycle_before_initial_sleep() -> None:
async def scenario() -> None:
store = EmptyStore()
task = asyncio.create_task(
scheduled_probe_loop(
store, # type: ignore[arg-type]
interval_seconds=3600,
timeout_seconds=1.0,
retries=0,
concurrency=1,
retention_days=30,
)
)
await asyncio.sleep(0)
assert store.prune_calls == 1
task.cancel()
with suppress(asyncio.CancelledError):
await task
asyncio.run(scenario())
def test_scheduler_pauses_live_probes_for_synthetic_demo() -> None:
async def scenario() -> None:
store = SyntheticStore()
task = asyncio.create_task(
scheduled_probe_loop(
store, # type: ignore[arg-type]
interval_seconds=3600,
timeout_seconds=1.0,
retries=0,
concurrency=1,
retention_days=30,
)
)
await asyncio.sleep(0)
assert store.prune_calls == 1
task.cancel()
with suppress(asyncio.CancelledError):
await task
asyncio.run(scenario())
+107
View File
@@ -0,0 +1,107 @@
from datetime import UTC, datetime, timedelta
from pathlib import Path
from app.models import HealthStatus, ProbeResult, ServiceCreate, ServiceKind, ServiceUpdate
from app.store import Store
def test_service_round_trip_and_update(tmp_path: Path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
created = store.create_service(
ServiceCreate(name="Example API", kind=ServiceKind.HTTP, url="https://example.test")
)
loaded = store.get_service(created.id)
assert loaded is not None
assert loaded.name == "Example API"
assert loaded.kind == ServiceKind.HTTP
assert loaded.url == "https://example.test"
updated = store.update_service(
created.id,
ServiceUpdate(name="Example TCP", kind=ServiceKind.TCP, host="127.0.0.1", port=443),
)
assert updated is not None
assert updated.kind == ServiceKind.TCP
assert updated.host == "127.0.0.1"
assert updated.port == 443
assert updated.updated_at >= created.updated_at
def test_delete_service_cleans_downstream_dependency(tmp_path: Path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
database = store.create_service(
ServiceCreate(name="Database", kind=ServiceKind.TCP, host="127.0.0.1", port=5432)
)
api = store.create_service(
ServiceCreate(
name="API",
kind=ServiceKind.HTTP,
url="https://example.test",
depends_on=[database.id],
)
)
assert store.delete_service(database.id) is True
loaded_api = store.get_service(api.id)
assert loaded_api is not None
assert loaded_api.depends_on == []
assert store.delete_service(database.id) is False
def test_stale_probe_after_service_delete_is_ignored(tmp_path: Path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
service = store.create_service(
ServiceCreate(name="Temporary", kind=ServiceKind.HTTP, url="https://example.test")
)
assert store.delete_service(service.id) is True
probe = ProbeResult(
id="stale-probe",
service_id=service.id,
status=HealthStatus.HEALTHY,
latency_ms=1.0,
detail="stale",
checked_at=datetime.now(UTC),
)
assert store.save_probe(probe) == probe
assert store.probe_history(service.id) == []
def test_availability_summary_and_retention(tmp_path: Path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
service = store.create_service(
ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://example.test")
)
now = datetime.now(UTC)
for index, probe_status in enumerate((HealthStatus.HEALTHY, HealthStatus.DEGRADED, HealthStatus.DOWN)):
store.save_probe(
ProbeResult(
id=f"probe-{index}",
service_id=service.id,
status=probe_status,
latency_ms=float(10 + index),
detail=probe_status.value,
checked_at=now - timedelta(minutes=index),
)
)
store.save_probe(
ProbeResult(
id="old-probe",
service_id=service.id,
status=HealthStatus.DOWN,
latency_ms=99.0,
detail="old",
checked_at=now - timedelta(days=40),
)
)
summary = store.availability_summary(service.id, hours=24)
assert summary.total_checks == 3
assert summary.healthy_checks == 1
assert summary.degraded_checks == 1
assert summary.down_checks == 1
assert summary.uptime_percent == 66.67
assert summary.average_latency_ms == 11.0
assert store.prune_probes(30) == 1
+159
View File
@@ -0,0 +1,159 @@
import json
import sqlite3
from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import uuid4
from app.config_io import export_document, import_document, parse_document
from app.incidents import sync_incidents
from app.models import HealthStatus, ProbeResult, ServiceCreate, ServiceKind
from app.store import Store
def _probe(service_id: str, status: HealthStatus, number: int) -> ProbeResult:
return ProbeResult(
id=f"probe-{service_id}-{number}",
service_id=service_id,
status=status,
latency_ms=4.0,
detail=status.value,
checked_at=datetime.now(UTC) + timedelta(microseconds=number),
)
def test_v110_schema_migrates_v101_database_without_reset(tmp_path: Path) -> None:
path = tmp_path / "servicemap.db"
with sqlite3.connect(path) as db:
db.executescript(
"""
CREATE TABLE services (
id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, kind TEXT NOT NULL,
url TEXT, host TEXT, port INTEGER, depends_on TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL, updated_at TEXT
);
CREATE TABLE probe_results (
id TEXT PRIMARY KEY, service_id TEXT NOT NULL, status TEXT NOT NULL,
latency_ms REAL, detail TEXT NOT NULL, checked_at TEXT NOT NULL
);
CREATE TABLE demo_sessions (
id INTEGER PRIMARY KEY CHECK (id = 1), snapshot_services TEXT NOT NULL,
snapshot_probes TEXT NOT NULL, demo_service_ids TEXT NOT NULL, started_at TEXT NOT NULL
);
"""
)
created = datetime.now(UTC).isoformat()
db.execute(
"INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
("legacy", "Legacy API", "http", "https://legacy.local", None, None, "[]", created, created),
)
db.execute(
"INSERT INTO probe_results VALUES (?, ?, ?, ?, ?, ?)",
("legacy-probe", "legacy", "healthy", 12.0, "legacy", created),
)
store = Store(str(path))
service = store.get_service("legacy")
assert service is not None
assert service.name == "Legacy API"
assert store.service_health("legacy") == HealthStatus.HEALTHY
assert store.latest_probe("legacy") is not None
assert store.path == path
def test_failure_and_recovery_thresholds_are_explicit(tmp_path: Path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
service = store.create_service(
ServiceCreate(
name="Threshold API",
kind=ServiceKind.HTTP,
url="https://threshold.local",
failure_threshold=2,
recovery_threshold=2,
)
)
store.save_probe(_probe(service.id, HealthStatus.DOWN, 1))
assert store.service_health(service.id) == HealthStatus.UNKNOWN
store.save_probe(_probe(service.id, HealthStatus.DOWN, 2))
assert store.service_health(service.id) == HealthStatus.DOWN
store.save_probe(_probe(service.id, HealthStatus.HEALTHY, 3))
assert store.service_health(service.id) == HealthStatus.RECOVERING
store.save_probe(_probe(service.id, HealthStatus.HEALTHY, 4))
assert store.service_health(service.id) == HealthStatus.HEALTHY
def test_incident_journal_acknowledges_resolves_and_does_not_duplicate(tmp_path: Path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
database = store.create_service(ServiceCreate(name="Database", kind=ServiceKind.TCP, host="db.local", port=5432))
api = store.create_service(ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://api.local", depends_on=[database.id]))
store.save_probe(_probe(database.id, HealthStatus.DOWN, 1))
store.save_probe(_probe(database.id, HealthStatus.DOWN, 2))
store.save_probe(_probe(api.id, HealthStatus.HEALTHY, 1))
active = store.list_incidents(active_only=True)
assert len(active) == 1
assert active[0].root_service_id == database.id
assert api.id in active[0].affected_service_ids
assert len([event for event in store.list_events(100) if event.event_type == "incident.opened"]) == 1
acknowledged = store.acknowledge_incident(active[0].id)
assert acknowledged is not None
assert acknowledged.status.value == "acknowledged"
store.save_probe(_probe(database.id, HealthStatus.HEALTHY, 3))
resolved = store.get_incident(active[0].id)
assert resolved is not None
assert resolved.status.value == "resolved"
assert resolved.resolved_at is not None
assert any(event.event_type == "incident.resolved" for event in store.list_events(100))
def test_incident_reconciliation_resolves_existing_duplicate_active_roots(tmp_path: Path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
service = store.create_service(ServiceCreate(name="Duplicate root", kind=ServiceKind.TCP, host="db.local", port=5432))
store.save_probe(_probe(service.id, HealthStatus.DOWN, 1))
store.save_probe(_probe(service.id, HealthStatus.DOWN, 2))
original = store.list_incidents(active_only=True)[0]
duplicate = original.model_copy(update={"id": str(uuid4()), "detected_at": original.detected_at + timedelta(seconds=1)})
store.upsert_incident(duplicate)
sync_incidents(store)
active = store.list_incidents(active_only=True)
assert len(active) == 1
assert active[0].root_service_id == service.id
assert store.get_incident(duplicate.id).status.value == "resolved"
assert any(event.event_type == "incident.duplicate_resolved" for event in store.incident_timeline(duplicate.id))
def test_maintenance_suppresses_incident_and_config_v2_round_trips(tmp_path: Path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
document = parse_document(
"""
version: 2
services:
- name: DNS edge
kind: dns
host: edge.internal
group: Edge
environment: staging
tags: [dns, edge]
criticality: high
interval_seconds: 15
depends_on: []
""",
"yaml",
)
result = import_document(store, document, "replace")
assert result.created == 1
service = store.get_service_by_name("DNS edge")
assert service is not None
assert service.group == "Edge"
assert service.interval_seconds == 15
assert parse_document(json.dumps(export_document(store).model_dump(mode="json")), "json").version == 2
updated = store.set_maintenance(service.id, True, "planned work")
assert updated is not None
assert store.service_health(service.id) == HealthStatus.MAINTENANCE
store.save_probe(_probe(service.id, HealthStatus.DOWN, 1))
sync_incidents(store)
assert store.list_incidents(active_only=True) == []
@@ -0,0 +1,322 @@
from __future__ import annotations
from pathlib import Path
from app.discovery import DiscoveryManager
from app.discovery_store import DiscoveryStore
from app.docker_discovery import _kind_and_endpoint, _preflight_candidate
from app.models import (
DiscoveryCategory,
DiscoveryConfidence,
DiscoveryRunStatus,
DockerCandidate,
DockerPort,
DockerSuggestion,
HealthStatus,
MonitoringState,
ProbeEndpointCandidate,
ProbeEndpointSource,
ProbeReachability,
ProbeResult,
ServiceCreate,
ServiceKind,
)
from app.store import Store
def _manager(tmp_path: Path) -> DiscoveryManager:
store = Store(str(tmp_path / "servicemap.db"))
return DiscoveryManager(store, DiscoveryStore(store))
def _candidate(
name: str,
reference: str,
*,
project: str | None = None,
compose_service: str | None = None,
labels: dict[str, str] | None = None,
networks: list[str] | None = None,
) -> DockerCandidate:
return DockerCandidate(
container_id=reference[-12:],
name=name,
image="example/service:1",
state="running",
networks=networks or [],
ports=[],
labels=labels or {},
stable_source_ref=reference,
compose_project=project,
compose_service=compose_service,
category=DiscoveryCategory.APPLICATION,
)
def test_internal_docker_dns_is_evidence_not_an_active_probe() -> None:
kind, url, host, port, evidence = _kind_and_endpoint(
"postgres",
[DockerPort(container_port="5432/tcp")],
{},
)
assert kind == ServiceKind.TCP
assert url is None
assert host == "postgres"
assert port == 5432
assert "internal container endpoint candidate" in evidence
def test_published_port_requires_an_explicit_probe_host() -> None:
ports = [DockerPort(container_port="8080/tcp", host_ip="0.0.0.0", host_port=1226)]
labels = {"servicemap.probe": "http"}
_, unsafe_url, unsafe_host, _, _ = _kind_and_endpoint("app", ports, labels)
kind, url, host, port, evidence = _kind_and_endpoint("app", ports, labels, "monitoring-host")
assert unsafe_url is None
assert unsafe_host == "app"
assert kind == ServiceKind.HTTP
assert url == "http://monitoring-host:1226"
assert host is None and port is None
assert "host-published endpoint" in evidence
def test_running_container_without_reachable_endpoint_is_runtime_observed(tmp_path: Path) -> None:
manager = _manager(tmp_path)
candidate = _candidate("worker", "docker:name:worker")
candidate.suggestions = [DockerSuggestion(name="worker", kind=ServiceKind.TCP, host="worker", port=9000)]
candidate.probe_candidates = [
ProbeEndpointCandidate(
source=ProbeEndpointSource.CONTAINER_INTERNAL,
kind=ServiceKind.TCP,
host="worker",
port=9000,
reachability=ProbeReachability.INTERNAL_ONLY,
)
]
payload = manager._payload_for_candidate(candidate)
assert payload.monitoring_enabled is False
assert payload.monitoring_state.value == "observed"
assert payload.probe_reachability == ProbeReachability.INTERNAL_ONLY
def test_docker_healthcheck_observation_does_not_enable_network_probe(tmp_path: Path) -> None:
manager = _manager(tmp_path)
candidate = _candidate("database", "docker:name:database")
candidate.healthcheck_present = True
candidate.probe_candidates = [
ProbeEndpointCandidate(
source=ProbeEndpointSource.DOCKER_HEALTHCHECK,
kind=ServiceKind.TCP,
reachability=ProbeReachability.VERIFIED,
confidence=DiscoveryConfidence.HIGH,
)
]
payload = manager._payload_for_candidate(candidate)
assert payload.monitoring_enabled is False
assert payload.monitoring_state.value == "docker_health"
assert payload.probe_source == ProbeEndpointSource.DOCKER_HEALTHCHECK
def test_appops_application_health_is_provenance_not_component_health(tmp_path: Path) -> None:
manager = _manager(tmp_path)
candidate = _candidate("application", "docker:name:application")
candidate.healthcheck_present = True
candidate.appops_health = "healthy"
candidate.appops_app_id = "northstar"
candidate.appops_discovery_confidence = 0.9
payload = manager._payload_for_candidate(candidate)
assert payload.monitoring_enabled is False
assert payload.monitoring_state == MonitoringState.DOCKER_HEALTH
assert payload.probe_source == ProbeEndpointSource.DOCKER_HEALTHCHECK
assert payload.runtime_health is None
service = manager.store.create_service(payload)
manager._record_candidate_evidence(candidate, service)
resolution = manager.store.health_resolution(service.id)
assert resolution.selected_evidence_id is None
assert resolution.evidence[0].scope.value == "application"
assert "not used as component" in resolution.explanation
def test_compose_reference_prefers_same_project_over_duplicate_stack_name(tmp_path: Path) -> None:
manager = _manager(tmp_path)
run = manager.discovery_store.start_run()
source = _candidate(
"api-a",
"docker:compose:stack-a:api",
project="stack-a",
compose_service="api",
labels={"com.docker.compose.depends_on": "postgres:service_healthy:false"},
)
postgres_a = _candidate("postgres-a", "docker:compose:stack-a:postgres", project="stack-a", compose_service="postgres")
postgres_b = _candidate("postgres-b", "docker:compose:stack-b:postgres", project="stack-b", compose_service="postgres")
suggestions = manager._relation_suggestions(run, [source, postgres_a, postgres_b], {})
assert len(suggestions) == 1
assert suggestions[0].target_ref == postgres_a.stable_source_ref
assert suggestions[0].confidence == DiscoveryConfidence.HIGH
def test_ambiguous_global_name_stays_review_only(tmp_path: Path) -> None:
manager = _manager(tmp_path)
run = manager.discovery_store.start_run()
source = _candidate(
"api",
"docker:name:api",
labels={"servicemap.depends_on": "redis"},
)
redis_a = _candidate("redis-a", "docker:compose:a:redis", project="a", compose_service="redis")
redis_b = _candidate("redis-b", "docker:compose:b:redis", project="b", compose_service="redis")
suggestions = manager._relation_suggestions(run, [source, redis_a, redis_b], {})
assert len(suggestions) == 1
assert suggestions[0].target_ref is None
assert suggestions[0].confidence == DiscoveryConfidence.LOW
assert "Manual review required" in " ".join(suggestions[0].evidence)
def test_reachability_repair_is_dry_run_first_and_preserves_probe_history(tmp_path: Path) -> None:
manager = _manager(tmp_path)
candidate = _candidate("legacy-db", "docker:name:legacy-db")
candidate.suggestions = [DockerSuggestion(name="legacy-db", kind=ServiceKind.TCP, host="legacy-db", port=5432)]
candidate.probe_candidates = [
ProbeEndpointCandidate(
source=ProbeEndpointSource.CONTAINER_INTERNAL,
kind=ServiceKind.TCP,
host="legacy-db",
port=5432,
reachability=ProbeReachability.INTERNAL_ONLY,
)
]
legacy = manager.store.create_service(
ServiceCreate(
name="legacy-db",
kind=ServiceKind.TCP,
host="legacy-db",
port=5432,
source_type="docker",
source_ref=candidate.stable_source_ref,
)
)
manager.store.save_probe(
ProbeResult(
id="legacy-probe",
service_id=legacy.id,
status=HealthStatus.DOWN,
latency_ms=None,
detail="legacy unreachable internal DNS target",
checked_at=legacy.created_at,
)
)
run = manager.discovery_store.start_run()
source = manager.discovery_store.save_source(
source_type="docker",
source_id=candidate.stable_source_ref or "",
source_name=candidate.name,
lifecycle=candidate.lifecycle,
metadata={"candidate": candidate.model_dump(mode="json")},
)
manager.discovery_store.save_observation(
run_id=run.id,
source=source,
source_name=candidate.name,
lifecycle=candidate.lifecycle,
change_type="changed",
metadata={"candidate": candidate.model_dump(mode="json")},
)
manager.discovery_store.finish_run(run.id, status=DiscoveryRunStatus.COMPLETED, source_counts={}, diff=run.diff)
preview = manager.repair_probe_reachability(run_id=run.id, dry_run=True)
assert preview.changed == 1
assert manager.store.get_service(legacy.id).monitoring_enabled is True # type: ignore[union-attr]
applied = manager.repair_probe_reachability(run_id=run.id, dry_run=False)
updated = manager.store.get_service(legacy.id)
assert applied.disabled_unsafe == 1
assert updated is not None and updated.monitoring_enabled is False
assert updated.monitoring_state.value == "observed"
assert len(manager.store.probe_history(legacy.id)) == 1
def test_container_http_port_is_not_misclassified_by_a_custom_host_port() -> None:
kind, url, host, port, evidence = _kind_and_endpoint(
"adminer",
[DockerPort(container_port="8080/tcp", host_ip="0.0.0.0", host_port=8070)],
{},
"127.0.0.1",
)
assert kind == ServiceKind.HTTP
assert url == "http://127.0.0.1:8070"
assert host is None and port is None
assert "HTTP-like container port" in evidence
def test_published_preflight_requires_a_real_tcp_connection(monkeypatch) -> None:
candidate = ProbeEndpointCandidate(
source=ProbeEndpointSource.PUBLISHED_PORT,
kind=ServiceKind.TCP,
host="127.0.0.1",
port=3306,
)
class _Connection:
def __enter__(self):
return self
def __exit__(self, *_: object) -> None:
return None
monkeypatch.setattr("app.docker_discovery.socket.create_connection", lambda *_args, **_kwargs: _Connection())
verified = _preflight_candidate(candidate)
assert verified.primary is True
assert verified.reachability == ProbeReachability.VERIFIED
assert verified.preflight_detail == "TCP connect succeeded"
def test_http_preflight_records_response_without_scanning_paths(monkeypatch) -> None:
candidate = ProbeEndpointCandidate(
source=ProbeEndpointSource.PUBLISHED_PORT,
kind=ServiceKind.HTTP,
url="http://127.0.0.1:8070",
)
class _Connection:
def __enter__(self):
return self
def __exit__(self, *_: object) -> None:
return None
class _Response:
status_code = 204
class _Client:
def __enter__(self):
return self
def __exit__(self, *_: object) -> None:
return None
def request(self, method: str, url: str) -> _Response:
assert method == "HEAD"
assert url == candidate.url
return _Response()
monkeypatch.setattr("app.docker_discovery.socket.create_connection", lambda *_args, **_kwargs: _Connection())
monkeypatch.setattr("app.docker_discovery.httpx.Client", lambda **_kwargs: _Client())
verified = _preflight_candidate(candidate)
assert verified.primary is True
assert verified.preflight_detail == "HTTP 204 responded"
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
from app.discovery_store import DiscoveryStore
from app.models import (
DiscoveryConfidence,
HealthStatus,
MonitoringState,
RelationEvidence,
RelationType,
Service,
ServiceCreate,
ServiceKind,
ServiceSnapshot,
TopologySuggestion,
)
from app.semantic import build_semantic_topology
from app.store import Store
def _service(identifier: str, name: str, *, application: str | None = None, category: str = "application") -> Service:
now = datetime.now(UTC)
return Service(
id=identifier,
name=name,
kind=ServiceKind.HTTP,
url=f"https://{identifier}.local",
application=application,
category=category,
created_at=now,
updated_at=now,
)
def _snapshot(service: Service, direct: HealthStatus, effective: HealthStatus | None = None) -> ServiceSnapshot:
return ServiceSnapshot(
service=service,
direct_status=direct,
dependency_status=effective or direct,
effective_status=effective or direct,
)
def test_semantic_topology_keeps_singletons_as_distinct_applications() -> None:
snapshots = [
_snapshot(_service("one", "One", category="application"), HealthStatus.HEALTHY),
_snapshot(_service("two", "Two", category="application"), HealthStatus.HEALTHY),
]
topology = build_semantic_topology(snapshots, [])
assert {app.name for app in topology.applications} == {"One", "Two"}
assert all(app.component_count == 1 for app in topology.applications)
def test_semantic_app_health_is_explainable_and_ignores_unmonitored_auxiliaries() -> None:
postgres = _service("postgres", "Zabbix PostgreSQL", application="Zabbix", category="database")
server = _service("server", "Zabbix Server", application="Zabbix", category="monitoring")
agent = _service("agent", "Zabbix Agent", application="Zabbix", category="monitoring")
agent.monitoring_enabled = False
agent.monitoring_state = MonitoringState.UNMONITORED
topology = build_semantic_topology([
_snapshot(postgres, HealthStatus.DOWN),
_snapshot(server, HealthStatus.HEALTHY),
_snapshot(agent, HealthStatus.UNMONITORED),
], [], root_service_ids={"postgres"}, affected_service_ids={"server"})
app = next(item for item in topology.applications if item.name == "Zabbix")
assert app.status == HealthStatus.DEGRADED
assert app.root_cause is True
assert "PostgreSQL" in app.status_explanation
assert app.monitored_components == 2
assert app.unmonitored_components == 1
components = {item.service_id: item for item in topology.components}
assert components["postgres"].root_cause is True
assert components["server"].root_cause is False
assert components["server"].affected is True
def test_logical_relation_merges_independent_evidence_and_improves_confidence(tmp_path: Path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
source = store.create_service(ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://api.local"))
target = store.create_service(ServiceCreate(name="Database", kind=ServiceKind.TCP, host="db", port=5432))
discovery = DiscoveryStore(store)
now = datetime.now(UTC)
for evidence_type, confidence, explanation in (
("compose", DiscoveryConfidence.MEDIUM, "Compose depends_on declares database"),
("environment", DiscoveryConfidence.HIGH, "DATABASE_HOST resolves to database"),
):
discovery.save_evidence(RelationEvidence(
id=f"evidence-{evidence_type}",
source_service_id=source.id,
target_service_id=target.id,
relation_type=RelationType.DEPENDS_ON,
evidence_type=evidence_type,
confidence=confidence,
explanation=explanation,
source_ref=f"docker:compose:stack:{evidence_type}",
first_seen_at=now,
last_seen_at=now,
))
relations = discovery.list_logical_relations()
assert len(relations) == 1
assert relations[0].confidence == DiscoveryConfidence.CONFIRMED
assert {item.evidence_type for item in relations[0].evidence} == {"compose", "environment"}
def test_repeated_suggestion_merges_changed_evidence_without_a_duplicate(tmp_path: Path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
discovery = DiscoveryStore(store)
run = discovery.start_run()
now = datetime.now(UTC)
base = {
"id": "suggest-rel", "run_id": run.id, "suggestion_type": "relation", "source_ref": "docker:api",
"target_ref": "docker:db", "relation_type": RelationType.DEPENDS_ON,
"confidence": DiscoveryConfidence.HIGH, "created_at": now, "updated_at": now,
}
discovery.save_suggestion(TopologySuggestion(**base, evidence=["Compose relationship"]), "canonical-relation")
second = {**base, "id": "suggest-rel-second"}
saved = discovery.save_suggestion(TopologySuggestion(**second, evidence=["Environment relationship"]), "canonical-relation")
assert saved.evidence == ["Compose relationship", "Environment relationship"]
assert len(discovery.list_suggestions()) == 1
+132
View File
@@ -0,0 +1,132 @@
from datetime import UTC, datetime, timedelta
from app.discovery_store import DiscoveryStore
from app.evidence import resolve_health
from app.models import (
EvidenceProvider,
EvidenceScope,
HealthEvidence,
HealthStatus,
ServiceCreate,
ServiceKind,
)
from app.store import Store
def _evidence(
evidence_id: str,
*,
provider: EvidenceProvider,
status: HealthStatus,
scope: EvidenceScope = EvidenceScope.COMPONENT,
service_id: str | None = "svc",
observed_at: datetime | None = None,
) -> HealthEvidence:
observed = observed_at or datetime.now(UTC)
return HealthEvidence(
id=evidence_id,
subject_type="service" if scope == EvidenceScope.COMPONENT else "application",
subject_id=service_id or "appops:demo",
service_id=service_id,
provider=provider,
provider_ref=provider.value,
status=status,
scope=scope,
confidence=0.8,
observed_at=observed,
valid_until=observed + timedelta(minutes=5),
received_at=observed,
summary="test evidence",
)
def test_application_scope_evidence_never_becomes_component_direct_health(tmp_path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
service = store.create_service(
ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://api.local")
)
store.record_health_evidence(
_evidence(
"appops-application",
provider=EvidenceProvider.APPOPS,
status=HealthStatus.DOWN,
scope=EvidenceScope.APPLICATION,
service_id=None,
),
service_ids=[service.id],
)
resolution = store.health_resolution(service.id)
assert resolution.selected_evidence_id is None
assert resolution.status == HealthStatus.UNKNOWN
assert "application-scoped" in resolution.explanation
def test_evidence_conflict_and_priority_are_visible(tmp_path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
service = store.create_service(
ServiceCreate(name="API", kind=ServiceKind.HTTP, url="https://api.local")
)
store.record_health_evidence(
_evidence(
"docker-down",
provider=EvidenceProvider.DOCKER,
status=HealthStatus.DOWN,
service_id=service.id,
)
)
store.record_health_evidence(
_evidence(
"probe-healthy",
provider=EvidenceProvider.PROBE,
status=HealthStatus.HEALTHY,
service_id=service.id,
)
)
resolution = store.health_resolution(service.id)
assert resolution.status == HealthStatus.HEALTHY
assert resolution.selected_provider == EvidenceProvider.PROBE
assert resolution.conflict is True
def test_expired_evidence_is_explained_but_not_selected() -> None:
old = datetime.now(UTC) - timedelta(hours=2)
resolution = resolve_health(
[
_evidence(
"expired",
provider=EvidenceProvider.DOCKER,
status=HealthStatus.DOWN,
observed_at=old,
),
]
)
assert resolution.selected_evidence_id is None
assert resolution.evidence[0].freshness.value == "expired"
def test_topology_snapshots_diff_canonical_service_and_dependency_changes(tmp_path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
discovery = DiscoveryStore(store)
database = store.create_service(
ServiceCreate(name="Database", kind=ServiceKind.TCP, host="db.local", port=5432)
)
first = discovery.capture_snapshot(run_id=None)
store.create_service(
ServiceCreate(
name="API",
kind=ServiceKind.HTTP,
url="https://api.local",
depends_on=[database.id],
)
)
second = discovery.capture_snapshot(run_id=None)
diff = discovery.topology_diff(after_snapshot_id=second.id, before_snapshot_id=first.id)
assert diff.summary["added"] == 2 # API service and its canonical dependency
assert {item.entity_type for item in diff.added} == {"service", "relationship"}
@@ -0,0 +1,347 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from app import observation as observation_module
from app import store as store_module
from app.docker_discovery import AppOpsInfo, AppOpsInventory, DockerHealthObservation
from app.evidence import freshness_for
from app.models import (
EvidenceProvider,
EvidenceScope,
HealthEvidence,
HealthResolutionReason,
HealthStatus,
ServiceCreate,
ServiceKind,
ServiceSnapshot,
)
from app.observation import ObservationManager, observation_ttl
from app.semantic import build_semantic_topology
from app.store import Store
def _service(identifier: str, name: str, *, application: str | None = "Northstar", source_ref: str | None = None):
return ServiceCreate(name=name, kind=ServiceKind.HTTP, url=f"https://{identifier}.test", application=application, source_ref=source_ref)
def _application_evidence(
evidence_id: str,
*,
status: HealthStatus = HealthStatus.HEALTHY,
confidence: float = 0.9,
observed_at: datetime | None = None,
valid_for: timedelta = timedelta(minutes=3),
app_id: str = "northstar",
) -> HealthEvidence:
now = observed_at or datetime.now(UTC)
return HealthEvidence(
id=evidence_id,
subject_type="application",
subject_id=f"appops:{app_id}",
provider=EvidenceProvider.APPOPS,
provider_ref=app_id,
status=status,
scope=EvidenceScope.APPLICATION,
confidence=confidence,
observed_at=now,
valid_until=now + valid_for,
received_at=now,
summary="AppOps aggregate health",
)
def _component_evidence(evidence_id: str, service_id: str, status: HealthStatus) -> HealthEvidence:
now = datetime.now(UTC)
return HealthEvidence(
id=evidence_id,
subject_type="service",
subject_id=service_id,
service_id=service_id,
provider=EvidenceProvider.DOCKER,
provider_ref=service_id,
status=status,
scope=EvidenceScope.COMPONENT,
confidence=0.95,
observed_at=now,
valid_until=now + timedelta(minutes=3),
received_at=now,
summary="Docker healthcheck",
)
def _snapshot(service, *, direct: HealthStatus, effective: HealthStatus | None = None, evidence: list[HealthEvidence] | None = None) -> ServiceSnapshot:
return ServiceSnapshot(
service=service,
direct_status=direct,
dependency_status=effective or direct,
effective_status=effective or direct,
health_evidence=evidence or [],
)
def test_appops_health_resolves_application_without_promoting_component_scope(tmp_path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
api = store.create_service(_service("api", "Northstar API"))
worker = store.create_service(_service("worker", "Northstar Worker"))
aggregate = _application_evidence("appops-northstar")
store.record_health_evidence(aggregate, service_ids=[api.id, worker.id])
snapshots = [
_snapshot(api, direct=HealthStatus.UNKNOWN, evidence=store.health_evidence_for_service(api.id)),
_snapshot(worker, direct=HealthStatus.UNKNOWN, evidence=store.health_evidence_for_service(worker.id)),
]
topology = build_semantic_topology(snapshots, [])
application = topology.applications[0]
assert application.status == HealthStatus.HEALTHY
assert application.health_resolution.reason_code == HealthResolutionReason.SELECTED_APPLICATION_EVIDENCE
assert application.health_resolution.confidence == 0.9
assert all(component.direct_status == HealthStatus.UNKNOWN for component in topology.components)
assert all(component.unknown_reason is not None for component in topology.components)
def test_component_failure_overrides_healthy_aggregate_and_exposes_conflict(tmp_path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
database = store.create_service(_service("db", "Northstar PostgreSQL"))
api = store.create_service(_service("api", "Northstar API"))
aggregate = _application_evidence("appops-northstar")
store.record_health_evidence(aggregate, service_ids=[database.id, api.id])
store.record_health_evidence(_component_evidence("db-down", database.id, HealthStatus.DOWN))
topology = build_semantic_topology(
[
_snapshot(database, direct=HealthStatus.DOWN, evidence=store.health_evidence_for_service(database.id)),
_snapshot(api, direct=HealthStatus.UNKNOWN, effective=HealthStatus.DEGRADED, evidence=store.health_evidence_for_service(api.id)),
],
[],
)
application = topology.applications[0]
assert application.status in {HealthStatus.DOWN, HealthStatus.DEGRADED}
assert application.health_resolution.reason_code == HealthResolutionReason.COMPONENT_FAILURE
assert application.health_resolution.conflict is True
def test_application_and_domain_coverage_treat_partial_unknown_as_coverage_not_failure() -> None:
now = datetime.now(UTC)
from app.models import Service
healthy = Service(id="healthy", **_service("healthy", "Known", application="App A").model_dump(), created_at=now, updated_at=now)
unknown = Service(id="unknown", **_service("unknown", "Unresolved", application="App A").model_dump(), created_at=now, updated_at=now)
topology = build_semantic_topology(
[_snapshot(healthy, direct=HealthStatus.HEALTHY), _snapshot(unknown, direct=HealthStatus.UNKNOWN)],
[],
)
application = topology.applications[0]
domain = topology.domains[0]
assert application.status == HealthStatus.HEALTHY
assert application.resolved_components == 1
assert application.unresolved_components == 1
assert application.component_coverage_percent == 50
assert domain.status == HealthStatus.HEALTHY
assert domain.application_coverage_percent == 100
def test_missing_stale_and_expired_application_evidence_have_structured_reasons() -> None:
now = datetime.now(UTC)
from app.models import Service
service = Service(id="app", **_service("app", "No signal").model_dump(), created_at=now, updated_at=now)
no_evidence = build_semantic_topology([_snapshot(service, direct=HealthStatus.UNKNOWN)], []).applications[0]
assert no_evidence.health_resolution.reason_code == HealthResolutionReason.NO_APPLICATION_EVIDENCE
stale_evidence = _application_evidence(
"stale", observed_at=now - timedelta(minutes=2, seconds=30), valid_for=timedelta(minutes=2)
)
stale = build_semantic_topology([_snapshot(service, direct=HealthStatus.UNKNOWN, evidence=[stale_evidence])], []).applications[0]
assert stale.health_resolution.reason_code == HealthResolutionReason.STALE_EVIDENCE
expired_evidence = _application_evidence(
"expired", observed_at=now - timedelta(minutes=8), valid_for=timedelta(minutes=2)
)
expired = build_semantic_topology([_snapshot(service, direct=HealthStatus.UNKNOWN, evidence=[expired_evidence])], []).applications[0]
assert expired.health_resolution.reason_code == HealthResolutionReason.EXPIRED_EVIDENCE
def test_same_named_postgres_sources_do_not_share_application_or_appops_claim(tmp_path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
first = store.create_service(_service("one", "PostgreSQL one", application="PostgreSQL", source_ref="docker:name:postgres-one"))
second = store.create_service(_service("two", "PostgreSQL two", application="PostgreSQL", source_ref="docker:name:postgres-two"))
store.record_health_evidence(_application_evidence("first", status=HealthStatus.HEALTHY, app_id="postgres-one"), service_ids=[first.id])
store.record_health_evidence(_application_evidence("second", status=HealthStatus.DOWN, app_id="postgres-two"), service_ids=[second.id])
topology = build_semantic_topology(
[
_snapshot(first, direct=HealthStatus.UNKNOWN, evidence=store.health_evidence_for_service(first.id)),
_snapshot(second, direct=HealthStatus.UNKNOWN, evidence=store.health_evidence_for_service(second.id)),
],
[],
)
assert len(topology.applications) == 2
assert sorted(item.status for item in topology.applications) == [HealthStatus.DOWN, HealthStatus.HEALTHY]
def test_observation_refresh_renews_evidence_without_duplicates_and_uses_bounded_ttl(tmp_path, monkeypatch) -> None:
store = Store(str(tmp_path / "servicemap.db"))
service = store.create_service(_service("api", "Northstar API", source_ref="docker:name:northstar-api"))
first_now = datetime(2026, 1, 1, tzinfo=UTC)
current = {"value": first_now}
monkeypatch.setattr(observation_module, "utc_now", lambda: current["value"])
monkeypatch.setattr(
observation_module,
"appops_inventory",
lambda _: AppOpsInventory(
{"northstar-api": AppOpsInfo("northstar", None, None, "healthy", "Northstar", 0.9)},
True,
"1 mapping loaded",
),
)
monkeypatch.setattr(
observation_module,
"observe_container_health",
lambda _: [DockerHealthObservation("abc", "northstar-api", "docker:name:northstar-api", "running", "healthy", True)],
)
events: list[dict[str, object]] = []
monkeypatch.setattr(store_module.event_bus, "publish", lambda event: events.append(event))
manager = ObservationManager(store, interval_seconds=60)
first = manager.refresh()
events.clear()
current["value"] = first_now + timedelta(minutes=20)
second = manager.refresh()
evidence = store.health_evidence_for_service(service.id)
active = [item for item in evidence if item.active]
assert first.docker_observations == second.docker_observations == 1
assert observation_ttl(60) == timedelta(seconds=180)
assert len(active) == 2
assert all(item.valid_until - item.observed_at == timedelta(seconds=180) for item in active)
assert all(freshness_for(item, now=current["value"]).value == "fresh" for item in active)
assert store.provider_statuses()[0].valid_until - store.provider_statuses()[0].observed_at == timedelta(seconds=180)
assert events == []
def test_appops_warning_is_degraded_application_evidence_only(tmp_path, monkeypatch) -> None:
store = Store(str(tmp_path / "servicemap.db"))
service = store.create_service(
_service("postgres", "PostgreSQL", source_ref="docker:name:postgresql15")
)
monkeypatch.setattr(
observation_module,
"appops_inventory",
lambda _: AppOpsInventory(
{"postgresql15": AppOpsInfo("postgres", "database", "critical", "warning", "PostgreSQL", 1.0)},
True,
"1 mapping loaded",
),
)
monkeypatch.setattr(
observation_module,
"observe_container_health",
lambda _: [
DockerHealthObservation(
"abc", "postgresql15", "docker:name:postgresql15", "running", None, False
)
],
)
result = ObservationManager(store, interval_seconds=60).refresh()
evidence = store.health_evidence_for_service(service.id)
topology = build_semantic_topology(
[_snapshot(service, direct=HealthStatus.UNKNOWN, evidence=evidence)], []
)
assert result.appops_observations == 1
assert len(evidence) == 1
assert evidence[0].scope == EvidenceScope.APPLICATION
assert evidence[0].status == HealthStatus.DEGRADED
assert topology.applications[0].status == HealthStatus.DEGRADED
assert topology.components[0].direct_status == HealthStatus.UNKNOWN
def test_loaded_evidence_reports_expired_freshness(tmp_path) -> None:
store = Store(str(tmp_path / "servicemap.db"))
service = store.create_service(_service("api", "Northstar API"))
old = datetime.now(UTC) - timedelta(minutes=10)
store.record_health_evidence(
_application_evidence("old-claim", observed_at=old, valid_for=timedelta(minutes=2)),
service_ids=[service.id],
)
assert store.health_evidence_for_service(service.id)[0].freshness.value == "expired"
assert store.health_evidence_for_services([service.id])[service.id][0].freshness.value == "expired"
def test_empty_broker_inventory_is_unavailable_with_existing_docker_services(
tmp_path, monkeypatch
) -> None:
store = Store(str(tmp_path / "servicemap.db"))
service = store.create_service(
_service("api", "Northstar API", source_ref="docker:name:northstar-api")
)
store.record_health_evidence(
_component_evidence("existing-docker-claim", service.id, HealthStatus.HEALTHY),
service_ids=[service.id],
)
monkeypatch.setenv("SERVICEMAP_DOCKER_PROVIDER", "broker")
monkeypatch.setattr(
observation_module,
"appops_inventory",
lambda _: AppOpsInventory({}, False, "AppOps unavailable"),
)
monkeypatch.setattr(observation_module, "observe_container_health", lambda _: [])
result = ObservationManager(store, interval_seconds=60).refresh()
assert result.docker_observations == 0
assert next(
item for item in store.provider_statuses() if item.provider == EvidenceProvider.DOCKER
).status.value == "unavailable"
assert [item.id for item in store.health_evidence_for_service(service.id)] == [
"existing-docker-claim"
]
def test_single_provider_outage_ages_only_its_claim_and_recovers_without_duplicates(tmp_path, monkeypatch) -> None:
store = Store(str(tmp_path / "servicemap.db"))
service = store.create_service(_service("api", "Northstar API", source_ref="docker:name:northstar-api"))
now = datetime(2026, 1, 1, tzinfo=UTC)
current = {"value": now}
mode = {"available": True}
monkeypatch.setattr(observation_module, "utc_now", lambda: current["value"])
monkeypatch.setattr(
observation_module,
"appops_inventory",
lambda _: (
AppOpsInventory(
{"northstar-api": AppOpsInfo("northstar", None, None, "healthy", "Northstar", 0.9)},
True,
"1 mapping loaded",
)
if mode["available"]
else AppOpsInventory({}, False, "AppOps inventory unavailable: ConnectError")
),
)
monkeypatch.setattr(
observation_module,
"observe_container_health",
lambda _: [DockerHealthObservation("abc", "northstar-api", "docker:name:northstar-api", "running", "healthy", True)],
)
manager = ObservationManager(store, interval_seconds=60)
manager.refresh()
mode["available"] = False
current["value"] += timedelta(seconds=60)
manager.refresh()
evidence_during_outage = store.health_evidence_for_service(service.id)
assert any(item.provider == EvidenceProvider.APPOPS and freshness_for(item, now=current["value"]).value == "fresh" for item in evidence_during_outage)
assert any(item.provider == EvidenceProvider.DOCKER and freshness_for(item, now=current["value"]).value == "fresh" for item in evidence_during_outage)
assert next(item for item in store.provider_statuses() if item.provider == EvidenceProvider.APPOPS).status.value == "unavailable"
mode["available"] = True
current["value"] += timedelta(seconds=60)
manager.refresh()
active = [item for item in store.health_evidence_for_service(service.id) if item.active]
assert len(active) == 2
assert next(item for item in store.provider_statuses() if item.provider == EvidenceProvider.APPOPS).status.value == "healthy"
+66
View File
@@ -0,0 +1,66 @@
name: servicemap
services:
backend:
build:
context: .
dockerfile: backend/Dockerfile
container_name: servicemap-backend
restart: unless-stopped
environment:
SERVICEMAP_DB: /data/servicemap.db
SERVICEMAP_PROBE_INTERVAL: ${SERVICEMAP_PROBE_INTERVAL:-60}
SERVICEMAP_PROBE_TIMEOUT: ${SERVICEMAP_PROBE_TIMEOUT:-5}
SERVICEMAP_PROBE_RETRIES: ${SERVICEMAP_PROBE_RETRIES:-1}
SERVICEMAP_PROBE_CONCURRENCY: ${SERVICEMAP_PROBE_CONCURRENCY:-10}
SERVICEMAP_RETENTION_DAYS: ${SERVICEMAP_RETENTION_DAYS:-30}
SERVICEMAP_EVENT_RETENTION_DAYS: ${SERVICEMAP_EVENT_RETENTION_DAYS:-90}
SERVICEMAP_TLS_WARNING_DAYS: ${SERVICEMAP_TLS_WARNING_DAYS:-30}
SERVICEMAP_FLAP_THRESHOLD: ${SERVICEMAP_FLAP_THRESHOLD:-6}
SERVICEMAP_OBSERVATION_INTERVAL_SECONDS: ${SERVICEMAP_OBSERVATION_INTERVAL_SECONDS:-60}
SERVICEMAP_PROBE_HOST: ${SERVICEMAP_PROBE_HOST:-}
volumes:
- ./data:/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).read()"]
interval: 20s
timeout: 5s
start_period: 10s
retries: 5
labels:
net.unraid.docker.managed: "dockerman"
security_opt:
- no-new-privileges:true
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
frontend:
build:
context: .
dockerfile: frontend/Dockerfile
container_name: servicemap
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
ports:
- "${SERVICEMAP_PORT:-1253}:80"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/health >/dev/null || exit 1"]
interval: 20s
timeout: 5s
start_period: 10s
retries: 5
labels:
net.unraid.docker.managed: "dockerman"
net.unraid.docker.webui: "http://${SERVICEMAP_WEBUI_HOST:-127.0.0.1}:${SERVICEMAP_PORT:-1253}"
security_opt:
- no-new-privileges:true
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
+70
View File
@@ -0,0 +1,70 @@
name: servicemap
services:
servicemap:
build:
context: .
dockerfile: Dockerfile.unraid
container_name: servicemap
restart: unless-stopped
environment:
SERVICEMAP_DB: /data/servicemap.db
SERVICEMAP_STATIC_DIR: /app/static
SERVICEMAP_PROBE_INTERVAL: ${SERVICEMAP_PROBE_INTERVAL:-60}
SERVICEMAP_PROBE_TIMEOUT: ${SERVICEMAP_PROBE_TIMEOUT:-5}
SERVICEMAP_PROBE_RETRIES: ${SERVICEMAP_PROBE_RETRIES:-1}
SERVICEMAP_PROBE_CONCURRENCY: ${SERVICEMAP_PROBE_CONCURRENCY:-10}
SERVICEMAP_RETENTION_DAYS: ${SERVICEMAP_RETENTION_DAYS:-30}
SERVICEMAP_EVENT_RETENTION_DAYS: ${SERVICEMAP_EVENT_RETENTION_DAYS:-90}
SERVICEMAP_TLS_WARNING_DAYS: ${SERVICEMAP_TLS_WARNING_DAYS:-30}
SERVICEMAP_FLAP_THRESHOLD: ${SERVICEMAP_FLAP_THRESHOLD:-6}
SERVICEMAP_DISCOVERY_INTERVAL_SECONDS: ${SERVICEMAP_DISCOVERY_INTERVAL_SECONDS:-0}
SERVICEMAP_OBSERVATION_INTERVAL_SECONDS: ${SERVICEMAP_OBSERVATION_INTERVAL_SECONDS:-60}
SERVICEMAP_DOCKER_HOST: ${SERVICEMAP_DOCKER_HOST:-unix:///var/run/docker.sock}
# "broker" is opt-in and has no socket fallback. It requires a dedicated
# registered ServiceMap identity and token file before activation.
SERVICEMAP_DOCKER_PROVIDER: ${SERVICEMAP_DOCKER_PROVIDER:-socket}
SERVICEMAP_DOCKER_BROKER_URL: ${SERVICEMAP_DOCKER_BROKER_URL:-}
SERVICEMAP_DOCKER_BROKER_TOKEN_FILE: ${SERVICEMAP_DOCKER_BROKER_TOKEN_FILE:-}
SERVICEMAP_APPOPS_URL: ${SERVICEMAP_APPOPS_URL:-}
SERVICEMAP_PROBE_HOST: ${SERVICEMAP_PROBE_HOST:-}
# Disabled is backwards-compatible. OIDC mode is fail-closed and needs
# issuer, audience and explicit role-group mappings before activation.
SERVICEMAP_AUTH_MODE: ${SERVICEMAP_AUTH_MODE:-disabled}
SERVICEMAP_OIDC_ISSUER: ${SERVICEMAP_OIDC_ISSUER:-}
SERVICEMAP_OIDC_AUDIENCE: ${SERVICEMAP_OIDC_AUDIENCE:-}
SERVICEMAP_OIDC_ROLE_CLAIMS: ${SERVICEMAP_OIDC_ROLE_CLAIMS:-groups,roles}
SERVICEMAP_OIDC_VIEWER_GROUPS: ${SERVICEMAP_OIDC_VIEWER_GROUPS:-}
SERVICEMAP_OIDC_OPERATOR_GROUPS: ${SERVICEMAP_OIDC_OPERATOR_GROUPS:-}
SERVICEMAP_OIDC_ADMIN_GROUPS: ${SERVICEMAP_OIDC_ADMIN_GROUPS:-}
volumes:
- servicemap_data:/data
- /var/run/docker.sock:/var/run/docker.sock:ro
ports:
- "${SERVICEMAP_PORT:-1253}:8000"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).read()"]
interval: 20s
timeout: 5s
start_period: 10s
retries: 5
labels:
net.unraid.docker.managed: "dockerman"
net.unraid.docker.webui: "http://${SERVICEMAP_WEBUI_HOST:-127.0.0.1}:${SERVICEMAP_PORT:-1253}"
net.unraid.docker.icon: "http://${SERVICEMAP_WEBUI_HOST:-127.0.0.1}:${SERVICEMAP_PORT:-1253}/icons/servicemap-dockerman-256.png"
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=32m
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
volumes:
servicemap_data:
name: servicemap_data
+119
View File
@@ -0,0 +1,119 @@
# ServiceMap architecture
## Runtime shape
ServiceMap remains deliberately small: one FastAPI process, one React single-page application and one SQLite database. Compose serves React through Nginx; the Unraid image bundles the same frontend and serves it from FastAPI.
```text
Browser
│ REST + SSE
▼
React workspaces
│
▼
FastAPI
├── service inventory and dependency validation
├── probe engine (HTTP, TCP, DNS, TLS)
├── state machine and flapping observation
├── health-evidence resolution and provider health
├── incident correlation and event journal
├── SSE fan-out bus
├── atomic config import/export
├── Docker/AppOps discovery and reconciliation
├── evidence-backed canonical topology overrides
└── optional per-service scheduler
│
▼
SQLite on /data
```
## Data ownership and migration
`Store` owns SQLite access and opens short-lived connections with WAL mode, a 5-second busy timeout and foreign keys enabled. Startup creates missing discovery tables and adds versioned metadata in place. v1.6 adds health evidence, evidence-to-service links and provider-status rows; the one-time migration projects only legitimate existing latest probe/runtime facts with their original timestamps. Existing rows receive safe defaults; the migration never deletes or rewrites topology data.
v1.6.1 adds no schema migration. Its health-only observation loop is distinct from discovery: every configured cadence it reads Docker runtime/healthcheck facts and AppOps application inventory, renews only source-scoped evidence/provider status, and skips demo state. It does not create services, suggestions, relationship evidence, snapshots, probe endpoints or operator overrides. The shared observation TTL is at least 150 seconds and three normal cycles, so a delayed provider cycle ages evidence rather than immediately turning the topology unknown. Provider failures are isolated; they update that provider's availability only and leave other current evidence intact.
v1.6.2 keeps the same schema and evidence scopes. An AppOps `warning` observation is degraded application evidence; it cannot resolve a component. Evidence freshness is recomputed when persisted claims are read, so an expired claim cannot appear fresh in the raw inspector/API payload. The current MCP Hub Docker read broker cannot replace the production socket provider yet: its registration covers two unrelated projects, its ServiceMap-scoped inventory is empty, and its short-lived HMAC service-token contract rejects a raw bearer secret. Cutover requires a dedicated ServiceMap read identity, full bounded inventory parity and an explicit network path; broker mode remains fail-closed with no socket fallback.
The HTTP discovery routes accept only the Docker host and AppOps URL selected in deployment configuration. A request body cannot turn the container into a general-purpose client for another Docker daemon or internal HTTP endpoint. The private-LAN deployment currently keeps OIDC disabled because it has no dedicated browser client or tested owner login path; mutation APIs therefore depend on the LAN perimeter. Broker mode must be deployed without the legacy Docker socket bind even though the current socket-mode Compose file retains it.
The v1.4 semantic projection is deliberately not a parallel datastore. `semantic.py` derives Domain → Application → Component from canonical services, relations, runtime/monitoring state and incident analysis. Logical relations are grouped at query time so one graph edge retains all independent evidence records and their first/last-seen provenance. v1.6.1 resolves application health separately: fresh aggregate AppOps evidence can answer an application but never a component; direct component failures override weak aggregate health and conflicting claims remain visible. Semantic application identity uses locked operator grouping, Compose provenance and stable source references before a display name, preventing generic provider labels from collapsing unrelated services. Bulk evidence lookup serves the complete semantic projection in one bounded SQL query rather than an application-by-application scan.
The service record stores descriptive metadata, group/environment/tags, criticality, source reference, application, runtime state/health, monitoring state, probe source/reachability/confidence, enablement, maintenance state, probe policy and state-machine counters. Probe rows remain immutable and retain `trigger` and bounded JSON `metadata` fields. `HealthEvidence` retains provider, subject, scope, confidence, observation/receipt/expiry timestamps, metadata and supersession. Application-scoped claims may be linked to component inspectors but cannot resolve a component's direct health.
Indexes cover service/time probe history, global probe time, event time/service/incident and incident status/root lookups. Availability is calculated with SQL aggregates over a bounded time window rather than loading all rows into Python. Evidence timestamp renewals retain a stable row and do not publish SSE; material provider status, evidence status, confidence, summary or metadata changes do.
## Health state and dependency semantics
The direct state machine is backend-owned, then exposed through an evidence policy:
```text
unknown ──success──> healthy
healthy ──failure threshold──> degraded/down
down/degraded ──success #1──> recovering
recovering ──recovery threshold──> healthy
any monitored state ──maintenance──> maintenance
```
The first failure can be held at the prior/unknown state when a failure threshold is greater than one. Repeated state changes in a bounded 30-minute query set the `flapping` observation and its count; flapping is not a competing health state.
Effective health is calculated recursively from direct state and dependencies. A down dependency makes a healthy dependent degraded; the stored probe observation is never rewritten. Cycles and missing dependencies are rejected before writes.
Maintenance and disabled monitoring do not produce a new incident. A running Docker component without a verified endpoint or Docker healthcheck is `unmonitored`, not `down`; this state cannot fabricate a root cause. Docker `unhealthy` and stopped/dead runtime observations are valid direct-down evidence without a synthetic network probe. The resolver selects fresh component-scoped evidence deterministically, exposes conflicts rather than collapsing them and excludes stale/expired claims. AppOps inventory health is application-scoped provenance, not a component probe.
## Probe engine
`probes.py` uses bounded async operations and one asyncio lock per service. Scheduler, probe-all and manual probe calls therefore coalesce at the service boundary rather than running duplicate network checks concurrently.
- HTTP follows redirects, supports expected status ranges and records status/latency.
- TCP opens and closes a bounded connection.
- DNS resolves a hostname, returns at most 20 unique addresses and reports no-answer/timeout failures.
- TLS performs a direct hostname-verified handshake, reports expiry metadata and applies a configurable warning threshold (`SERVICEMAP_TLS_WARNING_DAYS`, default 30).
Targets are explicitly typed. HTTP accepts only `http`/`https` URLs; TCP/TLS require host and port; DNS requires a host and does not accept arbitrary URI schemes. There is no `file://` or generic URL fetcher.
Per-service interval, timeout and retry settings override the global defaults. Scheduled observations are marked `scheduled`; UI-triggered observations are marked `manual`.
## Incidents and events
`incidents.py` deterministically finds a down service with no effectively-down dependency, then traverses the reverse graph for blast radius. `sync_incidents()` reconciles candidates with the durable journal:
- one active incident per root service;
- repeated probe cycles update impact instead of opening duplicates;
- acknowledgement changes `open` to `acknowledged` and persists a timestamp;
- recovery changes the record to `resolved` and retains `resolved_at`;
- state transitions, flapping, incident lifecycle and maintenance changes are journaled.
Incident timeline entries are events tied to an incident. Raw probe rows are retained independently under the configured probe retention; non-incident events use `SERVICEMAP_EVENT_RETENTION_DAYS` (default 90). Incident lifecycle events are retained with the incident record.
## SSE and frontend refresh
The durable SQLite event journal is the source of truth. A small thread-safe bounded queue is allocated per SSE client and receives notifications for probe completion, state changes, topology changes and incident lifecycle changes. Slow clients lose only transient wakeups, not durable history. The stream emits keepalives and unregisters queues on disconnect/shutdown.
React subscribes with SSE, debounces event refreshes and reconnects with backoff. It enables bounded fallback refresh only while the stream is unavailable; a healthy quiet stream does not trigger periodic full reloads. Authenticated clients use the same SSE protocol through `fetch` with a bearer header. The frontend never invents monitoring state optimistically; every node, counter and inspector value comes from the API.
## Production discovery and reconciliation
`DiscoveryStore` owns durable runs, source records, observations, relation evidence, suggestions, overrides, topology-change events and compact canonical topology snapshots. `DiscoveryManager` reads the configured Docker endpoint and optional read-only AppOps inventory, then separates observed candidates from inferred relationships and canonical services. Candidates include stable Compose/name identity, image, state, health, labels, networks, ports, Compose metadata, healthcheck presence and sanitized endpoint-host evidence. ServiceMap labels (`servicemap.*`), Compose `depends_on`, safe environment hostnames and AppOps mappings produce explainable suggestions. Numeric AppOps `discovery_confidence` gates classification strength, while its app health remains scoped provenance. Shared networks are low-confidence review hints only.
Discovery runs do not mutate canonical topology. Reconciliation updates existing observed records and only imports new services or accepts high-confidence dependency relationships when the user explicitly requests it. Compose references resolve only inside the same project; environment references use project/network/global-unique scope and ambiguous matches remain review suggestions. Manual overrides and locked edges are preserved across later runs. Missing Docker identities move through `missing`, `stale` and `retired` without deleting probe history. Health propagation uses canonical dependency edges only; evidence-only or rejected relationships cannot affect health.
Published ports become active candidates only when `SERVICEMAP_PROBE_HOST` is explicitly configured. Internal Docker names are retained as evidence with `internal_only` reachability, never sent to the network probe engine by default. The reachability-repair endpoint provides a dry-run report before it disables unsafe legacy candidates or selects a verified published endpoint; it does not delete history and resolves false incidents with an endpoint-superseded event reason.
After reconciliation, a revision-hashed snapshot is captured only when canonical services or dependency edges change. The Changes workspace compares two snapshots and makes a service/relationship change actionable without auto-applying anything. The production UI starts with the canonical topology, exposes discovery diffs and evidence, supports individual accept/reject decisions, and offers an explicit edit mode for pinned node layout and manual dependency edges. Hidden/ignored lifecycle records remain available through the `include_hidden` API for reconciliation and audit context. ELK route sections are passed to `LivingEdge`, so its directed SVG path follows the actual orthogonal layout.
## Configuration
Config v1 is still accepted. Config v2 serializes the service metadata and policy fields by human-readable service name. Parsing validates endpoint fields, duplicate names, duplicate/self edges and document-contained cycles. Import then validates the projected merge or replace graph and commits it in one `BEGIN IMMEDIATE` transaction.
## Demo safety
The Northstar demo is built from deterministic UUIDs and synthetic probe records. Entering demo stores JSON snapshots of services, probes, events, incidents, discovery evidence, health evidence and provider state in the durable `demo_sessions` row. Reset replaces only the synthetic workspace while preserving the original snapshot. Exit restores the snapshot and deletes the session. Scheduled and manual live probes and discovery reconciliation are blocked while the demo is active.
## Deployment trust boundary
The Unraid target uses `/data` on the named `servicemap_data` volume, port mapping `1253:8000`, a healthcheck on `/health`, `no-new-privileges`, dropped capabilities, a read-only root filesystem and bounded `/tmp` tmpfs. Managed validation gates exact-SHA autoredeploy. Socket discovery remains a high-trust Docker API boundary even with a read-only bind. The opt-in broker provider requires a dedicated short-lived ServiceMap token, registration-scoped read capability and shared network, and never falls back to the socket. AppOps access is read-only enrichment.
## Optional authorization
`SERVICEMAP_AUTH_MODE=disabled` preserves the deployed v1.5 behavior. In `oidc` mode FastAPI validates issuer/audience/signed bearer JWTs against OIDC discovery and JWKS; it maps explicit roles or configured Authentik groups to viewer, operator and admin. `/health` remains anonymous for deployment recovery, while APIs fail closed when OIDC configuration is incomplete. No client credentials, issuer secrets or tokens are stored in ServiceMap's SQLite database or frontend configuration.
Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

+13
View File
@@ -0,0 +1,13 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY frontend/package.json ./package.json
COPY frontend/package-lock.json ./package-lock.json
RUN npm ci --no-audit --no-fund
COPY frontend/ ./
RUN npm run build
FROM nginx:1.27-alpine
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+170
View File
@@ -0,0 +1,170 @@
import { expect, test } from "@playwright/test";
type ServiceSnapshot = {
service: {
id: string;
name: string;
depends_on: string[];
};
latest_probe: { status: string } | null;
effective_status: string;
};
const topologyOf = (snapshots: ServiceSnapshot[]) =>
snapshots
.map(({ service }) => ({
id: service.id,
name: service.name,
depends_on: [...service.depends_on].sort(),
}))
.sort((left, right) => left.id.localeCompare(right.id));
const isDemoMutation = (path: string) =>
(response: {
url: () => string;
request: () => { method: () => string };
}) =>
response.url().endsWith(path) && response.request().method() === "POST";
test("Explore demo exposes evidence-backed root cause and reset-safe exit", async ({ page, request }) => {
test.setTimeout(60_000);
// The Playwright server uses a persistent temporary database, so restore any
// session left by an interrupted run before taking this test's baseline.
const cleanup = await request.post("/api/v1/demo/exit");
expect(cleanup.ok()).toBeTruthy();
const baselineResponse = await request.get("/api/v1/services");
expect(baselineResponse.ok()).toBeTruthy();
const baselineTopology = topologyOf(await baselineResponse.json());
await page.goto("/");
const explore = page.getByRole("button", { name: "Explore demo", exact: true });
await expect(explore).toBeEnabled({ timeout: 30_000 });
const loadResponsePromise = page.waitForResponse(isDemoMutation("/api/v1/demo"));
await explore.click();
if (baselineTopology.length > 0) {
const confirmation = page
.getByRole("alert")
.filter({ hasText: "temporarily replace this topology" });
await expect(confirmation).toBeVisible();
await confirmation.getByRole("button", { name: "Explore demo", exact: true }).click();
}
const loadResponse = await loadResponsePromise;
expect(loadResponse.ok()).toBeTruthy();
expect(await loadResponse.json()).toMatchObject({
active: true,
synthetic: true,
scenario_name: "Northstar Platform",
service_count: 9,
total: 9,
root_cause_service_name: "PostgreSQL",
affected_count: 5,
});
const demoServicesResponse = await request.get("/api/v1/services");
expect(demoServicesResponse.ok()).toBeTruthy();
const demoServices: ServiceSnapshot[] = await demoServicesResponse.json();
expect(demoServices).toHaveLength(9);
const postgres = demoServices.find(({ service }) => service.name === "PostgreSQL");
expect(postgres).toBeDefined();
expect(postgres!.latest_probe?.status).toBe("down");
expect(postgres!.effective_status).toBe("down");
const [incidentsResponse, historyResponse, availabilityResponse] =
await Promise.all([
request.get("/api/v1/incidents"),
request.get(`/api/v1/services/${postgres!.service.id}/history?limit=50`),
request.get(`/api/v1/services/${postgres!.service.id}/availability?hours=24`),
]);
expect(incidentsResponse.ok()).toBeTruthy();
const incident = (await incidentsResponse.json()) as {
active: boolean;
candidates: Array<{
service_name: string;
affected_service_ids: string[];
}>;
};
expect(incident.active).toBe(true);
expect(incident.candidates[0]?.service_name).toBe("PostgreSQL");
expect(incident.candidates[0]?.affected_service_ids).toHaveLength(5);
expect(historyResponse.ok()).toBeTruthy();
expect(await historyResponse.json()).toHaveLength(24);
expect(availabilityResponse.ok()).toBeTruthy();
expect(await availabilityResponse.json()).toMatchObject({
total_checks: 24,
down_checks: 2,
uptime_percent: 91.67,
});
const demoBanner = page.getByRole("status", { name: "Explore demo scenario" });
await expect(demoBanner).toBeVisible();
await expect(demoBanner).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(page.getByText("Demo scenario", { exact: false })).toBeVisible();
await expect(page.getByText("PostgreSQL", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Likely root cause", { exact: true })).toBeVisible();
await expect(page.getByText("Health evidence", { exact: true })).toBeVisible();
const workspaces = page.getByRole("navigation", { name: "ServiceMap workspaces" });
await workspaces.getByRole("button", { name: "Overview" }).click();
await expect(page.getByRole("heading", { name: "9/9 with current health evidence" })).toBeVisible();
await expect(page.getByRole("button", { name: "Review unresolved" })).toBeDisabled();
await page.locator(".overview-lead").getByRole("button", { name: /1 down/i }).click();
await expect(page.getByRole("combobox", { name: "Status", exact: true })).toHaveValue("down");
await workspaces.getByRole("button", { name: "Incidents" }).click();
await expect(
page.getByRole("heading", { name: "Active and historical incidents" }),
).toBeVisible();
await page.getByLabel("Search incidents").fill("does not exist");
await expect(page.getByText("No incidents match these filters.")).toBeVisible();
await page.getByLabel("Search incidents").fill("");
await workspaces.getByRole("button", { name: "Topology", exact: true }).click();
await expect(page.getByRole("region", { name: "Topology commands" })).toBeVisible();
await page.getByRole("region", { name: "Topology commands" }).locator("summary").filter({ hasText: "Filters" }).click();
await expect(page.getByRole("combobox", { name: "Filter topology by status" })).toBeVisible();
const inspector = page.getByRole("complementary");
await expect(inspector.getByRole("heading", { name: "PostgreSQL" })).toBeVisible();
await expect(inspector.getByText("Likely root cause", { exact: true })).toBeVisible();
await expect(inspector.getByText("Health evidence", { exact: true })).toBeVisible();
const reset = page.locator("header").getByRole("button", { name: "Reset demo" });
const resetResponsePromise = page.waitForResponse(isDemoMutation("/api/v1/demo/reset"));
await reset.click();
const resetResponse = await resetResponsePromise;
expect(resetResponse.ok()).toBeTruthy();
expect(await resetResponse.json()).toMatchObject({
active: true,
root_cause_service_name: "PostgreSQL",
});
// A rendered PostgreSQL node can be left over from before Reset completed.
// Wait for the demo region to leave its busy state before attempting Exit.
await expect(demoBanner).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(page.getByText("PostgreSQL", { exact: true }).first()).toBeVisible();
const exit = page.getByRole("button", { name: "Exit demo", exact: true });
await expect(exit).toBeEnabled();
const exitResponsePromise = page.waitForResponse(isDemoMutation("/api/v1/demo/exit"));
await exit.click();
const exitResponse = await exitResponsePromise;
expect(exitResponse.ok()).toBeTruthy();
expect(await exitResponse.json()).toMatchObject({ active: false, restored: true });
await expect(demoBanner).toBeHidden({ timeout: 30_000 });
await expect(
page.getByText("Demo closed. Your previous topology was restored.", { exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Explore demo", exact: true }),
).toBeEnabled({ timeout: 30_000 });
const restoredDemoResponse = await request.get("/api/v1/demo");
expect(restoredDemoResponse.ok()).toBeTruthy();
expect(await restoredDemoResponse.json()).toMatchObject({ active: false });
const restoredServicesResponse = await request.get("/api/v1/services");
expect(restoredServicesResponse.ok()).toBeTruthy();
expect(topologyOf(await restoredServicesResponse.json())).toEqual(baselineTopology);
});
+38
View File
@@ -0,0 +1,38 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import tseslint from 'typescript-eslint'
export default [
{
ignores: ['dist/**', 'coverage/**', 'playwright-report/**', 'test-results/**'],
},
js.configs.recommended,
...tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
globals: globals.browser,
},
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{
args: 'all',
argsIgnorePattern: '^_',
caughtErrors: 'all',
caughtErrorsIgnorePattern: '^_',
ignoreRestSiblings: true,
varsIgnorePattern: '^_',
},
],
},
},
{
files: ['vite.config.ts', 'playwright.config.ts'],
languageOptions: {
globals: globals.node,
},
},
]
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#071722" />
<meta name="description" content="ServiceMap — production infrastructure intelligence" />
<link rel="icon" href="/brand/servicemap-mark.svg" type="image/svg+xml" />
<link rel="icon" href="/icons/favicon-32.png" sizes="32x32" type="image/png" />
<link rel="icon" href="/icons/favicon-16.png" sizes="16x16" type="image/png" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<title>ServiceMap — Infrastructure Intelligence</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /health {
proxy_pass http://backend:8000/health;
}
location / {
try_files $uri /index.html;
}
}
+3929
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "servicemap-frontend",
"private": true,
"version": "1.6.2",
"type": "module",
"scripts": {
"dev": "vite",
"lint": "eslint . --max-warnings=0",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:e2e": "playwright test"
},
"dependencies": {
"@xyflow/react": "^12.8.5",
"elkjs": "^0.12.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.57.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.2",
"eslint": "^10.11.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.12.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.70.1",
"vite": "^7.1.5",
"vitest": "^3.2.4"
}
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig, devices } from "@playwright/test";
import { join } from "node:path";
import { tmpdir } from "node:os";
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
fullyParallel: false,
reporter: "list",
use: {
baseURL: "http://127.0.0.1:4173",
trace: "retain-on-failure",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: "python -m uvicorn app.main:app --app-dir ../backend --host 127.0.0.1 --port 4173",
url: "http://127.0.0.1:4173/health",
reuseExistingServer: !process.env.CI,
env: {
...process.env,
SERVICEMAP_DB: join(tmpdir(), "servicemap-playwright-v160.db"),
SERVICEMAP_STATIC_DIR: "dist",
SERVICEMAP_AUTH_MODE: "disabled",
SERVICEMAP_PROBE_INTERVAL: "0",
},
},
});
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="ServiceMap"><rect width="512" height="512" rx="112" fill="#eef8ff"/><path d="M128 148 256 92l128 56v176l-128 76-128-76Z M128 148l128 84 128-84m-128 84v168" fill="none" stroke="#123a57" stroke-width="22"/><circle cx="128" cy="148" r="31" fill="#18755e"/><circle cx="384" cy="148" r="31" fill="#18755e"/><circle cx="256" cy="92" r="31" fill="#18755e"/><circle cx="256" cy="400" r="35" fill="#9a6516"/><circle cx="256" cy="232" r="42" fill="#d9eefb" stroke="#123a57" stroke-width="13"/></svg>

After

Width:  |  Height:  |  Size: 575 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title"><title id="title">ServiceMap</title><rect width="512" height="512" rx="112" fill="#071722"/><path d="M128 148 256 92l128 56v176l-128 76-128-76Z" fill="none" stroke="#72b8e7" stroke-width="22"/><path d="M128 148 256 232l128-84M256 232v168" fill="none" stroke="#72b8e7" stroke-width="18"/><circle cx="128" cy="148" r="31" fill="#7ee2ac"/><circle cx="384" cy="148" r="31" fill="#7ee2ac"/><circle cx="256" cy="92" r="31" fill="#7ee2ac"/><circle cx="256" cy="400" r="35" fill="#ffd27c"/><circle cx="256" cy="232" r="42" fill="#0d2940" stroke="#eef8ff" stroke-width="13"/></svg>

After

Width:  |  Height:  |  Size: 670 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="ServiceMap"><rect width="512" height="512" rx="112" fill="currentColor"/><path d="M128 148 256 92l128 56v176l-128 76-128-76Z M128 148l128 84 128-84m-128 84v168" fill="none" stroke="#fff" stroke-width="22"/><circle cx="128" cy="148" r="31" fill="#fff"/><circle cx="384" cy="148" r="31" fill="#fff"/><circle cx="256" cy="92" r="31" fill="#fff"/><circle cx="256" cy="400" r="35" fill="#fff"/><circle cx="256" cy="232" r="42" fill="currentColor" stroke="#fff" stroke-width="13"/></svg>

After

Width:  |  Height:  |  Size: 567 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1420 512" role="img" aria-labelledby="title"><title id="title">ServiceMap</title><g transform="translate(0 0)"><rect width="512" height="512" rx="112" fill="#071722"/><path d="M128 148 256 92l128 56v176l-128 76-128-76Z" fill="none" stroke="#72b8e7" stroke-width="22"/><path d="M128 148 256 232l128-84M256 232v168" fill="none" stroke="#72b8e7" stroke-width="18"/><circle cx="128" cy="148" r="31" fill="#7ee2ac"/><circle cx="384" cy="148" r="31" fill="#7ee2ac"/><circle cx="256" cy="92" r="31" fill="#7ee2ac"/><circle cx="256" cy="400" r="35" fill="#ffd27c"/><circle cx="256" cy="232" r="42" fill="#0d2940" stroke="#eef8ff" stroke-width="13"/></g><text x="570" y="317" fill="#eef8ff" font-family="Arial,Helvetica,sans-serif" font-size="176" font-weight="700" letter-spacing="-8">ServiceMap</text></svg>

After

Width:  |  Height:  |  Size: 854 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+1
View File
@@ -0,0 +1 @@
{"name":"ServiceMap","short_name":"ServiceMap","description":"Production infrastructure intelligence","start_url":"/","display":"standalone","background_color":"#07111d","theme_color":"#071722","icons":[{"src":"/icons/icon-192.png","sizes":"192x192","type":"image/png"},{"src":"/icons/icon-512.png","sizes":"512x512","type":"image/png"}]}
+3739
View File
File diff suppressed because it is too large. Load diff
+49
View File
@@ -0,0 +1,49 @@
import { BaseEdge, type EdgeProps } from "@xyflow/react";
import { memo } from "react";
export type LivingEdgeData = {
relation?: "depends_on" | "routes_to" | "review";
signal?: boolean;
selected?: boolean;
route?: { x: number; y: number }[];
};
function LivingEdgeView({
sourceX,
sourceY,
targetX,
targetY,
markerEnd,
data,
}: EdgeProps) {
const edgeData = (data ?? {}) as LivingEdgeData;
const route = edgeData.route?.filter(
(point) => Number.isFinite(point.x) && Number.isFinite(point.y),
);
const path =
route && route.length >= 2
? route
.map((point, index) => `${index === 0 ? "M" : "L"}${point.x} ${point.y}`)
.join(" ")
: `M${sourceX} ${sourceY} L${targetX} ${targetY}`;
const relation = edgeData.relation ?? "depends_on";
const classes = [
"living-edge-path",
`living-edge-${relation}`,
edgeData.selected ? "is-selected" : "",
edgeData.signal ? "has-signal" : "",
]
.filter(Boolean)
.join(" ");
return (
<g className="living-edge" aria-hidden="true">
<BaseEdge path={path} markerEnd={markerEnd} className={classes} />
{edgeData.signal && (
<path d={path} className="living-edge-signal" markerEnd={markerEnd} />
)}
</g>
);
}
export const LivingEdge = memo(LivingEdgeView);
+132
View File
@@ -0,0 +1,132 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from './api'
const service = {
id: 'svc-1',
name: 'API',
kind: 'http' as const,
url: 'https://api.local',
host: null,
port: null,
depends_on: [],
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('ServiceMap API client', () => {
it('creates services with the v1 JSON contract', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(service), {
status: 201,
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', fetchMock)
const result = await api.createService({
name: 'API',
kind: 'http',
url: 'https://api.local',
depends_on: [],
})
expect(result.name).toBe('API')
expect(fetchMock).toHaveBeenCalledWith(
'/api/v1/services',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
name: 'API',
kind: 'http',
url: 'https://api.local',
depends_on: [],
}),
}),
)
})
it('uses dedicated bulk probing and incident endpoints', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ requested: 0, completed: 0, results: [] }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ generated_at: '2026-01-01T00:00:00Z', active: false, candidates: [], degraded_service_ids: [], down_service_ids: [] }), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
await api.probeAll()
await api.incidents()
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/probe-all')
expect(fetchMock.mock.calls[1][0]).toBe('/api/v1/incidents')
})
it('uses the safe synthetic demo session endpoints', async () => {
const demoState = {
active: true,
synthetic: true,
scenario_name: 'Northstar Platform',
service_count: 9,
root_cause_service_id: 'postgresql',
root_cause_service_name: 'PostgreSQL',
affected_service_ids: ['api', 'web'],
affected_service_names: ['API Gateway', 'Web Frontend'],
affected_count: 2,
}
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(demoState), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ ...demoState, created: 9, updated: 0, total: 9, restored: false }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ ...demoState, created: 9, updated: 0, total: 9, restored: false }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ active: false, synthetic: false, scenario_name: null, service_count: 0, root_cause_service_id: null, root_cause_service_name: null, affected_service_ids: [], affected_service_names: [], affected_count: 0, created: 0, updated: 0, total: 0, restored: true }), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
await api.demoState()
await api.loadDemo()
await api.resetDemo()
await api.exitDemo()
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
'/api/v1/demo',
'/api/v1/demo',
'/api/v1/demo/reset',
'/api/v1/demo/exit',
])
expect(fetchMock.mock.calls[1][1]).toEqual(expect.objectContaining({ method: 'POST' }))
})
it('uses the production discovery and canonical edge contracts', async () => {
const run = { run: { id: 'run-1', status: 'completed', started_at: '2026-01-01T00:00:00Z', completed_at: '2026-01-01T00:01:00Z', source_counts: {}, diff: { new: 1, changed: 0, missing: 0, unchanged: 0, stale: 0, new_suggestions: 1 }, error: null }, candidates: [], suggestions: [], observations: [], appops_available: true, appops_mappings: 1, proxy_routes: 2 }
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(run), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ id: 'edge-1' }), { status: 201 }))
vi.stubGlobal('fetch', fetchMock)
await api.runDiscovery('unix:///var/run/docker.sock', 'http://appops.local:1216')
await api.discoverySuggestions('open')
await api.createEdge({ source_service_id: 'dependent', target_service_id: 'dependency', lock: true })
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/discovery/run')
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ docker_host: 'unix:///var/run/docker.sock', appops_url: 'http://appops.local:1216', include_stopped: false })
expect(fetchMock.mock.calls[1][0]).toBe('/api/v1/discovery/suggestions?status=open')
expect(fetchMock.mock.calls[2][0]).toBe('/api/v1/topology/edges')
})
it('previews and applies reachability repair through the explicit safety endpoint', async () => {
const report = { run_id: 'run-1', dry_run: true, inspected: 2, changed: 1, disabled_unsafe: 1, enabled_published: 0, docker_health_observed: 0, unmonitored: 1, resolved_incidents: 0, changes: [] }
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(report), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ ...report, dry_run: false }), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
await api.repairDiscoveredProbes(true, 'run-1')
await api.repairDiscoveredProbes(false, 'run-1')
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
'/api/v1/discovery/probes/repair?dry_run=true&run_id=run-1',
'/api/v1/discovery/probes/repair?dry_run=false&run_id=run-1',
])
})
})
+940
View File
@@ -0,0 +1,940 @@
export type HealthStatus =
| "healthy"
| "degraded"
| "down"
| "unknown"
| "unmonitored"
| "recovering"
| "maintenance";
export type ServiceKind = "http" | "tcp" | "dns" | "tls";
export type Criticality = "low" | "medium" | "high" | "critical";
export type DiscoveryCategory =
| "application"
| "database"
| "cache"
| "proxy"
| "monitoring"
| "development"
| "storage"
| "infrastructure"
| "background_worker"
| "system"
| "unknown";
export type ComponentRole =
| "frontend"
| "api"
| "database"
| "cache"
| "proxy"
| "worker"
| "storage"
| "monitoring"
| "agent"
| "scheduler"
| "auxiliary"
| "unknown";
export type DiscoveryVisibility = "visible" | "hidden" | "ignored";
export type DiscoveryLifecycle =
"active" | "missing" | "stale" | "ignored" | "retired";
export type DiscoveryConfidence = "confirmed" | "high" | "medium" | "low";
export type RelationType =
"depends_on" | "routes_to" | "uses_storage" | "communicates_with";
export type MonitoringState =
| "active_probe"
| "docker_health"
| "appops_observation"
| "observed"
| "unmonitored"
| "disabled";
export type ProbeEndpointSource =
| "manual"
| "explicit_label"
| "known_url"
| "appops"
| "reverse_proxy"
| "published_port"
| "docker_healthcheck"
| "container_internal"
| "discovered_environment";
export type ProbeReachability =
"verified" | "reachable" | "unverified" | "internal_only" | "not_configured";
export type Service = {
id: string;
name: string;
kind: ServiceKind;
url: string | null;
host: string | null;
port: number | null;
depends_on: string[];
description: string | null;
tags: string[];
group: string | null;
environment: string | null;
owner: string | null;
enabled: boolean;
monitoring_enabled: boolean;
monitoring_state: MonitoringState;
probe_source: ProbeEndpointSource;
probe_reachability: ProbeReachability;
probe_confidence: DiscoveryConfidence;
runtime_state: string | null;
runtime_health: string | null;
application: string | null;
domain: string | null;
component_role: ComponentRole;
application_locked: boolean;
domain_locked: boolean;
component_role_locked: boolean;
criticality: Criticality;
category: DiscoveryCategory;
visibility: DiscoveryVisibility;
lifecycle: DiscoveryLifecycle;
source_type: string | null;
source_ref: string | null;
first_seen_at: string | null;
last_seen_at: string | null;
last_discovery_status: string | null;
container_image: string | null;
compose_project: string | null;
layout_x: number | null;
layout_y: number | null;
layout_pinned: boolean;
interval_seconds: number | null;
timeout_seconds: number | null;
retries: number | null;
failure_threshold: number;
recovery_threshold: number;
expected_status_min: number | null;
expected_status_max: number | null;
availability_target: number | null;
maintenance_enabled: boolean;
maintenance_reason: string | null;
state: HealthStatus;
failure_streak: number;
recovery_streak: number;
flapping: boolean;
flap_count: number;
last_state_change_at: string | null;
created_at: string;
updated_at: string;
};
export type ProbeResult = {
id: string;
service_id: string;
status: HealthStatus;
latency_ms: number | null;
detail: string;
checked_at: string;
trigger: string;
metadata: Record<string, unknown>;
};
export type EvidenceProvider = "active_probe" | "docker" | "appops" | "runtime" | "manual";
export type EvidenceScope = "component" | "application";
export type EvidenceFreshness = "fresh" | "stale" | "expired";
export type HealthResolutionReason =
| "selected_component_evidence"
| "application_scoped_only"
| "no_current_component_evidence"
| "stale_evidence"
| "expired_evidence"
| "selected_application_evidence"
| "component_failure"
| "component_dependency_impact"
| "component_coverage"
| "no_application_evidence";
export type HealthEvidence = {
id: string;
subject_type: string;
subject_id: string;
service_id: string | null;
provider: EvidenceProvider;
provider_ref: string | null;
status: HealthStatus;
scope: EvidenceScope;
confidence: number;
observed_at: string;
valid_until: string;
received_at: string;
summary: string;
metadata: Record<string, unknown>;
active: boolean;
superseded_by: string | null;
freshness: EvidenceFreshness;
};
export type HealthResolution = {
status: HealthStatus;
selected_evidence_id: string | null;
selected_provider: EvidenceProvider | null;
confidence: number | null;
reason_code: HealthResolutionReason;
explanation: string;
conflict: boolean;
evidence: HealthEvidence[];
};
export type ProviderStatus = {
provider: EvidenceProvider;
status: "healthy" | "degraded" | "unavailable" | "unknown";
observed_at: string;
valid_until: string;
detail: string;
metadata: Record<string, unknown>;
};
export type ServiceSnapshot = {
service: Service;
latest_probe: ProbeResult | null;
availability_24h: AvailabilitySummary | null;
dependency_status: HealthStatus;
effective_status: HealthStatus;
direct_status: HealthStatus;
flapping: boolean;
flap_count: number;
next_probe_at: string | null;
dependents: string[];
sources: ServiceSource[];
evidence: RelationEvidence[];
health_evidence: HealthEvidence[];
health_resolution: HealthResolution;
};
export type ServiceSource = {
id: string;
source_type: string;
source_id: string;
source_name: string | null;
first_seen_at: string;
last_seen_at: string;
lifecycle: DiscoveryLifecycle;
managed: boolean;
metadata: Record<string, unknown>;
};
export type RelationEvidence = {
id: string;
source_service_id: string;
target_service_id: string | null;
target_ref: string | null;
relation_type: RelationType;
evidence_type: string;
confidence: DiscoveryConfidence;
explanation: string;
source_ref: string | null;
locked: boolean;
active: boolean;
first_seen_at: string;
last_seen_at: string;
};
export type LogicalRelation = {
id: string;
source_service_id: string;
target_service_id: string | null;
target_ref: string | null;
relation_type: RelationType;
confidence: DiscoveryConfidence;
active: boolean;
locked: boolean;
first_seen_at: string;
last_seen_at: string;
evidence: RelationEvidence[];
};
export type TopologySnapshot = {
id: string;
run_id: string | null;
created_at: string;
revision: string;
service_count: number;
relationship_count: number;
summary: Record<string, number>;
};
export type TopologyChange = {
change_type: "added" | "removed" | "changed";
entity_type: "service" | "relationship";
entity_id: string;
name: string;
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
};
export type TopologyDiff = {
before_snapshot_id: string | null;
after_snapshot_id: string;
added: TopologyChange[];
removed: TopologyChange[];
changed: TopologyChange[];
summary: Record<string, number>;
};
export type SemanticComponent = {
service_id: string;
name: string;
kind: ServiceKind;
application_id: string;
domain_id: string;
role: ComponentRole;
direct_status: HealthStatus;
effective_status: HealthStatus;
unknown_reason: HealthResolutionReason | null;
runtime_state: string | null;
runtime_health: string | null;
monitoring_state: MonitoringState;
latest_latency_ms: number | null;
dependency_ids: string[];
dependent_ids: string[];
root_cause: boolean;
affected: boolean;
};
export type SemanticApplication = {
id: string;
name: string;
domain_id: string;
component_ids: string[];
component_count: number;
roles: ComponentRole[];
status: HealthStatus;
status_explanation: string;
health_resolution: {
status: HealthStatus;
confidence: number | null;
explanation: string;
reason_code: HealthResolutionReason;
selected_evidence_id: string | null;
selected_provider: EvidenceProvider | null;
evidence_freshness: EvidenceFreshness | null;
conflict: boolean;
resolved_components: number;
unresolved_components: number;
component_coverage_percent: number;
};
dependency_application_ids: string[];
dependent_application_ids: string[];
monitored_components: number;
unmonitored_components: number;
resolved_components: number;
unresolved_components: number;
component_coverage_percent: number;
root_cause: boolean;
affected: boolean;
};
export type SemanticDomain = {
id: string;
name: string;
application_ids: string[];
application_count: number;
status: HealthStatus;
resolved_application_count: number;
unresolved_application_count: number;
application_coverage_percent: number;
incident_count: number;
root_cause: boolean;
affected: boolean;
};
export type SemanticTopology = {
generated_at: string;
domains: SemanticDomain[];
applications: SemanticApplication[];
components: SemanticComponent[];
relations: LogicalRelation[];
monitoring_coverage: Record<string, number>;
};
export type AvailabilitySummary = {
hours: number;
total_checks: number;
healthy_checks: number;
degraded_checks: number;
down_checks: number;
uptime_percent: number | null;
average_latency_ms: number | null;
last_checked_at: string | null;
target_percent: number | null;
meets_target: boolean | null;
};
export type ServiceInput = {
name: string;
kind: ServiceKind;
url?: string;
host?: string;
port?: number;
depends_on?: string[];
description?: string;
tags?: string[];
group?: string;
environment?: string;
owner?: string;
enabled?: boolean;
monitoring_enabled?: boolean;
monitoring_state?: MonitoringState;
probe_source?: ProbeEndpointSource;
probe_reachability?: ProbeReachability;
probe_confidence?: DiscoveryConfidence;
runtime_state?: string;
runtime_health?: string;
application?: string;
domain?: string;
component_role?: ComponentRole;
application_locked?: boolean;
domain_locked?: boolean;
component_role_locked?: boolean;
criticality?: Criticality;
category?: DiscoveryCategory;
visibility?: DiscoveryVisibility;
lifecycle?: DiscoveryLifecycle;
layout_x?: number;
layout_y?: number;
layout_pinned?: boolean;
interval_seconds?: number;
timeout_seconds?: number;
retries?: number;
failure_threshold?: number;
recovery_threshold?: number;
expected_status_min?: number;
expected_status_max?: number;
availability_target?: number;
source_type?: string;
source_ref?: string;
first_seen_at?: string;
last_seen_at?: string;
last_discovery_status?: string;
container_image?: string;
compose_project?: string;
maintenance_enabled?: boolean;
maintenance_reason?: string;
};
export type IncidentCandidate = {
service_id: string;
service_name: string;
status: HealthStatus;
reason: string;
affected_service_ids: string[];
affected_service_names: string[];
};
export type IncidentStatus = "open" | "acknowledged" | "resolved";
export type IncidentRecord = {
id: string;
status: IncidentStatus;
root_service_id: string;
root_service_name: string;
started_at: string;
detected_at: string;
acknowledged_at: string | null;
resolved_at: string | null;
title: string;
root_cause_reason: string;
affected_service_ids: string[];
affected_service_names: string[];
severity: Criticality;
latest_update_at: string;
};
export type IncidentTimelineEvent = {
id: string;
incident_id: string;
event_type: string;
message: string;
created_at: string;
service_id: string | null;
};
export type IncidentDetail = {
incident: IncidentRecord;
timeline: IncidentTimelineEvent[];
};
export type IncidentOverview = {
generated_at: string;
active: boolean;
candidates: IncidentCandidate[];
degraded_service_ids: string[];
down_service_ids: string[];
incidents: IncidentRecord[];
};
export type EventRecord = {
id: string;
event_type: string;
service_id: string | null;
incident_id: string | null;
from_status: HealthStatus | null;
to_status: HealthStatus | null;
message: string;
metadata: Record<string, unknown>;
created_at: string;
};
export type OverviewSummary = {
generated_at: string;
total_services: number;
health_counts: Record<string, number>;
active_incidents: number;
likely_root_causes: string[];
flapping_services: string[];
recent_events: EventRecord[];
monitoring_coverage: Record<string, number>;
};
export type DemoState = {
active: boolean;
synthetic: boolean;
scenario_name: string | null;
service_count: number;
root_cause_service_id: string | null;
root_cause_service_name: string | null;
affected_service_ids: string[];
affected_service_names: string[];
affected_count: number;
};
export type DemoMutationResult = DemoState & {
created: number;
updated: number;
total: number;
restored: boolean;
};
export type DockerPort = {
container_port: string;
host_ip: string | null;
host_port: number | null;
};
export type DockerSuggestion = {
name: string;
kind: ServiceKind;
host: string | null;
port: number | null;
url: string | null;
group: string | null;
evidence: string[];
confidence: string;
depends_on_names: string[];
source_type: string | null;
source_ref: string | null;
stable_source_ref: string | null;
category: DiscoveryCategory;
visibility: DiscoveryVisibility;
relation_type: RelationType;
};
export type DockerCandidate = {
container_id: string;
name: string;
image: string;
state: string;
health: string | null;
networks: string[];
ports: DockerPort[];
labels: Record<string, string>;
import_state: string;
suggestions: DockerSuggestion[];
dependency_suggestions: string[];
stable_source_ref: string | null;
compose_project: string | null;
compose_service: string | null;
category: DiscoveryCategory;
visibility: DiscoveryVisibility;
lifecycle: DiscoveryLifecycle;
healthcheck_present: boolean;
environment_evidence: string[];
proxy_route_evidence: string[];
appops_app_id: string | null;
appops_category: string | null;
appops_criticality: string | null;
appops_health: string | null;
probe_candidates: ProbeEndpointCandidate[];
};
export type ProbeEndpointCandidate = {
source: ProbeEndpointSource;
kind: ServiceKind;
url: string | null;
host: string | null;
port: number | null;
reachability: ProbeReachability;
confidence: DiscoveryConfidence;
primary: boolean;
evidence: string;
preflight_detail: string | null;
preflight_latency_ms: number | null;
};
export type ProbeRepairChange = {
service_id: string;
service_name: string;
previous_monitoring_state: MonitoringState;
next_monitoring_state: MonitoringState;
previous_endpoint: string | null;
next_endpoint: string | null;
next_probe_source: ProbeEndpointSource | null;
next_probe_confidence: DiscoveryConfidence | null;
reason: string;
};
export type ProbeRepairReport = {
run_id: string | null;
dry_run: boolean;
inspected: number;
changed: number;
disabled_unsafe: number;
enabled_published: number;
docker_health_observed: number;
appops_observed: number;
runtime_observed: number;
unmonitored: number;
resolved_incidents: number;
changes: ProbeRepairChange[];
};
export type DockerDiscoveryResult = {
docker_host: string;
candidates: DockerCandidate[];
proxy_routes?: number;
};
export type DiscoveryObservation = {
id: string;
run_id: string;
source_id: string;
source_type: string;
source_name: string;
lifecycle: DiscoveryLifecycle;
change_type: string;
observed_at: string;
metadata: Record<string, unknown>;
};
export type TopologySuggestion = {
id: string;
run_id: string;
suggestion_type: string;
status: "open" | "accepted" | "rejected" | "superseded";
source_ref: string | null;
source_service_id: string | null;
target_ref: string | null;
target_service_id: string | null;
proposed_name: string | null;
proposed_kind: ServiceKind | null;
proposed_url: string | null;
proposed_host: string | null;
proposed_port: number | null;
proposed_category: DiscoveryCategory;
proposed_group: string | null;
relation_type: RelationType | null;
confidence: DiscoveryConfidence;
evidence: string[];
created_at: string;
updated_at: string;
};
export type DiscoveryDiff = {
new: number;
changed: number;
missing: number;
unchanged: number;
stale: number;
new_suggestions: number;
};
export type DiscoveryRun = {
id: string;
status: "idle" | "discovering" | "reconciling" | "completed" | "failed";
started_at: string;
completed_at: string | null;
source_counts: Record<string, number>;
diff: DiscoveryDiff;
error: string | null;
};
export type DiscoveryResult = DockerDiscoveryResult & {
run: DiscoveryRun;
suggestions: TopologySuggestion[];
observations: DiscoveryObservation[];
appops_available: boolean;
appops_mappings: number;
proxy_routes: number;
};
export type DiscoveryStatus = {
current: DiscoveryRun | null;
last_completed: DiscoveryRun | null;
active: boolean;
};
export type ReconcileResult = {
run: DiscoveryRun;
created: number;
updated: number;
unchanged: number;
missing: number;
retired: number;
accepted_relations: number;
open_suggestions: number;
service_ids: string[];
};
let accessToken: string | null = null;
/** An optional in-memory OIDC bearer token; never persisted in topology config. */
export function setApiAccessToken(token: string | null): void {
accessToken = token?.trim() || null;
}
function requestHeaders(init?: RequestInit): HeadersInit {
return {
"Content-Type": "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
...init?.headers,
};
}
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
headers: requestHeaders(init),
...init,
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.detail || `Request failed (${response.status})`);
}
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
}
export const api = {
listServices: (includeHidden = false) =>
request<ServiceSnapshot[]>(
`/api/v1/services?include_hidden=${includeHidden}`,
),
createService: (payload: ServiceInput) =>
request<Service>("/api/v1/services", {
method: "POST",
body: JSON.stringify(payload),
}),
updateService: (serviceId: string, payload: ServiceInput) =>
request<Service>(`/api/v1/services/${serviceId}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
probeService: (serviceId: string) =>
request<ProbeResult>(`/api/v1/services/${serviceId}/probe`, {
method: "POST",
}),
probeAll: () =>
request<{ requested: number; completed: number; results: ProbeResult[] }>(
"/api/v1/probe-all",
{ method: "POST" },
),
deleteService: (serviceId: string) =>
request<void>(`/api/v1/services/${serviceId}`, { method: "DELETE" }),
history: (serviceId: string, limit = 24) =>
request<ProbeResult[]>(
`/api/v1/services/${serviceId}/history?limit=${limit}`,
),
availability: (serviceId: string, hours = 24) =>
request<AvailabilitySummary>(
`/api/v1/services/${serviceId}/availability?hours=${hours}`,
),
serviceEvidence: (serviceId: string) =>
request<HealthResolution>(`/api/v1/services/${serviceId}/evidence`),
providers: () => request<ProviderStatus[]>("/api/v1/providers"),
incidents: () => request<IncidentOverview>("/api/v1/incidents"),
incident: (incidentId: string) =>
request<IncidentDetail>(`/api/v1/incidents/${incidentId}`),
acknowledgeIncident: (incidentId: string) =>
request<IncidentDetail>(`/api/v1/incidents/${incidentId}/acknowledge`, {
method: "POST",
}),
events: (limit = 50) =>
request<EventRecord[]>(`/api/v1/events?limit=${limit}`),
overview: () => request<OverviewSummary>("/api/v1/overview"),
maintenance: (serviceId: string, enabled: boolean, reason?: string) =>
request<Service>(`/api/v1/services/${serviceId}/maintenance`, {
method: "POST",
body: JSON.stringify({ enabled, reason }),
}),
demoState: () => request<DemoState>("/api/v1/demo"),
loadDemo: () =>
request<DemoMutationResult>("/api/v1/demo", { method: "POST" }),
resetDemo: () =>
request<DemoMutationResult>("/api/v1/demo/reset", { method: "POST" }),
exitDemo: () =>
request<DemoMutationResult>("/api/v1/demo/exit", { method: "POST" }),
importConfig: (
content: string,
format: "yaml" | "json",
mode: "merge" | "replace",
) =>
request<{ created: number; updated: number; total: number }>(
"/api/v1/config/import",
{ method: "POST", body: JSON.stringify({ content, format, mode }) },
),
exportConfig: async (format: "yaml" | "json") => {
const response = await fetch(`/api/v1/config?format=${format}`, { headers: requestHeaders() });
if (!response.ok) throw new Error(`Export failed (${response.status})`);
return response.text();
},
discoverDocker: (dockerHost: string) =>
request<DockerDiscoveryResult>("/api/v1/docker/discover", {
method: "POST",
body: JSON.stringify({ docker_host: dockerHost }),
}),
runDiscovery: (
dockerHost?: string,
appopsUrl?: string,
includeStopped = false,
) =>
request<DiscoveryResult>("/api/v1/discovery/run", {
method: "POST",
body: JSON.stringify({
docker_host: dockerHost || undefined,
appops_url: appopsUrl || undefined,
include_stopped: includeStopped,
}),
}),
discoveryStatus: () => request<DiscoveryStatus>("/api/v1/discovery/status"),
discoveryItems: (runId?: string) =>
request<DiscoveryObservation[]>(
`/api/v1/discovery/items${runId ? `?run_id=${encodeURIComponent(runId)}` : ""}`,
),
discoverySuggestions: (status = "open") =>
request<TopologySuggestion[]>(
`/api/v1/discovery/suggestions?status=${status}`,
),
acceptSuggestion: (suggestionId: string) =>
request<TopologySuggestion>(
`/api/v1/discovery/suggestions/${suggestionId}/accept`,
{ method: "POST" },
),
rejectSuggestion: (suggestionId: string) =>
request<TopologySuggestion>(
`/api/v1/discovery/suggestions/${suggestionId}/reject`,
{ method: "POST" },
),
reconcileDiscovery: (runId?: string, applyHighConfidence = false) =>
request<ReconcileResult>("/api/v1/discovery/reconcile", {
method: "POST",
body: JSON.stringify({
run_id: runId,
apply_high_confidence: applyHighConfidence,
}),
}),
repairDiscoveredProbes: (dryRun = true, runId?: string) =>
request<ProbeRepairReport>(
`/api/v1/discovery/probes/repair?dry_run=${dryRun}${runId ? `&run_id=${encodeURIComponent(runId)}` : ""}`,
{ method: "POST" },
),
topologyEdges: () => request<RelationEvidence[]>("/api/v1/topology/edges"),
topologyRelations: () =>
request<LogicalRelation[]>("/api/v1/topology/relations"),
topologySnapshots: (limit = 30) =>
request<TopologySnapshot[]>(`/api/v1/topology/snapshots?limit=${limit}`),
topologyDiff: (afterSnapshotId: string, beforeSnapshotId?: string) =>
request<TopologyDiff>(
`/api/v1/topology/diff?after_snapshot_id=${encodeURIComponent(afterSnapshotId)}${beforeSnapshotId ? `&before_snapshot_id=${encodeURIComponent(beforeSnapshotId)}` : ""}`,
),
semanticTopology: () =>
request<SemanticTopology>("/api/v1/topology/semantic"),
createEdge: (payload: {
source_service_id: string;
target_service_id: string;
relation_type?: RelationType;
lock?: boolean;
explanation?: string;
}) =>
request<RelationEvidence>("/api/v1/topology/edges", {
method: "POST",
body: JSON.stringify(payload),
}),
edgeAction: (
evidenceId: string,
action: "confirm" | "reject" | "lock" | "remove",
) =>
request<RelationEvidence>(`/api/v1/topology/edges/${evidenceId}/action`, {
method: "POST",
body: JSON.stringify({ action }),
}),
};
export function subscribeToEvents(
onEvent: (event: EventRecord | Record<string, unknown>) => void,
onError?: () => void,
onOpen?: () => void,
): () => void {
if (accessToken) {
const controller = new AbortController();
void (async () => {
try {
const response = await fetch("/api/v1/events/stream", {
headers: { Authorization: `Bearer ${accessToken}`, Accept: "text/event-stream" },
signal: controller.signal,
});
if (!response.ok || !response.body) throw new Error(`SSE failed (${response.status})`);
onOpen?.();
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!controller.signal.aborted) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? "";
for (const frame of frames) {
const data = frame
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim())
.join("\n");
if (!data) continue;
try {
onEvent(JSON.parse(data) as EventRecord);
} catch {
// Ignore a malformed event and retain the durable polling fallback.
}
}
}
if (!controller.signal.aborted) onError?.();
} catch {
if (!controller.signal.aborted) onError?.();
}
})();
return () => controller.abort();
}
const source = new EventSource("/api/v1/events/stream");
const emit = (message: MessageEvent) => {
try {
onEvent(JSON.parse(message.data) as EventRecord);
} catch {
/* reconnect/poll remains the fallback */
}
};
source.onmessage = emit;
for (const eventType of [
"probe.completed",
"service.state_changed",
"service.maintenance_changed",
"flapping.detected",
"incident.opened",
"incident.updated",
"incident.acknowledged",
"incident.resolved",
"topology.changed",
]) {
source.addEventListener(eventType, emit);
}
source.onerror = () => onError?.();
source.onopen = () => onOpen?.();
return () => source.close();
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { changedFields, filterSuggestions } from "./auditViewModel";
import type { TopologySuggestion } from "./api";
describe("change explanation", () => {
it("shows only changed fields with their before and after values", () => {
expect(changedFields(
{ name: "API", lifecycle: "active", depends_on: ["db"] },
{ name: "API", lifecycle: "stale", depends_on: ["db", "cache"] },
)).toEqual([
{ field: "lifecycle", before: "active", after: "stale" },
{ field: "depends_on", before: "db", after: "db, cache" },
]);
});
});
describe("discovery review", () => {
const suggestion = (id: string, confidence: "low" | "high" | "confirmed", evidence: string[]): TopologySuggestion => ({
id, confidence, evidence, suggestion_type: "relation", status: "open",
run_id: "run", source_ref: "source", source_service_id: null,
target_ref: "target", target_service_id: null, proposed_name: null,
proposed_kind: null, proposed_url: null, proposed_host: null,
proposed_port: null, proposed_category: "unknown", proposed_group: null,
relation_type: null, created_at: "", updated_at: "",
});
it("filters by confidence and evidence text", () => {
const items = [suggestion("low", "low", ["Shared Docker network hint: bridge"]), suggestion("high", "high", ["Explicit link"] )];
expect(filterSuggestions(items, { query: "bridge", confidence: "all", type: "all" }).map((item) => item.id)).toEqual(["low"]);
expect(filterSuggestions(items, { query: "", confidence: "high", type: "all" }).map((item) => item.id)).toEqual(["high"]);
});
it("keeps the actionable queue focused on high-confidence findings", () => {
const items = [suggestion("low", "low", []), suggestion("high", "high", []), suggestion("confirmed", "confirmed", [])];
expect(filterSuggestions(items, { query: "", confidence: "actionable", type: "all" }).map((item) => item.id)).toEqual(["high", "confirmed"]);
});
});
+31
View File
@@ -0,0 +1,31 @@
import type { TopologySuggestion } from "./api";
const displayValue = (value: unknown): string => {
if (value == null || value === "") return "—";
if (Array.isArray(value)) return value.map(displayValue).join(", ") || "—";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
};
export function changedFields(before: Record<string, unknown> | null, after: Record<string, unknown> | null) {
if (!before || !after) return [];
return Array.from(new Set([...Object.keys(before), ...Object.keys(after)]))
.filter((field) => JSON.stringify(before[field]) !== JSON.stringify(after[field]))
.map((field) => ({ field, before: displayValue(before[field]), after: displayValue(after[field]) }));
}
export function filterSuggestions(
suggestions: TopologySuggestion[],
filters: { query: string; confidence: string; type: string },
) {
const query = filters.query.trim().toLowerCase();
return suggestions.filter((item) =>
(filters.confidence === "all" ||
(filters.confidence === "actionable"
? ["high", "confirmed"].includes(item.confidence)
: item.confidence === filters.confidence)) &&
(filters.type === "all" || item.suggestion_type === filters.type) &&
(!query || [item.proposed_name, item.source_ref, item.target_ref, ...item.evidence]
.some((value) => value?.toLowerCase().includes(query))),
);
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { demoWalkthroughSteps } from "./DemoWalkthrough";
describe("demo walkthrough", () => {
it("keeps the semantic incident story ordered and compact", () => {
expect(demoWalkthroughSteps).toHaveLength(4);
expect(demoWalkthroughSteps.map((step) => step.title)).toEqual([
"See the infrastructure",
"Follow the data layer",
"Trace the impact",
"Inspect the root cause",
]);
});
});
+77
View File
@@ -0,0 +1,77 @@
export const demoWalkthroughSteps = [
{
title: "See the infrastructure",
description:
"Start with the semantic domain landscape: Edge, Application and Data.",
},
{
title: "Follow the data layer",
description:
"Open Data Platform to compare PostgreSQL with its healthy Redis sibling.",
},
{
title: "Trace the impact",
description:
"Follow the dependency path from the unavailable database to affected applications.",
},
{
title: "Inspect the root cause",
description:
"Review PostgreSQL direct health, effective health, probe history and downstream impact.",
},
] as const;
type DemoWalkthroughProps = {
step: number;
affectedCount: number;
busy: boolean;
onStepChange: (step: number) => void;
onExit: () => void;
};
export function DemoWalkthrough({
step,
affectedCount,
busy,
onStepChange,
onExit,
}: DemoWalkthroughProps) {
const current = demoWalkthroughSteps[step] ?? demoWalkthroughSteps[0];
return (
<section className="demo-walkthrough" aria-label="Demo walkthrough">
<div className="demo-walkthrough-copy">
<span className="eyebrow">
Demo walkthrough · {step + 1} / {demoWalkthroughSteps.length}
</span>
<strong>{current.title}</strong>
<p>
{current.description} PostgreSQL is the likely root cause affecting{" "}
{affectedCount} downstream services.
</p>
</div>
<div className="demo-walkthrough-actions">
<button
className="ghost"
onClick={() => onStepChange(Math.max(0, step - 1))}
disabled={step === 0 || busy}
>
Back
</button>
<button
className="ghost"
onClick={() =>
onStepChange(
step === demoWalkthroughSteps.length - 1 ? 0 : step + 1,
)
}
disabled={busy}
>
{step === demoWalkthroughSteps.length - 1 ? "Restart" : "Next"}
</button>
<button onClick={onExit} disabled={busy}>
Exit demo
</button>
</div>
</section>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useRef } from "react";
import { subscribeToEvents } from "../api";
type Options = {
onEvent: () => void;
onFallback: () => void;
fallbackIntervalMs?: number;
};
/**
* Event-driven refresh with bounded recovery when SSE is unavailable.
*
* The fallback is intentionally active only while disconnected. That avoids
* continuously reloading a healthy dashboard every few seconds just because
* it happens to be open in a browser tab.
*/
export function useRealtimeEvents({
onEvent,
onFallback,
fallbackIntervalMs = 60_000,
}: Options): void {
const eventRef = useRef(onEvent);
const fallbackRef = useRef(onFallback);
useEffect(() => {
eventRef.current = onEvent;
fallbackRef.current = onFallback;
}, [onEvent, onFallback]);
useEffect(() => {
let disposed = false;
let connected = false;
let retryMs = 1_000;
let close: (() => void) | null = null;
let reconnectTimer: number | undefined;
let fallbackTimer: number | undefined;
const stop = () => {
close?.();
close = null;
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
if (fallbackTimer !== undefined) window.clearInterval(fallbackTimer);
reconnectTimer = undefined;
fallbackTimer = undefined;
};
const enableFallback = () => {
if (fallbackTimer !== undefined) return;
fallbackTimer = window.setInterval(() => {
if (!connected && !document.hidden) fallbackRef.current();
}, fallbackIntervalMs);
};
const connect = () => {
if (disposed || document.hidden || typeof EventSource === "undefined") {
connected = false;
enableFallback();
return;
}
close?.();
close = subscribeToEvents(
() => {
connected = true;
retryMs = 1_000;
if (fallbackTimer !== undefined) {
window.clearInterval(fallbackTimer);
fallbackTimer = undefined;
}
eventRef.current();
},
() => {
if (disposed) return;
connected = false;
close?.();
close = null;
enableFallback();
if (reconnectTimer === undefined) {
reconnectTimer = window.setTimeout(() => {
reconnectTimer = undefined;
retryMs = Math.min(retryMs * 2, 30_000);
connect();
}, retryMs);
}
},
() => {
connected = true;
retryMs = 1_000;
if (fallbackTimer !== undefined) {
window.clearInterval(fallbackTimer);
fallbackTimer = undefined;
}
},
);
};
const onVisibility = () => {
if (document.hidden) {
connected = false;
close?.();
close = null;
enableFallback();
} else {
if (fallbackTimer !== undefined) {
window.clearInterval(fallbackTimer);
fallbackTimer = undefined;
}
connect();
eventRef.current();
}
};
connect();
document.addEventListener("visibilitychange", onVisibility);
return () => {
disposed = true;
document.removeEventListener("visibilitychange", onVisibility);
stop();
};
}, [fallbackIntervalMs]);
}
+376
View File
@@ -0,0 +1,376 @@
:root {
--surface: #091725;
--surface-raised: #0d2033;
--line: #203d59;
--ink-muted: #91a6bf;
--accent: #78a8d5;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #eef4ff;
background: #07111d;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top, #162843 0, #08111f 42%, #050a12 100%); }
button, input, select, textarea { font: inherit; }
button { border: 1px solid #355779; border-radius: 11px; padding: 10px 14px; background: #17324f; color: #f4f8ff; cursor: pointer; transition: border-color .15s ease, background .15s ease, transform .15s ease; }
button:hover:not(:disabled) { border-color: #5d84ad; background: #1b3a5c; }
button:active:not(:disabled) { transform: translateY(1px); }
button:disabled { cursor: wait; opacity: .6; }
button.ghost { background: #0b1a2b; }
button.danger { color: #ffb1ba; border-color: #6d3944; background: #29151c; }
button.text-button { padding: 0; border: 0; background: transparent; color: #8da9c9; font-size: 12px; }
input, select, textarea { width: 100%; margin-top: 6px; border: 1px solid #29435f; border-radius: 10px; padding: 10px 11px; color: #eef4ff; background: #0a1625; outline: none; }
textarea { min-height: 270px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5; }
input:focus, select:focus, textarea:focus { border-color: #638ab4; box-shadow: 0 0 0 3px rgba(99, 138, 180, .12); }
label { color: #98abc3; font-size: 13px; }
fieldset { min-width: 0; }
.shell { width: min(1540px, calc(100% - 40px)); margin: 0 auto; padding: 26px 0 42px; }
.topbar, .hero, .panel-heading, .inspector-title { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
.topbar { min-height: 58px; padding: 6px 0 12px; }
.brand-lockup { display: flex; align-items: center; gap: 10px; }
.brand-lockup img { width: 38px; height: 38px; }
.toolbar, .button-row { display: flex; align-items: center; gap: 9px; }
.button-row.wrap { flex-wrap: wrap; }
.eyebrow, .kicker { text-transform: uppercase; letter-spacing: .14em; font-size: 11px; color: #87a1c2; }
h1, h2, h3, p { margin-top: 0; }
h1 { margin-bottom: 0; font-size: 27px; letter-spacing: -.02em; }
h1 small { color: #7e97b7; font-size: 12px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
h2 { max-width: 760px; margin-bottom: 9px; font-size: clamp(29px, 3.6vw, 46px); line-height: 1.04; letter-spacing: -.035em; }
h3 { margin: 6px 0 12px; font-size: 22px; letter-spacing: -.02em; }
.hero { margin-bottom: 12px; padding: 14px 18px; border: 1px solid #1f3550; border-radius: 14px; background: linear-gradient(135deg, rgba(14, 30, 51, .92), rgba(9, 21, 36, .86)); }
.operations-strip { display: flex; align-items: center; justify-content: space-between; gap: 22px; margin-bottom: 12px; padding: 12px 16px; border: 1px solid #1f3550; border-radius: 14px; background: #0b1b2e; }
.operations-strip > div:first-child { display: grid; gap: 3px; }
.operations-strip strong { font-size: 16px; }
.operations-strip small { color: #8fa6c2; font-size: 12px; }
.compact-hero { align-items: center; }
.compact-hero h2 { margin: 3px 0; font-size: clamp(20px, 2vw, 28px); }
.compact-hero .kicker { margin-bottom: 3px; }
.compact-hero .muted { font-size: 12px; line-height: 1.4; }
.demo-mode .hero { margin-bottom: 10px; padding: 18px 22px; }
.demo-mode .hero h2 { max-width: 650px; font-size: clamp(28px, 3vw, 39px); }
.muted { max-width: 760px; margin-bottom: 0; color: #91a4bd; line-height: 1.55; }
.muted.compact { margin-top: 10px; font-size: 13px; }
.error-banner, .notice-banner { margin-bottom: 14px; padding: 11px 13px; border-radius: 11px; }
.error-banner { border: 1px solid #7b3842; background: #35151c; color: #ffd7dc; }
.notice-banner { display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid #315d56; background: #102b28; color: #c8eee5; }
.demo-banner, .demo-confirm { align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 14px; padding: 14px 16px; border-radius: 14px; }
.demo-banner { display: grid; grid-template-columns: minmax(0, 1fr) auto; border: 1px solid #755d2b; background: linear-gradient(110deg, #2a2515, #16251f); }
.demo-confirm { display: flex; }
.demo-confirm { border: 1px solid #496d91; background: #0e2338; }
.demo-banner strong, .demo-confirm strong { display: block; margin-top: 4px; color: #fff2c9; font-size: 16px; }
.demo-confirm strong { color: #dcecff; }
.demo-banner p, .demo-confirm p { margin: 4px 0 0; color: #b6c4ba; font-size: 13px; line-height: 1.45; }
.demo-confirm p { color: #a9bdd2; }
.demo-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 8px; flex-wrap: wrap; }
.demo-walkthrough { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-top: 12px; border-top: 1px solid #66562d; }
.demo-walkthrough-copy { min-width: 0; }
.demo-walkthrough-copy strong { font-size: 14px; }
.demo-walkthrough-copy p { max-width: 700px; }
.demo-walkthrough-actions { display: flex; flex: 0 0 auto; gap: 8px; flex-wrap: wrap; }
.health-summary { display: grid; grid-template-columns: repeat(4, minmax(64px, 1fr)); gap: 6px; min-width: 300px; }
.health-summary div, .incident-metrics div { padding: 9px; border: 1px solid #233d5a; border-radius: 10px; background: #0a1828; }
.health-summary strong, .incident-metrics strong { display: block; font-size: 20px; }
.health-summary span, .incident-metrics span { color: #8198b3; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
.panel-tabs { display: flex; gap: 7px; margin-bottom: 12px; overflow-x: auto; }
.panel-tabs button { flex: 0 0 auto; padding: 8px 12px; border-color: #203a56; background: #091726; color: #90a5bf; }
.panel-tabs button.active { border-color: #557da8; background: #142a43; color: #eef6ff; }
.workspace { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; align-items: start; }
.topology-workspace:has(.topology-drawer) { grid-template-columns: minmax(0, 1fr) minmax(330px, 380px); }
.demo-mode .canvas-card { min-height: 480px; height: min(620px, calc(100vh - 360px)); }
.canvas-card { min-height: 500px; height: min(700px, calc(100vh - 370px)); overflow: hidden; border: 1px solid #203754; border-radius: 16px; background: #08131f; box-shadow: 0 26px 90px rgba(0,0,0,.28); }
.empty-state { height: 100%; min-height: 620px; display: grid; place-content: center; gap: 8px; padding: 30px; text-align: center; color: #8fa4be; }
.empty-state strong { color: #eaf3ff; font-size: 22px; }
.side-panel { display: grid; min-width: 0; align-content: start; gap: 12px; height: min(700px, calc(100vh - 370px)); min-height: 500px; overflow-x: hidden; overflow-y: auto; padding-right: 2px; }
.topology-drawer { position: sticky; top: 14px; border-left: 1px solid #203754; padding-left: 14px; }
.panel-section { min-width: 0; padding: 19px; border: 1px solid #203754; border-radius: 18px; background: rgba(8, 20, 34, .95); box-shadow: 0 18px 50px rgba(0,0,0,.18); }
.form-section { padding: 0; }
.form-section summary { padding: 16px 19px; cursor: pointer; list-style: none; }
.form-section summary::-webkit-details-marker { display: none; }
.form-section summary::after { content: '+'; color: #7f99b7; font-size: 18px; }
.form-section[open] summary::after { content: '−'; }
.form-section .service-form { padding: 0 19px 19px; }
.tall-panel { min-height: 520px; }
.service-form { display: grid; gap: 13px; margin-top: 13px; }
.field-row { display: grid; grid-template-columns: 1fr 105px; gap: 10px; }
.semantic-fields { display: grid; gap: 9px; margin: 4px 0; padding: 10px; border: 1px solid #284563; border-radius: 10px; }
.semantic-fields legend { padding: 0 5px; color: #89a8c9; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
.semantic-fields .field-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.semantic-fields .muted { align-self: end; font-size: 11px; }
.dependency-fieldset { display: grid; gap: 7px; margin: 0; padding: 11px; border: 1px solid #263d57; border-radius: 11px; max-height: 150px; overflow-y: auto; }
.dependency-fieldset legend { padding: 0 5px; color: #8197b2; font-size: 12px; }
.check-row { display: flex; align-items: center; gap: 8px; }
.check-row input { width: auto; margin: 0; }
.inspector-section { min-height: 280px; }
.observation-callout { border: 1px solid #31576c; background: #102433; color: #b7d9e8; }
.root-cause-label { display: inline-block; margin-top: 2px; color: #ffd27c; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; }
.inspector-callout { display: grid; gap: 5px; margin: 13px 0; padding: 11px 12px; border-radius: 10px; font-size: 12px; line-height: 1.4; }
.inspector-callout strong { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
.inspector-callout span, .inspector-callout small { color: #b7c8d9; }
.root-cause-callout { border: 1px solid #80622f; background: #2a2517; color: #ffe7a2; }
.impacted-callout { border: 1px solid #6a4f2a; background: #241f16; color: #f2c977; }
.inspector dl { display: grid; gap: 8px; margin: 15px 0; }
.inspector dl div { display: grid; grid-template-columns: 125px minmax(0, 1fr); gap: 9px; }
dt { color: #7f94ae; font-size: 12px; }
dd { margin: 0; overflow-wrap: anywhere; color: #dce8f7; font-size: 12px; }
.probe-detail { padding: 10px; border-radius: 9px; background: #0a1625; color: #9db0c8; font-size: 12px; line-height: 1.45; }
.health-pill { display: inline-flex; padding: 5px 9px; border-radius: 999px; border: 1px solid currentColor; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; white-space: nowrap; }
.status-healthy { color: #7ee2ac !important; }
.status-degraded { color: #ffd27c !important; }
.status-down { color: #ff929e !important; }
.status-unknown { color: #9aabc0 !important; }
.status-unmonitored { color: #87a9c4 !important; }
.history-strip { display: flex; align-items: flex-end; gap: 4px; min-height: 24px; margin: 13px 0; padding: 7px 8px; border-radius: 9px; background: #08131f; overflow-x: auto; }
.history-dot { flex: 0 0 8px; width: 8px; height: 18px; border-radius: 4px; background: currentColor; opacity: .85; }
.incident-metrics { display: grid; grid-template-columns: repeat(2, 1fr); gap: 9px; margin: 15px 0; }
.candidate-list, .docker-list { display: grid; gap: 9px; margin-top: 14px; }
.candidate { width: 100%; display: grid; gap: 5px; text-align: left; background: #0a1727; }
.candidate strong { font-size: 15px; }
.candidate small, .docker-card small { color: #8196af; line-height: 1.4; overflow-wrap: anywhere; }
.config-label { display: grid; gap: 4px; margin: 14px 0; }
.full-button { width: 100%; margin-top: 11px; }
.docker-card { display: grid; gap: 7px; padding: 12px; border: 1px solid #243c57; border-radius: 12px; background: #0a1727; }
.docker-card > div { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.docker-card > div span { color: #85a0be; font-size: 11px; }
.empty-inline { padding: 18px 0 8px; color: #8094ad; font-size: 13px; }
.react-flow__node { min-width: 165px; border: 1px solid #36587e !important; background: #10233a !important; color: #edf6ff !important; border-radius: 13px !important; padding: 0 !important; box-shadow: 0 10px 30px rgba(0,0,0,.25); font-size: 12px; }
.react-flow__node.service-node { min-width: 198px; overflow: visible; }
.react-flow__node.application-node { min-width: 226px; border-radius: 15px !important; }
.application-node .service-node-body { min-height: 122px; background: linear-gradient(145deg, rgba(21, 51, 76, .88), rgba(12, 29, 47, .96)); }
.scope-label { display: block; margin: 8px 0 4px; color: #91a8c2; font-size: 11px; }
.node-kicker { color: #7fb6dd; font-size: 9px; letter-spacing: .1em; text-transform: uppercase; }
.react-flow__node.service-node .react-flow__handle { width: 8px; height: 8px; border: 2px solid #9bb5d1; background: #173552; }
.service-node-body { display: grid; gap: 4px; min-height: 108px; padding: 12px 14px; border-radius: 12px; }
.service-node-body strong { overflow-wrap: anywhere; font-size: 14px; line-height: 1.15; }
.service-node-body small { color: #91a8c2; font-size: 10px; }
.node-badges { min-height: 15px; }
.node-badge { display: inline-flex; padding: 2px 5px; border-radius: 4px; font-size: 9px; font-weight: 700; letter-spacing: .08em; }
.app-micro-topology { display: flex; gap: 4px; margin: 6px 0 2px; }
.app-micro-topology i { display: grid; width: 16px; height: 16px; place-items: center; border: 1px solid #416181; border-radius: 50%; color: #b7d9f4; background: #102842; font-size: 8px; font-style: normal; font-weight: 800; }
.app-micro-topology i.role-database, .app-micro-topology i.role-cache, .app-micro-topology i.role-storage { border-radius: 4px; background: #19353c; }
.app-micro-topology i.role-proxy, .app-micro-topology i.role-api { border-radius: 3px; background: #29395c; }
.root-cause-badge { background: #825f25; color: #ffe8a3; }
.impacted-badge { background: #68452a; color: #ffd69b; }
.node-status { width: fit-content; font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.react-flow__node.service-node:has(.root-cause-badge) { border-color: #d39b38 !important; box-shadow: 0 0 0 2px rgba(211,155,56,.28), 0 12px 36px rgba(211,155,56,.2); }
.react-flow__node.selected { outline: 2px solid #dcecff; outline-offset: 3px; }
.react-flow__node.status-healthy { border-color: #3b8c67 !important; box-shadow: 0 10px 34px rgba(47,128,90,.12); }
.react-flow__node.status-degraded { border-color: #9a7330 !important; box-shadow: 0 10px 34px rgba(154,115,48,.12); }
.react-flow__node.status-down { border-color: #9b414d !important; box-shadow: 0 10px 34px rgba(155,65,77,.14); }
.react-flow__node.status-unmonitored { border-color: #466a86 !important; box-shadow: none; }
.react-flow__edge-path { stroke: #668ab3; stroke-width: 2; }
.living-edge-path { fill: none; stroke: #668ab3; stroke-width: 2; transition: stroke .18s ease, stroke-width .18s ease, opacity .18s ease; }
.living-edge-routes_to { stroke: #82a9cf; stroke-dasharray: 8 4; }
.living-edge-review { stroke: #58718c; stroke-dasharray: 3 6; opacity: .68; }
.living-edge-path.is-selected { stroke: #d5ebff; stroke-width: 3.2; }
.living-edge-signal { fill: none; stroke: #bde6ff; stroke-width: 3.2; stroke-linecap: round; stroke-dasharray: 2 20; animation: servicemap-edge-signal 1.7s linear infinite; filter: drop-shadow(0 0 3px rgba(143, 211, 255, .8)); }
.edge-down .living-edge-path { stroke: #b55967; }
.edge-degraded .living-edge-path { stroke: #b08a45; }
.edge-down .living-edge-signal, .edge-degraded .living-edge-signal { stroke: #ffd17b; }
@keyframes servicemap-edge-signal { to { stroke-dashoffset: -44; } }
@media (prefers-reduced-motion: reduce) { .living-edge-signal { animation: none; stroke-dasharray: none; opacity: .85; } }
.react-flow__edge-marker { fill: #668ab3; }
.react-flow__edge.edge-down .react-flow__edge-path { stroke: #b55967; }
.react-flow__edge.edge-degraded .react-flow__edge-path { stroke: #b08a45; }
.react-flow__edge.edge-down .react-flow__edge-marker { fill: #b55967; }
.react-flow__edge.edge-degraded .react-flow__edge-marker { fill: #b08a45; }
.react-flow__controls { border: 1px solid #274563; overflow: hidden; border-radius: 12px; }
.react-flow__controls button { background: #10233a; color: #fff; border-bottom-color: #274563; }
.react-flow__minimap { border: 1px solid #274563; border-radius: 12px; overflow: hidden; background: #08131f !important; }
/* v1.1 information architecture */
.overview-grid, .incident-workspace, .services-workspace { display: grid; gap: 16px; }
.overview-grid { grid-template-columns: minmax(0, 1.35fr) minmax(300px, .9fr); }
.overview-lead { min-height: 270px; }
.overview-lead h2 { margin: 9px 0; font-size: clamp(28px, 3vw, 42px); }
.overview-incident { min-height: 270px; }
.summary-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 9px; margin-top: 28px; }
.summary-cards button { display: block; min-width: 0; padding: 14px; border: 1px solid #233d5a; border-radius: 14px; background: #0a1828; text-align: left; }
.summary-cards strong { display: block; font-size: 26px; }
.summary-cards span { color: #a9bdd4; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
.overview-root-list { max-height: 220px; overflow-y: auto; padding-right: 4px; }
.coverage-overview { grid-column: span 2; }
.coverage-overview h3 { margin-bottom: 0; }
.coverage-breakdown { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; margin: 14px 0; }
.coverage-breakdown div { padding: 10px; border: 1px solid #233d5a; border-radius: 11px; background: #0a1828; }
.coverage-breakdown strong, .coverage-breakdown span { display: block; }
.coverage-breakdown strong { font-size: 20px; }
.coverage-breakdown span { margin-top: 3px; color: #91a6bf; font-size: 10px; line-height: 1.25; text-transform: uppercase; letter-spacing: .06em; }
.event-panel { grid-column: 1 / -1; }
.event-list { display: grid; gap: 2px; margin-top: 12px; }
.event-row { display: flex; align-items: flex-start; gap: 11px; padding: 10px 0; border-bottom: 1px solid #172c43; }
.event-dot { flex: 0 0 8px; width: 8px; height: 8px; margin-top: 5px; border-radius: 50%; background: #78a8d5; box-shadow: 0 0 0 4px rgba(120,168,213,.12); }
.event-row strong, .event-row small { display: block; }
.event-row strong { font-size: 13px; }
.event-row small { margin-top: 3px; color: #8095ae; font-size: 11px; }
.incident-workspace { grid-template-columns: minmax(0, 1.05fr) minmax(320px, .85fr); }
.incident-list-panel, .incident-detail-panel { min-height: 560px; }
.incident-card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: #8398b3; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
.incident-card.resolved { opacity: .72; }
.review-filters { display: grid; grid-template-columns: minmax(170px, 1fr) repeat(2, minmax(130px, auto)); gap: 10px; margin-top: 16px; }
.review-filters label { display: grid; gap: 5px; color: #afc3d9; font-size: 12px; }
.review-filters input, .review-filters select { min-width: 0; width: 100%; margin: 0; }
.incident-list-panel .review-filters { grid-template-columns: minmax(170px, 1fr) minmax(130px, 170px); }
.timeline { display: grid; gap: 0; margin-top: 20px; }
.timeline-row { display: grid; grid-template-columns: 17px minmax(0, 1fr); gap: 10px; min-height: 58px; }
.timeline-line { position: relative; width: 9px; height: 9px; margin-top: 4px; border: 2px solid #78a8d5; border-radius: 50%; background: #0b1b2c; }
.timeline-line::after { content: ''; position: absolute; top: 7px; left: 2px; width: 1px; height: 50px; background: #315271; }
.timeline-row:last-child .timeline-line::after { display: none; }
.timeline-row strong, .timeline-row small { display: block; }
.timeline-row strong { font-size: 13px; line-height: 1.35; }
.timeline-row small { margin-top: 3px; color: #8298b2; font-size: 11px; }
.services-workspace { grid-template-columns: minmax(0, 1fr); align-items: start; }
.services-workspace:has(.service-drawer) { grid-template-columns: minmax(0, 1fr) minmax(340px, 400px); }
.service-drawer { position: sticky; top: 14px; display: grid; align-content: start; gap: 12px; max-height: calc(100vh - 42px); overflow-y: auto; padding: 0 2px 2px 14px; border-left: 1px solid #203754; }
.drawer-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 2px 0; }
.table-panel { min-height: 630px; }
.table-toolbar { display: grid; grid-template-columns: minmax(180px, 1.5fr) auto repeat(5, minmax(105px, 1fr)); gap: 9px; margin-bottom: 15px; }
.search-field { min-width: 0; }
.service-table { display: grid; gap: 1px; overflow-x: auto; }
.service-table-head, .service-table-row { display: grid; grid-template-columns: minmax(180px, 1.25fr) minmax(115px, .8fr) minmax(110px, .65fr) minmax(130px, .9fr) minmax(100px, .7fr) minmax(75px, .45fr) minmax(75px, .45fr); gap: 12px; align-items: center; min-width: 920px; }
.service-table-head { padding: 8px 12px; color: #7f96b2; font-size: 10px; text-transform: uppercase; letter-spacing: .1em; }
.service-table-row { width: 100%; padding: 12px; border: 1px solid #1d3853; border-radius: 10px; background: #0a1727; color: #dbe9f8; text-align: left; }
.service-table-row:hover { background: #10243a; }
.service-table-row[aria-pressed="true"] { border-color: #78a8d5; background: #102943; box-shadow: inset 3px 0 #8dc5ee; }
.monitoring-cell { display: grid; gap: 2px; }
.service-table-row span { min-width: 0; overflow-wrap: anywhere; font-size: 12px; }
.service-table-row strong, .service-table-row small { display: block; }
.service-table-row strong { font-size: 13px; }
.service-table-row small { margin-top: 3px; color: #8197b1; font-size: 11px; }
.graph-tools { display: none; }
.topology-command-bar { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; margin-bottom: 12px; padding: 9px 11px; border: 1px solid #203754; border-radius: 14px; background: rgba(8, 20, 34, .94); box-shadow: 0 16px 40px rgba(0,0,0,.14); }
.topology-command-actions { display: flex; align-items: center; justify-content: flex-end; gap: 7px; flex-wrap: wrap; }
.filter-chip { justify-self: start; padding: 4px 8px; border-color: #456b8d; border-radius: 999px; background: #102c43; color: #c5e2f7; font-size: 10px; }
.topology-command-actions input { width: 175px; margin: 0; padding: 7px 9px; font-size: 12px; }
.compact-button { padding: 7px 9px; border-radius: 9px; font-size: 11px; }
.filter-popover { position: relative; }
.filter-popover summary { cursor: pointer; list-style: none; padding: 7px 9px; border: 1px solid #355779; border-radius: 9px; background: #0b1a2b; color: #eef4ff; font-size: 11px; }
.filter-popover summary::-webkit-details-marker { display: none; }
.filter-popover > div { position: absolute; z-index: 12; right: 0; top: calc(100% + 7px); display: grid; grid-template-columns: repeat(2, minmax(145px, 1fr)); gap: 9px; width: 330px; padding: 12px; border: 1px solid #355779; border-radius: 12px; background: #0b1b2e; box-shadow: 0 20px 50px rgba(0,0,0,.42); }
.filter-popover label { font-size: 11px; }
.filter-popover select { padding: 7px 8px; font-size: 12px; }
.mode-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
.mode-tabs button { padding: 7px 9px; font-size: 11px; }
.mode-tabs button.active { border-color: #79a6d1; background: #193956; }
.topology-breadcrumbs { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; color: #9fb4ca; font-size: 12px; }
.domain-node .service-node-body { min-width: 205px; }
.application-node .service-node-body { min-width: 225px; }
.component-node .service-node-body { min-width: 198px; }
.react-flow__node.domain-node { border-style: double; }
.filter-row { display: grid; grid-template-columns: minmax(0, 1fr) 125px; gap: 8px; }
.detail-grid { display: grid; gap: 8px; margin: 15px 0; }
.detail-grid div { display: grid; grid-template-columns: 125px minmax(0, 1fr); gap: 9px; }
.discovery-panel, .config-panel { max-width: 980px; min-height: 620px; }
.suggestion { display: grid; gap: 4px; margin-top: 9px; padding: 10px; border: 1px solid #294563; border-radius: 9px; background: #0c1d30; }
.suggestion small { color: #92a8c1; }
.suggestion button { margin-top: 4px; }
.discovery-workspace { display: grid; gap: 16px; grid-template-columns: minmax(0, 1.05fr) minmax(340px, .95fr); align-items: start; }
.discovery-workspace > .discovery-observations { grid-column: 1 / -1; }
.discovery-panel, .suggestion-panel, .discovery-observations { min-height: 0; }
.discovery-summary { display: grid; grid-template-columns: repeat(5, minmax(72px, 1fr)); gap: 8px; margin: 18px 0 12px; }
.discovery-summary div { display: grid; gap: 4px; padding: 12px; border: 1px solid #233d5a; border-radius: 12px; background: #0a1828; }
.discovery-summary strong { font-size: 21px; }
.discovery-summary span { color: #8198b3; font-size: 10px; text-transform: uppercase; letter-spacing: .07em; }
.discovery-callout, .notice-inline { display: grid; gap: 4px; margin: 13px 0; padding: 11px 12px; border: 1px solid #315c5e; border-radius: 10px; background: #0c2528; color: #bce8dd; font-size: 12px; line-height: 1.45; }
.probe-safety-callout { display: grid; gap: 7px; margin: 13px 0; padding: 12px; border: 1px solid #496d91; border-radius: 10px; background: #0d2237; color: #d0e5fa; font-size: 12px; line-height: 1.45; }
.probe-safety-callout span, .probe-safety-callout small { color: #9fb8d2; }
.coverage-change-list { display: grid; gap: 6px; max-height: 260px; overflow-y: auto; }
.coverage-change-list > div { display: grid; gap: 2px; padding: 8px 9px; border: 1px solid #294863; border-radius: 8px; background: #0a1b2b; }
.coverage-change-list strong { font-size: 12px; }
.coverage-change-list small { font-size: 11px; line-height: 1.35; }
.discovery-callout span { color: #91bdb8; }
.notice-inline { display: block; color: #b8d8f5; border-color: #315271; background: #0d2237; }
.suggestion-list { display: grid; gap: 10px; margin-top: 14px; max-height: 720px; overflow-y: auto; padding-right: 2px; }
.discovery-panel .field-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.review-confirm { margin-top: 14px; padding: 14px; border: 1px solid #b28a46; border-radius: 12px; background: #322714; }
.review-confirm p { color: #e0d2b6; line-height: 1.5; }
.show-more { margin-top: 12px; }
.suggestion-card { display: grid; gap: 7px; padding: 13px; border: 1px solid #2a4663; border-radius: 13px; background: #0a1727; }
.suggestion-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.suggestion-card-heading strong { display: block; margin-top: 4px; font-size: 14px; line-height: 1.3; }
.suggestion-card > small { color: #8fa5be; }
.evidence-list { display: grid; gap: 4px; margin-top: 6px; }
.evidence-list strong { color: #9db4cf; font-size: 11px; text-transform: uppercase; letter-spacing: .07em; }
.evidence-list small { display: block; color: #849bb6; line-height: 1.4; overflow-wrap: anywhere; }
.provider-statuses { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.provider-statuses small { border: 1px solid #294968; border-radius: 999px; padding: 3px 7px; text-transform: capitalize; }
.change-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 9px; margin-top: 14px; align-items: start; }
.snapshot-picker { display: grid; gap: 5px; max-width: 440px; margin: 14px 0; color: #afc3d9; font-size: 12px; }
.change-card { padding: 12px; border: 1px solid #294563; border-radius: 10px; background: #0a1727; }
.change-card summary { display: grid; gap: 4px; cursor: pointer; }
.change-card summary strong { color: #e3efff; }
.change-card summary small { color: #9cb2cb; }
.change-fields { display: grid; gap: 6px; margin: 10px 0; }
.change-fields div { display: grid; gap: 2px; padding: 7px; border: 1px solid #203b57; border-radius: 6px; }
.change-fields strong { font-size: 11px; text-transform: capitalize; }
.change-fields small { overflow-wrap: anywhere; color: #a8bed6; }
.current-evidence-callout { border-color: #3b6d7b; background: #102b37; }
.observation-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 6px; margin: 14px 0; }
.observation-row { display: grid; grid-template-columns: minmax(0, 1.2fr) auto; gap: 3px 9px; padding: 9px 10px; border: 1px solid #203b57; border-radius: 9px; background: #0a1727; }
.observation-row strong { overflow-wrap: anywhere; font-size: 12px; }
.observation-row span, .observation-row small { color: #8ca3bd; font-size: 11px; }
.observation-row small { grid-column: 1 / -1; }
.candidate-details { margin-top: 14px; }
.candidate-details summary { cursor: pointer; color: #a9bdd4; font-size: 12px; }
.compact-list { max-height: 430px; overflow-y: auto; }
.group-chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.group-chips button { padding: 6px 8px; font-size: 11px; }
.group-chips button.active { border-color: #79a6d1; background: #193956; }
.edge-inspector { border-color: #46698b; }
.edge-inspector .detail-grid { max-height: 280px; overflow-y: auto; }
.flap-badge { margin-left: 4px; background: #3d3f74; color: #d9d8ff; }
.status-recovering { color: #a9c5ff !important; }
.status-maintenance { color: #b6a9dc !important; }
.react-flow__edge.edge-dimmed { opacity: .28; }
.react-flow__edge.edge-down .react-flow__edge-path, .react-flow__edge.edge-degraded .react-flow__edge-path { stroke-dasharray: 7 5; }
.react-flow__node.status-recovering { border-color: #687ab1 !important; }
.react-flow__node.status-maintenance { border-color: #705d99 !important; }
.react-flow__node.service-node:has(.flap-badge) { border-style: dashed !important; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
@media (max-width: 1120px) {
.workspace { grid-template-columns: 1fr; }
.side-panel { max-height: none; grid-template-columns: repeat(2, minmax(0, 1fr)); overflow: visible; }
.tall-panel { grid-column: 1 / -1; }
.canvas-card { height: 620px; min-height: 620px; }
.services-workspace, .incident-workspace { grid-template-columns: 1fr; }
.discovery-workspace { grid-template-columns: 1fr; }
.discovery-workspace > .discovery-observations { grid-column: auto; }
.service-side { max-height: none; grid-template-columns: repeat(2, minmax(0, 1fr)); overflow: visible; }
.coverage-overview { grid-column: auto; }
.topology-command-bar { grid-template-columns: 1fr; }
.topology-command-actions { justify-content: flex-start; }
.topology-drawer, .service-drawer { position: static; height: auto; max-height: none; border-left: 0; padding-left: 0; overflow: visible; }
}
@media (max-width: 760px) {
.shell { width: min(100% - 24px, 1540px); padding-top: 18px; }
.topbar, .hero, .operations-strip { align-items: flex-start; flex-direction: column; }
.toolbar { width: 100%; flex-wrap: wrap; }
.toolbar button { flex: 1 1 auto; }
.health-summary { min-width: 0; width: 100%; grid-template-columns: repeat(2, 1fr); }
.canvas-card { height: 520px; min-height: 520px; }
.side-panel { grid-template-columns: 1fr; }
.service-side { grid-template-columns: 1fr; }
.field-row { grid-template-columns: 1fr; }
.discovery-panel .field-row, .review-filters, .incident-list-panel .review-filters { grid-template-columns: 1fr; }
.table-toolbar, .overview-grid { grid-template-columns: 1fr; }
.summary-cards { grid-template-columns: repeat(2, 1fr); }
.coverage-breakdown { grid-template-columns: repeat(2, 1fr); }
.change-list { grid-template-columns: 1fr; }
.topology-command-actions input { width: 100%; flex: 1 1 180px; }
.filter-popover > div { position: fixed; right: 12px; left: 12px; top: auto; width: auto; }
.discovery-summary { grid-template-columns: repeat(2, 1fr); }
.inspector-title { align-items: flex-start; }
.demo-banner { display: flex; align-items: flex-start; flex-direction: column; }
.demo-confirm, .demo-walkthrough { align-items: flex-start; flex-direction: column; }
.demo-actions { width: 100%; }
.demo-actions button, .demo-walkthrough-actions button { flex: 1 1 auto; }
}
+223
View File
@@ -0,0 +1,223 @@
import { describe, expect, it } from "vitest";
import {
applicationEdges,
applicationTopologyLayout,
} from "./App";
import type { SemanticTopology, ServiceSnapshot } from "./api";
import { semanticTopologyGraph } from "./topology/semanticGraph";
function component(application: string, number: number): ServiceSnapshot {
const id = `${application}-${number}`;
return {
service: {
id,
name: `${application} component ${number}`,
kind: "http",
url: null,
host: null,
port: null,
depends_on:
number === 0 && application !== "Application 0"
? [`Application ${Number(application.split(" ")[1]) - 1}-0`]
: [],
description: null,
tags: [],
group: application,
environment: "production",
owner: null,
enabled: true,
monitoring_enabled: false,
monitoring_state: "unmonitored",
probe_source: "container_internal",
probe_reachability: "internal_only",
probe_confidence: "low",
runtime_state: "running",
runtime_health: null,
application,
domain: null,
component_role: "unknown",
application_locked: false,
domain_locked: false,
component_role_locked: false,
criticality: "medium",
category: "application",
visibility: "visible",
lifecycle: "active",
source_type: "docker",
source_ref: `docker:name:${id}`,
first_seen_at: null,
last_seen_at: null,
last_discovery_status: "running",
container_image: null,
compose_project: null,
layout_x: null,
layout_y: null,
layout_pinned: false,
interval_seconds: null,
timeout_seconds: null,
retries: null,
failure_threshold: 1,
recovery_threshold: 1,
expected_status_min: null,
expected_status_max: null,
availability_target: null,
maintenance_enabled: false,
maintenance_reason: null,
state: "unmonitored",
failure_streak: 0,
recovery_streak: 0,
flapping: false,
flap_count: 0,
last_state_change_at: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
latest_probe: null,
availability_24h: null,
direct_status: "unmonitored",
dependency_status: "healthy",
effective_status: "unmonitored",
flapping: false,
flap_count: 0,
next_probe_at: null,
dependents: [],
sources: [],
evidence: [],
health_evidence: [],
health_resolution: {
status: "unknown",
selected_evidence_id: null,
selected_provider: null,
confidence: null,
reason_code: "no_current_component_evidence",
explanation: "No current component-scoped health evidence is available.",
conflict: false,
evidence: [],
},
};
}
describe("application topology layout", () => {
it("collapses 150 components into a deterministic 25-application landscape", () => {
const services = Array.from({ length: 25 }, (_, app) =>
Array.from({ length: 6 }, (_, componentNumber) =>
component(`Application ${app}`, componentNumber),
),
).flat();
const nodes = applicationTopologyLayout(services);
const edges = applicationEdges(services, "landscape", new Set());
expect(nodes).toHaveLength(25);
expect(edges).toHaveLength(24);
expect(
new Set(nodes.map((node) => `${node.position.x}:${node.position.y}`))
.size,
).toBe(25);
});
it("keeps unrelated singleton applications distinct instead of grouping them by category", () => {
const services = Array.from({ length: 40 }, (_, app) =>
component(`Singleton ${app}`, 0),
);
const nodes = applicationTopologyLayout(services);
const edges = applicationEdges(services, "landscape", new Set());
expect(nodes).toHaveLength(40);
expect(new Set(nodes.map((node) => node.data.name)).size).toBe(40);
expect(nodes.every((node) => node.data.memberIds?.length === 1)).toBe(true);
expect(edges).toHaveLength(0);
});
it("balances a large independent application landscape into readable columns", () => {
const services = Array.from({ length: 40 }, (_, app) => [
component(`Stack ${app}`, 0),
component(`Stack ${app}`, 1),
]).flat();
const nodes = applicationTopologyLayout(services);
expect(nodes).toHaveLength(40);
expect(new Set(nodes.map((node) => node.position.y)).size).toBe(5);
expect(new Set(nodes.map((node) => node.position.x)).size).toBe(8);
});
it("keeps a 500-component inventory bounded to its application-level nodes", () => {
const services = Array.from({ length: 100 }, (_, app) =>
Array.from({ length: 5 }, (_, componentNumber) =>
component(`Application ${app}`, componentNumber),
),
).flat();
const nodes = applicationTopologyLayout(services);
const edges = applicationEdges(services, "landscape", new Set());
expect(nodes).toHaveLength(100);
expect(new Set(nodes.map((node) => node.id)).size).toBe(100);
expect(edges).toHaveLength(99);
});
it("marks only the exact root-cause component, not its healthy siblings", () => {
const topology: SemanticTopology = {
generated_at: "2026-09-13T12:00:00Z",
domains: [],
applications: [],
components: [
{
service_id: "postgres",
name: "PostgreSQL",
kind: "tcp",
application_id: "data",
domain_id: "data-domain",
role: "database",
direct_status: "down",
effective_status: "down",
unknown_reason: null,
runtime_state: "running",
runtime_health: "down",
monitoring_state: "active_probe",
latest_latency_ms: null,
dependency_ids: [],
dependent_ids: ["api"],
root_cause: true,
affected: false,
},
{
service_id: "redis",
name: "Redis",
kind: "tcp",
application_id: "data",
domain_id: "data-domain",
role: "cache",
direct_status: "healthy",
effective_status: "healthy",
unknown_reason: null,
runtime_state: "running",
runtime_health: "healthy",
monitoring_state: "active_probe",
latest_latency_ms: 11,
dependency_ids: [],
dependent_ids: ["api"],
root_cause: false,
affected: false,
},
],
relations: [],
monitoring_coverage: { active_probe: 2 },
};
const graph = semanticTopologyGraph(
topology,
"components",
"data",
false,
"",
"all",
);
expect(graph.nodes.find((node) => node.id === "postgres")?.data.rootCause).toBe(true);
expect(graph.nodes.find((node) => node.id === "redis")?.data.rootCause).toBe(false);
});
});
+117
View File
@@ -0,0 +1,117 @@
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import type {
ComponentRole,
Criticality,
HealthStatus,
ServiceKind,
} from "../api";
export type ServiceNodeData = {
name: string;
status: HealthStatus;
kind: ServiceKind;
latency: number | null;
dependencyCount: number;
rootCause: boolean;
impacted: boolean;
group: string | null;
criticality: Criticality;
flapping: boolean;
category: string;
application?: string;
applicationNode?: boolean;
memberIds?: string[];
nodeKind?: "domain" | "application" | "component";
role?: ComponentRole;
roles?: ComponentRole[];
explanation?: string;
monitoredComponents?: number;
unmonitoredComponents?: number;
domainId?: string;
};
export type ServiceNode = Node<ServiceNodeData, "service">;
const statusLabel = (status: HealthStatus) =>
status === "unmonitored"
? "Not monitored"
: status[0].toUpperCase() + status.slice(1);
const statusIcon = (status: HealthStatus) =>
({
healthy: "✓",
degraded: "△",
down: "!",
recovering: "↻",
maintenance: "Ⅱ",
unknown: "?",
unmonitored: "○",
})[status];
export function ServiceNodeView({ data }: NodeProps<ServiceNode>) {
const targetLabel =
data.nodeKind === "domain"
? `${data.memberIds?.length ?? 0} components`
: data.applicationNode
? `${data.monitoredComponents ?? 0} resolved · ${data.unmonitoredComponents ?? 0} unresolved`
: data.latency != null
? `${data.latency} ms · ${data.kind.toUpperCase()}`
: data.kind.toUpperCase();
return (
<>
<Handle type="target" position={Position.Left} />
<div
className={`service-node-body status-${data.status}`}
aria-label={`${data.name}, ${statusLabel(data.status)}${data.rootCause ? ", likely root cause" : ""}`}
>
<div className="node-badges">
{data.rootCause && (
<span className="node-badge root-cause-badge">ROOT CAUSE</span>
)}
{!data.rootCause && data.impacted && (
<span className="node-badge impacted-badge">IMPACTED</span>
)}
{data.flapping && (
<span className="node-badge flap-badge">FLAPPING</span>
)}
</div>
{data.nodeKind === "domain" && (
<span className="node-kicker">
DOMAIN · {data.memberIds?.length ?? 0} components
</span>
)}
{data.applicationNode && (
<span className="node-kicker">
APPLICATION · {data.memberIds?.length ?? 0} components
</span>
)}
{data.applicationNode && data.roles?.length ? (
<span
className="app-micro-topology"
aria-label={`Component roles: ${data.roles.join(", ")}`}
>
{data.roles.slice(0, 5).map((role) => (
<i key={role} className={`role-${role}`}>
{role.slice(0, 1).toUpperCase()}
</i>
))}
</span>
) : null}
{data.nodeKind === "component" && (
<span className="node-kicker">{data.role ?? data.category}</span>
)}
<strong>{data.name}</strong>
<span className={`node-status status-${data.status}`}>
<i aria-hidden="true">{statusIcon(data.status)}</i>{" "}
{statusLabel(data.status)}
</span>
<small>{targetLabel} · {data.criticality}</small>
<small>
{data.explanation ??
`${data.category.replace("_", " ")} · ${data.dependencyCount} ${data.dependencyCount === 1 ? "dependency" : "dependencies"}`}
</small>
</div>
<Handle type="source" position={Position.Right} />
</>
);
}
@@ -0,0 +1,191 @@
import type { Dispatch, SetStateAction } from "react";
import type { Criticality, HealthStatus, MonitoringState } from "../api";
import {
domainLocation,
isTopologyRoot,
topologyRoot,
topologyUp,
type TopologyLocation,
} from "../topologyNavigation";
export type TopologyMode = "landscape" | "dependencies" | "impact";
export type FilterStatus = "all" | "unresolved" | HealthStatus;
export type MonitoringFilter = "all" | MonitoringState;
type TopologyCommandBarProps = {
location: TopologyLocation;
setLocation: Dispatch<SetStateAction<TopologyLocation>>;
selectedDomain: { id: string; name: string } | null;
selectedApplication: { name: string } | null;
mode: TopologyMode;
setMode: (mode: TopologyMode) => void;
setImpact: (enabled: boolean) => void;
showImpact: boolean;
search: string;
setSearch: (value: string) => void;
statusFilter: FilterStatus;
setStatusFilter: (value: FilterStatus) => void;
monitoringFilter: MonitoringFilter;
setMonitoringFilter: (value: MonitoringFilter) => void;
groupFilter: string;
environmentFilter: string;
setEnvironmentFilter: (value: string) => void;
criticalityFilter: string;
setCriticalityFilter: (value: string) => void;
domainFilter: string;
setDomainFilter: (value: string) => void;
applicationFilter: string;
setApplicationFilter: (value: string) => void;
lifecycleFilter: string;
setLifecycleFilter: (value: string) => void;
filtersOpen: boolean;
setFiltersOpen: (value: boolean) => void;
domains: string[];
applications: string[];
environments: string[];
activeFilters: Array<{ label: string; value: string; clear: () => void }>;
onFit: () => void;
onArrange: () => void;
inspectorOpen: boolean;
onToggleInspector: () => void;
};
export function TopologyCommandBar({
location,
setLocation,
selectedDomain,
selectedApplication,
mode,
setMode,
setImpact,
showImpact,
search,
setSearch,
statusFilter,
setStatusFilter,
monitoringFilter,
setMonitoringFilter,
groupFilter,
environmentFilter,
setEnvironmentFilter,
criticalityFilter,
setCriticalityFilter,
domainFilter,
setDomainFilter,
applicationFilter,
setApplicationFilter,
lifecycleFilter,
setLifecycleFilter,
filtersOpen,
setFiltersOpen,
domains,
applications,
environments,
activeFilters,
onFit,
onArrange,
inspectorOpen,
onToggleInspector,
}: TopologyCommandBarProps) {
const activeFilterCount = [
statusFilter,
monitoringFilter,
groupFilter,
environmentFilter,
criticalityFilter,
domainFilter,
applicationFilter,
lifecycleFilter,
].filter((value) => value !== "all").length;
return (
<section className="topology-command-bar" aria-label="Topology commands">
<div className="topology-breadcrumbs" aria-label="Topology hierarchy">
{!isTopologyRoot(location) && (
<button
className="ghost compact-button"
onClick={() => setLocation((current) => topologyUp(current))}
>
← Back
</button>
)}
<button className="text-button" onClick={() => setLocation(topologyRoot())}>
Infrastructure
</button>
{selectedDomain && (
<>
<span>›</span>
<button
className="text-button"
onClick={() => setLocation(domainLocation(selectedDomain.id))}
>
{selectedDomain.name}
</button>
</>
)}
{selectedApplication && (
<>
<span>›</span>
<span>{selectedApplication.name}</span>
</>
)}
</div>
<div className="topology-command-actions">
{(["landscape", "dependencies", "impact"] as TopologyMode[])
.filter((candidate) => candidate !== "impact" || showImpact)
.map((candidate) => (
<button
key={candidate}
className={mode === candidate ? "active compact-button" : "ghost compact-button"}
onClick={() => {
setMode(candidate);
setImpact(candidate === "impact");
}}
>
{candidate === "impact"
? "Impact"
: candidate[0].toUpperCase() + candidate.slice(1)}
</button>
))}
<input
aria-label="Search topology"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search topology"
/>
<button
className={statusFilter === "down" || statusFilter === "degraded" ? "compact-button" : "ghost compact-button"}
onClick={() => setStatusFilter(statusFilter === "all" ? "down" : "all")}
>
Problems only
</button>
<details
className="filter-popover"
open={filtersOpen}
onToggle={(event) => setFiltersOpen((event.target as HTMLDetailsElement).open)}
>
<summary>Filters{activeFilterCount ? ` (${activeFilterCount})` : ""}</summary>
<div>
<label>Status<select aria-label="Filter topology by status" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as FilterStatus)}><option value="all">All status</option><option value="unresolved">Unresolved health</option>{(["healthy", "degraded", "down", "recovering", "unmonitored", "unknown", "maintenance"] as HealthStatus[]).map((item) => <option key={item} value={item}>{item === "unmonitored" ? "Not monitored" : item}</option>)}</select></label>
<label>Monitoring<select aria-label="Filter topology by monitoring source" value={monitoringFilter} onChange={(event) => setMonitoringFilter(event.target.value as MonitoringFilter)}><option value="all">Any source</option>{(["active_probe", "docker_health", "appops_observation", "observed", "unmonitored"] as MonitoringState[]).map((item) => <option key={item} value={item}>{item.replaceAll("_", " ")}</option>)}</select></label>
<label>Domain<select aria-label="Filter topology by domain" value={domainFilter} onChange={(event) => setDomainFilter(event.target.value)}><option value="all">All domains</option>{domains.map((item) => <option key={item}>{item}</option>)}</select></label>
<label>Application<select aria-label="Filter topology by application" value={applicationFilter} onChange={(event) => setApplicationFilter(event.target.value)}><option value="all">All applications</option>{applications.map((item) => <option key={item}>{item}</option>)}</select></label>
<label>Environment<select aria-label="Filter topology by environment" value={environmentFilter} onChange={(event) => setEnvironmentFilter(event.target.value)}><option value="all">All environments</option>{environments.map((item) => <option key={item}>{item}</option>)}</select></label>
<label>Criticality<select aria-label="Filter topology by criticality" value={criticalityFilter} onChange={(event) => setCriticalityFilter(event.target.value)}><option value="all">All criticality</option>{(["low", "medium", "high", "critical"] as Criticality[]).map((item) => <option key={item}>{item}</option>)}</select></label>
<label>Lifecycle<select aria-label="Filter topology by lifecycle" value={lifecycleFilter} onChange={(event) => setLifecycleFilter(event.target.value)}><option value="all">All lifecycle</option>{["active", "missing", "stale", "retired", "ignored"].map((item) => <option key={item}>{item}</option>)}</select></label>
</div>
</details>
<button className="ghost compact-button" onClick={onFit}>Fit</button>
<button className="ghost compact-button" onClick={onArrange}>Arrange</button>
<button className="ghost compact-button" onClick={onToggleInspector}>{inspectorOpen ? "Hide details" : "Details"}</button>
</div>
{activeFilters
.filter((item) => item.value !== "all")
.map(({ label, value, clear }) => (
<button key={label} className="filter-chip" onClick={clear}>
{label}: {value} ×
</button>
))}
</section>
);
}
+285
View File
@@ -0,0 +1,285 @@
import { MarkerType, type Edge } from "@xyflow/react";
import type { HealthStatus, ServiceSnapshot } from "../api";
import type { ServiceNode } from "./ServiceNode";
export type LegacyTopologyMode = "landscape" | "dependencies" | "impact";
const effectiveStatus = (snapshot: ServiceSnapshot): HealthStatus =>
snapshot.effective_status ?? snapshot.latest_probe?.status ?? "unknown";
export function topologyLayout(
services: ServiceSnapshot[],
rootCauseId: string | null = null,
impactedIds: Set<string> = new Set(),
): ServiceNode[] {
const byId = new Map(services.map((item) => [item.service.id, item]));
const memo = new Map<string, number>();
const levelOf = (id: string, visiting = new Set<string>()): number => {
if (memo.has(id)) return memo.get(id) ?? 0;
if (visiting.has(id)) return 0;
const snapshot = byId.get(id);
if (!snapshot || snapshot.service.depends_on.length === 0) {
memo.set(id, 0);
return 0;
}
const next = new Set(visiting);
next.add(id);
const level =
1 +
Math.max(
0,
...snapshot.service.depends_on.map((dependency) =>
levelOf(dependency, next),
),
);
memo.set(id, level);
return level;
};
const grouped = new Map<number, ServiceSnapshot[]>();
const dependentCount = new Map<string, number>();
for (const snapshot of services) {
const level = levelOf(snapshot.service.id);
grouped.set(level, [...(grouped.get(level) ?? []), snapshot]);
for (const dependencyId of snapshot.service.depends_on)
dependentCount.set(
dependencyId,
(dependentCount.get(dependencyId) ?? 0) + 1,
);
}
const nodes: ServiceNode[] = [];
for (const [level, items] of [...grouped.entries()].sort(
([a], [b]) => a - b,
)) {
items.sort((a, b) => {
if (level === 0) {
const fanout =
(dependentCount.get(b.service.id) ?? 0) -
(dependentCount.get(a.service.id) ?? 0);
if (fanout) return fanout;
}
return `${a.service.group ?? ""}-${a.service.name}`.localeCompare(
`${b.service.group ?? ""}-${b.service.name}`,
);
});
const rowSpacing = items.length > 4 ? 92 : 118;
items.forEach((snapshot, index) => {
const status = effectiveStatus(snapshot);
nodes.push({
id: snapshot.service.id,
type: "service",
position:
snapshot.service.layout_pinned &&
snapshot.service.layout_x != null &&
snapshot.service.layout_y != null
? { x: snapshot.service.layout_x, y: snapshot.service.layout_y }
: {
x: level * 224,
y: (index - (items.length - 1) / 2) * rowSpacing,
},
selected: snapshot.service.id === rootCauseId,
data: {
name: snapshot.service.name,
status,
kind: snapshot.service.kind,
latency: snapshot.latest_probe?.latency_ms ?? null,
dependencyCount: snapshot.service.depends_on.length,
rootCause: snapshot.service.id === rootCauseId,
impacted: impactedIds.has(snapshot.service.id),
group: snapshot.service.group,
criticality: snapshot.service.criticality,
flapping: snapshot.flapping,
category: snapshot.service.category,
},
className: `service-node status-${status}`,
});
});
}
return nodes;
}
const statusRank: Record<HealthStatus, number> = {
down: 6,
degraded: 5,
recovering: 4,
healthy: 3,
unknown: 2,
unmonitored: 1,
maintenance: 0,
};
export const applicationName = (snapshot: ServiceSnapshot) =>
snapshot.service.application ||
snapshot.service.compose_project ||
snapshot.service.name;
const LANDSCAPE_ROWS_PER_COLUMN = 5;
/**
* Keep the landscape legible on hosts where discovery finds many one-container
* applications. Application identity must never fall back to a shared
* category bucket: two independent containers are still two applications.
*/
export function landscapeGroups(
services: ServiceSnapshot[],
_rootCauseId: string | null = null,
_impactedIds: Set<string> = new Set(),
): Map<string, ServiceSnapshot[]> {
const applications = new Map<string, ServiceSnapshot[]>();
for (const snapshot of services) {
const name = applicationName(snapshot);
applications.set(name, [...(applications.get(name) ?? []), snapshot]);
}
return applications;
}
function landscapeGroupForService(
services: ServiceSnapshot[],
rootCauseId: string | null,
impactedIds: Set<string>,
): Map<string, string> {
const result = new Map<string, string>();
for (const [name, members] of landscapeGroups(
services,
rootCauseId,
impactedIds,
)) {
for (const member of members) result.set(member.service.id, name);
}
return result;
}
export function applicationTopologyLayout(
services: ServiceSnapshot[],
rootCauseId: string | null = null,
impactedIds: Set<string> = new Set(),
): ServiceNode[] {
const groups = landscapeGroups(services, rootCauseId, impactedIds);
const appForService = landscapeGroupForService(
services,
rootCauseId,
impactedIds,
);
const dependencies = new Map<string, Set<string>>();
for (const [application, members] of groups) {
const linked = new Set<string>();
for (const member of members)
for (const dependency of member.service.depends_on) {
const target = appForService.get(dependency);
if (target && target !== application) linked.add(target);
}
dependencies.set(application, linked);
}
const levels = new Map<string, number>();
const levelOf = (
application: string,
visiting = new Set<string>(),
): number => {
if (levels.has(application)) return levels.get(application) ?? 0;
if (visiting.has(application)) return 0;
const next = new Set(visiting);
next.add(application);
const level =
1 +
Math.max(
-1,
...[...(dependencies.get(application) ?? [])].map((item) =>
levelOf(item, next),
),
);
levels.set(application, Math.max(0, level));
return Math.max(0, level);
};
const byLevel = new Map<number, string[]>();
for (const name of groups.keys()) {
const level = levelOf(name);
byLevel.set(level, [...(byLevel.get(level) ?? []), name]);
}
const nodes: ServiceNode[] = [];
let levelOffset = 0;
for (const [, names] of [...byLevel.entries()].sort(
([a], [b]) => a - b,
)) {
names.sort((a, b) => a.localeCompare(b));
const columns = Math.ceil(names.length / LANDSCAPE_ROWS_PER_COLUMN);
names.forEach((name, index) => {
const members = groups.get(name) ?? [];
const primary =
members.find((item) => item.service.id === rootCauseId) ??
members.sort(
(a, b) =>
statusRank[effectiveStatus(b)] - statusRank[effectiveStatus(a)],
)[0];
const aggregate =
members
.map(effectiveStatus)
.sort((a, b) => statusRank[b] - statusRank[a])[0] ?? "unmonitored";
const rootCause = members.some((item) => item.service.id === rootCauseId);
const impacted = members.some((item) => impactedIds.has(item.service.id));
const column = Math.floor(index / LANDSCAPE_ROWS_PER_COLUMN);
const rowsInColumn = Math.min(
LANDSCAPE_ROWS_PER_COLUMN,
names.length - column * LANDSCAPE_ROWS_PER_COLUMN,
);
const row = index % LANDSCAPE_ROWS_PER_COLUMN;
nodes.push({
id: `app:${name}`,
type: "service",
position: {
x: levelOffset + column * 270,
y: (row - (rowsInColumn - 1) / 2) * 138,
},
selected: rootCause,
data: {
name,
status: aggregate,
kind: primary?.service.kind ?? "tcp",
latency: primary?.latest_probe?.latency_ms ?? null,
dependencyCount: dependencies.get(name)?.size ?? 0,
rootCause,
impacted,
group: name,
criticality: primary?.service.criticality ?? "medium",
flapping: members.some((item) => item.flapping),
category: primary?.service.category ?? "unknown",
application: name,
applicationNode: true,
memberIds: members.map((item) => item.service.id),
},
className: `service-node application-node status-${aggregate}`,
});
});
levelOffset += columns * 270 + 105;
}
return nodes;
}
export function applicationEdges(
services: ServiceSnapshot[],
mode: LegacyTopologyMode,
impactedIds: Set<string>,
rootCauseId: string | null = null,
): Edge[] {
const apps = landscapeGroupForService(services, rootCauseId, impactedIds);
const dedupe = new Set<string>();
return services.flatMap((snapshot) =>
snapshot.service.depends_on.flatMap((dependencyId) => {
const sourceApp = apps.get(dependencyId);
const targetApp = apps.get(snapshot.service.id);
if (!sourceApp || !targetApp || sourceApp === targetApp) return [];
const id = `app:${sourceApp}->app:${targetApp}`;
if (dedupe.has(id)) return [];
dedupe.add(id);
const impacted = impactedIds.has(snapshot.service.id);
return [
{
id,
source: `app:${sourceApp}`,
target: `app:${targetApp}`,
className: `edge-${effectiveStatus(snapshot)}${mode === "impact" && !impacted ? " edge-dimmed" : ""}`,
markerEnd: { type: MarkerType.ArrowClosed },
},
];
}),
);
}
+256
View File
@@ -0,0 +1,256 @@
import { MarkerType, type Edge } from "@xyflow/react";
import type { HealthStatus, SemanticTopology } from "../api";
import type { ServiceNode } from "./ServiceNode";
export type SemanticView = "domains" | "applications" | "components";
export type SemanticFilterStatus = "all" | "unresolved" | HealthStatus;
/** Projects canonical services and relationships into the active semantic level. */
export function semanticTopologyGraph(
topology: SemanticTopology,
view: SemanticView,
scopeId: string | null,
incidentOnly: boolean,
search: string,
statusFilter: SemanticFilterStatus,
): { nodes: ServiceNode[]; edges: Edge[] } {
const applicationById = new Map(
topology.applications.map((item) => [item.id, item]),
);
const componentById = new Map(
topology.components.map((item) => [item.service_id, item]),
);
const domainById = new Map(topology.domains.map((item) => [item.id, item]));
const scopedApplications =
view === "applications" && scopeId
? topology.applications.filter((item) => item.domain_id === scopeId)
: topology.applications;
const scopedComponents =
view === "components" && scopeId
? topology.components.filter((item) => item.application_id === scopeId)
: topology.components;
const isIncidentVisible = <
T extends { root_cause: boolean; affected: boolean },
>(
item: T,
) => !incidentOnly || item.root_cause || item.affected;
const nodes: ServiceNode[] =
view === "domains"
? topology.domains.filter(isIncidentVisible).map((domain) => ({
id: domain.id,
type: "service",
position: { x: 0, y: 0 },
selected: domain.root_cause,
data: {
name: domain.name,
status: domain.status,
kind: "http",
latency: null,
dependencyCount: 0,
rootCause: domain.root_cause,
impacted: domain.affected,
group: domain.name,
criticality: "medium",
flapping: false,
category: "domain",
memberIds: domain.application_ids.flatMap(
(id) => applicationById.get(id)?.component_ids ?? [],
),
nodeKind: "domain",
explanation: `${domain.resolved_application_count}/${domain.application_count} resolved · ${domain.unresolved_application_count} unresolved${domain.incident_count ? ` · ${domain.incident_count} incident` : ""}`,
},
className: `service-node domain-node status-${domain.status}`,
}))
: view === "applications"
? scopedApplications.filter(isIncidentVisible).map((app) => ({
id: app.id,
type: "service",
position: { x: 0, y: 0 },
selected: app.root_cause,
data: {
name: app.name,
status: app.status,
kind: "http",
latency: null,
dependencyCount: app.dependency_application_ids.length,
rootCause: app.root_cause,
impacted: app.affected,
group: domainById.get(app.domain_id)?.name ?? null,
criticality: "medium",
flapping: false,
category: "application",
application: app.name,
applicationNode: true,
memberIds: app.component_ids,
nodeKind: "application",
roles: app.roles,
explanation: app.status_explanation,
monitoredComponents: app.resolved_components,
unmonitoredComponents: app.unresolved_components,
domainId: app.domain_id,
},
className: `service-node application-node status-${app.status}`,
}))
: scopedComponents.filter(isIncidentVisible).map((component) => ({
id: component.service_id,
type: "service",
position: { x: 0, y: 0 },
data: {
name: component.name,
status: component.effective_status,
kind: component.kind,
latency: component.latest_latency_ms,
dependencyCount: component.dependency_ids.length,
rootCause: component.root_cause,
impacted: component.affected,
group:
applicationById.get(component.application_id)?.name ?? null,
criticality: "medium",
flapping: false,
category: component.role,
nodeKind: "component",
role: component.role,
explanation: component.unknown_reason
? `${component.unknown_reason.replaceAll("_", " ")} · ${component.dependent_ids.length} dependents`
: `${component.monitoring_state.replace("_", " ")} · ${component.dependent_ids.length} dependents`,
},
className: `service-node component-node status-${component.effective_status}`,
}));
const normalizedSearch = search.trim().toLocaleLowerCase();
const visibleNodes = nodes.filter((node) => {
const descendants = node.data.memberIds ?? [node.id];
const descendantMatches = descendants.some((serviceId) =>
componentById
.get(serviceId)
?.name.toLocaleLowerCase()
.includes(normalizedSearch),
);
return (
(!normalizedSearch ||
`${node.data.name} ${node.data.explanation ?? ""}`
.toLocaleLowerCase()
.includes(normalizedSearch) ||
descendantMatches) &&
(statusFilter === "all" ||
(statusFilter === "unresolved"
? ["unknown", "unmonitored"].includes(node.data.status)
: node.data.status === statusFilter))
);
});
const nodeIds = new Set(visibleNodes.map((node) => node.id));
const nodeIdFor = (serviceId: string): string | null => {
const component = componentById.get(serviceId);
if (!component) return null;
if (view === "components") return component.service_id;
if (view === "applications") return component.application_id;
return component.domain_id;
};
const canonicalPairs = topology.components.flatMap((component) =>
component.dependency_ids.map((target_service_id) => ({
source_service_id: component.service_id,
target_service_id,
active: true,
relation_type: "depends_on",
})),
);
const relationshipPairs = topology.relations
.filter((relation) => relation.active && relation.target_service_id)
.map((relation) => ({
source_service_id: relation.source_service_id,
target_service_id: relation.target_service_id as string,
active: relation.active,
relation_type: relation.relation_type,
}));
const uniquePairs = new Map<
string,
{
source_service_id: string;
target_service_id: string;
active: boolean;
relation_type: string;
}
>();
for (const relation of [...canonicalPairs, ...relationshipPairs]) {
const key = `${relation.source_service_id}->${relation.target_service_id}`;
if (!uniquePairs.has(key)) uniquePairs.set(key, relation);
}
const statusRank: Record<HealthStatus, number> = {
down: 5,
degraded: 4,
recovering: 3,
maintenance: 2,
unknown: 1,
unmonitored: 0,
healthy: 0,
};
const projected = new Map<
string,
{
id: string;
source: string;
target: string;
status: HealthStatus;
weight: number;
relationType: string;
}
>();
for (const relation of uniquePairs.values()) {
if (!relation.active) continue;
// A relation is stored dependent -> dependency; render dependency -> dependent.
const source = nodeIdFor(relation.target_service_id);
const target = nodeIdFor(relation.source_service_id);
if (
!source ||
!target ||
source === target ||
!nodeIds.has(source) ||
!nodeIds.has(target)
)
continue;
const id = `${source}->${target}`;
const status =
componentById.get(relation.source_service_id)?.effective_status ??
"unknown";
const existing = projected.get(id);
if (existing) {
existing.weight += 1;
if (statusRank[status] > statusRank[existing.status])
existing.status = status;
} else {
projected.set(id, {
id,
source,
target,
status,
weight: 1,
relationType: relation.relation_type,
});
}
}
const projectedEdges = [...projected.values()].sort(
(left, right) =>
right.weight - left.weight || left.id.localeCompare(right.id),
);
const edgeLimit =
view === "domains" ? 12 : view === "applications" ? 28 : Infinity;
const highSignal =
view === "components"
? projectedEdges
: projectedEdges.filter((edge) => edge.weight >= 2);
const visibleEdges = (highSignal.length ? highSignal : projectedEdges).slice(
0,
edgeLimit,
);
return {
nodes: visibleNodes,
edges: visibleEdges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
markerEnd: { type: MarkerType.ArrowClosed },
animated: edge.status === "down" || edge.status === "degraded",
className: `edge-${edge.status} relation-${edge.relationType}`,
})),
};
}
+88
View File
@@ -0,0 +1,88 @@
import type ElkConstructor from "elkjs/lib/elk.bundled.js";
import type { Edge, Node } from "@xyflow/react";
type ElkInstance = InstanceType<typeof ElkConstructor>;
export type TopologyRoute = { x: number; y: number }[];
export type TopologyLayout = {
positions: Record<string, { x: number; y: number }>;
routes: Record<string, TopologyRoute>;
};
type ElkPoint = { x: number; y: number };
type ElkRoutedEdge = {
id: string;
sections?: { startPoint?: ElkPoint; bendPoints?: ElkPoint[]; endPoint?: ElkPoint }[];
};
let elk: ElkInstance | null = null;
const cache = new Map<string, TopologyLayout>();
async function layoutEngine(): Promise<ElkInstance> {
if (elk) return elk;
const { default: ElkConstructor } = await import("elkjs/lib/elk.bundled.js");
elk = new ElkConstructor();
return elk;
}
/** The key intentionally excludes health, latency and selected state. */
export function topologyStructureKey(nodes: Node[], edges: Edge[]): string {
return `${nodes
.map((node) => node.id)
.sort()
.join(",")}|${edges
.map((edge) => `${edge.source}>${edge.target}`)
.sort()
.join(",")}`;
}
export async function arrangeTopology(
nodes: Node[],
edges: Edge[],
): Promise<TopologyLayout> {
const key = topologyStructureKey(nodes, edges);
const cached = cache.get(key);
if (cached) return cached;
const layout = await (await layoutEngine()).layout({
id: "servicemap-topology",
layoutOptions: {
"elk.algorithm": "layered",
"elk.direction": "RIGHT",
"elk.edgeRouting": "ORTHOGONAL",
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
"elk.spacing.nodeNode": "44",
"elk.layered.spacing.nodeNodeBetweenLayers": "92",
"elk.padding": "[top=42,left=42,bottom=42,right=42]",
},
children: nodes.map((node) => ({ id: node.id, width: 230, height: 132 })),
edges: edges.map((edge) => ({
id: edge.id,
sources: [edge.source],
targets: [edge.target],
})),
});
const positions = Object.fromEntries(
(layout.children ?? []).map((node) => [
node.id,
{ x: node.x ?? 0, y: node.y ?? 0 },
]),
);
const routedEdges = (layout as unknown as { edges?: ElkRoutedEdge[] }).edges ?? [];
const routes = Object.fromEntries(
routedEdges.map((edge) => [
edge.id,
(edge.sections ?? []).flatMap((section) => [
section.startPoint,
...(section.bendPoints ?? []),
section.endPoint,
]).filter((point): point is ElkPoint => Boolean(point)),
]),
);
const result = { positions, routes };
cache.set(key, result);
return result;
}
export function clearTopologyLayoutCache(): void {
cache.clear();
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import {
applicationLocation,
domainLocation,
topologyRoot,
topologyUp,
} from "./topologyNavigation";
describe("semantic topology navigation", () => {
it("returns components to their parent domain applications", () => {
expect(topologyUp(applicationLocation("domain-monitoring", "app-zabbix"))).toEqual(
domainLocation("domain-monitoring"),
);
});
it("returns a domain to the infrastructure root", () => {
expect(topologyUp(domainLocation("domain-monitoring"))).toEqual(topologyRoot());
});
});
+37
View File
@@ -0,0 +1,37 @@
export type TopologyLevel = "domains" | "applications" | "components";
export type TopologyLocation = {
level: TopologyLevel;
domainId: string | null;
applicationId: string | null;
};
export const topologyRoot = (): TopologyLocation => ({
level: "domains",
domainId: null,
applicationId: null,
});
export const domainLocation = (domainId: string): TopologyLocation => ({
level: "applications",
domainId,
applicationId: null,
});
export const applicationLocation = (
domainId: string | null,
applicationId: string,
): TopologyLocation => ({
level: "components",
domainId,
applicationId,
});
export const topologyUp = (location: TopologyLocation): TopologyLocation => {
if (location.level === "components" && location.domainId)
return domainLocation(location.domainId);
return topologyRoot();
};
export const isTopologyRoot = (location: TopologyLocation): boolean =>
location.level === "domains";
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "vite.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8000',
'/health': 'http://localhost:8000',
},
},
test: {
exclude: ['**/node_modules/**', '**/dist/**', 'e2e/**'],
},
})
+12
View File
@@ -0,0 +1,12 @@
{
"name": "servicemap-workspace",
"version": "1.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "servicemap-workspace",
"version": "1.6.2"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "servicemap-workspace",
"private": true,
"version": "1.6.2",
"scripts": {
"install:frontend": "npm --prefix frontend ci --no-audit --no-fund",
"lint": "npm run install:frontend && npm --prefix frontend run lint",
"test": "npm run install:frontend && npm --prefix frontend run test",
"typecheck": "npm run install:frontend && npm --prefix frontend run typecheck",
"build": "npm run install:frontend && npm --prefix frontend run build"
}
}
+2
View File
@@ -0,0 +1,2 @@
-c backend/requirements-dev.lock
-e ./backend[dev]
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0"?>
<Container version="2">
<Name>ServiceMap</Name>
<Repository>servicemap-servicemap</Repository>
<Network>bridge</Network>
<Shell>sh</Shell>
<Privileged>false</Privileged>
<Support>https://gitea.example.test/Jens/ServiceMap</Support>
<Project>https://gitea.example.test/Jens/ServiceMap</Project>
<Overview>ServiceMap maps Docker infrastructure, monitoring evidence, dependency health and incident impact. This template mirrors the existing Compose-managed servicemap container; it does not replace or migrate that deployment.</Overview>
<Category>Tools:Status:Network:</Category>
<WebUI>http://[IP]:[PORT:1253]</WebUI>
<Icon>http://[IP]:[PORT:1253]/icons/servicemap-dockerman-256.png</Icon>
<ExtraParams>--security-opt=no-new-privileges:true</ExtraParams>
<PostArgs/>
<CPUset/>
<Requires>Existing servicemap Compose deployment; Docker socket is read-only.</Requires>
<Config Name="WebUI" Target="8000" Default="1253" Mode="tcp" Description="ServiceMap WebUI" Type="Port" Display="always" Required="true" Mask="false">1253</Config>
<Config Name="Named data volume" Target="/data" Default="servicemap_data" Mode="rw" Description="Existing named Docker volume containing the persistent SQLite database. Preserve it; do not reset or replace it." Type="Path" Display="always" Required="true" Mask="false">servicemap_data</Config>
<Config Name="Docker socket" Target="/var/run/docker.sock" Default="/var/run/docker.sock" Mode="ro" Description="Read-only Docker discovery source." Type="Path" Display="advanced" Required="true" Mask="false">/var/run/docker.sock</Config>
<Config Name="Database path" Target="SERVICEMAP_DB" Default="/data/servicemap.db" Mode="" Description="Persistent SQLite database path." Type="Variable" Display="advanced" Required="true" Mask="false">/data/servicemap.db</Config>
<Config Name="Static files" Target="SERVICEMAP_STATIC_DIR" Default="/app/static" Mode="" Description="Bundled frontend assets." Type="Variable" Display="advanced" Required="true" Mask="false">/app/static</Config>
<Config Name="Probe interval" Target="SERVICEMAP_PROBE_INTERVAL" Default="60" Mode="" Description="Default scheduled-probe interval in seconds; set 0 to disable." Type="Variable" Display="always" Required="true" Mask="false">60</Config>
<Config Name="Docker host" Target="SERVICEMAP_DOCKER_HOST" Default="unix:///var/run/docker.sock" Mode="" Description="Read-only local Docker socket." Type="Variable" Display="advanced" Required="true" Mask="false">unix:///var/run/docker.sock</Config>
<Config Name="AppOps URL" Target="SERVICEMAP_APPOPS_URL" Default="http://[IP]:[PORT:1216]" Mode="" Description="Optional read-only AppOps enrichment endpoint." Type="Variable" Display="advanced" Required="false" Mask="false">http://[IP]:[PORT:1216]</Config>
<Config Name="Probe host" Target="SERVICEMAP_PROBE_HOST" Default="" Mode="" Description="Optional host address reachable from the container for explicitly published Docker probes." Type="Variable" Display="advanced" Required="false" Mask="false" />
</Container>