Initial curated ServiceMap source from 7a0a61232d05ba9097432abef6f7f4102a64d0b8
No files matched your search
@@ -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*
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
"""ServiceMap backend package."""
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)))
|
||||
@@ -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))
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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")]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 397 KiB |
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 447 B |
|
After Width: | Height: | Size: 803 B |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -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"}]}
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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))),
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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/**'],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "servicemap-workspace",
|
||||
"version": "1.6.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "servicemap-workspace",
|
||||
"version": "1.6.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-c backend/requirements-dev.lock
|
||||
-e ./backend[dev]
|
||||
@@ -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>
|
||||