From 04d3c97cc15b4d5e201c503da6aebf1447c603df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=84=9D=28Junseok=20Oh=29DevOps?= <52226147+Atom-oh@users.noreply.github.com> Date: Sun, 15 Nov 2026 01:04:07 +0000 Subject: [PATCH 1/3] fix(discovery): preserve S3 placement and inference dependencies --- CHANGELOG.md | 8 +- docs/runbooks/graph-read-contract.md | 32 ++++ .../runbooks/steampipe-quota-and-staleness.md | 17 +++ .../2026-11-15-fsi-discovery-remediation.md | 73 +++++++++ scripts/v2/steampipe/sync_lambda.py | 46 +++++- .../test_sync_inventory_additions.py | 2 +- scripts/v2/steampipe/test_sync_s3_public.py | 2 +- scripts/v2/steampipe/test_sync_s3_regions.py | 114 +++++++++++++++ scripts/v2/steampipe/test_sync_sdk_partial.py | 2 +- web/app/topology/services/page.test.tsx | 18 +++ web/app/topology/services/page.tsx | 17 ++- web/components/topology/E2eGraphCanvas.tsx | 1 + web/lib/inference-evidence.ts | 34 +++++ web/lib/inference-topology.test.ts | 138 ++++++++++++++++++ web/lib/trace-graph.ts | 22 +++ web/lib/trace-source.test.ts | 25 ++++ web/lib/trace-source.ts | 25 +++- 17 files changed, 554 insertions(+), 22 deletions(-) create mode 100644 docs/superpowers/plans/2026-11-15-fsi-discovery-remediation.md create mode 100644 scripts/v2/steampipe/test_sync_s3_regions.py create mode 100644 web/lib/inference-evidence.ts create mode 100644 web/lib/inference-topology.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1612d3cf7..139e0cf30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - AI workspace sidebars: the assistant conversation list and diagnosis settings/report list can be resized from 192–384px or hidden, with browser-local width and visibility preferences. Diagnosis reports and the sidebar scroll independently, table-of-contents navigation stays within the report, and smaller screens use a sidebar overlay. -- Service map from complete trace counts: ClickHouse trace sources using the standard OTel exporter table now count service calls, database access, workloads and messaging across the whole one-hour window with bounded read-only aggregate queries (two, plus a third for span links when the table has them), instead of joining the newest 1,000 spans, so low-volume calls and databases no longer drop out and calls whose parent fell outside the sample no longer show as orphans. If an aggregate query fails, that rebuild falls back to the newest-span sample and is marked partial. Custom or generated ClickHouse queries and Tempo keep span sampling. The node cap keeps each kept database, queue or workload together with its caller. Prometheus/Mimir service-graph metrics add service-to-service calls only: rows Tempo marks as database, queue or uninstrumented-peer connections no longer appear as same-named services. Rebuild the datasource graph catalog (`datasource_index`) to pick up the new metric query. +- Service map from complete trace counts: ClickHouse trace sources using the standard OTel exporter table now count service calls, database access, workloads and messaging across the whole one-hour window with bounded read-only aggregate queries (two, plus a third for span links when the table has them), instead of joining the newest 1,000 spans, so low-volume calls and databases no longer drop out and calls whose parent fell outside the sample no longer show as orphans. If an aggregate query fails, that rebuild falls back to the newest-span sample and is marked partial. Custom or generated ClickHouse queries and Tempo keep span sampling. The node cap keeps each kept database, queue or workload together with its caller. Prometheus/Mimir service-graph metrics add service-to-service calls only: rows Tempo marks as database, queue or uninstrumented-peer connections no longer appear as same-named services. Rebuild the datasource graph catalog (`datasource_index`) to pick up the new metric query. Explicit AgentCore runtime ARNs and model/provider attributes now produce scoped runtime and requested-model observations, including isolated client spans and aggregate rows. Runtime/model cards disclose unverified telemetry identity and never link a claim into AWS inventory. - Assistant time-series tables: completed answers can show line or bar charts from explicit time and numeric metric columns, with separate scales for each metric and the original table retained. Missing samples and partial-row time labels remain intact; streaming, non-time tables, IDs, malformed values and text bars are never inferred as numeric metrics. - Web framework and runtime refresh: Next.js 16.4.5, React 19.3.1 and Node 26.13.0 retain authentication, ownership checks and standalone deployment; the request body guard uses the Next.js proxy convention. Refresh JavaScript/Python dependencies and AgentCore SDKs with compatibility pins, Terraform 1.17.1/provider locks, and Python 3.14 Lambda/container runtimes. The documentation guide builds and deploys on Node 26 with refreshed documentation security dependencies and Mermaid 12, preserving all four locales. Preserve gateway authentication, feature gates and release safeguards. - EKS member onboarding: the member cluster guide is now two owner commands — an Access Entry for the registered member read role and a cluster-scoped `AmazonEKSViewPolicy` association — with no Kubernetes group, kubeconfig, kubectl or RBAC step; View never reads Kubernetes Secrets, and the guide tells owners to remove any existing `AmazonEKSAdminViewPolicy`. Because View cannot list nodes, member node lists come from read-only EC2/node-group APIs through the same role and are labeled as an EC2/node-group view (no kubelet status, instance-type capacity as allocatable, no Fargate nodes); their Ready count shows as "EC2 running N · Ready unknown" and they are left out of reserved-% figures; a failed node read no longer marks the whole cluster unreachable. When an administrator opens EKS, member clusters whose Access Entry is verified are listed first in an "Access Entry verified" callout with a one-click [조회 등록] button; nothing is registered until the administrator clicks it (an app registration record only; AWSops creates no AWS or Kubernetes permissions), and an unregistered cluster stays unregistered. In diagnosis, the Kubernetes map and the node drilldown, EC2/node-group nodes read as "EC2 running · Ready unknown" with Pressure, Allocatable and Reserved shown as unknown instead of as unhealthy or fully allocatable. Entries created from the earlier guide keep working. @@ -81,7 +81,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Grouped cost charts: the ECS Tasks page gains v1's Cost by Service chart (CPU vs Memory daily-cost split per cluster-scoped service from the shared estimator constants — FARGATE tasks only, top 10, sample-labeled past the 500-row cap; EC2 tasks are excluded [no estimate] and serviceless tasks are excluded [nothing to group under]; both $ series share ONE scale so the CPU-vs-Memory comparison stays real), and the EKS container-cost page gains the Node Daily Cost + Pod Count chart (from the page's own OpenCost node allocation + pods list, no new fetch, cost-desc sorted; Top 15 by cost with a counted title; a cluster with ANY unattributed pod renders '—' pod values on its nodes — a shown count could undercount, so it is never a confident number under incomplete attribution) — on a new multi-series grouped-bar primitive whose per-series scaling replaces v1's dual axis for MIXED-unit series only (value labels carry the real numbers/units, and a null value renders '—' with an empty track). - Detail drill-down quick wins: the Bedrock model detail panel gains per-model Invocations / Token time-series charts over the selected range (the API now preserves per-model series instead of discarding them into the fleet sum; an empty series reads 'no time-series data'), the ElastiCache detail chains each attached security group to its inbound rules (the RDS drill-down section/route reused — synced inventory only, no live AWS call, unsynced SGs read 'not synced'), and the EBS volume detail gains live measured metrics (Read/Write IOPS via period-sum conversion, Queue Length, Burst Balance — latest values + 1-hour sparklines on the shared live-metric contract). - WAF/EKS quick wins: two new synced inventory types — WAF Rule Groups (scope donut, WCU capacity bar) and WAF IP Sets (IPv4/IPv6 distribution, address counts; an absent addresses field reads unknown, never 0) — joining the Security group overview's per-type count tiles (v1's three-KPI waf page maps to the group overview + dedicated type pages; visible after a terraform apply + the next sync); the EKS container-cost page's remaining hardcoded Korean strings now translate (4-language registration for the title/subtitle/estimate banner/empty states/search placeholder); and the EKS nodes fleet's Total Memory tile gains an allocatable + reserved% hint (omitted when allocatable is unreported). -- Drill-down/onboarding quick wins: the S3 bucket detail gains a Tags section (per-bucket tags newly synced — a bucket with no tags reads '—', an access-denied bucket shows nothing; visible after a terraform apply [new read-only IAM grant + sync-lambda zip] and the next sync), the EKS node drilldown's pods table gains Pod IP and Service Account columns ('-' when unknown), and the /eks page shows a no-access banner when clusters are registered but zero live K8s data is reachable — with the raw per-cluster failure reason and a locale-aware link to the docs-site EKS overview guide. +- Drill-down/onboarding quick wins: the S3 bucket detail gains a Tags section (per-bucket tags newly synced — a bucket with no tags reads '—', an access-denied bucket shows nothing; visible after a terraform apply [new read-only IAM grant + sync-lambda zip] and the next sync), the EKS node drilldown's pods table gains Pod IP and Service Account columns ('-' when unknown), and the /eks page shows a no-access banner when clusters are registered but zero live K8s data is reachable — with the raw per-cluster failure reason and a locale-aware link to the docs-site EKS overview guide. S3 collectors enumerate all bounded pages and use ListBuckets region metadata when available, so a denied GetBucketLocation does not discard known bucket placement; inaccessible security attributes remain explicitly unknown and incomplete pagination still blocks publication. - Compliance completion email: when a benchmark run successfully completes, a best-effort SNS email goes out with the benchmark name, scope, total/passed/failed counts, pass rate, and a /compliance link — reusing the AI-diagnosis notification topic, flag, and admin pause switch (paused or unconfigured ⇒ silently skipped; a mail failure never affects the run), limited to one mail per benchmark per 60 minutes (re-runs don't re-blast subscribers), with a durable per-run delivery record (a new compliance_runs notified_at/notify_outcome migration, agent read view re-projected; recorded as a dated ADR-013 amendment). - ECS Tasks page: a collapsible Cost Calculation Basis panel documents the Daily $/Monthly estimates — the Fargate unit-price table and formula rendered from the SAME constants the estimator computes with (the deriver now imports the shared cost-basis source), a worked example, and caveats (Fargate launch type only, ephemeral storage not priced, static prices, ×30 monthly). - Home dashboard: a Monthly Cost Impact (est.) DIVERGING BAR chart (signed bars around a shared zero axis — increase on the warm pole, decrease on the positive pole, symmetric scaling, the 30d count delta as a muted sub-figure — shown inline on wide screens and as a visible second line on small ones (a title tooltip never surfaces on touch); a non-finite value renders '—', never a fabricated $0; the pole pair's colorblind safety was validated — CVD ΔE 13.9 light / 12.2 dark, ≥ the 8 target — and every bar carries a visible signed label) — 30-day resource-count change × a static per-type unit-cost heuristic, sorted by |impact| (top 8), explicitly labeled as a heuristic rather than billing data; fed by its own fixed 35-day account-scoped trend fetch (visible on the default view), including fully-removed types' savings, hidden when the latest snapshot is stale, when the REGION scope is narrowed (snapshots carry no region dimension; a narrowed account scope now prices that account's own deltas), or when the latest day is missing a weighted type the baseline has (a partial sync fan-out), and excluding no-baseline/no-weight types rather than showing $0. @@ -716,7 +716,7 @@ First release of the **v2 line** (versioned independently from the v1 1.x line, ### Changed - AI 작업 화면 사이드바: AI Assistant 대화 목록과 AI 진단 설정·리포트 목록을 192~384px로 조절하거나 숨길 수 있으며, 폭과 표시 상태를 브라우저에 저장합니다. 진단 본문과 사이드바는 독립적으로 스크롤되고 목차 이동은 본문 영역 안에서만 동작하며, 작은 화면에서는 목록이 오버레이로 열립니다. -- 전체 트레이스 집계 기반 서비스 맵: 표준 OTel exporter 테이블을 쓰는 ClickHouse 트레이스 원본은 최신 스팬 1,000개를 이어 붙이는 대신, 제한된 읽기 전용 집계 쿼리(2개, 테이블에 span link가 있으면 1개 추가)로 1시간 구간 전체의 서비스 호출·DB 접근·워크로드·메시징을 셉니다. 호출량이 적은 연결이나 DB가 맵에서 빠지지 않고, 부모 스팬이 표본 밖에 있던 호출도 끊긴 스팬으로 표시되지 않습니다. 집계 쿼리가 실패하면 그 수집은 최신 스팬 표본으로 대신 읽고 부분 수집으로 표시합니다. 사용자 정의·생성된 ClickHouse 쿼리와 Tempo는 기존 스팬 표본 방식을 유지합니다. 노드 상한을 적용할 때도 남기는 DB·큐·워크로드는 호출하는 서비스와 함께 남깁니다. Prometheus/Mimir 서비스 그래프 지표는 서비스 간 호출만 더하며, Tempo가 DB·큐·계측되지 않은 상대와의 연결로 표시한 행은 더 이상 같은 이름의 서비스로 그려지지 않습니다. 새 지표 쿼리를 쓰려면 데이터소스 그래프 카탈로그(`datasource_index`)를 다시 만들어야 합니다. +- 전체 트레이스 집계 기반 서비스 맵: 표준 OTel exporter 테이블을 쓰는 ClickHouse 트레이스 원본은 최신 스팬 1,000개를 이어 붙이는 대신, 제한된 읽기 전용 집계 쿼리(2개, 테이블에 span link가 있으면 1개 추가)로 1시간 구간 전체의 서비스 호출·DB 접근·워크로드·메시징을 셉니다. 호출량이 적은 연결이나 DB가 맵에서 빠지지 않고, 부모 스팬이 표본 밖에 있던 호출도 끊긴 스팬으로 표시되지 않습니다. 집계 쿼리가 실패하면 그 수집은 최신 스팬 표본으로 대신 읽고 부분 수집으로 표시합니다. 사용자 정의·생성된 ClickHouse 쿼리와 Tempo는 기존 스팬 표본 방식을 유지합니다. 노드 상한을 적용할 때도 남기는 DB·큐·워크로드는 호출하는 서비스와 함께 남깁니다. Prometheus/Mimir 서비스 그래프 지표는 서비스 간 호출만 더하며, Tempo가 DB·큐·계측되지 않은 상대와의 연결로 표시한 행은 더 이상 같은 이름의 서비스로 그려지지 않습니다. 새 지표 쿼리를 쓰려면 데이터소스 그래프 카탈로그(`datasource_index`)를 다시 만들어야 합니다. 명시적인 AgentCore Runtime ARN과 모델·제공자 속성으로 단독 client span 및 집계 행에서도 Runtime·요청 모델 관측을 표시합니다. 해당 카드는 미검증 telemetry 식별자임을 알리고 AWS 인벤토리 소유권으로 연결하지 않습니다. - 어시스턴트 시계열 표: 완성된 응답의 명시적인 시간·숫자 지표 열을 지표별 별도 축의 선·막대 차트로 표시하고 원본 표를 보존합니다. 빈 샘플과 부분 구간 시간 표시는 유지하며, 스트리밍·비시계열 표·ID·잘못된 수치·문자 막대를 숫자 지표로 추정하지 않습니다. - 웹 프레임워크·런타임 최신화: Next.js 16.4.5, React 19.3.1, Node 26.13.0으로 인증·소유권 검사·standalone 배포를 유지하고 요청 본문 제한을 Next.js proxy 규약으로 옮깁니다. 호환성 버전 고정과 함께 JavaScript/Python 의존성·AgentCore SDK, Terraform 1.17.1·provider 잠금파일, Python 3.14 Lambda·컨테이너 런타임을 갱신합니다. 문서 가이드는 Node 26에서 빌드·배포하며 문서 사이트의 보안 의존성과 Mermaid 12를 갱신하고 4개 언어를 유지합니다. Gateway 인증·기능 게이트·릴리스 보호 장치는 유지합니다. - EKS 멤버 온보딩: 멤버 클러스터 가이드가 소유자 명령 두 개(등록된 멤버 읽기 역할의 Access Entry 생성, 클러스터 범위 `AmazonEKSViewPolicy` 연결)로 줄었으며 Kubernetes 그룹·kubeconfig·kubectl·RBAC 단계가 없습니다. View는 Kubernetes Secrets를 읽지 않으며, 가이드는 기존 `AmazonEKSAdminViewPolicy` 연결을 제거하도록 안내합니다. View로는 노드를 조회할 수 없으므로 멤버 노드 목록은 같은 역할로 읽기 전용 EC2/노드그룹 API에서 가져와 EC2/노드그룹 기준으로 표시합니다(kubelet 상태 없음, allocatable은 인스턴스 타입 용량, Fargate 노드 제외). 이 노드의 Ready 수는 "EC2 running N · Ready unknown"으로 표시되며 예약 비율 계산에서 제외됩니다. 노드 조회만 실패해도 클러스터 전체가 조회 불가로 표시되지 않습니다. 관리자가 EKS 화면을 열면 Access Entry가 확인된 멤버 클러스터가 "Access Entry 확인됨" 안내 영역에 먼저 표시되고 [조회 등록] 버튼 한 번으로 등록할 수 있으며, 관리자가 누르기 전에는 등록되지 않습니다(앱 등록 기록만 저장, AWSops는 AWS·Kubernetes 권한을 만들지 않음). 해제한 클러스터는 해제 상태로 유지됩니다. 진단 표·Kubernetes 맵·노드 상세에서도 EC2/노드그룹 노드는 "EC2 running · Ready unknown"으로 표시되며 Pressure·Allocatable·Reserved는 비정상이나 전량 할당 대신 알 수 없음으로 표시됩니다. 이전 가이드로 만든 항목도 그대로 동작합니다. @@ -767,7 +767,7 @@ First release of the **v2 line** (versioned independently from the v1 1.x line, - 그룹 비용 차트: ECS Tasks 페이지에 v1의 Cost by Service 차트(클러스터 스코프 서비스별 CPU vs Memory 일일 비용 분해 — 공용 추정 상수 사용, FARGATE 한정 상위 10, 500행 캡 초과 시 표본 기준 표기; EC2 태스크는 추정 불가로·서비스 없는 태스크는 그룹 기준 부재로 제외; 두 $ 시리즈는 하나의 공용 스케일 사용 — CPU vs Memory 비교가 실제 비율 유지), EKS 컨테이너 비용 페이지에 Node별 일일 비용 + Pod 수 차트(페이지 자체의 OpenCost 노드 할당 + pods 목록, 신규 fetch 없음, 비용 내림차순; 비용 상위 15개 + 개수 표기 제목; 귀속 안 된 pod가 하나라도 있는 클러스터는 그 노드들의 Pod 값을 '—'로 표시 — 불완전 귀속에서는 표시 수치가 과소집계일 수 있으므로 절대 확정 숫자로 그리지 않음) 추가 — 신규 멀티 시리즈 그룹 바 프리미티브 기반, v1 이중 축의 시리즈별 자체 스케일 대체는 혼합 단위 시리즈에만 적용(값 라벨이 실제 수치/단위 표기, null 값은 빈 트랙과 '—'). - 상세 드릴다운 퀵윈: Bedrock 모델 상세 패널에 선택 기간 기준 모델별 호출/토큰 시계열 차트(API가 모델별 series를 합산에 버리지 않고 보존; 빈 시리즈는 '시계열 데이터 없음'), ElastiCache 상세에 연결된 보안 그룹별 인바운드 규칙 전개(RDS 드릴다운 섹션/라우트 재사용 — 동기화 인벤토리만 사용, 라이브 AWS 호출 없음, 미동기화 SG는 'not synced'), EBS 볼륨 상세에 실측 라이브 메트릭(기간 합계 환산 Read/Write IOPS·Queue Length·Burst Balance — 최신값 + 1시간 스파크라인, 공용 라이브 메트릭 계약) 추가. - WAF/EKS 퀵윈: 신규 sync 인벤토리 타입 2종 — WAF Rule Groups(scope 도넛, WCU 용량 바)·WAF IP Sets(IPv4/IPv6 분포, 주소 수 — addresses 필드 부재는 0이 아닌 미상) — Security 그룹 개요의 타입별 카운트 타일에 합류(v1의 waf 3-KPI 페이지는 그룹 개요 + 전용 타입 페이지 구조로 대응; terraform apply + 다음 sync 후 표시); EKS 컨테이너 비용 페이지의 잔여 한국어 하드코딩 문자열 번역 적용(제목/부제/추정 배너/빈 상태/검색 placeholder 4개 언어 등록); EKS 노드 플릿의 Total Memory 타일에 allocatable + reserved% 힌트(allocatable 미보고 시 생략) 추가. -- 드릴다운/온보딩 퀵윈: S3 버킷 상세에 Tags 섹션(버킷별 태그 신규 sync — 태그 없음은 '—', 권한 거부 버킷은 미표시; terraform apply[신규 읽기 전용 IAM 권한 + sync 람다 zip] 후 다음 sync부터 표시), EKS 노드 드릴다운 Pods 테이블에 Pod IP·Service Account 컬럼('-'=미상), /eks 페이지에 접근 불가 배너(클러스터는 등록됐지만 라이브 K8s 데이터를 하나도 읽지 못할 때 — 클러스터별 실패 원문과 언어별 docs 사이트 EKS 개요 가이드 링크 표시) 추가. +- 드릴다운/온보딩 퀵윈: S3 버킷 상세에 Tags 섹션(버킷별 태그 신규 sync — 태그 없음은 '—', 권한 거부 버킷은 미표시; terraform apply[신규 읽기 전용 IAM 권한 + sync 람다 zip] 후 다음 sync부터 표시), EKS 노드 드릴다운 Pods 테이블에 Pod IP·Service Account 컬럼('-'=미상), /eks 페이지에 접근 불가 배너(클러스터는 등록됐지만 라이브 K8s 데이터를 하나도 읽지 못할 때 — 클러스터별 실패 원문과 언어별 docs 사이트 EKS 개요 가이드 링크 표시) 추가. S3 수집기는 제한된 전체 페이지를 열거하고 ListBuckets 리전 정보를 우선 사용하므로 GetBucketLocation 거부로 이미 확인된 버킷 위치를 버리지 않습니다. 접근할 수 없는 보안 속성은 미확인으로 남기며 불완전한 페이지 수집은 게시를 차단합니다. - 컴플라이언스 완료 이메일: 벤치마크 실행이 성공적으로 완료되면 벤치마크명·scope·전체/통과/실패 건수·통과율·/compliance 링크가 담긴 best-effort SNS 이메일 발송 — AI 진단 알림의 토픽·플래그·관리자 일시중지 스위치를 재사용(중지/미설정이면 조용히 생략, 메일 실패는 실행 결과에 영향 없음), 벤치마크당 60분 1건 제한(재실행 재발송 방지), 실행별 내구 배달 레코드(compliance_runs notified_at/notify_outcome 신규 마이그레이션, agent 읽기 뷰 재투영; ADR-013 개정으로 기록). - ECS Tasks 페이지: 접이식 '비용 계산 근거' 패널 — Daily $/Monthly 추정의 Fargate 단가표와 수식을 추정기가 실제로 쓰는 동일 상수로 렌더링(deriver가 공용 cost-basis 소스를 import하도록 변경), 계산 예시와 주의사항(FARGATE launch type 한정, 임시 스토리지 미반영, 고정 단가, ×30 월 추정) 포함. - 홈 대시보드: '월 비용 영향 추정' DIVERGING 바 차트(공유 0축 기준 서명 바 — 증가는 warm 극, 감소는 positive 극, 대칭 스케일, 30일 수량 델타는 보조 수치 — 넓은 화면은 인라인, 좁은 화면은 눈에 보이는 둘째 줄(title 툴팁은 터치에서 뜨지 않음); 비정상 값은 $0을 지어내지 않고 '—'; 색상쌍 색각 안전성 검증 CVD ΔE 라이트 13.9/다크 12.2 — 목표 8 이상, 모든 바에 서명된 값 라벨) — 30일 리소스 수량 변화 × 타입별 정적 단가 휴리스틱, |영향| 내림차순 상위 8, 청구 데이터가 아닌 근사임을 명시; 전용 35일 계정 스코프 추이 조회로 기본 화면에서도 표시, 완전히 제거된 타입의 절감도 포함, 최신 스냅샷이 오래됐거나 리전 스코프가 좁혀졌거나(스냅샷에 리전 차원 없음; 계정 스코프를 좁히면 해당 계정의 변화량으로 산정) 기준일에 있던 가중치 타입이 최신일에 누락(부분 sync fan-out)이면 숨김, 30일 기준값·단가 항목 없는 타입은 $0로 표시하지 않고 제외. diff --git a/docs/runbooks/graph-read-contract.md b/docs/runbooks/graph-read-contract.md index 1ebf794d9..63dace111 100644 --- a/docs/runbooks/graph-read-contract.md +++ b/docs/runbooks/graph-read-contract.md @@ -4,6 +4,38 @@ Missing graph clocks can mean legacy rows without collection state; missing metadata is unknown, not collection failure. A stale publication can have a newer attempt or failed producer. A busy/failed read describes the API, not a collection outcome. +### Missing inference dependencies + +Check that the application's telemetry store is registered and has a ready graph +query before interpreting an absent service. The standard ClickHouse adapter +aggregates the whole bounded window; a deployment still using the older raw-span +adapter can lose low-volume calls behind health traffic. + +Client spans with a valid `agentcore.runtime.arn` create a reported runtime +destination even without a server span. A reporting service with the same valid +runtime resource ARN can connect through a `hosts` relationship when its account +and region do not conflict. These joins remain isolated by source and environment. +Explicit `gen_ai.provider.name`/`gen_ai.system`, requested model and endpoint +attributes describe a requested model dependency. They do not prove successful +inference, the final provider behind a Gateway, or an executing model's region. +Unknown endpoints remain caller-local. Runtime/model cards label the identity as +an unverified telemetry claim; no inventory ownership follows from it. + +Producers must supply Kubernetes resource identity for Pod correlation and +runtime resource identity for AgentCore. Do not assign Kubernetes identity to +every span received by a shared collector. No prompts, completions or request +bodies are required by the inference graph mapper. + +Verify from `web/`: + +```bash +npx vitest run lib/inference-topology.test.ts lib/trace-source.test.ts app/topology/services/page.test.tsx +``` + +After the reviewed deployment, inspect the registered source, fresh collection +state and one approved synthetic request. A keep-alive HTTP 200 is not model +execution proof. + ### Placement and live VPC connections `/topology/infra` defaults to the persisted placement view from diff --git a/docs/runbooks/steampipe-quota-and-staleness.md b/docs/runbooks/steampipe-quota-and-staleness.md index 2d5087711..b38f23a62 100644 --- a/docs/runbooks/steampipe-quota-and-staleness.md +++ b/docs/runbooks/steampipe-quota-and-staleness.md @@ -1,5 +1,22 @@ # Runbook — Steampipe 쿼터 및 인벤토리 신선도 / Steampipe Quota and Inventory Staleness +## S3 location-read denials + +The S3 inventory and public-access collectors use paginated `ListBuckets` +`BucketRegion` metadata when present. A denied `GetBucketLocation` therefore does +not erase region evidence that the authorized bucket listing already supplied. +Responses without valid list-region metadata still use the location read; a +failed fallback remains incomplete. The collectors finish enumeration before +returning rows, reject repeated tokens/duplicate identities, and fail closed after +100 pages of at most 1,000 buckets. + +Denied security-property reads remain explicit unknown attributes. A completed +listing is not proof that bucket security was inspected, and release readiness +still rejects unknown attributes. Failed pagination cannot authorize pruning or +successful publication. This fix adds no IAM grant and does not modify bucket +policies. Ship the collector through the reviewed Terraform saved-plan procedure, +then inspect its terminal ledger and graph source qualification. + ## Optional host scope / 선택적 호스트 범위 `INVENTORY_HOST_ONLY=true` requires `EXPECTED_HOST_ACCOUNT_ID` and exactly one enabled diff --git a/docs/superpowers/plans/2026-11-15-fsi-discovery-remediation.md b/docs/superpowers/plans/2026-11-15-fsi-discovery-remediation.md new file mode 100644 index 000000000..f280bfb8a --- /dev/null +++ b/docs/superpowers/plans/2026-11-15-fsi-discovery-remediation.md @@ -0,0 +1,73 @@ +# Service discovery remediation + +The user approved remediation of the preceding live discovery review on +2026-11-15. Work starts from `origin/dev` in an isolated worktree; unrelated +changes in the original checkout are preserved. + +## Scope and decisions + +- Preserve read-only product posture, datasource administration, credential + binding, account boundaries and truthful partial/unknown evidence. +- Use authoritative paginated S3 bucket-list region metadata instead of requiring + a separately denied location read. Retain the existing fallback for responses + without that metadata, and retain unknown security attributes. +- Reuse the whole-window ClickHouse aggregation already merged into dev. Extend + its allowlisted evidence so it does not discard managed inference dependencies. +- Model remote runtime/model calls as telemetry claims, not inventory ownership. + Keep namespace/account/environment/source isolation and avoid name-only joins. +- Prepare producer identity enrichment in the FSI source repository. Runtime + resources must not acquire invented Kubernetes identities. +- Register the FSI store only through authorized datasource administration. + Do not bypass authentication or retrieve unrelated credentials. +- Use existing reviewed release and saved Terraform-plan procedures. A runtime + readiness failure remains a failure; no override of migration or collection + gates is introduced. + +## Tasks + +- [x] S3 inventory: tests for bucket-region metadata, pagination, denied location + fallback and incomplete enumeration; implement in + `scripts/v2/steampipe/sync_lambda.py`. +- [x] Inference topology: tests for allowlisted runtime/model attributes, + malformed/conflicting scope, grouped ClickHouse evidence and bounded output; + implement in `web/lib/trace-source.ts`, a focused inference-evidence helper and + `web/lib/trace-graph.ts`, with display support if required. +- [x] Verify existing whole-window aggregation against the observed health-check + saturation case and preserve explicit partial-result semantics. +- [x] Prepare and verify FSI trace resource enrichment using its GitOps source, + and examine an authorized datasource registration path. +- [x] Run focused regression suites and build validation; inspect the final diff + against product and identity constraints. +- [ ] Create reviewable commits/PRs where authorized, run required reviews, and + deploy only through the existing environment's permitted release process. +- [ ] Recheck DNS/current origins, source registration, source freshness, + inference graph evidence and bounded synthetic inference where permitted. + +## Verification and workflow constraints + +Use the locked dependencies and appropriate Python runtime. Tests must first +demonstrate the missing behavior. No live AWS call is made from unit tests. +Real backend probes remain separate from test results. + +The user requires immediate subagent closure, while this session exposes no +subagent close operation. Work and local review therefore run inline; no +independent subagent review is claimed. Required repository PR reviews remain +part of delivery. + +Operational evidence and account-specific values remain in private scratch +artifacts, not public documentation. Record any deployment or authorization +blocker separately from completed code changes. + +## Verification record + +- Python 3.12 inventory suite: 279 passed. Loopback tests were rerun outside + the sandbox after its socket restriction prevented setup. +- Web suite after the final identity-isolation change: 5,912 passed, 281 skipped + across 382 passed and four skipped files. Skips are not integration evidence. +- Companion producer validation is recorded in its own repository. +- FSI producer changes are in its separate review branch; registration and live + rollout remain pending and require authorized administrator access. +- No production configuration, app-data registration or graph publication has + been changed by this task. +- Final web production build passed. Operational measurements remain in private + review artifacts and are not included in this public plan. diff --git a/scripts/v2/steampipe/sync_lambda.py b/scripts/v2/steampipe/sync_lambda.py index 59afb640a..92271aee2 100644 --- a/scripts/v2/steampipe/sync_lambda.py +++ b/scripts/v2/steampipe/sync_lambda.py @@ -757,6 +757,42 @@ def _fetch_alb_listener_rules(): return _sdk_collection(rows, "resource_id", "region", failures) +def _list_s3_buckets(s3): + """Finish bounded enumeration before publishing any bucket evidence.""" + buckets, names, tokens, token = [], set(), set(), None + for _ in range(100): + args = {"MaxBuckets": 1000} + if token is not None: + args["ContinuationToken"] = token + response = s3.list_buckets(**args) + page = response.get("Buckets") + if not isinstance(page, list): + raise RuntimeError("Incomplete S3 bucket listing") + for bucket in page: + name = bucket.get("Name") if isinstance(bucket, dict) else None + if not isinstance(name, str) or not name or name in names: + raise RuntimeError("Invalid S3 bucket listing identity") + names.add(name) + buckets.append(bucket) + token = response.get("ContinuationToken") + if token is None: + return buckets + if not isinstance(token, str) or not token or token in tokens: + raise RuntimeError("Incomplete S3 bucket listing pagination") + tokens.add(token) + raise RuntimeError("S3 bucket listing exceeded page limit") + + +def _s3_bucket_region(s3, bucket): + # Paginated ListBuckets supplies BucketRegion under ListAllMyBuckets. A + # bucket-policy deny on GetBucketLocation must not discard that evidence. + region = bucket.get("BucketRegion") + if isinstance(region, str) and re.fullmatch(r"[a-z]{2,8}(?:-[a-z0-9]+)+-\d+", region): + return region + location = s3.get_bucket_location(Bucket=bucket["Name"]).get("LocationConstraint") + return "eu-west-1" if location == "EU" else location or "us-east-1" + + def _fetch_s3_public_access(s3=None): """Per-bucket S3 public-access flags (denial-safe), for the /security Public-S3 finding. Steampipe's aws_s3_bucket public-access columns trigger per-bucket GetBucketPolicyStatus/ @@ -771,13 +807,12 @@ def _fetch_s3_public_access(s3=None): rows = [] failures = [] unknown_attrs = 0 - for b in s3.list_buckets().get("Buckets", []) or []: + for b in _list_s3_buckets(s3): name = b["Name"] transient_failed = False denied_attrs = [] try: - loc = s3.get_bucket_location(Bucket=name).get("LocationConstraint") - region = loc or "us-east-1" # null LocationConstraint => us-east-1 + region = _s3_bucket_region(s3, b) except ClientError as e: # Never upsert under region "" — that lands a NEW row under a different # conflict key while the partial run skips both prune phases, leaving the @@ -862,13 +897,12 @@ def _fetch_s3_security(s3=None): rows = [] failures = [] unknown_attrs = 0 - for b in s3.list_buckets().get("Buckets", []) or []: + for b in _list_s3_buckets(s3): name = b["Name"] transient_failed = False denied_attrs = [] try: - loc = s3.get_bucket_location(Bucket=name).get("LocationConstraint") - region = loc or "us-east-1" + region = _s3_bucket_region(s3, b) except ClientError as e: # Never upsert under region "" — that lands a NEW row under a different # conflict key while the partial run skips both prune phases, leaving the diff --git a/scripts/v2/steampipe/test_sync_inventory_additions.py b/scripts/v2/steampipe/test_sync_inventory_additions.py index ccd2703f6..10eac78e3 100644 --- a/scripts/v2/steampipe/test_sync_inventory_additions.py +++ b/scripts/v2/steampipe/test_sync_inventory_additions.py @@ -269,7 +269,7 @@ class _FakeS3PolicyStatus: def __init__(self, policy_status_by_bucket): self._ps = policy_status_by_bucket - def list_buckets(self): + def list_buckets(self, **kwargs): return {"Buckets": [{"Name": n} for n in self._ps]} def get_bucket_location(self, Bucket): diff --git a/scripts/v2/steampipe/test_sync_s3_public.py b/scripts/v2/steampipe/test_sync_s3_public.py index bffebaf1a..38e600ef1 100644 --- a/scripts/v2/steampipe/test_sync_s3_public.py +++ b/scripts/v2/steampipe/test_sync_s3_public.py @@ -12,7 +12,7 @@ def __init__(self, buckets, denied=(), no_pab=(), no_policy=(), loc_denied=()): self._no_policy = set(no_policy) self._loc_denied = set(loc_denied) - def list_buckets(self): + def list_buckets(self, **kwargs): return {"Buckets": [{"Name": b} for b in self._buckets]} def get_bucket_location(self, Bucket): diff --git a/scripts/v2/steampipe/test_sync_s3_regions.py b/scripts/v2/steampipe/test_sync_s3_regions.py new file mode 100644 index 000000000..c3e6eb94b --- /dev/null +++ b/scripts/v2/steampipe/test_sync_s3_regions.py @@ -0,0 +1,114 @@ +"""Authoritative list-region evidence must survive denied bucket metadata reads.""" +import pytest +from botocore.exceptions import ClientError + +import sync_lambda + + +COLLECTORS = (sync_lambda._fetch_s3_security, sync_lambda._fetch_s3_public_access) + + +class BucketClient: + def __init__(self, pages, *, deny_attributes=False): + self.pages = pages + self.deny_attributes = deny_attributes + self.list_calls = [] + self.location_calls = [] + + def list_buckets(self, **kwargs): + self.list_calls.append(kwargs) + page = self.pages[len(self.list_calls) - 1] + if isinstance(page, Exception): + raise page + return page + + def get_bucket_location(self, Bucket): + self.location_calls.append(Bucket) + if Bucket == "legacy": + return {"LocationConstraint": None} + raise ClientError({"Error": {"Code": "AccessDenied"}}, "GetBucketLocation") + + def __getattr__(self, name): + if name.startswith("get_"): + def attribute(**kwargs): + if self.deny_attributes: + raise ClientError({"Error": {"Code": "AccessDenied"}}, name) + return {} + return attribute + raise AttributeError(name) + + +@pytest.mark.parametrize("collect", COLLECTORS) +def test_list_region_keeps_denied_bucket_with_truthful_unknown_attributes(collect): + client = BucketClient( + [{"Buckets": [{"Name": "locked", "BucketRegion": "ap-northeast-2"}]}], + deny_attributes=True, + ) + rows, _, _, evidence = collect(client) + assert len(rows) == 1 + assert rows[0]["region"] == "ap-northeast-2" + assert rows[0]["attributes_unknown"] + assert rows[0]["bucket_policy_is_public"] is None + assert client.location_calls == [] + assert evidence["failure_count"] == 0 + assert evidence["unknown_attribute_count"] > 0 + + +@pytest.mark.parametrize("collect", COLLECTORS) +def test_collects_all_list_pages_and_falls_back_only_for_missing_region(collect): + client = BucketClient([ + {"Buckets": [{"Name": "first", "BucketRegion": "eu-west-1"}], + "ContinuationToken": "private-next-page"}, + {"Buckets": [{"Name": "legacy"}]}, + ]) + rows, _, _, evidence = collect(client) + assert [(row["name"], row["region"]) for row in rows] == [ + ("first", "eu-west-1"), ("legacy", "us-east-1"), + ] + assert client.location_calls == ["legacy"] + assert client.list_calls == [ + {"MaxBuckets": 1000}, + {"MaxBuckets": 1000, "ContinuationToken": "private-next-page"}, + ] + assert evidence["failure_count"] == 0 + + +@pytest.mark.parametrize("collect", COLLECTORS) +def test_failed_later_page_never_returns_a_complete_first_page(collect): + client = BucketClient([ + {"Buckets": [{"Name": "first", "BucketRegion": "eu-west-1"}], + "ContinuationToken": "next"}, + ClientError({"Error": {"Code": "SlowDown"}}, "ListBuckets"), + ]) + with pytest.raises(ClientError): + collect(client) + + +@pytest.mark.parametrize("collect", COLLECTORS) +def test_repeated_page_token_fails_closed_without_disclosing_token(collect): + client = BucketClient([ + {"Buckets": [], "ContinuationToken": "private-token"}, + {"Buckets": [], "ContinuationToken": "private-token"}, + ]) + with pytest.raises(RuntimeError, match="bucket listing") as error: + collect(client) + assert "private-token" not in str(error.value) + + +@pytest.mark.parametrize("collect", COLLECTORS) +def test_missing_region_and_denied_location_remain_incomplete(collect): + client = BucketClient([{"Buckets": [{"Name": "locked"}]}]) + rows, _, _, evidence = collect(client) + assert rows == [] + assert evidence["failure_count"] == 1 + + +@pytest.mark.parametrize("collect", COLLECTORS) +def test_duplicate_bucket_identity_does_not_prove_complete_enumeration(collect): + client = BucketClient([ + {"Buckets": [{"Name": "duplicate", "BucketRegion": "eu-west-1"}], + "ContinuationToken": "next"}, + {"Buckets": [{"Name": "duplicate", "BucketRegion": "us-east-1"}]}, + ]) + with pytest.raises(RuntimeError, match="bucket listing"): + collect(client) diff --git a/scripts/v2/steampipe/test_sync_sdk_partial.py b/scripts/v2/steampipe/test_sync_sdk_partial.py index b1df47098..e5188204d 100644 --- a/scripts/v2/steampipe/test_sync_sdk_partial.py +++ b/scripts/v2/steampipe/test_sync_sdk_partial.py @@ -108,7 +108,7 @@ def test_s3_security_collector_skips_transiently_degraded_row_and_discloses_unkn # would overwrite the bucket's last-known-good row content with None fields while the # partial run skips both prune phases. class FakeS3: - def list_buckets(self): + def list_buckets(self, **kwargs): return {"Buckets": [{"Name": "secret-bucket"}]} def get_bucket_location(self, Bucket): diff --git a/web/app/topology/services/page.test.tsx b/web/app/topology/services/page.test.tsx index aeaa192c6..f42fe86bb 100644 --- a/web/app/topology/services/page.test.tsx +++ b/web/app/topology/services/page.test.tsx @@ -18,6 +18,24 @@ import ServiceMapPage from './page'; afterEach(() => { cleanup(); vi.unstubAllGlobals(); push.mockReset(); }); +it.each(['runtime', 'model'])('labels %s dependencies as claims and exposes their exact endpoint details', async kind => { + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ + nodes: [{ id: 'inference:1', kind, label: 'inference-target', meta: { + sourceId: 'clickhouse:1', claimedAccountId: '111122223333', claimedRegion: 'us-east-1', + provider: 'aws.bedrock', model: 'example.model', endpoint: 'example.internal', + arn: 'arn:aws:bedrock-agentcore:us-east-1:111122223333:runtime/example', + identityProvenance: 'telemetry_claim', infra_ref: 'untrusted-inventory-ref', + } }], edges: [], captured_at: null, + }) }))); + render(); + const node = await screen.findByRole('button', { name: new RegExp(`${kind}: inference-target`) }); + expect(node.textContent).toContain('Telemetry claim'); + fireEvent.click(node); + expect(screen.getByText('example.internal')).toBeTruthy(); + expect(screen.queryByRole('link', { name: '인프라 리소스 보기 →' })).toBeNull(); +}); + it('labels queue attribution as unverified telemetry and never navigates it into AWS inventory', async () => { vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ diff --git a/web/app/topology/services/page.tsx b/web/app/topology/services/page.tsx index 8cfea61bf..579889a5d 100644 --- a/web/app/topology/services/page.tsx +++ b/web/app/topology/services/page.tsx @@ -24,11 +24,14 @@ const COLORS: Record = { db: ['#C8902F', '#FBEFE0', '#33260C'], workload: ['#8A5BD0', '#F1E9FF', '#241A3E'], queue: ['#4D906C', '#E9F5EE', '#0E2E1C'], + runtime: ['#7053B5', '#EFE9FF', '#26183E'], + model: ['#B76A32', '#FFF0E2', '#3B2414'], }; const HEIGHT = 76, QUEUE_HEIGHT = 144; +const isClaim = (kind: string | undefined) => ['queue', 'runtime', 'model'].includes(kind ?? ''); const relLabel: Record = { calls: 'calls', queries: 'queries', runs_on: 'runs on', - linked: 'async link', publishes: 'publishes', consumes: 'consumes', + linked: 'async link', publishes: 'publishes', consumes: 'consumes', hosts: 'hosts', }; const text = (value: unknown) => typeof value === 'string' ? value : ''; @@ -82,7 +85,7 @@ export default function ServiceMapPage() { const { nodes, edges } = useMemo(() => { const kinds = new Map(visible.nodes.map(n => [n.id, n.kind])); const pos = new Map(layoutFlow(visible, { rankdir: viewport.vertical ? 'TB' : 'LR', nodeSize: id => ({ - width: 240, height: kinds.get(id) === 'queue' ? QUEUE_HEIGHT : HEIGHT, + width: 240, height: isClaim(kinds.get(id)) ? QUEUE_HEIGHT : HEIGHT, }) }).map(p => [p.id, p])); const nodes: Node[] = visible.nodes.map(n => { const [border, light, deep] = COLORS[n.kind] ?? ['#9AA6B2', '#EEF0F2', '#1F262D']; @@ -91,11 +94,11 @@ export default function ServiceMapPage() { return { id: n.id, position: pos.get(n.id) ?? { x: 0, y: 0 }, data: { label:
+ style={{ maxHeight: (isClaim(n.kind) ? QUEUE_HEIGHT : HEIGHT) - 18, overflowY: 'auto' }}>
{n.kind}: {n.label}
{context}
{source &&
sourceId: {source}
} - {n.kind === 'queue' &&
+ {isClaim(n.kind) &&
{tt('Telemetry claim · AWS identity unverified')}
claimedAccountId: {text(n.meta?.claimedAccountId) || '—'}
claimedRegion: {text(n.meta?.claimedRegion) || '—'} @@ -104,7 +107,7 @@ export default function ServiceMapPage() { sourcePosition: viewport.vertical ? Position.Bottom : Position.Right, targetPosition: viewport.vertical ? Position.Top : Position.Left, style: { background: dark ? deep : light, border: `${n.id === focusId ? 2 : 1}px solid ${border}`, - borderRadius: 10, padding: 8, width: 240, height: n.kind === 'queue' ? QUEUE_HEIGHT : HEIGHT, + borderRadius: 10, padding: 8, width: 240, height: isClaim(n.kind) ? QUEUE_HEIGHT : HEIGHT, color: dark ? '#E3E9EE' : '#16202A', cursor: 'pointer' }, }; }); @@ -183,9 +186,9 @@ export default function ServiceMapPage() {
{selected &&
{tt('선택한 리소스 상세')} · {selected.kind}: {selected.label} - {selected.kind === 'queue' &&

{tt('Telemetry claim · AWS identity unverified')}

} + {isClaim(selected.kind) &&

{tt('Telemetry claim · AWS identity unverified')}

}
- {['environment','cluster','namespace','sourceId','host','system','broker','destination','identityProvenance','deployment','pods','spanCount','errorSpanCount','unknownStatusSpanCount','claimedAccountId','claimedRegion'].map(key => + {['environment','cluster','namespace','sourceId','host','system','broker','destination','arn','provider','model','endpoint','port','identityProvenance','deployment','pods','spanCount','errorSpanCount','unknownStatusSpanCount','claimedAccountId','claimedRegion'].map(key => selected.meta?.[key] != null ?
{key}
{String(selected.meta[key])}
: null)}
{/* A claimed queue ARN is never an inventory bridge. */} diff --git a/web/components/topology/E2eGraphCanvas.tsx b/web/components/topology/E2eGraphCanvas.tsx index ba841fbff..d7de9cad0 100644 --- a/web/components/topology/E2eGraphCanvas.tsx +++ b/web/components/topology/E2eGraphCanvas.tsx @@ -387,6 +387,7 @@ export default function E2eGraphCanvas({ graph: inputGraph }: { graph: E2eGraph
{tt('스냅샷 표시 시각은 개별 노드·관계의 수집 시각이나 관측 구간이 아닙니다.')}
} {['cluster', 'namespace', 'deployment', 'pod', 'pods', 'resolved', 'podName', 'podNamespace', 'instanceId', 'az', 'subnetId', 'serviceName', 'ip', 'vpcId', 'region', 'match', 'host', 'dbName', 'componentId', 'type', + 'arn', 'provider', 'model', 'endpoint', 'port', 'claimedAccountId', 'claimedRegion', 'identityProvenance', 'count', 'members', 'membersTruncated', 'memberIdentities', 'ownership_evidence', 'ownership_reason', 'ambiguity', 'e2e_correlation_blocked', 'workloadReadStatus'].map((key) => { if (groupedTarget && (key === 'pod' || key === 'namespace')) return null; const nested = object(selected.meta.endpoint) ? selected.meta.endpoint[key] : undefined; diff --git a/web/lib/inference-evidence.ts b/web/lib/inference-evidence.ts new file mode 100644 index 000000000..33cb5b35e --- /dev/null +++ b/web/lib/inference-evidence.ts @@ -0,0 +1,34 @@ +import type { TraceSpan } from './trace-source'; + +/** These are reported destination identities, never verified inventory ownership. */ +export function runtimeClaim(value: unknown) { + if (typeof value !== 'string' || value.length > 512) return null; + const match = /^arn:(aws(?:-[a-z0-9-]+)?):bedrock-agentcore:([a-z]{2,8}(?:-[a-z0-9]+)+-\d+):(\d{12}):runtime\/([A-Za-z0-9_][A-Za-z0-9_-]{0,255})$/.exec(value); + return match ? { + arn: value, name: match[4], claimedAccountId: match[3], claimedRegion: match[2], + identityProvenance: 'telemetry_claim' as const, + } : null; +} + +export function reportingRuntime(span: TraceSpan) { + const claim = runtimeClaim(span.runtimeResourceArn ?? span.cloudResourceId); + if (!claim || (span.accountId && span.accountId !== claim.claimedAccountId) + || (span.region && span.region !== claim.claimedRegion) + || (span.cloudResourceId && span.cloudResourceId !== claim.arn)) return null; + return claim; +} + +export function modelClaim(span: TraceSpan) { + if (span.kind !== 'CLIENT' || span.runtimeArn !== undefined) return null; + const provider = span.genAiProvider + ?? (/^(?:Amazon )?Bedrock Runtime$/i.test(span.rpcService ?? '') + || span.rpcService === 'bedrock-runtime' ? 'aws.bedrock' : undefined); + const model = span.genAiModel; + if (!provider || !/^[a-z][a-z0-9._-]{0,63}$/.test(provider) || !model + || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(model) || model.includes('://')) return null; + const endpoint = span.inferenceEndpoint?.toLowerCase().replace(/\.$/, ''); + if (endpoint !== undefined && (endpoint.length > 253 || !endpoint.split('.').every(label => + /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)))) return null; + return { provider, model, endpoint: endpoint ?? null, port: span.inferencePort ?? null, + identityProvenance: 'telemetry_claim' as const }; +} diff --git a/web/lib/inference-topology.test.ts b/web/lib/inference-topology.test.ts new file mode 100644 index 000000000..4144904d5 --- /dev/null +++ b/web/lib/inference-topology.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from 'vitest'; +vi.mock('@/lib/datasources', () => ({})); +vi.mock('@/lib/mcp-lambda-invoke', () => ({})); +import { mapOtelRow } from './trace-source'; +import { buildTraceGraph } from './trace-graph'; + +const runtimeArn = 'arn:aws:bedrock-agentcore:ap-northeast-2:123456789012:runtime/insurance_analysis-AbCd012345'; +function span(attributes: Record = {}, resource: Record = {}, kind = 'Client') { + return mapOtelRow({ + TraceId: 'trace-a', SpanId: 'span-a', ServiceName: 'orchestrator', SpanKind: kind, + Timestamp: '2026-11-15T00:00:00Z', Duration: 5_000_000, + ResourceAttributes: { 'cloud.account.id': '123456789012', 'cloud.region': 'ap-northeast-2', + 'service.namespace': 'agents', 'deployment.environment': 'demo', ...resource }, + SpanAttributes: attributes, + }); +} + +describe('inference dependency observations', () => { + it('keeps an exact runtime destination from an isolated client span without claiming inventory ownership', () => { + const graph = buildTraceGraph([span({ + 'agentcore.runtime.arn': runtimeArn, 'agentcore.runtime.name': 'untrusted-display-name', + })], [], []); + const runtime = graph.nodes.find(node => node.kind === 'runtime'); + expect(runtime).toMatchObject({ + label: 'insurance_analysis-AbCd012345', + meta: { arn: runtimeArn, claimedAccountId: '123456789012', claimedRegion: 'ap-northeast-2', + identityProvenance: 'telemetry_claim' }, + }); + expect(runtime!.meta).not.toHaveProperty('infra_ref'); + expect(runtime!.meta).not.toHaveProperty('accountId'); + expect(graph.edges).toContainEqual(expect.objectContaining({ + source: graph.nodes.find(node => node.kind === 'service')!.id, target: runtime!.id, rel: 'calls', + })); + }); + + it('links the same runtime claim to its reporting service and that service to its model', () => { + const request = span({ 'agentcore.runtime.arn': runtimeArn }); + const modelCall = { ...span({ + 'gen_ai.system': 'aws.bedrock', 'gen_ai.request.model': 'global.anthropic.example-v1', + 'server.address': 'bedrock-runtime.ap-northeast-2.amazonaws.com', + 'gen_ai.prompt': 'private prompt', 'gen_ai.completion': 'private response', + }, { 'cloud.resource_id': runtimeArn }), service: 'insurance_analysis', spanId: 'model-call' }; + const graph = buildTraceGraph([request, modelCall], [], []); + const runtime = graph.nodes.find(node => node.kind === 'runtime')!; + const model = graph.nodes.find(node => node.kind === 'model')!; + const service = graph.nodes.find(node => node.label === 'insurance_analysis')!; + expect(graph.nodes.filter(node => node.kind === 'runtime')).toHaveLength(1); + expect(graph.edges).toEqual(expect.arrayContaining([ + expect.objectContaining({ source: runtime.id, target: service.id, rel: 'hosts' }), + expect.objectContaining({ source: service.id, target: model.id, rel: 'calls' }), + ])); + expect(model.meta).toMatchObject({ model: 'global.anthropic.example-v1', provider: 'aws.bedrock', + endpoint: 'bedrock-runtime.ap-northeast-2.amazonaws.com', identityProvenance: 'telemetry_claim' }); + expect(JSON.stringify(graph)).not.toContain('private prompt'); + expect(JSON.stringify(graph)).not.toContain('private response'); + }); + + it('retains distinct GPU model endpoints and weighted aggregate call counts', () => { + const first = { ...span({ 'gen_ai.provider.name': 'vllm', 'gen_ai.request.model': 'vendor/model', + 'server.address': 'gpu-a.sllm.svc.cluster.local' }), weight: 7 }; + const second = { ...span({ 'gen_ai.provider.name': 'vllm', 'gen_ai.request.model': 'vendor/model', + 'server.address': 'gpu-b.sllm.svc.cluster.local' }), spanId: 'second', weight: 2 }; + const graph = buildTraceGraph([first, second], [], []); + expect(graph.nodes.filter(node => node.kind === 'model')).toHaveLength(2); + expect(graph.edges.filter(edge => edge.rel === 'calls').map(edge => edge.meta.spanCount).sort()) + .toEqual([2, 7]); + }); + + it('recognizes Bedrock SDK model attributes without requiring a remote server span', () => { + const graph = buildTraceGraph([span({ + 'rpc.service': 'Bedrock Runtime', 'rpc.method': 'InvokeModel', + 'aws.bedrock.model_id': 'vendor.model-v1', 'server.address': 'bedrock-runtime.us-east-1.amazonaws.com', + })], [], []); + expect(graph.nodes.find(node => node.kind === 'model')?.meta).toMatchObject({ + model: 'vendor.model-v1', provider: 'aws.bedrock', + }); + }); + + it('keeps model servers on different ports distinct', () => { + const graph = buildTraceGraph([8000, 8080].map(port => ({ + ...span({ 'gen_ai.system': 'vllm', 'gen_ai.request.model': 'vendor/model', + 'server.address': 'gpu.internal', 'server.port': String(port) }), spanId: `span-${port}`, + })), [], []); + expect(graph.nodes.filter(node => node.kind === 'model')).toHaveLength(2); + }); + + it('does not join identical cluster-local model names across clusters', () => { + const graph = buildTraceGraph(['cluster-a', 'cluster-b'].map(cluster => ({ + ...span({ 'gen_ai.system': 'vllm', 'gen_ai.request.model': 'vendor/model', + 'server.address': 'gpu.sllm.svc.cluster.local' }, { 'k8s.cluster.name': cluster }), + spanId: cluster, + })), [], []); + expect(graph.nodes.filter(node => node.kind === 'model')).toHaveLength(2); + }); + + it('does not infer a direct model call from model annotations on a runtime invocation', () => { + const graph = buildTraceGraph([span({ 'agentcore.runtime.arn': runtimeArn, + 'gen_ai.system': 'aws.bedrock', 'gen_ai.request.model': 'example.model' })], [], []); + expect(graph.nodes.filter(node => node.kind === 'runtime')).toHaveLength(1); + expect(graph.nodes.filter(node => node.kind === 'model')).toHaveLength(0); + }); + + it.each([ + { 'agentcore.runtime.arn': 'arn:aws:s3:::not-a-runtime' }, + { 'agentcore.runtime.arn': `${runtimeArn}?token=private` }, + { 'agentcore.runtime.name': 'same-name-without-arn' }, + { 'gen_ai.request.model': 'model-without-provider' }, + { 'gen_ai.provider.name': 'vllm', 'gen_ai.request.model': 'https://private@example.test/model' }, + ])('rejects incomplete or malformed destination claims: %j', attributes => { + const graph = buildTraceGraph([span(attributes)], [], []); + expect(graph.nodes.filter(node => ['runtime', 'model'].includes(node.kind))).toEqual([]); + }); + + it('does not turn server or internal annotations into outgoing model calls', () => { + const graph = buildTraceGraph(['Server', 'Internal'].map((kind, index) => ({ + ...span({ 'gen_ai.system': 'aws.bedrock', 'gen_ai.request.model': 'example.model', + 'agentcore.runtime.arn': runtimeArn }, {}, kind), spanId: `span-${index}`, + })), [], []); + expect(graph.edges.filter(edge => edge.rel === 'calls')).toEqual([]); + }); + + it('does not bind a runtime resource whose account conflicts with the reporting resource', () => { + const graph = buildTraceGraph([span({}, { + 'cloud.resource_id': runtimeArn, 'cloud.account.id': '999999999999', + })], [], []); + expect(graph.edges.filter(edge => edge.rel === 'hosts')).toEqual([]); + }); + + it('isolates claims across telemetry sources and environments', () => { + const graph = buildTraceGraph([ + { ...span({ 'agentcore.runtime.arn': runtimeArn }), sourceId: 'clickhouse:1' }, + { ...span({ 'agentcore.runtime.arn': runtimeArn }), sourceId: 'clickhouse:2', spanId: 'b' }, + { ...span({ 'agentcore.runtime.arn': runtimeArn }, { 'deployment.environment': 'prod' }), + sourceId: 'clickhouse:1', spanId: 'c' }, + ], [], []); + expect(graph.nodes.filter(node => node.kind === 'runtime')).toHaveLength(3); + }); +}); diff --git a/web/lib/trace-graph.ts b/web/lib/trace-graph.ts index 2a3e14d60..4232caef8 100644 --- a/web/lib/trace-graph.ts +++ b/web/lib/trace-graph.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import type { TraceIdentity, TraceSpan, ServiceGraphCall } from './trace-source'; import { queueClaimMeta, queueDestinationArn } from './trace-evidence'; import { DB_ENDPOINT_KINDS } from './infra-topology'; +import { modelClaim, reportingRuntime, runtimeClaim } from './inference-evidence'; export interface TraceNode { id: string; kind: string; label: string; meta: Record } export interface InstanceObservation { @@ -188,6 +189,13 @@ export function buildTraceGraph( return added; } }; + const runtimeNode = (claim: NonNullable>, span: TraceSpan) => { + // Exact ARN claims can connect caller and runtime telemetry only within one source/environment. + const id = nodeId('runtime', { sourceId: span.sourceId, environment: span.environment }, claim.arn); + if (!nodes.has(id)) nodes.set(id, { id, kind: 'runtime', label: claim.name, + meta: { ...claim, sourceId: span.sourceId ?? null, environment: span.environment ?? null } }); + return id; + }; for (const span of byId.values()) { // An aggregate row stands for `weight` spans; a raw span for one. @@ -201,6 +209,20 @@ export function buildTraceGraph( const traceIds = meta.traceIds as string[]; if (traceIds.length < 20 && !traceIds.includes(span.traceId)) traceIds.push(span.traceId); + const hostRuntime = reportingRuntime(span); + if (hostRuntime) edge(runtimeNode(hostRuntime, span), sid, 'hosts', w); + const targetRuntime = span.kind === 'CLIENT' ? runtimeClaim(span.runtimeArn) : null; + if (targetRuntime) edge(sid, runtimeNode(targetRuntime, span), 'calls', w); + const model = modelClaim(span); + if (model) { + // Preserve cluster/namespace even with a host: private DNS may be reused by another cluster. + // An unknown endpoint is additionally caller-local. + const id = nodeId('model', span, + JSON.stringify([model.provider, model.model, model.endpoint, model.port, model.endpoint ? null : span.service])); + if (!nodes.has(id)) nodes.set(id, { id, kind: 'model', label: model.model, + meta: { ...model, sourceId: span.sourceId ?? null, environment: span.environment ?? null } }); + edge(sid, id, 'calls', w); + } if (span.parentSpanId) { const parent = byId.get(spanKey(span.sourceId, span.traceId, span.parentSpanId)); if (parent) { diff --git a/web/lib/trace-source.test.ts b/web/lib/trace-source.test.ts index 9b390dc0f..75781216a 100644 --- a/web/lib/trace-source.test.ts +++ b/web/lib/trace-source.test.ts @@ -29,6 +29,7 @@ import { extractServiceGraphCalls, type TraceSpan, } from './trace-source'; +import { buildTraceGraph } from './trace-graph'; const span = (over: Partial = {}): TraceSpan => ({ traceId: 't1', spanId: 's1', service: 'svc', kind: 'SERVER', startMs: 0, durationMs: 1, ...over, @@ -108,6 +109,30 @@ describe('ClickHouseOtelAggregateSource (whole-window aggregates)', () => { return { rows, collectionStatus: rows.length ? 'ok' : 'empty' }; }); + it('retains low-volume inference groups alongside frequent health traffic across the whole window', async () => { + const arn = 'arn:aws:bedrock-agentcore:ap-northeast-2:123456789012:runtime/analysis-AbCd012345'; + answer([ + group({ SpanKind: 'Server', SpanAttributes: {}, Weight: '50000', SampleSpanId: 'health' }), + group({ SpanAttributes: { 'agentcore.runtime.arn': arn }, Weight: '2', SampleSpanId: 'runtime' }), + group({ SpanAttributes: { 'gen_ai.provider.name': 'aws.bedrock', 'gen_ai.request.model': 'vendor.model', + 'server.address': 'bedrock-runtime.ap-northeast-2.amazonaws.com' }, + Weight: '1', SampleSpanId: 'model' }), + ], []); + const out = await new ClickHouseOtelAggregateSource().recentSpans(60, 1000, END); + expect(out.status).toBe('ok'); + const sql = invokeMcpLambdaTool.mock.calls[0][0].args.sql; + expect(sql).toContain("SpanAttributes['agentcore.runtime.arn'] != ''"); + expect(sql).toContain("SpanAttributes['gen_ai.request.model'] != ''"); + expect(sql).toContain("'gen_ai.provider.name'"); + expect(sql).not.toContain("'gen_ai.prompt'"); + expect(sql).toContain('GROUP BY ServiceName'); + const graph = buildTraceGraph(out.items, out.calls ?? [], []); + expect(graph.nodes.filter(node => node.kind === 'runtime')).toHaveLength(1); + expect(graph.nodes.filter(node => node.kind === 'model')).toHaveLength(1); + expect(graph.edges.filter(edge => edge.rel === 'calls').map(edge => edge.meta.spanCount).sort()) + .toEqual([1, 2]); + }); + it('accepts only the catalog exporter template and reads its optional columns', () => { expect(otelAggregateShape(undefined)).toEqual({ table: 'otel_traces', spanKind: true, statusCode: true, links: true }); const catalog = 'SELECT TraceId, SpanId, ParentSpanId, ServiceName, Timestamp, Duration, ResourceAttributes, ' + diff --git a/web/lib/trace-source.ts b/web/lib/trace-source.ts index c32102efc..b5d74e2fc 100644 --- a/web/lib/trace-source.ts +++ b/web/lib/trace-source.ts @@ -38,6 +38,14 @@ export interface TraceSpan extends TraceIdentity { cloudResourceId?: string; hostId?: string; serviceInstanceId?: string; + /** Explicit inference metadata only; prompts, completions and HTTP bodies are never retained. */ + runtimeArn?: string; + runtimeResourceArn?: string; + genAiProvider?: string; + genAiModel?: string; + inferenceEndpoint?: string; + inferencePort?: number; + rpcService?: string; messagingSystem?: string; messagingDestination?: string; messagingBroker?: string; @@ -209,6 +217,12 @@ function spanMetadata(out: TraceSpan, resource: Obj, span: Obj): void { cloudResourceId: identityClaim(resource['cloud.resource_id']), hostId: identityClaim(resource['host.id']), serviceInstanceId: identityClaim(resource['service.instance.id']), + runtimeResourceArn: identityClaim(resource['agentcore.runtime.arn']), + runtimeArn: identityClaim(span['agentcore.runtime.arn']), + genAiProvider: identityClaim(span['gen_ai.provider.name']) ?? identityClaim(span['gen_ai.system']), + genAiModel: identityClaim(span['gen_ai.request.model']) ?? identityClaim(span['aws.bedrock.model_id']), + inferenceEndpoint: identityClaim(span['server.address']) ?? identityClaim(span['net.peer.name']), + rpcService: identityClaim(span['rpc.service']), k8sNamespace: text(resource['k8s.namespace.name']), k8sCluster: text(resource['k8s.cluster.name']), k8sPod: text(resource['k8s.pod.name']), k8sDeployment: text(resource['k8s.deployment.name']), dbSystem: text(span['db.system']), dbName: text(span['db.name']), @@ -221,6 +235,7 @@ function spanMetadata(out: TraceSpan, resource: Obj, span: Obj): void { for (const [key, value] of Object.entries(fields)) if (value !== undefined) Object.assign(out, { [key]: value }); const address = text(span['server.address']) ?? text(span['net.peer.name']); const port = numeric(span['server.port']) ?? numeric(span['net.peer.port']); + if (port !== undefined && Number.isInteger(port) && port > 0 && port <= 65535) out.inferencePort = port; if (out.messagingSystem && address && port !== undefined && Number.isInteger(port) && port > 0 && port <= 65535) { out.messagingBroker = `${address.trim().toLowerCase()}:${port}`; @@ -430,10 +445,13 @@ const AGG_RESOURCE_KEYS = [ 'service.name', 'cloud.account.id', 'cloud.region', 'deployment.environment.name', 'deployment.environment', 'service.namespace', 'service.version', 'cloud.resource_id', 'host.id', 'service.instance.id', 'k8s.namespace.name', 'k8s.cluster.name', 'k8s.deployment.name', + 'agentcore.runtime.arn', ] as const; const AGG_SPAN_KEYS = [ 'db.system', 'db.name', 'server.address', 'net.peer.name', 'server.port', 'net.peer.port', 'peer.service', 'messaging.system', 'messaging.destination.name', 'messaging.destination', + 'agentcore.runtime.arn', 'gen_ai.provider.name', 'gen_ai.system', 'gen_ai.request.model', + 'aws.bedrock.model_id', 'rpc.service', ] as const; // Node scope only (trace-graph.ts scope()); edge endpoints must land on the same service nodes. const AGG_IDENTITY_KEYS = [ @@ -448,7 +466,8 @@ function aggregateSql(shape: OtelAggregateShape, windowMins: number, endMs: numb `AND Timestamp <= fromUnixTimestamp64Milli(${endMs})`; const kind = shape.spanKind ? 'SpanKind' : "'' AS SpanKind"; const status = shape.statusCode ? 'StatusCode' : "'' AS StatusCode"; - // Non-db/messaging spans drop their peer attributes so HTTP peers do not multiply groups. + // Ordinary HTTP peers still collapse. Explicit inference destinations must survive + // grouping even when the managed service exports no matching server span. const groups = 'SELECT ServiceName, SpanKind, StatusCode, RA AS ResourceAttributes, SA AS SpanAttributes, ' + 'count() AS Weight, intDiv(sum(Duration), 1000000) AS DurationMsSum, toUnixTimestamp64Milli(max(Timestamp)) AS LastTimestamp, ' + 'toUnixTimestamp64Milli(min(Timestamp)) AS FirstTimestamp, any(TraceId) AS SampleTraceId, ' + @@ -458,7 +477,9 @@ function aggregateSql(shape: OtelAggregateShape, windowMins: number, endMs: numb `mapSort(mapApply((k, v) -> (k, if(k = 'cloud.resource_id' AND NOT (${EC2_CLAIM}), 'unbindable', v)), ` + `mapFilter((k, v) -> has(${list(AGG_RESOURCE_KEYS)}, k) AND (k NOT IN ('host.id', 'service.instance.id') ` + `OR ${EC2_CLAIM}), ResourceAttributes))) AS RA, ` + - "if(SpanAttributes['db.system'] != '' OR SpanAttributes['messaging.system'] != '', " + + "if(SpanAttributes['db.system'] != '' OR SpanAttributes['messaging.system'] != '' " + + "OR SpanAttributes['agentcore.runtime.arn'] != '' OR SpanAttributes['gen_ai.request.model'] != '' " + + "OR SpanAttributes['aws.bedrock.model_id'] != '', " + `mapSort(mapFilter((k, v) -> has(${list(AGG_SPAN_KEYS)}, k), SpanAttributes)), ` + 'mapFilter((k, v) -> false, SpanAttributes)) AS SA ' + `FROM ${shape.table} WHERE ${window}) ` + From 662f383a464e8f778380e096ca1b19d6b68be1b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=84=9D=28Junseok=20Oh=29DevOps?= <52226147+Atom-oh@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:30:05 +0000 Subject: [PATCH 2/3] fix: address review feedback (iteration 1/5) --- agent/lambda/inventory_read_mcp.py | 24 +++++++ agent/lambda/test_inventory_read_mcp.py | 29 +++++++- docs/api-reference.md | 33 ++++++++- docs/runbooks/agent-sql-reader.md | 31 +++++++-- docs/runbooks/graph-read-contract.md | 7 ++ ...SF8H7_trace_inference_claim_provenance.sql | 67 +++++++++++++++++++ web/app/api/graph/route.test.ts | 8 +++ web/app/api/graph/route.ts | 4 +- .../topology/E2eGraphCanvas.test.tsx | 9 +++ web/components/topology/E2eGraphCanvas.tsx | 1 + web/lib/fixtures/trace-inference-claims.json | 47 +++++++++++++ web/lib/inference-evidence.ts | 24 +++++-- web/lib/inference-topology.test.ts | 11 ++- 13 files changed, 281 insertions(+), 14 deletions(-) create mode 100644 terraform/foundation/migrations/01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql create mode 100644 web/lib/fixtures/trace-inference-claims.json diff --git a/agent/lambda/inventory_read_mcp.py b/agent/lambda/inventory_read_mcp.py index d92d045cf..20e0238f0 100644 --- a/agent/lambda/inventory_read_mcp.py +++ b/agent/lambda/inventory_read_mcp.py @@ -75,6 +75,10 @@ def _inventory_stale_after_minutes(env=None): "account/region fallbacks. identityProvenance is always telemetry_claim, even when an ARN " "names the host; queues never bridge into inventory. Shared destination ARNs join across " "callers only within datasource/environment; the same ARN can have separate nodes in each scope." + " Runtime and requested-model identities are also telemetry claims, never inventory ownership " + "or proof of successful inference. Runtime qualifiers come only from a valid runtime ARN; " + "requested models have null account/region claims. A hosts edge is reported runtime context, " + "not a traced request. The requested model/endpoint does not prove the provider behind a Gateway." ) MATERIALIZED_TOPOLOGY_NOTE = ( @@ -190,6 +194,23 @@ def build_topology_chain(by_type, root=None): return chains +def _inference_claim_meta(kind, meta): + """Typed, bounded read projection; never trust stored ownership or provenance.""" + fields = {"sourceId": 512, "environment": 512} + fields.update({"arn": 512} if kind == "runtime" else {"provider": 64, "model": 256, "endpoint": 253}) + safe = {key: meta[key] for key, limit in fields.items() + if isinstance(meta.get(key), str) and len(meta[key]) <= limit} + port = meta.get("port") + if kind == "model" and type(port) in (int, float) and 1 <= port <= 65535 and port == int(port): + safe["port"] = port + arn = re.fullmatch( + r"arn:aws(?:-[a-z0-9-]+)?:bedrock-agentcore:([a-z]{2,8}(?:-[a-z0-9]+)+-\d+):(\d{12}):runtime/[A-Za-z0-9_][A-Za-z0-9_-]{0,255}", + safe.get("arn", ""), + ) if kind == "runtime" else None + return {**safe, "claimedAccountId": arn[2] if arn else None, + "claimedRegion": arn[1] if arn else None, "identityProvenance": "telemetry_claim"} + + def _fetch_topology_graph(resource_id=None, cls="flow", limit=500): """Read the materialized topology graph from topology_nodes/edges (ADR-010[legacy 043]). @@ -312,6 +333,9 @@ def _parse_meta(m): "meta": _parse_meta(r.get("meta"))} for r in node_rows if r.get("id")] if cls == "trace": for node in nodes: + if node["kind"] in ("runtime", "model"): + node["meta"] = _inference_claim_meta(node["kind"], node["meta"]) + continue if node["kind"] != "queue": continue meta = node["meta"].copy() diff --git a/agent/lambda/test_inventory_read_mcp.py b/agent/lambda/test_inventory_read_mcp.py index 8763a3bd3..68b16358a 100644 --- a/agent/lambda/test_inventory_read_mcp.py +++ b/agent/lambda/test_inventory_read_mcp.py @@ -949,7 +949,7 @@ def setUpClass(cls): cls._psql((migrations / name).read_text()) for suffix in ("_topology_inventory_evidence.sql", "_graph_attempt_disclosure.sql", "_graph_projection_parity.sql", "_trace_queue_claim_provenance.sql", - "_graph_read_indexes.sql"): + "_graph_read_indexes.sql", "_trace_inference_claim_provenance.sql"): for path in sorted(migrations.glob("*" + suffix)): cls._psql(path.read_text()) @@ -1003,6 +1003,22 @@ def setUp(self): def tearDown(self): inv._execute_override = None + def test_inference_view_projection_preserves_only_typed_claim_evidence(self): + cases = json.loads((Path(__file__).resolve().parents[2] + / "web/lib/fixtures/trace-inference-claims.json").read_text()) + for index, case in enumerate(cases): + meta = json.dumps(case["meta"]).replace("'", "''") + self._psql( + "INSERT INTO public.topology_nodes(account_id,id,kind,label,meta,run_id,class) VALUES " + f"('self','claim-{index}','{case['kind']}','reported','{meta}'::jsonb,'test','trace');" + ) + rows = self._execute("SELECT id,meta FROM topology_nodes ORDER BY id") + self.assertEqual([row["meta"] for row in rows], [case["expected"] for case in cases]) + permissions = self._execute( + "SELECT has_table_privilege(current_user,'public.topology_nodes','SELECT') AS base_read" + ) + self.assertFalse(permissions[0]["base_read"]) + def _execute(self, sql, params=None): # Translate the Data API's named scalar binds into PostgreSQL PREPARE binds. # JSON ID sets remain scalar string binds. This shim verifies SQL/view semantics; @@ -1340,6 +1356,17 @@ def _state(self, status="ok", details=None, captured_at=CAPTURED): return {"status": status, "attempted_at": self.ATTEMPTED, "captured_at": captured_at, "details": {"sources": [], "retainedPrevious": False} if details is None else details} + def test_inference_claim_projection_matches_web_contract(self): + cases = json.loads((Path(__file__).resolve().parents[2] + / "web/lib/fixtures/trace-inference-claims.json").read_text()) + for case in cases: + with self.subTest(case=case["name"]): + body, _ = self._read(self._state(), nodes=[{ + "id": "claim:one", "kind": case["kind"], "label": "reported target", "meta": case["meta"], + }]) + self.assertEqual(body["nodes"][0]["meta"], case["expected"]) + self.assertIn("Runtime and requested-model identities", body["note"]) + def test_queue_arn_claims_remain_unverified_including_before_projection_migration(self): cases = json.loads((Path(__file__).resolve().parents[2] / "web/lib/fixtures/trace-queue-claims.json").read_text()) diff --git a/docs/api-reference.md b/docs/api-reference.md index 198f35f5e..f5cef9ea7 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -565,14 +565,43 @@ vetoes binding). Generated or custom templates, Tempo and other sources keep spa graph mixing them sums both kinds of count. Publication keeps at most 200 nodes and 500 edges (`nodeDrops`/`edgeDrops`). Nodes are selected -from the heaviest edges first, both endpoints together, so a kept database, queue or workload -always keeps its caller; remaining capacity goes to other nodes by span and metric volume. +from the heaviest edges first, both endpoints together, including runtime/model relationships; +remaining capacity goes to other nodes by span and metric volume. Service-graph metrics contribute service→service `calls` only. Rows whose Tempo `connection_type` is `database`, `messaging_system` or `virtual_node` name a database, queue or uninstrumented peer rather than a service and are not drawn; databases and queues come from trace spans with their host-qualified identity. +### Trace inference claims + +The trace graph can also return `runtime` and `model` nodes from explicit span +attributes. A client span with a valid AgentCore runtime ARN produces a `calls` +relationship to that runtime. A reporting service with a matching runtime resource +ARN and non-conflicting account/region produces `runtime → service` with `rel=hosts`. +That relationship is reported execution context, not a traced request. An explicit +provider and requested model on a client span produces a requested-model `calls` +relationship; a valid runtime invocation does not also invent a direct model call. + +Runtime claims join only by exact ARN within source/environment. Model claims +retain source/account/region/environment/cluster/namespace/port isolation; an +unknown endpoint is also caller-local. Provider names are normalized to lowercase. +Neither relationship proves successful inference or the final provider behind a +Gateway, and capped publications still disclose partial coverage. + +HTTP, MCP and the current SQL-reader view force +`identityProvenance=telemetry_claim` for these kinds, including retained rows. +Only bounded typed `sourceId`, `environment`, runtime `arn`, or model +`provider`/`model`/`endpoint` and a valid numeric `port` survive. Runtime +`claimedAccountId`/`claimedRegion` are derived from a valid ARN; models and invalid +runtime ARNs have null qualifiers. Stored ownership/provenance, `infra_ref`, raw +provider blobs and prompt/response content are excluded. + +The SQL-reader projection requires the standalone migration described in the +[inference projection runbook](runbooks/agent-sql-reader.md#trace-inference-projection). +Until that migration is applied, the agent may have the node kind/label but lack +its identity fields. A source merge is not deployment proof. + ### Trace edge instance observations Trace `queries` edges (service → `db`) may carry three additive, optional `meta` fields built by diff --git a/docs/runbooks/agent-sql-reader.md b/docs/runbooks/agent-sql-reader.md index 50c185e92..c49769f4d 100644 --- a/docs/runbooks/agent-sql-reader.md +++ b/docs/runbooks/agent-sql-reader.md @@ -330,9 +330,9 @@ baseline and this owner; audit the allowlist from this file, not the baseline. ### Current topology evidence contract The current owner of `sql_reader.topology_nodes.meta` is -[`01M27B0000C6QWJ50NRJ8YAH9D_trace_queue_claim_provenance.sql`](../../terraform/foundation/migrations/01M27B0000C6QWJ50NRJ8YAH9D_trace_queue_claim_provenance.sql). -It selects named JSON keys with type checks for telemetry fields and a trace-queue -exception. The current materialized flow writer persists `ownership_evidence` and +[`01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql`](../../terraform/foundation/migrations/01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql). +It preserves the prior queue/non-claim projection and adds bounded typed runtime/model +claim projections. The current materialized flow writer persists `ownership_evidence` and `targetCapturedAt` on **target nodes**, with VPC/subnet or ambiguity metadata where applicable. `targetCapturedAt` dates only the target-group inventory row; it does not date the independent task/subnet/pod evidence or establish current ownership. Host ECS @@ -365,6 +365,10 @@ The projection is not an ownership validator: destination-ARN-derived `claimedAccountId`/`claimedRegion`. Queue `accountId`, `region` and `infra_ref` are removed. Neither parsed ARN syntax nor a storage partition's `account_id` proves telemetry ownership. +- Trace runtime/model nodes always expose `identityProvenance='telemetry_claim'`. + Runtime account/region qualifiers derive only from a valid runtime ARN; model + qualifiers remain null. Both kinds exclude stored ownership, inventory references + and raw provider content. The node's exposed `captured_at` is graph materialization time, not its underlying inventory capture or observation time. For collection quality, consult @@ -393,13 +397,14 @@ state-read failure or detected publication change without extending the stored multi-query snapshot. The internal writer scheduling clock `lastSourceAttemptedAtMs` stays outside the projection and is covered by `web/lib/graph-reader-privacy.test.ts`. Apply the new collection projection with the existing `make migrate` operator flow; -the queue projection remains separately owned by the migration above. +the node projection is owned by the inference migration above, preserving queue semantics. ### Trace queue projection / 트레이스 큐 투영 `01M279W0J9HNG1QT0MAS60KV8K_topology_graph_collection_state.sql` introduces graph evidence; `01M2HM8BR5ZC0JZWGQ9ZFV1WT2_graph_projection_parity.sql` is the current collection-state projection owner, adding bounded inventory source clocks/provenance; -`01M27B0000C6QWJ50NRJ8YAH9D_trace_queue_claim_provenance.sql` supersedes its node projection. +`01M27B0000C6QWJ50NRJ8YAH9D_trace_queue_claim_provenance.sql` introduced the queue projection; +the current inference migration preserves that branch. After `make migrate`, spot-check `sql_reader.topology_nodes` where `class='trace' AND kind='queue'`: `claimedAccountId/claimedRegion` must match only parsed destination ARN qualifiers, including retained rows; non-ARN/malformed destinations have null claims and no reporter fallback. @@ -408,6 +413,22 @@ copies stay absent for trace queues. SELECT is granted only on the view, never t `scripts/v2/workers/test_graph_collection.py` exercises this contract on disposable PostgreSQL, including grant restoration, reapplication, denied base-table reads and denied view writes. +### Trace inference projection + +Apply `01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql` through +the reviewed standalone migration procedure before releasing the matching web +and inventory-reader Lambda. `CREATE OR REPLACE VIEW` and `GRANT` are outside the +automatic web migration subset; do not add a bypass or change historical headers. +This preserves the existing view columns and SELECT grant and adds no base-table access. + +For `class='trace' AND kind IN ('runtime','model')`, spot-check the named-field +projection, forced provenance and null model qualifiers. Malformed runtime ARNs +must not inherit stored or caller account/region. The shared +`web/lib/fixtures/trace-inference-claims.json` cases cover HTTP, MCP and the actual +PostgreSQL view under `awsops_sql_reader` in `TestTopologySelectionSQL`, including +continued denial of `public.topology_nodes` reads. Inference identity is still +unavailable through an older view even when the Lambda adds a provenance note. + Trace edge instance observations (`instance_observations[]`, `instance_observations_omitted`, `instance_observations_unbound`; see [API reference](../api-reference.md#trace-edge-instance-observations)) are not part of the agent evidence contract: `sql_reader.topology_edges` projects only numeric diff --git a/docs/runbooks/graph-read-contract.md b/docs/runbooks/graph-read-contract.md index 63dace111..0a3e958ee 100644 --- a/docs/runbooks/graph-read-contract.md +++ b/docs/runbooks/graph-read-contract.md @@ -36,6 +36,13 @@ After the reviewed deployment, inspect the registered source, fresh collection state and one approved synthetic request. A keep-alive HTTP 200 is not model execution proof. +The matching SQL-reader projection is delivered by +`01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql`. Run it through +the reviewed standalone migration path before web/Lambda release; views and +grants are not admitted by automatic web migrations. The MCP and HTTP readers +also project retained runtime/model claims at read time, preserving forced +provenance and excluding stored ownership or raw provider content. + ### Placement and live VPC connections `/topology/infra` defaults to the persisted placement view from diff --git a/terraform/foundation/migrations/01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql b/terraform/foundation/migrations/01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql new file mode 100644 index 000000000..0573ea925 --- /dev/null +++ b/terraform/foundation/migrations/01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql @@ -0,0 +1,67 @@ +-- since: 0.10.1 +-- Extend view-only trace evidence to runtime/model claims. No base-table grant. +-- Queue and non-claim projections retain their existing contract. +CREATE OR REPLACE VIEW sql_reader.topology_nodes +WITH (security_invoker = false) AS +SELECT account_id, id, kind, label, run_id, captured_at, class, + CASE WHEN class = 'trace' AND kind IN ('runtime', 'model') THEN + coalesce(( + SELECT jsonb_object_agg(k, v) + FROM jsonb_each(CASE WHEN jsonb_typeof(meta) = 'object' THEN meta ELSE '{}'::jsonb END) AS fields(k, v) + WHERE ( + jsonb_typeof(v) = 'string' AND ( + (k IN ('sourceId', 'environment') AND length(v #>> '{}') <= 512) + OR (kind = 'runtime' AND k = 'arn' AND length(v #>> '{}') <= 512) + OR (kind = 'model' AND ( + (k = 'provider' AND length(v #>> '{}') <= 64) + OR (k = 'model' AND length(v #>> '{}') <= 256) + OR (k = 'endpoint' AND length(v #>> '{}') <= 253) + )) + ) + ) OR ( + kind = 'model' AND k = 'port' AND + CASE WHEN jsonb_typeof(v) = 'number' THEN + (v #>> '{}')::numeric BETWEEN 1 AND 65535 + AND (v #>> '{}')::numeric = trunc((v #>> '{}')::numeric) + ELSE false END + ) + ), '{}'::jsonb) + || jsonb_build_object( + 'identityProvenance', 'telemetry_claim', + 'claimedAccountId', runtime_arn.parts[2], + 'claimedRegion', runtime_arn.parts[1] + ) + ELSE + coalesce(( + SELECT jsonb_object_agg(k, v) FROM jsonb_each(meta) AS fields(k, v) + WHERE ( + k = ANY(ARRAY['invType','targetType','recordType','service','bucket','domain', + 'unresolved','resolvedTarget','ecsService','task','cluster','groupLabel','members', + 'aliases','port','health']) + OR (k = ANY(ARRAY['sourceId','accountId','region','environment','serviceNamespace', + 'namespace','deployment','system','host','dbName','infra_ref','destination','broker']) + AND jsonb_typeof(v) = 'string') + OR (k = ANY(ARRAY['spanCount','errorSpanCount','unknownStatusSpanCount','sampledDurationMs']) + AND jsonb_typeof(v) = 'number') + ) AND NOT (class = 'trace' AND kind = 'queue' + AND k = ANY(ARRAY['accountId','region','infra_ref'])) + ), '{}'::jsonb) + || CASE WHEN class = 'trace' AND kind = 'queue' THEN jsonb_build_object( + 'identityProvenance', 'telemetry_claim', + 'claimedAccountId', destination_arn.parts[2], + 'claimedRegion', nullif(destination_arn.parts[1], '') + ) ELSE '{}'::jsonb END + END AS meta +FROM public.topology_nodes +LEFT JOIN LATERAL regexp_match( + CASE WHEN class = 'trace' AND kind = 'queue' AND jsonb_typeof(meta->'destination') = 'string' + THEN btrim(meta->>'destination', E' \t\n\r\f\013') END, + '^arn:[a-z0-9-]+:[a-z0-9-]+:([a-z0-9-]*):([0-9]{12}):[^[:space:]]+$' +) AS destination_arn(parts) ON true +LEFT JOIN LATERAL regexp_match( + CASE WHEN class = 'trace' AND kind = 'runtime' AND jsonb_typeof(meta->'arn') = 'string' + AND length(meta->>'arn') <= 512 AND meta->>'arn' !~ '[[:space:]]' THEN meta->>'arn' END, + '^arn:aws(?:-[a-z0-9-]+)?:bedrock-agentcore:([a-z]{2,8}(?:-[a-z0-9]+)+-[0-9]+):([0-9]{12}):runtime/[A-Za-z0-9_][A-Za-z0-9_-]{0,255}$' +) AS runtime_arn(parts) ON true; + +GRANT SELECT ON sql_reader.topology_nodes TO awsops_sql_reader; diff --git a/web/app/api/graph/route.test.ts b/web/app/api/graph/route.test.ts index d1ee25b08..634bdc190 100644 --- a/web/app/api/graph/route.test.ts +++ b/web/app/api/graph/route.test.ts @@ -14,6 +14,7 @@ vi.mock('@/lib/db', () => ({ getPool: () => sharedPool })); import { GET } from './route'; import { graphReadTransaction, graphTransaction, GraphReadBusy, GraphReadDeadline } from '@/lib/graph-transaction'; import claimCases from '../../../lib/fixtures/trace-queue-claims.json'; +import inferenceClaims from '../../../lib/fixtures/trace-inference-claims.json'; afterEach(() => vi.restoreAllMocks()); describe('graph collection evidence API', () => { @@ -29,6 +30,13 @@ describe('graph collection evidence API', () => { }); }); + it.each(inferenceClaims)('projects retained inference evidence: $name', async fixture => { + query.mockImplementation(async (sql: string) => ({ rows: sql.includes('FROM topology_nodes') + ? [{ id: 'claim:one', kind: fixture.kind, label: 'reported target', meta: fixture.meta }] : [] })); + const body = await (await GET(new Request('http://localhost/api/graph?class=trace'))).json(); + expect(body.nodes[0].meta).toEqual(fixture.expected); + }); + it('returns failed collection evidence even when no graph nodes exist', async () => { const response = await GET(new Request('http://localhost/api/graph?class=trace')); expect(response.status).toBe(200); diff --git a/web/app/api/graph/route.ts b/web/app/api/graph/route.ts index 0dc30f31b..ba1a9fb85 100644 --- a/web/app/api/graph/route.ts +++ b/web/app/api/graph/route.ts @@ -4,6 +4,7 @@ import { downstream, upstream, FANOUT_CAP } from '@/lib/graph-query'; import { readGraphState, graphDiagnostic, type GraphClass } from '@/lib/graph-state'; import { graphReadTransaction, GraphReadBusy, GraphReadDeadline } from '@/lib/graph-transaction'; import { queueClaimMeta } from '@/lib/trace-evidence'; +import { inferenceClaimMeta } from '@/lib/inference-evidence'; import { redactInventorySecrets } from '@/lib/inventory-redaction'; import { splitUnionNodeId, unionNodeId, type GraphSelection } from '@/lib/graph-identity'; @@ -14,7 +15,8 @@ function evidenceNodes(rows: Record[], cls: string, union: boolean) const meta = redactInventorySecrets(node.meta); const { account_id, ...fields } = node; return { ...fields, id: union && account_id ? unionNodeId(account_id, node.id) : node.id, - meta: cls === 'trace' && node.kind === 'queue' ? queueClaimMeta(meta ?? {}) : meta }; + meta: cls === 'trace' && node.kind === 'queue' ? queueClaimMeta(meta ?? {}) + : cls === 'trace' && ['runtime', 'model'].includes(node.kind) ? inferenceClaimMeta(node.kind, meta ?? {}) : meta }; }); } diff --git a/web/components/topology/E2eGraphCanvas.test.tsx b/web/components/topology/E2eGraphCanvas.test.tsx index aecfba468..2eb635e66 100644 --- a/web/components/topology/E2eGraphCanvas.test.tsx +++ b/web/components/topology/E2eGraphCanvas.test.tsx @@ -59,6 +59,15 @@ async function select(label: string) { } describe('E2eGraphCanvas', () => { + it('keeps nested network endpoint fields without an object-valued inference endpoint row', async () => { + const nodes = graph.nodes.map(node => node.id === 'p1' + ? { ...node, meta: { endpoint: { ip: '10.0.1.1', podName: 'pod-a' } } } : node); + render(); + const detail = await select('shop/pod-a'); + expect(detail.getByText('10.0.1.1')).toBeTruthy(); + expect(detail.queryByText('[object Object]')).toBeNull(); + }); + it.each(['ko', 'en', 'zh', 'ja'] as const)('names the actual context control in the %s guide', lang => { const label = applyTerms(lang, '참고 정보 (캐시된 구성·경유 구성요소)'); const path = lang === 'ko' ? '../../../docs-site/docs/resources/topology.md' diff --git a/web/components/topology/E2eGraphCanvas.tsx b/web/components/topology/E2eGraphCanvas.tsx index d7de9cad0..3f06704c1 100644 --- a/web/components/topology/E2eGraphCanvas.tsx +++ b/web/components/topology/E2eGraphCanvas.tsx @@ -389,6 +389,7 @@ export default function E2eGraphCanvas({ graph: inputGraph }: { graph: E2eGraph {['cluster', 'namespace', 'deployment', 'pod', 'pods', 'resolved', 'podName', 'podNamespace', 'instanceId', 'az', 'subnetId', 'serviceName', 'ip', 'vpcId', 'region', 'match', 'host', 'dbName', 'componentId', 'type', 'arn', 'provider', 'model', 'endpoint', 'port', 'claimedAccountId', 'claimedRegion', 'identityProvenance', 'count', 'members', 'membersTruncated', 'memberIdentities', 'ownership_evidence', 'ownership_reason', 'ambiguity', 'e2e_correlation_blocked', 'workloadReadStatus'].map((key) => { + if (key === 'endpoint' && object(selected.meta.endpoint)) return null; if (groupedTarget && (key === 'pod' || key === 'namespace')) return null; const nested = object(selected.meta.endpoint) ? selected.meta.endpoint[key] : undefined; const value = selected.meta[key] ?? nested; diff --git a/web/lib/fixtures/trace-inference-claims.json b/web/lib/fixtures/trace-inference-claims.json new file mode 100644 index 000000000..9b0325e6a --- /dev/null +++ b/web/lib/fixtures/trace-inference-claims.json @@ -0,0 +1,47 @@ +[ + { + "name": "runtime ignores stored ownership", + "kind": "runtime", + "meta": { + "sourceId": "tempo:1", "environment": "demo", + "arn": "arn:aws:bedrock-agentcore:ap-northeast-2:123456789012:runtime/example-AbCd012345", + "accountId": "999999999999", "region": "us-west-2", "infra_ref": "private-inventory", + "claimedAccountId": "999999999999", "claimedRegion": "us-west-2", + "identityProvenance": "aws_verified", "row": {"secret": "hidden"} + }, + "expected": { + "sourceId": "tempo:1", "environment": "demo", + "arn": "arn:aws:bedrock-agentcore:ap-northeast-2:123456789012:runtime/example-AbCd012345", + "claimedAccountId": "123456789012", "claimedRegion": "ap-northeast-2", + "identityProvenance": "telemetry_claim" + } + }, + { + "name": "invalid runtime retains no account claim", + "kind": "runtime", + "meta": {"arn": "not-a-runtime-arn", "claimedAccountId": "999999999999", "region": "us-west-2"}, + "expected": {"arn": "not-a-runtime-arn", "claimedAccountId": null, "claimedRegion": null, "identityProvenance": "telemetry_claim"} + }, + { + "name": "requested model has no ownership qualifiers", + "kind": "model", + "meta": { + "sourceId": "clickhouse:2", "environment": "demo", + "provider": "aws.bedrock", "model": "vendor.model", "endpoint": "bedrock-runtime.ap-northeast-2.amazonaws.com", "port": 443, + "accountId": "999999999999", "region": "us-west-2", "infra_ref": "private-inventory", + "claimedAccountId": "999999999999", "claimedRegion": "us-west-2", + "identityProvenance": "aws_verified", "prompt": "hidden", "row": {"secret": "hidden"} + }, + "expected": { + "sourceId": "clickhouse:2", "environment": "demo", + "provider": "aws.bedrock", "model": "vendor.model", "endpoint": "bedrock-runtime.ap-northeast-2.amazonaws.com", "port": 443, + "claimedAccountId": null, "claimedRegion": null, "identityProvenance": "telemetry_claim" + } + }, + { + "name": "wrong JSON types are not exposed as model identities", + "kind": "model", + "meta": {"sourceId": 5, "provider": {}, "model": [], "endpoint": {"secret": "hidden"}, "port": "hidden", "row": {}}, + "expected": {"claimedAccountId": null, "claimedRegion": null, "identityProvenance": "telemetry_claim"} + } +] diff --git a/web/lib/inference-evidence.ts b/web/lib/inference-evidence.ts index 33cb5b35e..d6941f58d 100644 --- a/web/lib/inference-evidence.ts +++ b/web/lib/inference-evidence.ts @@ -4,7 +4,7 @@ import type { TraceSpan } from './trace-source'; export function runtimeClaim(value: unknown) { if (typeof value !== 'string' || value.length > 512) return null; const match = /^arn:(aws(?:-[a-z0-9-]+)?):bedrock-agentcore:([a-z]{2,8}(?:-[a-z0-9]+)+-\d+):(\d{12}):runtime\/([A-Za-z0-9_][A-Za-z0-9_-]{0,255})$/.exec(value); - return match ? { + return match && match[0] === value ? { arn: value, name: match[4], claimedAccountId: match[3], claimedRegion: match[2], identityProvenance: 'telemetry_claim' as const, } : null; @@ -19,10 +19,10 @@ export function reportingRuntime(span: TraceSpan) { } export function modelClaim(span: TraceSpan) { - if (span.kind !== 'CLIENT' || span.runtimeArn !== undefined) return null; - const provider = span.genAiProvider + if (span.kind !== 'CLIENT' || runtimeClaim(span.runtimeArn) !== null) return null; + const provider = (span.genAiProvider ?? (/^(?:Amazon )?Bedrock Runtime$/i.test(span.rpcService ?? '') - || span.rpcService === 'bedrock-runtime' ? 'aws.bedrock' : undefined); + || span.rpcService === 'bedrock-runtime' ? 'aws.bedrock' : undefined))?.toLowerCase(); const model = span.genAiModel; if (!provider || !/^[a-z][a-z0-9._-]{0,63}$/.test(provider) || !model || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(model) || model.includes('://')) return null; @@ -32,3 +32,19 @@ export function modelClaim(span: TraceSpan) { return { provider, model, endpoint: endpoint ?? null, port: span.inferencePort ?? null, identityProvenance: 'telemetry_claim' as const }; } + +/** Read-time projection also covers retained rows. Keep in sync with the SQL view and MCP. */ +export function inferenceClaimMeta(kind: string, meta: Record = {}): Record { + const safe: Record = {}; + const fields: Record = { sourceId: 512, environment: 512 }; + if (kind === 'runtime') fields.arn = 512; + else Object.assign(fields, { provider: 64, model: 256, endpoint: 253 }); + for (const [key, limit] of Object.entries(fields)) { + if (typeof meta[key] === 'string' && meta[key].length <= limit) safe[key] = meta[key]; + } + if (kind === 'model' && typeof meta.port === 'number' + && Number.isInteger(meta.port) && meta.port >= 1 && meta.port <= 65535) safe.port = meta.port; + const runtime = kind === 'runtime' ? runtimeClaim(safe.arn) : null; + return { ...safe, claimedAccountId: runtime?.claimedAccountId ?? null, + claimedRegion: runtime?.claimedRegion ?? null, identityProvenance: 'telemetry_claim' }; +} diff --git a/web/lib/inference-topology.test.ts b/web/lib/inference-topology.test.ts index 4144904d5..5f828d3be 100644 --- a/web/lib/inference-topology.test.ts +++ b/web/lib/inference-topology.test.ts @@ -5,7 +5,7 @@ import { mapOtelRow } from './trace-source'; import { buildTraceGraph } from './trace-graph'; const runtimeArn = 'arn:aws:bedrock-agentcore:ap-northeast-2:123456789012:runtime/insurance_analysis-AbCd012345'; -function span(attributes: Record = {}, resource: Record = {}, kind = 'Client') { +function span(attributes: Record = {}, resource: Record = {}, kind = 'Client') { return mapOtelRow({ TraceId: 'trace-a', SpanId: 'span-a', ServiceName: 'orchestrator', SpanKind: kind, Timestamp: '2026-11-15T00:00:00Z', Duration: 5_000_000, @@ -16,6 +16,15 @@ function span(attributes: Record = {}, resource: Record { + it('normalizes provider case and does not let an invalid runtime annotation suppress a model', () => { + const graph = buildTraceGraph([span({ + 'gen_ai.system': 'AWS.Bedrock', 'gen_ai.request.model': 'vendor.model', + 'agentcore.runtime.arn': 'invalid-annotation', + })], [], []); + expect(graph.nodes.find(node => node.kind === 'model')?.meta.provider).toBe('aws.bedrock'); + expect(graph.nodes.filter(node => node.kind === 'runtime')).toEqual([]); + }); + it('keeps an exact runtime destination from an isolated client span without claiming inventory ownership', () => { const graph = buildTraceGraph([span({ 'agentcore.runtime.arn': runtimeArn, 'agentcore.runtime.name': 'untrusted-display-name', From 3923f375ccbce4cc74d434285510e78cdf447219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=84=9D=28Junseok=20Oh=29DevOps?= <52226147+Atom-oh@users.noreply.github.com> Date: Sun, 15 Nov 2026 08:00:17 +0000 Subject: [PATCH 3/3] fix: address review feedback (iteration 2/5) --- docs/api-reference.md | 5 +++-- docs/runbooks/agent-sql-reader.md | 11 ++++++----- docs/runbooks/graph-read-contract.md | 4 ++++ docs/runbooks/steampipe-quota-and-staleness.md | 4 ++++ web/components/topology/E2eGraphCanvas.test.tsx | 14 ++++++++++++++ web/components/topology/E2eGraphCanvas.tsx | 17 +++++++++++++++-- 6 files changed, 46 insertions(+), 9 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index f5cef9ea7..a15b073f3 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -565,8 +565,9 @@ vetoes binding). Generated or custom templates, Tempo and other sources keep spa graph mixing them sums both kinds of count. Publication keeps at most 200 nodes and 500 edges (`nodeDrops`/`edgeDrops`). Nodes are selected -from the heaviest edges first, both endpoints together, including runtime/model relationships; -remaining capacity goes to other nodes by span and metric volume. +from the heaviest edges first, both endpoints together. A kept database, queue, workload, +runtime or model relationship keeps its caller; a `hosts` relationship keeps both its runtime +and reporting service. Remaining capacity goes to other nodes by span and metric volume. Service-graph metrics contribute service→service `calls` only. Rows whose Tempo `connection_type` is `database`, `messaging_system` or `virtual_node` name a database, queue or diff --git a/docs/runbooks/agent-sql-reader.md b/docs/runbooks/agent-sql-reader.md index c49769f4d..e893345d5 100644 --- a/docs/runbooks/agent-sql-reader.md +++ b/docs/runbooks/agent-sql-reader.md @@ -382,13 +382,14 @@ raw provider JSON or widen grants. Computed `metadataTruncated` discloses omitte `agent/lambda/test_inventory_view_contract.py` still reads the original `01KYVY9J2E8AMF35WR4J7036A3_agent_sql_reader_role.sql` for its topology assertions. Those baseline text assertions do **not** enforce the current topology projection. -Inspect the current migration and the queue/view cases in -`scripts/v2/workers/test_graph_collection.py`; this clarification does not retarget -tests or claim a fresh PostgreSQL execution. The current collection-view projection and +Inspect the current migration and shared inference fixtures in +`agent/lambda/test_inventory_read_mcp.py::TestTopologySelectionSQL`. +`scripts/v2/workers/test_graph_collection.py` covers the historical queue/collection +projections, not the latest inference view owner. The current collection-view projection and API repeatable-read/timeout/cap behavior are independently covered by `web/lib/graph-read-postgres.test.ts`; the Python reader's actual canonical/raw-ID, neighbourhood and truncation SQL is covered by `TestTopologySelectionSQL` under the -current collection, queue-provenance and read-index migrations. See +current collection, queue-provenance, inference-provenance and read-index migrations. See [local test prerequisites](graph-read-contract.md#reader-postgresql-verification). Its `selection`, `truncation`, `readOutcome` and `snapshotConsistent` envelope is reader-specific and does not assert HTTP transaction parity. `readOutcome` describes @@ -410,7 +411,7 @@ After `make migrate`, spot-check `sql_reader.topology_nodes` where `class='trace retained rows; non-ARN/malformed destinations have null claims and no reporter fallback. `identityProvenance` is always `telemetry_claim`; `accountId`, `region`, `infra_ref` and whole-row copies stay absent for trace queues. SELECT is granted only on the view, never the base table. -`scripts/v2/workers/test_graph_collection.py` exercises this contract on disposable PostgreSQL, +`scripts/v2/workers/test_graph_collection.py` exercises the historical queue projection on disposable PostgreSQL, including grant restoration, reapplication, denied base-table reads and denied view writes. ### Trace inference projection diff --git a/docs/runbooks/graph-read-contract.md b/docs/runbooks/graph-read-contract.md index 0a3e958ee..f8e6bb571 100644 --- a/docs/runbooks/graph-read-contract.md +++ b/docs/runbooks/graph-read-contract.md @@ -11,6 +11,10 @@ query before interpreting an absent service. The standard ClickHouse adapter aggregates the whole bounded window; a deployment still using the older raw-span adapter can lose low-volume calls behind health traffic. +Outbound runtime/model calls also require a `CLIENT` span kind. A table without +`SpanKind` leaves that classification unknown and cannot establish those outgoing +calls; resource-side `hosts` observations may still be present. + Client spans with a valid `agentcore.runtime.arn` create a reported runtime destination even without a server span. A reporting service with the same valid runtime resource ARN can connect through a `hosts` relationship when its account diff --git a/docs/runbooks/steampipe-quota-and-staleness.md b/docs/runbooks/steampipe-quota-and-staleness.md index b38f23a62..3642a4a15 100644 --- a/docs/runbooks/steampipe-quota-and-staleness.md +++ b/docs/runbooks/steampipe-quota-and-staleness.md @@ -10,6 +10,10 @@ failed fallback remains incomplete. The collectors finish enumeration before returning rows, reject repeated tokens/duplicate identities, and fail closed after 100 pages of at most 1,000 buckets. +Legacy `EU` location constraints normalize to `eu-west-1`. Old rows keyed by +`EU` or an unknown region can coexist with the corrected row while a collection +is incomplete; only a complete sweep authorizes the existing prune phases. + Denied security-property reads remain explicit unknown attributes. A completed listing is not proof that bucket security was inspected, and release readiness still rejects unknown attributes. Failed pagination cannot authorize pruning or diff --git a/web/components/topology/E2eGraphCanvas.test.tsx b/web/components/topology/E2eGraphCanvas.test.tsx index 2eb635e66..fbcdd779b 100644 --- a/web/components/topology/E2eGraphCanvas.test.tsx +++ b/web/components/topology/E2eGraphCanvas.test.tsx @@ -59,6 +59,20 @@ async function select(label: string) { } describe('E2eGraphCanvas', () => { + it.each([ + ['runtime', 'aws_verified'], ['model', undefined], ['queue', 'telemetry_claim'], ['service', 'telemetry_claim'], + ])('discloses unverified %s claims on both the card and selected-node details', async (kind, provenance) => { + const claim: E2eNode = { id: 'claim', kind: kind!, label: 'reported target', layer: 'service', + meta: { identityProvenance: provenance, claimedAccountId: '123456789012', claimedRegion: 'ap-northeast-2' } }; + const { container } = render(); + const notice = 'Telemetry claim · AWS identity unverified'; + await waitFor(() => expect(container.querySelector(`[data-e2e-kind="${kind}"]`)?.textContent).toContain(notice)); + const node = vi.mocked(ReactFlow).mock.lastCall?.[0].nodes?.find(n => n.id === 'claim'); + expect(Number(node?.style?.height)).toBeGreaterThan(76); + const detail = await select('reported target'); + expect(detail.getByText(notice)).toBeTruthy(); + }); + it('keeps nested network endpoint fields without an object-valued inference endpoint row', async () => { const nodes = graph.nodes.map(node => node.id === 'p1' ? { ...node, meta: { endpoint: { ip: '10.0.1.1', podName: 'pod-a' } } } : node); diff --git a/web/components/topology/E2eGraphCanvas.tsx b/web/components/topology/E2eGraphCanvas.tsx index 3f06704c1..688e6cb56 100644 --- a/web/components/topology/E2eGraphCanvas.tsx +++ b/web/components/topology/E2eGraphCanvas.tsx @@ -113,6 +113,13 @@ function identityWithheld(node: E2eNode): boolean { return node.meta.correlation === 'ambiguous' || ownershipVeto(node.meta, text(node.meta.region), text(node.meta.vpcId ?? node.meta.vpc_id)); } +function telemetryClaim(node: E2eNode | null | undefined): boolean { + return node?.layer === 'service' && (['runtime', 'model', 'queue'].includes(node.kind) + || node.meta.identityProvenance === 'telemetry_claim'); +} +function nodeSize(node: E2eNode | undefined) { + return telemetryClaim(node) ? { ...NODE_SIZE, height: 112 } : NODE_SIZE; +} export default function E2eGraphCanvas({ graph: inputGraph }: { graph: E2eGraph }) { const { tt, lang } = useI18n(); @@ -186,7 +193,7 @@ export default function E2eGraphCanvas({ graph: inputGraph }: { graph: E2eGraph }, [view, selected, overview, eligible.nodes]); const fitOptions = useMemo(() => ({ ...viewport, padding: 0.18, maxZoom: 1.15 }), [viewport]); const { nodes, edges } = useMemo(() => { - const positions = new Map(layoutFlow(view, { nodeSize: () => NODE_SIZE }).map((p) => [p.id, p])); + const positions = new Map(layoutFlow(view, { nodeSize: id => nodeSize(byId.get(id)) }).map((p) => [p.id, p])); const nodes: Node[] = view.nodes.map((n) => { const flow = flowOf(n); const color = identityWithheld(n) ? '#c08438' @@ -204,10 +211,13 @@ export default function E2eGraphCanvas({ graph: inputGraph }: { graph: E2eGraph {flow ? `${tt(METRIC_LABELS[text(n.meta.metric)] ?? (text(n.meta.metric) || '네트워크 관측'))} · ${metricValue(flow.value, text(n.meta.unit) || text(flow.unit), locale)}` : `${tt(SOURCE_LABELS[n.layer])} · ${identityWithheld(n) ? tt('식별 보류') : n.kind}`} + {telemetryClaim(n) && + {tt('Telemetry claim · AWS identity unverified')} + }
) }, style: { - ...NODE_SIZE, padding: '8px 11px', borderRadius: 10, overflow: 'hidden', + ...nodeSize(n), padding: '8px 11px', borderRadius: 10, overflow: 'hidden', fontSize: 13, color: dark ? '#e3e9ee' : '#16202a', background: dark ? (n.kind === 'connection' ? '#0b3147' : '#18232f') : (n.kind === 'connection' ? '#eaf6fd' : '#ffffff'), border: `${n.id === selected?.id ? 2 : 1}px solid ${color}`, cursor: 'pointer', @@ -373,6 +383,9 @@ export default function E2eGraphCanvas({ graph: inputGraph }: { graph: E2eGraph ) : ( <> {groupedTarget &&

{tt('여러 타깃을 묶은 구성 기록입니다.')}

} + {telemetryClaim(selected) &&

+ {tt('Telemetry claim · AWS identity unverified')} +

}
{selected.layer !== 'network' && typeof selected.meta.id === 'string' && (
ID
{selected.meta.id}