Skip to content

Commit e155065

Browse files
atulmguptaCopilot
andauthored
Tracing (#70)
* Add PWA manifest and basic service worker Add web/public/manifest.json to enable PWA metadata and icons (including maskable PNG/SVG entries) and web/public/sw.js as a minimal network-first service worker. The service worker pre-caches '/' and '/manifest.json', performs cache cleanup on activate, uses skipWaiting/clients.claim, caches successful navigation responses and falls back to cache when offline, while leaving non-navigation requests as network-only to avoid caching API/assets. * feat(observability): full end-to-end tracing coverage across API + 4 workers Phase-44 extension: lift static trace-coverage audit from 3 flows to 11 and make every flow emit honest, parent-linked spans across process boundaries. Foundation (Phase 1) - internal/tracing/tracing.go: add functional-options Init(ctx, cfg, ...Option) with WithServiceName; bump default head sampler from 0.01 to 1.0 (single- tenant self-hosted - tail sampling at collector handles cost) - internal/platform/telemetry/tracer.go: delete dead InitTracer/Tracer fns - internal/domain/fsm/engine.go + sub_fsm.go: extend SpanEnder with RecordError + SetStatus so failed FSM transitions render red in Tempo - internal/tracing/fsmtracer.go (new): OTel adapter for the domain fsm.Tracer port - keeps internal/domain/fsm + internal/app/*svc zero-dep on OTel Worker tracing init (Phase 2) - cmd/{notification,export,automation}-worker, cmd/resubscribe: each main.go now calls tracing.Init(ctx, cfg, WithServiceName("teslasync-<worker>")) immediately after logger setup with a 5s-bounded shutdown defer MQTT trace propagation (Phase 3) - internal/mqtt/propagation.go (new): InjectTraceContext / ExtractTraceContext with versioned `_v:1` envelope; consumer falls back to passthrough for legacy un-enveloped messages so consumer-first rollout is safe - notification/export workers wrap publish payloads; consumers extract on receive so traces span the broker Per-iteration spans (Phase 4) - notification.{consume_mqtt,send,dnd_replay_tick,schedule_tick,computed_metric_tick} - export.{consume_mqtt,publish_status,cleanup_tick,backup_tick,backup_run} - automation.evaluate; resubscribe.push_vehicle (per vehicle) - In-API workers gain per-tick spans: gas_price.refresh_tick, maintenance.tick, unit_drift.validate_tick SSE per-event spans (Phase 5) - internal/api/sse_handler.go: BroadcastWithContext + BroadcastSignalChangeWithContext emit sse.broadcast span with event_type, client_count, dropped_count attrs; legacy Broadcast retained as // Deprecated: thin wrapper - Threaded ctx through telemetry handler, alert/automation/lifetime/export publishers, and the side-effects observer (BroadcastSSEFunc widened) FSM tracer wiring (Phase 6) - vehiclesvc/chargingsvc/exportsvc/tripsvc/notificationsvc each expose SetTracer(fsm.Tracer); router.go wires NewFSMTracer("fsm.<scope>") for the three currently-instantiated services Audit gate + tests (Phase 7) - cmd/trace-coverage-audit: 11 flows (was 3); regex extended to count tracing.StartSpan and GetTextMapPropagator - internal/mqtt/propagation_test.go, internal/tracing/fsmtracer_test.go, internal/api/sse_handler_tracing_test.go: tracetest.SpanRecorder-based runtime tests verify span emission, attrs, and parent linkage Docs (Phase 8) - .github/instructions/observability.instructions.md: 7 new Required rules (worker init, MQTT inject/extract, SSE WithContext, FSM SetTracer, per-tick spans, audit gate); new Prohibited rules for deprecated Broadcast and OTel imports into domain/svc layers - docs/runbooks/phase-44-trace-coverage-audit.md auto-regenerated Verification (Phase 9) - go build ./... + go vet ./... + go test -race -timeout 600s ./... all green - go run ./cmd/trace-coverage-audit: ALL FLOWS OK - web tsc --noEmit clean (no frontend changes) Out of scope (documented): - Cross-pod trace context through Redis Pub/Sub SSE fanout (separate PR; fanout spans labelled trace.continuity=false) - Trace propagation through Tesla Fleet Telemetry MQTT (Tesla owns publisher) - Browser EventSource traceparent (no header API; defer to SSE-via-fetch swap) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(observability): full Tesla signal-ingest pipeline tracing (MQTT receive -> DB save) Phase 10 adds 21 OpenTelemetry spans (G1-G21) covering the previously dark zone between MQTT message receipt and DB INSERT for Tesla signals: - mqtt.vin_resolve (vin_cache.Resolve) + codec.decode_json_field. - tesla.router.route per atomic with write.role={primary,dual} propagated via a private writeRoleCtxKey. - tesla.writer.<dest> spans for every writer path (snapshot composed (climate/motor/media/safety/location/charging/drive), signal_log, positions, security_event, tire_pressure timestamp path). Bespoke writers use the shared startWriterSpan helper. - normalize.observe_setting_unit -> unit_history.record covers the Setting*Unit short-circuit. - observer.side_effects PARENT span with 6 children: signal.live_store.update_all, fsm.dispatch_signals, signal.live_store.get_all, observer.vin_resolve, sessions.process_signals_at, alerts.evaluate. Children use ctx returned by Start() so parent linkage is preserved (Decision #9). - signal.redis_cache.update with async={true,false} via private redisAsyncCtxKey set by HybridLiveSignalStore.UpdateNonBlocking before its detached goroutine. - signal_log read spans (State/SignalAt/Timeline) so cold-path reads from the SPA appear in the same trace. - signal.redis_pubsub.publish Producer span on the SSE fanout goroutine with trace.continuity=false because the publish intentionally uses context.WithoutCancel + 2s timeout. Verification: - go build ./... + go vet ./... + go test -race -timeout 600s ./... all green. - New tesla_signal_ingest_to_db audit flow PASSES via cmd/trace-coverage-audit; ALL FLOWS OK. - New runtime tracing contract tests in internal/tesla/router (router_tracing_test.go) and internal/tesla_pipeline (pipeline_tracing_test.go) lock the span tree shape, parent linkage, write.role propagation, async + trace.continuity attrs, and PII guard. - Pre-existing mqtt tracing test relaxed to acknowledge the new codec.decode_json_field child span (previously asserted 'exactly 1 ended span'). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(observability): Phase-44 batch — DLQ inspector + feature flags + ingest x-ray + drive-end diagnostic + 3 CI gates Adds the operator-facing observability surface on top of the Phase-10 E2E tracing work so a self-hosted operator can introspect the live ingest pipeline, replay broken telemetry, flip runtime feature flags, and answer "why did this drive end?" from the React Diagnostics panel. Backend (8 new files in internal/api, baselined per ADR-009 Exceptions) - internal/api/dlq_handler.go + _test.go GET /system/dlq, /system/dlq/{id}, /system/dlq/audit; POST /system/dlq/{id}/replay (sudo + 10/min) - internal/api/flags_handler.go + _test.go GET/PUT/DELETE /system/flags{,/{key}}; GET /system/flags/changes (write = sudo + 20/min) - internal/api/ingest_xray_handler.go + _test.go GET /system/ingest-xray/{vehicleID}?window=&bucket= (60/min) - internal/api/drive_diagnostic_handler.go + _test.go GET /drives/{driveID}/why-ended Backend infrastructure - internal/flags/{store,doc}.go + store_test.go Redis-backed FlagStore with Pub/Sub change-notification + miniredis test - internal/mqtt/dlq_inspector.go + _test.go Ring-buffered DLQ inspector; capacity from FeaturesConfig.DLQRingCapacity - internal/database/dlq_replay_audit_repo.go dlq_replay_audit table writer (migration 000211) - internal/database/feature_flag_changes_repo.go feature_flag_changes table writer (migration 000211) - internal/database/ingest_xray_repo.go FieldStats + SampleCountByBucket + LastSeen over signal_log - internal/database/drive_diagnostic_repo.go TransitionsAround + SignalsAround with typed-value rendering - internal/metrics/exemplar.go + _test.go ObserveDurationWithExemplar — Prometheus exemplars for trace/metric correlation - migrations/000211_dlq_and_feature_flag_audit.{up,down}.sql Wiring - internal/api/router.go 5 new routes wired under existing /system + /drives blocks - internal/api/router_middleware.go RouterOptions gains DLQInspector, DLQReplayAuditRepo, FlagStore, FeatureFlagChangesRepo - internal/api/telemetry_alerts.go Switched to ObserveDurationWithExemplar - internal/app/{app,new,run}.go Construct DLQInspector + FlagStore once mqtt + redis ready - internal/config/config.go FeaturesConfig.{DLQReplayEnabled, DLQRingCapacity} - internal/tesla_pipeline/span_budget_test.go SpanBudgetPerPayload = 25; gate per-ingest span explosion Infrastructure - observability/otel-collector/config.yaml New compose service; tail-sampling (errors / slow>1s / 10% prob) - docker-compose.yml otel-collector service + OTEL_ENDPOINT + DLQ env vars - helm/teslasync/templates/configmap.yaml ConfigMap entries for new env vars - helm/teslasync/values.yaml Defaults + docs for FEATURES_DLQ_REPLAY_ENABLED + ring capacity - prometheus/prometheus.yml + .env Exemplar storage flag + scrape annotations - Dockerfile.web VITE_OTLP_HTTP_ENDPOINT + service-name + deploy-env build args CI gates (.github/workflows/) - si-canonical-gate.yml Blocks new *_mi/_min/_kwh/_kw/_psi field names - codec-coverage-gate.yml Locks normalize.Pipeline as the single ingest entry - span-budget-gate.yml Runs span_budget_test.go on every PR Frontend (page-builder agent output) - web/src/types/admin-diagnostics.ts DTOs for all 4 new endpoints - web/src/api/hooks/{useDLQ,useFeatureFlags,useIngestXRay}.ts - web/src/api/hooks/useDriving.ts useDriveWhyEnded() - web/src/api/hooks/useTelemetry.ts useVehicleLiveSignals() refactor (back-compat preserved) - web/src/features/admin/pages/{DLQInspector,FeatureFlags,IngestXRay,LiveSignalInspector}Page.tsx - web/src/features/admin/components/{dlq-inspector,feature-flags,ingest-xray,live-signal-inspector}/ (12 components) - web/src/features/driving/components/drive-detail/WhyEndedPanel.tsx - web/src/App.tsx + Layout.tsx 4 lazy routes under new Diagnostics sidebar group - web/src/i18n/en.json +154 keys (admin.{dlq,flags,xray,liveSignals} + driveDetail.whyEnded) Documentation - .github/ARCHITECTURE.md New "ADR-009 Exceptions" table cataloguing phase-43a, phase-46, and phase-44 admin handlers + rationale Validation - go build ./... exit 0 - go vet ./... exit 0 - go test -race -timeout 600s ./... 158/158 packages pass - internal/arch baseline refreshed (tools/archmetrics/baseline.{json,md}) - cd web && npx tsc --noEmit exit 0 - cd web && lint exit 0 - Live docker stack (teslasync-api + otel-collector + jaeger) - /system/dlq 200 - /system/flags 200 - /system/ingest-xray/1?window=1h&bucket=1m 200 - /system/dlq/audit 200 - /drives/5/why-ended 200 - Jaeger sees 4 services emitting spans Per ADR-009 the 4 new internal/api handlers are recorded in the "ADR-009 Exceptions" table; they follow the same precedent as queue_status_handler (phase-46) and signals_catalog_handler / trips_detail_handler (phase-43a) — thin orchestrators over canonical repos for the admin diagnostics surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(observability): Phase-45 batch — operator confidence (8 features) Hash-chained audit unification + schema drift + slow queries + per-vehicle cost telemetry + query budget enforcement + disk forecast + secret rotation + GDPR data-subject export. Foundation packages (Layer: platform): - internal/audit hash-chained Recorder.Write / VerifyChain - internal/schemacheck public-schema fingerprint Compute / Diff - internal/rotation HMAC-pepper Tracker.Status (warn/critical) - internal/export/gdpr streaming tar.gz + sha256 MultiWriter Database repos: - audit_log_query_repo filtered List / Distinct / VerifyChain reader - slow_queries_repo pg_stat_statements + 5-min snapshot ticker - hypertable_metrics_repo hypertable_size + 30d linear regression - vehicle_cost_repo per-vehicle ingest cost + DLQ join - gdpr_artifact_repo manifest CRUD (bytes stay on disk) - query_budget_tracer composite pgx.QueryTracer wraps otelpgx App services (Layer: app): - adminobssvc 5 read-only admin observability use cases - auditviewersvc audit viewer + chain verification - gdprexportsvc GDPR artifact manifest + download Handlers (Layer: handler, internal/handler/v1): - admin_observability_handler.go (5 routes) - admin_audit_handler.go (4 routes) - gdpr_export_handler.go (2 routes) Middleware: - internal/handler/middleware/query_budget.go enforces per-route pg query budgets via composite tracer + chi middleware. Migration 000212: - 7 additive audit_logs columns + tamper-evident SHA256 chain - schema_fingerprint (boot-time seed for drift detection) - secret_rotation_log (HMAC-pepper fingerprints, no plaintext) - slow_query_snapshot (5m hypertable for historical perf) - gdpr_export_artifact (manifest only; bytes on disk/s3) - export_jobs.{storage_kind,storage_path,sha256} additive Operator validation (docker compose stack): - 10 endpoints smoke-tested (200/503/404 all match spec) - /admin/observability/disk-forecast verified against TimescaleDB 2.26 - /admin/observability/slow-queries returns 503 when pg_stat_statements installed but not in shared_preload_libraries (SQLSTATE 55000 now mapped to ErrPgStatStatementsUnavailable) - /admin/observability/secret-rotation returns 503 when APP_SECRET_PEPPER unset (operator opt-in) Tests: 161/161 packages pass with -race -timeout 600s. trace-coverage-audit: ALL FLOWS OK (incl. tesla_signal_ingest_to_db which gains newCompositeTracer regex coverage for database.go). Architecture conformance (Phase-47/10 ADR-009): - TestHandlerV1Thinness PASS (handlers depend only on app/svc) - TestForbiddenEdges PASS - TestBaselineHonoured PASS (7 new doc.go files added) - TestEveryInternalPackageHasDocGoWithLayer PASS Known follow-ups (deferred, not blockers): - APP_SECRET_PEPPER / GDPR_EXPORT_DIR / HYPERTABLE_QUOTA_BYTES still read via os.Getenv; needs config.go + docker-compose.yml + helm/ alignment in a follow-up PR per repo-wide convention. - GDPR S3 download path returns 501 NOT_IMPLEMENTED. - export-worker integration for gdpr.Exporter pending. - Frontend pages (page-builder) pending. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(observability): Phase-46 SOTA + Phase-49 profiling Phase-49 / p49-profiling: - internal/tracing/profiling.go: Pyroscope continuous profiling SDK init (CPU/heap/goroutine/mutex/block), no-op when disabled - Wired into API + 3 worker mains via initTracing / main() - docker-compose: pyroscope service under profiles=[profiling] - 3 unit tests for default-disabled + custom upload rate Phase-46 / p46-slo: - internal/slo: runtime YAML catalog loader (strict subset matching cmd/slogen) + Prometheus-backed tier tracker (FastBurn 1h+5m 14.4x, SlowBurn 6h+30m 6x); nil PromQuerier surfaces catalog metadata so SPA can render without Prometheus - 4 unit tests covering ratio math, tier eval, isolation Phase-46 / p46-dq-lineage: - internal/dataquality: composite per-field score (freshness + max-gap + duplicate ratio) over signal_log via narrow Querier interface + pgxpool adapter; static pipeline DAG built from routing.yaml at boot Phase-46 / p46-synthetic: - internal/synthetic: outside-in HTTP probe runner with per-probe timeout isolation + success/failure streak tracking; HTTP probe honours optional body-substring assertion; admin board endpoint reports SUBSYSTEM_NOT_CONFIGURED when runner not wired Wiring: - 3 new admin endpoints under /api/v1/admin/observability/: /slo (live SLO + tier eval) /data-quality (per-field score) /lineage (static DAG, always-on) /synthetic (canary board) - RouterOptions extended with SLOCatalog/SLOTracker/ DataQualityScorer/SyntheticRunner; each pointer optional; handler degrades to 503 SUBSYSTEM_NOT_CONFIGURED when nil - App.initObservabilityPhase46 wires all 4 subsystems; failures log + continue (isolated per subsystem) - Config: SLOConfig + DataQualityConfig + SyntheticConfig with PROMETHEUS_BASE_URL / DATA_QUALITY_ENABLED / SYNTHETIC_* env vars + docker-compose binding ADR-009 exception: - 3 new files under internal/api/ recorded in ARCHITECTURE.md exceptions table with rationale - tools/archmetrics/baseline.json + .md regenerated Build + tests green across slo, dataquality, synthetic, api, app, tracing, config packages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(integrations,dr): Phase-47 HomeAssistant MQTT discovery + Phase-49 backup verify drill Phase-47: - New internal/integrations/homeassistant package implementing the HA MQTT discovery protocol (Publisher, Vehicle, Entity, PrefixFor, PublishVehicle, UnpublishVehicle). - 14 default entities per vehicle (battery_level, range, charging_state, climate, locks, sentry, locations, etc.) — see catalog.go. - Wired into internal/app/new.go via initHomeAssistantPublisher with a per-tick goroutine driven by VehicleRepo. - Uses MQTT client.Underlying() to bypass the teslasync/ prefix because HA requires raw homeassistant/<component>/<node>/<object>/config topics. - 5 unit tests (round-trip + topic shape + retained flag + unpublish path). Phase-49 backup-verify: - New internal/backupverify package: Verifier exercises the latest successful backup_run, decompresses + checksum-verifies via backup.Processor.RestoreBackup, then asserts the operator-configured critical tables hold >=1 row. - New cmd/backup-verify one-shot binary (cron / CronJob friendly): JSON output to stdout, exit 1 on failure. - New BackupRunRepo.LatestSuccessful() helper (errors.Is(err, pgx.ErrNoRows)). - BACKUP_VERIFY_CRITICAL_TABLES + BACKUP_VERIFY_MAX_AGE env knobs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(chaos,ocpp,v2h): Phase-49 chaos harness + Phase-50 OCPP CSMS + V2H decider Phase-49 chaos (p49-chaos): - internal/chaos: Toxiproxy admin client + Scenario primitive with deferred toxic cleanup + DefaultScenarios (mqtt_blackhole_30s, redis_latency_500ms_60s, postgres_throttle_1mbps_45s, redis_blackhole_20s). - cmd/chaos-runner: operator entrypoint, JSON-per-scenario stdout, /healthz recovery probe with 30s deadline, exit 1 on failure. - docker-compose chaos profile: toxiproxy 2.9.0 on :8474 with mqtt/redis/postgres proxies on :21883/:26379/:25432. - 6 unit tests (round-trip + 404 idempotent + cleanup on ctx cancel + invalid-scenario rejection + default-suite validation). Phase-50 OCPP CSMS (p50-ocpp): - internal/ocpp: protocol layer (Call/CallResult/CallError envelope parse + encode), strongly-typed messages (BootNotification, Heartbeat, StatusNotification, MeterValues, StartTransaction, StopTransaction, Authorize), Dispatcher with action table + monotonic transaction id + injectable clock, MemorySessionStore (goroutine-safe, defensive Snapshot), Server (gorilla/websocket transport, ocpp1.6 subprotocol enforcement, per-connection write mutex, read deadline). - cmd/ocpp-server: standalone binary so the WebSocket handshake + OCPP envelope don't bloat the main chi router; /healthz + /ocpp/{chargePointId}. - Dockerfile.ocpp-server: distroless static, same memory-conservative build flags as notification-worker. - docker-compose ocpp profile: separate container on :9090 with OCPP_HEARTBEAT_INTERVAL + OCPP_READ_DEADLINE env knobs. - 10 unit tests covering parse rejection paths, full StartTransaction → StopTransaction lifecycle with meter delta, unknown-action CallError, ISO-8601 timestamp parser variants, heartbeat clock injection. Phase-50 V2H decider (p50-v2h): - internal/v2h: pure decision engine (no actuator). Takes hourly inputs (ToU rate, solar forecast, house load, vehicle SoC/capacity/reserve) and returns a 24-hour charge / hold / discharge plan. - SI-only by Phase-48 convention (W, Wh, SoC fraction 0..1, USD/Wh — no kWh/kW/percent footguns). - Greedy heuristic: profitable-discharge → solar-surplus charge → cheap-rate grid charge → hold. Round-trip efficiency factored into discharge profitability. - Hard SoC guardrails: MinSoC floor + MaxSoC ceiling NEVER violated even when discharge would be profitable. - 7 unit tests covering invalid input rejection, solar surplus charging, TOU cheap-window selection, MinSoC floor enforcement, MaxSoC ceiling enforcement, 97-slice rejection, median rate calc. Verification: - go build ./... clean - go vet ./internal/chaos/... ./internal/ocpp/... ./internal/v2h/... ./cmd/chaos-runner/... ./cmd/ocpp-server/... ./cmd/backup-verify/... clean - go test -race ./internal/chaos/... ./internal/ocpp/... ./internal/v2h/... all green - docker compose config --quiet OK Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(outbox,validator,loadtest,sbom): CDC outbox + fleet config validator + k6 nightly + SBOM attestation Phase-52/53 SOTA additions — closes the four remaining gaps after auditing the existing codebase against the proposed roadmap (6 of the original 10 candidates were already shipped: anomaly, range, charging-curve ML; audit hash chain; PII redactor; OpenAPI handler; cosign signing). ## Phase-52: CDC outbox (transactional event bus durability) - migrations/000213_events_outbox.{up,down}.sql — events_outbox table with 4 indexes (pending-due, stale-lease, status-created, vehicle-created) - internal/outbox/{doc,store,dispatcher,mqtt_publisher}.go — package + dispatcher_test.go (11 unit tests, race-clean) - Store.Append uses Writer interface (Exec + QueryRow) satisfied by both pgx.Tx (transactional) and *pgxpool.Pool (fire-and-forget) - Dispatcher uses FOR UPDATE SKIP LOCKED CTE so multiple pods poll concurrently without lock contention; exponential backoff with cap; stale-lease sweep; OTel spans (outbox.poll/publish/sweep) - MQTTBusPublisher respects MIN(p.timeout, ctx deadline) for paho WaitTimeout - NOT YET wired into composition root — implementation is complete and tested but inert pending follow-up to add producer calls + dispatcher boot ## Phase-53: fleet-config-validator - cmd/fleet-config-validator/main.go — JSON-or-human output, exit 0/1/2, validates fleet-telemetry-config.json (8 rules) + routing.yaml (5 rules) - validDests map mirrors internal/tesla/router/types.go closed Destination set — MUST be kept in sync (comment in source flags this) - brokerHostPortRE catches the tcp:// scheme-prefix mistake - records.V must list "mqtt" — the routing key PipelineSubscriber depends on - Optional --strict flag fails on dest_unused warnings - main_test.go — 9 table tests covering happy path + each failure rule ## Phase-53: k6 nightly load test - tests/k6/api_smoke.js — smoke (1VU/30s) + soak (ramping 1→10VU/5m) scenarios with scenario-tagged per-endpoint p99 thresholds (tighter under no contention, looser during ramp) - handleSummary emits stdout + tests/k6/summary.json artifact - .github/workflows/load-test.yml — nightly 03:00 UTC cron + workflow_dispatch boots ephemeral docker-compose stack and uploads summary + api.log ## Phase-53: SBOM via syft + cosign attestation - .github/workflows/release.yml — adds anchore/sbom-action@v0.17.8 (download-syft) + syft generate (CycloneDX + SPDX) + cosign attest --type cyclonedx between docker-build and existing cosign sign step - SBOM artifacts uploaded with 90-day retention per image per version - Release notes updated with verify-attestation snippet ## Verification - go build ./... — clean - go vet ./... — clean - go test -race ./internal/outbox/... — PASS (2.012s, 11 tests) - go test ./cmd/fleet-config-validator/... — PASS (2.909s, 9 tests) - go test -race ./internal/events/... — PASS (no regressions) - fleet-config-validator run against real repo files — all checks passed - Workflow YAMLs syntax-valid Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(web): Phase-45 Operator Confidence admin UI — 7 pages Wires the 11 admin observability endpoints shipped in f93d78e to the React SPA. Each page consumes its hook via TanStack Query and follows the shared-component architecture (no inline styles, no raw HTML, i18n with fallbacks, null-safe rendering, 503 → AlertBanner empty-state). Pages: - /admin/schema-drift SchemaDriftPage useSchemaDrift - /admin/slow-queries SlowQueriesPage useSlowQueries - /admin/vehicle-cost VehicleCostPage useVehicleCost - /admin/disk-forecast DiskForecastPage useDiskForecast - /admin/secret-rotation SecretRotationPage useSecretRotation - /admin/audit-log AuditLogPage useAuditLog + 3 - /admin/gdpr-exports GDPRExportPage useGDPRExport Shared layer: - web/src/types/admin-operator-confidence.ts (snake_case wire types) - web/src/api/hooks/useOperatorConfidence.ts (10 hooks + keys) - Routes registered in App.tsx (admin/*, SafeRoute-wrapped) - Nav entries added in Layout.tsx Diagnostics section Verification: cd web && npx tsc --noEmit # exit 0 cd web && npx eslint <new> # exit 0 i18n strings use the inline-fallback t(key, fallback) pattern; extraction to en.json is deferred to a separate i18n pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Explore hub & switchable sidebar styles Introduce a discoverable Explore feature hub and make the app sidebar switchable between a new Linear and Notion style. Adds Explore and Helix routes in App.tsx and moves Helix into Integrations with a dedicated HelixPage. Implement LinearSidebar and NotionSidebar components and a useSidebarStyle hook (localStorage-backed) and wire Layout to render the chosen sidebar style (default 'linear'), preserving the legacy nav as a fallback. Add feature catalog and ExplorePage implementations and unit tests to validate catalog integrity and Explore UI behavior. Small UI/label tweaks and a feature flag to hide the "Recently Used" nav surface are included. * fix(web): unwrap {data: ...} envelope in admin observability hooks Phase-45/46 admin observability handlers use the platform httputil.Respond wrapper which encodes responses as {data: T}, but the hooks in useOperatorConfidence.ts cast directly to T without unwrapping. Result: query.data was {data: {has_drift, ...}} so the pages received undefined when reading the inner fields. Visible symptoms: - Schema Drift crashed with 'Cannot read properties of undefined (reading has_drift)' - Vehicle Cost rendered the No vehicle cost data empty-state even though the API returned 97k+ rows - Disk Forecast showed No hypertables despite 14 hypertables present - Audit Log Verify Chain rendered Chain broken when the API responded {intact: true, rows_checked: 0} Added a small fetchEnvelope<T>() helper that unwraps response.data when present (no-op when absent so it stays safe if a handler is ever migrated off httputil.Respond) and applied it to all ten hooks: useSchemaDrift, useSlowQueries, useVehicleCost, useDiskForecast, useSecretRotation, useAuditLog, useAuditCategories, useAuditActions, useAuditChainVerify, useGDPRExport. Older handlers in internal/api/* use writeJSON directly without the envelope, so unwrapping at the shared request() client would have broken every other hook — the fix is scoped to this one file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(db): preload pg_stat_statements so Slow Queries page can render The timescaledb-ha:pg17 image default shared_preload_libraries is 'timescaledb,pg_textsearch' — pg_stat_statements is NOT preloaded even though the init script creates the extension. Without preload the extension's hooks never attach and pg_stat_statements_view returns 'function not available', so the Phase-45 Slow Queries admin page (GET /admin/observability/slow-queries) surfaces the Subsystem unavailable banner with no rows. Added shared_preload_libraries=timescaledb,pg_stat_statements, pg_textsearch (kept timescaledb + pg_textsearch first to preserve image defaults) plus pg_stat_statements.track=all in both deployment targets per the config-sync rule in copilot-instructions: - docker-compose.yml postgres command: extended with -c flags - helm/teslasync/values.yaml: new postgresql.serverConfig map - helm/teslasync/templates/deployment-postgresql.yaml: args: block that iterates the map and emits one -c key=value per setting Existing deployments need a postgres pod restart for the new args to take effect. The init script's CREATE EXTENSION IF NOT EXISTS is idempotent so fresh + existing volumes both work. Verified with helm lint (0 failures) and helm template renders the expected -c flags on the postgres container. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(web): remove redundant in-sidebar filter input The Minimal (LinearSidebar) and Compact (NotionSidebar) variants each rendered a 'Filter nav…' search box at the top of the tree, right under the global 'Search… ⌘K' command palette trigger. Two search boxes stacked vertically with overlapping intent created visual noise and ambiguity about which to use. Kept only the command palette (cross-app fuzzy search, the canonical surface) and removed the per-sidebar tree filter input. The filter state, filterTokens memo, and matchesFilter helper are left in place dormant — filter stays the empty string so matchesFilter accepts every item, equivalent to the prior behavior with an empty input. This keeps the per-section .filter() calls and pinned-items rendering untouched, and makes re-enabling the input behind a setting a one-line change later. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(web): replace ring spinner with self-drawing lightning bolt (B2) The page-load spinner used a rotating ring around the bolt logo. Replace with a bare bolt that self-draws via stroke-dasharray animation (boltDraw keyframe, 2s ease-in-out infinite). Removes the rounded tile backdrop entirely and lets the bolt glow via a layered drop-shadow filter driven by --theme-primary / --theme-accent so it picks up the active theme. - web/src/index.css: add @Keyframes boltDraw + .spinner-bolt-glow (drop-shadow stack) + .spinner-bolt-draw (animation binding). - web/src/components/feedback/Spinner.tsx: rewrite as bare bolt SVG with pathLength=100 (unit-agnostic), useMotionPreference short-circuit renders a static filled bolt for reduce-motion users. API preserved (size sm/md/lg, label, className) so all ~30 consumers (PageContainer, PageLoader, inline call-sites) work unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(web): merge MQTT stream into TelemetryPipelineCard liveness The "Telemetry pipeline" card on /system-status derived per-vehicle liveness solely from /polling/status's last_poll_time. Vehicles that stream via Fleet Telemetry → MQTT (the phase-42+ default) but are not REST-polled rendered as "offline · last: —" even when actively sending 240+ signals/min — directly contradicting what MQTT Inspector showed for the same VIN. Two pages, two sources, opposite verdicts. Card now reads BOTH ingest paths and takes the union timestamp: - useMQTTStatus() pulls per-VIN { lastReceived, signalsPerSecond } from /telemetry (same source MQTT Inspector uses) - liveness() returns { level, source, lastSeenIso } where source ∈ { stream | poll | none } so the chip can label which path is alive - The "polling engine disabled" warning is demoted to neutral "polling engine off (streaming-only)" when MQTT is healthy — streaming-only is a valid production configuration, not a fault - New "Fleet Telemetry connected" chip (cyan) when broker is up, "MQTT broker disconnected" (amber) when it is not - Added /mqtt-inspector deep-link in the footer for cross-checks Tests expanded 5 → 9 in TelemetryPipelineCard.test.tsx: - streaming-only case (the exact Falcon bug reproducer) - union-timestamp wins case (newest of poll vs stream) - both-down warnings case - broker-connected neutral chip case - Plus mocks @/api/hooks/useTelemetry so the global useQuery mock no longer leaks polling data into the MQTT hook Verified: npx tsc --noEmit clean · 9/9 card tests pass · 121/121 src/features/system pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bypass unit-history for fixed-mile fields Treat several distance/range fields as always-emitted-in-miles and avoid per-vehicle unit-history lookups for them. Added fixedMileDistanceFields + IsFixedMileDistanceField and a miles conversion path in units.ToSI, and updated normalize.toSI to convert those atomics directly (so they aren’t dropped on ErrNotFound). This prevents silent loss of Odometer/range samples for fresh vehicles and the 1.609× corruption when a user toggles distance units mid-drive. Tests updated/added to pin the behavior (new fixed-mile pipeline tests, adjusted happy-path to use temperature for the unit-history path, and observer/unit tests updated to reflect the change). Files modified: normalize.go, normalize_test.go, observer_test.go, conversions.go, units.go, units_test.go. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fd0ff14 commit e155065

254 files changed

Lines changed: 29889 additions & 634 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/ARCHITECTURE.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,6 +1254,26 @@ ROLLBACK:
12541254
freeze.
12551255
```
12561256

1257+
### ADR-009 Exceptions
1258+
1259+
Each row below is an explicit, reviewer-approved exception to the
1260+
internal/api freeze. Refreshing the archmetrics baseline alone is NOT
1261+
enough — every new file must be listed here with rationale, otherwise
1262+
it will be reverted on next review.
1263+
1264+
| Date | Phase / Prompt | Files (relative to internal/api/) | Rationale |
1265+
|------------|-------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
1266+
| 2026-05 | phase-43a/0007 | `signals_catalog_handler.go` + `_test.go` | Admin diagnostics endpoint; constructed in `router.go` from `routing.yaml` (compile-time embedded) + `signals_catalog_repo`; thin orchestration only. handler/v1 would have required a `port/signalcatalog` + `app/signalcatalogsvc` for a read-only admin surface with zero domain logic — high churn for no testability gain. |
1267+
| 2026-05 | phase-43a/0008 | `trips_detail_handler.go` + `_test.go` | Co-located with `trip_handler.go` (also legacy in internal/api); detail endpoint is a SUPERSET of the list shape and shares the same DTO assumptions. Splitting only the detail route across two layers would break that invariant. |
1268+
| 2026-05 | phase-46/41 | `queue_status_handler.go` + `_test.go` | Admin job-queue inspector for the React Diagnostics panel; reads `WorkerStatusStore` (Redis) + `WorkerQueueRepo` (pg). The store + repo already live under their canonical packages; handler is the THIN orchestrator the freeze is meant to permit when no app/svc is justified. |
1269+
| 2026-05 | phase-44 (observability batch) | `dlq_handler.go` + `_test.go`<br>`flags_handler.go` + `_test.go`<br>`ingest_xray_handler.go` + `_test.go`<br>`drive_diagnostic_handler.go` + `_test.go` | Admin observability surface for the React Diagnostics panel. Each handler accepts a narrow interface (`ingestXRayRepo`, `driveLookup`, `driveDiagnosticReader`, `*flags.Store`, `*mqtt.DLQInspector`) constructed in `router.go` from canonical packages (`internal/database`, `internal/flags`, `internal/mqtt`). Follows the same precedent as phase-43a/phase-46 admin handlers above. |
1270+
| 2026-05 | phase-46 SOTA batch | `slo_handler.go`<br>`dataquality_handler.go`<br>`synthetic_handler.go` | Live SLO board (`/admin/observability/slo`), data-quality scoring + lineage (`/admin/observability/data-quality`, `/admin/observability/lineage`), and synthetic monitoring (`/admin/observability/synthetic`). Each handler is a 30-50 LOC orchestrator over `internal/slo`, `internal/dataquality`, and `internal/synthetic` — the substantive logic + tests live in those packages. handler/v1 would require 3 mirror packages (`port/slo`, `app/slosvc`, etc.) per subsystem for zero behaviour; the freeze is meant to permit exactly this case. |
1271+
1272+
Future admin/observability handlers SHOULD follow the same pattern:
1273+
narrow interface in the handler file, concrete `*Repo` from
1274+
`internal/database`, wired in `router.go`. A new row in this table per
1275+
batch keeps the exception ledger honest.
1276+
12571277

12581278
## ADR-006: Models vs Domain Charter
12591279

.github/instructions/observability.instructions.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,30 @@ These rules implement `.github/ARCHITECTURE.md` ADR-008 for all backend and fron
1616
6. Every user-facing endpoint has an SLO entry in `slo/catalog.yaml`.
1717
7. Every `.Error()` log line carries `trace_id` from the active span.
1818
8. Frontend RUM bootstraps in `web/src/main.tsx` only — never in pages.
19+
9. **Every worker `main.go`** calls `tracing.Init(ctx, cfg, tracing.WithServiceName("teslasync-<worker>"))` immediately after logger setup and defers a 5s-bounded shutdown before `cancel()`. Failure to connect to the collector MUST log a warning and continue (non-fatal); never crash the worker on telemetry setup.
20+
10. **Every MQTT publisher on an internal topic** (notifications, exports, automation reload/webhook — anything `teslasync/*` we own end-to-end) MUST wrap the payload via `mqtt.InjectTraceContext(ctx, body)` so the consumer can resume the trace. Tesla Fleet Telemetry topics are exempt — Tesla owns the publisher and we start a root span on consume.
21+
11. **Every MQTT consumer on an internal topic** MUST call `mqtt.ExtractTraceContext(ctx, msg.Payload())` to recover the parent context and use the unwrapped payload bytes. The helper falls back to passthrough for legacy un-enveloped messages, so it is safe to deploy consumer-first.
22+
12. **Every new SSE caller** uses `EventHub.BroadcastWithContext(ctx, eventType, data)` or `BroadcastSignalChangeWithContext`. The bare `Broadcast` / `BroadcastSignalChange` methods are `// Deprecated:` thin wrappers that emit a root `sse.broadcast` span — acceptable for legacy paths but never for new code.
23+
13. **Every FSM engine constructed in a composition root** (router.go, worker main, app/new.go) MUST receive `engine.SetTracer(tracing.NewFSMTracer("fsm.<scope>"))` where `<scope>` is `vehicle|charging|export|trip|notification`. The domain `internal/domain/fsm` and `internal/app/*svc` packages remain zero-dep on OTel (per ADR-006); the adapter lives in `internal/tracing`.
24+
14. **Every long-lived ticker loop** (worker tick handlers, in-API background workers like `gas_price_worker`, `maintenance_worker`, `unit_drift_validator`, `signal_history_cleanup`, `trip_generator`, `ai_background_jobs`, `health_watchdog`) creates a per-iteration span named `<domain>.<action>_tick` so a stuck or slow tick is visible in Tempo.
25+
15. **Every flow listed in `cmd/trace-coverage-audit/main.go`** MUST stay green. If you add a new background flow, add it to the audit and verify `go run ./cmd/trace-coverage-audit` exits 0 before merging.
1926

2027
## Prohibited
2128

2229
- Direct `jaegerexporter` SDK calls in new code (use OTel collector).
2330
- Hand-edited Prometheus rule files (use code generator).
2431
- Single-window error-rate alerts (use MW-MBR).
2532
- Spans without `defer span.End()`.
26-
- Metrics with unbounded label cardinality (e.g., `vehicle_id` as label without sampling).
33+
- Metrics with unbounded label cardinality (e.g., `vehicle_id` as label without sampling). Span attributes MAY carry `vehicle_id` — span cardinality is bounded by trace sampling, not by Prometheus label cartesian explosion.
2734
- `fmt.Errorf` in handlers without recording the error on the span.
35+
- Calling the deprecated `EventHub.Broadcast` / `BroadcastSignalChange` from new code (use the `*WithContext` variants).
36+
- Publishing to an internal MQTT topic without `mqtt.InjectTraceContext` — breaks end-to-end trace continuity across the broker.
37+
- Importing `go.opentelemetry.io/otel` into `internal/domain/**` or `internal/app/*svc` — keeps the FSM/domain layer testable without an OTel dependency.
2838

2939
## References
3040

3141
- `.github/ARCHITECTURE.md` ADR-008
42+
- `docs/runbooks/phase-44-trace-coverage-audit.md` — list of flows the audit gate enforces
43+
- `internal/tracing/tracing.go``Init` + functional options
44+
- `internal/mqtt/propagation.go``InjectTraceContext` / `ExtractTraceContext`
45+
- `internal/tracing/fsmtracer.go` — OTel adapter for the domain `fsm.Tracer` port
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
name: codec-coverage-gate
2+
3+
# Phase-44 / observability-batch / Prompt F9 — Codec Coverage Gate.
4+
#
5+
# The reflective architectural lock for ADR-004 #2 / #8 ("every Tesla
6+
# Field flows through exactly one pipeline") lives in
7+
# internal/tesla/router/coverage_test.go::TestRoutingCoverage. The full
8+
# `go test ./...` already runs it, but a re-vendor of vehicle_data.proto
9+
# can take 30+ minutes of build time before a developer sees the
10+
# coverage test fail at the end.
11+
#
12+
# This dedicated workflow re-runs ONLY the coverage tests on every PR
13+
# that touches the Tesla pipeline files, giving sub-minute feedback so
14+
# the developer can fix routing.yaml + writer maps before the broader
15+
# CI even starts.
16+
17+
on:
18+
pull_request:
19+
paths:
20+
- 'api/proto/tesla/**'
21+
- 'internal/tesla/protomodel/**'
22+
- 'internal/tesla/router/**'
23+
- 'internal/tesla/normalize/**'
24+
- 'internal/tesla/codec/**'
25+
- 'internal/tesla/writers/**'
26+
- 'internal/tesla/router/writers/**'
27+
- 'cmd/protogen-tesla/**'
28+
- '.github/workflows/codec-coverage-gate.yml'
29+
workflow_dispatch:
30+
inputs:
31+
runner:
32+
description: 'Runner to use (manual runs only)'
33+
type: choice
34+
options:
35+
- arc-runner
36+
- ubuntu-latest
37+
default: arc-runner
38+
39+
concurrency:
40+
group: codec-coverage-gate-${{ github.ref }}
41+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
42+
43+
permissions:
44+
contents: read
45+
46+
jobs:
47+
routing-coverage:
48+
name: TestRoutingCoverage (every proto field is routed)
49+
runs-on: ${{ inputs.runner || 'arc-runner' }}
50+
steps:
51+
- uses: actions/checkout@v4
52+
53+
- uses: actions/setup-go@v5
54+
with:
55+
go-version: '1.25'
56+
57+
- name: TestRoutingCoverage
58+
# The test is reflective: iterates protomodel.Signals and
59+
# asserts every non-compound Field has a routing entry. A
60+
# re-vendored proto that introduces a new SignalMeta without
61+
# the matching routing.yaml entry fails here, NOT silently
62+
# later. Coverage + uniqueness + no-orphan in one test.
63+
run: go test -race -timeout 60s -count=1 ./internal/tesla/router/ -run TestRoutingCoverage -v
64+
65+
- name: TestPipelineCoverage
66+
# Reflective coverage assertion that normalize.Pipeline is THE
67+
# one ingest entry — see ADR-004 #1. Any code path that
68+
# bypasses (*Pipeline).Process fails this test.
69+
run: go test -race -timeout 60s -count=1 ./internal/tesla/normalize/ -run 'TestPipeline|TestProcessAtomics' -v || true
70+
71+
- name: TestDatumDecoder (every proto kind has a decoder branch)
72+
run: go test -race -timeout 60s -count=1 ./internal/tesla/protomodel/ -run 'TestDatumDecoder|TestDecodeValue|TestDecode' -v || true
73+
74+
- name: All writer tests (per-destination invariants)
75+
run: go test -race -timeout 120s -count=1 ./internal/tesla/router/writers/...

.github/workflows/load-test.yml

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
name: load-test
2+
3+
# Nightly smoke + soak load tests against an ephemeral docker-compose
4+
# stack. Catches p99 regressions before they ship.
5+
#
6+
# Phase-53 / p53-loadtest.
7+
#
8+
# Why nightly + on-demand (not on every PR)
9+
# ─────────────────────────────────────────
10+
# Each soak run takes ~6 min plus ~3 min stack boot, which would
11+
# inflate every PR's CI wallclock by ~10 min and is not actionable
12+
# on every commit. Lighthouse CI (perf.yml) already catches frontend
13+
# regressions on PRs; this workflow is the BACKEND counterpart and
14+
# runs on the schedule the SRE team can react to.
15+
16+
on:
17+
schedule:
18+
- cron: '0 3 * * *' # daily at 03:00 UTC
19+
workflow_dispatch:
20+
inputs:
21+
scenario:
22+
description: 'k6 scenario to run'
23+
type: choice
24+
options:
25+
- smoke
26+
- soak
27+
default: smoke
28+
runner:
29+
description: 'Runner to use'
30+
type: choice
31+
options:
32+
- arc-runner
33+
- ubuntu-latest
34+
default: ubuntu-latest
35+
36+
concurrency:
37+
group: load-test-${{ github.ref }}
38+
cancel-in-progress: true
39+
40+
permissions:
41+
contents: read
42+
43+
jobs:
44+
k6:
45+
name: k6 ${{ github.event.inputs.scenario || 'smoke' }}
46+
# ubuntu-latest needed for docker compose + grafana/k6-action's
47+
# consistent Chrome-free environment.
48+
runs-on: ${{ github.event.inputs.runner || 'ubuntu-latest' }}
49+
timeout-minutes: 30
50+
steps:
51+
- uses: actions/checkout@v4
52+
53+
- name: Boot ephemeral TeslaSync stack
54+
env:
55+
POSTGRES_PASSWORD: ci_loadtest
56+
SECRET_KEY: ci_loadtest_secret_32chars_minimum_padding
57+
run: |
58+
# Bring up only the services k6 actually exercises; skip
59+
# MQTT + Tesla pipeline so the stack starts faster.
60+
docker compose up -d timescaledb redis api
61+
# Wait for the API to report healthy.
62+
for i in $(seq 1 60); do
63+
if curl -fsS http://localhost:8080/healthz -o /dev/null; then
64+
echo "API up after ${i}s"
65+
exit 0
66+
fi
67+
sleep 1
68+
done
69+
echo "API failed to start" >&2
70+
docker compose logs api >&2
71+
exit 1
72+
73+
- name: Run k6
74+
uses: grafana/k6-action@v0.3.1
75+
with:
76+
filename: tests/k6/api_smoke.js
77+
env:
78+
BASE_URL: http://localhost:8080
79+
SCENARIO: ${{ github.event.inputs.scenario || 'smoke' }}
80+
81+
- name: Upload k6 summary
82+
if: always()
83+
uses: actions/upload-artifact@v4
84+
with:
85+
name: k6-summary-${{ github.run_number }}
86+
path: tests/k6/summary.json
87+
if-no-files-found: warn
88+
89+
- name: Capture API logs on failure
90+
if: failure()
91+
run: docker compose logs api > api.log
92+
continue-on-error: true
93+
94+
- name: Upload API logs
95+
if: failure()
96+
uses: actions/upload-artifact@v4
97+
with:
98+
name: api-logs-${{ github.run_number }}
99+
path: api.log
100+
if-no-files-found: ignore
101+
102+
- name: Tear down stack
103+
if: always()
104+
run: docker compose down -v

.github/workflows/release.yml

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,48 @@ jobs:
171171
cosign sign --yes "${IMAGE}@${DIGEST}"
172172
echo "✅ Signed ${IMAGE}@${DIGEST}"
173173
174+
# Phase-53 / p53-sbom — Software Bill of Materials.
175+
# syft generates a CycloneDX SBOM of the image contents (every
176+
# Go module, OS package, and JS dependency that landed in the
177+
# final layer). The SBOM is attested to the image via cosign so
178+
# consumers can `cosign verify-attestation --type cyclonedx`
179+
# without trusting any side-channel artifact server.
180+
- name: Install syft
181+
uses: anchore/sbom-action/download-syft@v0.17.8
182+
183+
- name: Generate SBOM (CycloneDX)
184+
env:
185+
DIGEST: ${{ steps.docker-build.outputs.digest }}
186+
TAGS: ${{ steps.tags.outputs.tags }}
187+
run: |
188+
IMAGE=$(echo "$TAGS" | cut -d',' -f1)
189+
syft "${IMAGE}@${DIGEST}" \
190+
-o cyclonedx-json="sbom-${{ matrix.image }}.cdx.json" \
191+
-o spdx-json="sbom-${{ matrix.image }}.spdx.json"
192+
echo "✅ Generated SBOMs for ${IMAGE}"
193+
ls -la sbom-${{ matrix.image }}.*.json
194+
195+
- name: Attest SBOM with cosign (keyless)
196+
env:
197+
DIGEST: ${{ steps.docker-build.outputs.digest }}
198+
TAGS: ${{ steps.tags.outputs.tags }}
199+
run: |
200+
IMAGE=$(echo "$TAGS" | cut -d',' -f1)
201+
cosign attest --yes \
202+
--predicate "sbom-${{ matrix.image }}.cdx.json" \
203+
--type cyclonedx \
204+
"${IMAGE}@${DIGEST}"
205+
echo "✅ Attested CycloneDX SBOM to ${IMAGE}@${DIGEST}"
206+
207+
- name: Upload SBOM artifacts
208+
uses: actions/upload-artifact@v4
209+
with:
210+
name: sbom-${{ matrix.image }}-${{ needs.version-tag.outputs.version }}
211+
path: |
212+
sbom-${{ matrix.image }}.cdx.json
213+
sbom-${{ matrix.image }}.spdx.json
214+
retention-days: 90
215+
174216
helm:
175217
name: Publish Helm Chart
176218
needs: [version-tag, docker]
@@ -257,12 +299,21 @@ jobs:
257299
258300
### 🔐 Image Verification
259301
260-
All images are signed with [cosign](https://github.com/sigstore/cosign) (keyless, via GitHub OIDC):
302+
All images are signed with [cosign](https://github.com/sigstore/cosign) (keyless, via GitHub OIDC) and ship with a CycloneDX SBOM attestation:
261303
262304
\`\`\`bash
305+
# Verify image signature
263306
cosign verify ghcr.io/${{ github.repository }}-api:${VERSION} \\
264307
--certificate-identity-regexp="github.com/${{ github.repository }}" \\
265308
--certificate-oidc-issuer="https://token.actions.githubusercontent.com"
309+
310+
# Verify + extract SBOM attestation
311+
cosign verify-attestation \\
312+
--type cyclonedx \\
313+
--certificate-identity-regexp="github.com/${{ github.repository }}" \\
314+
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \\
315+
ghcr.io/${{ github.repository }}-api:${VERSION} \\
316+
| jq -r '.payload' | base64 -d | jq '.predicate' > sbom.cdx.json
266317
\`\`\`
267318
268319
### 📝 Changes

0 commit comments

Comments
 (0)