Skip to content

Latest commit

 

History

History
105 lines (92 loc) · 15.9 KB

File metadata and controls

105 lines (92 loc) · 15.9 KB

AGENT.MD — Architecture & Boundaries

Context map for AI agents and humans. Update this file whenever an architectural boundary or project configuration changes.

What this app is

A personal Azure portfolio / ops dashboard. A Blazor WASM front-end shows App Service health, cost, SSL expiry, zombie-app detection, and downtime diagnosis. The backend is a minimal-API BFF that reads an Azure inventory report from Azure Table Storage (with a local JSON fallback).

There are exactly two pages: / (the app catalog) and /azure (the ops dashboard).

The home-page catalog in apps.json is authoritative: live Azure inventory decorates catalog entries with health and deployment URLs, but probe failures never remove apps from the portfolio. /api/diag/summary is the compact first-paint contract for /azure; the full report and history load only when advanced diagnostics is opened.

Authentication — no real login, by design; a fake dev/test scheme exists server-side

The site is publicly browsable with no real login — no /auth/* routes, no AuthenticationStateProvider, no Microsoft OAuth or any other real identity provider, and no login UI (see the header deviation note under Cross-cutting conventions → Management actions). Host/FakeAuthHandler.cs (NET_RULES §3) reads X-Fake-User/X-Fake-Roles headers to build a ClaimsPrincipal — it exists purely to let a caller opt into the management role server-side (gating /api/impersonate) and is registered only outside Production, throwing in its own constructor if the environment is Production. Default identity with no headers sent is the anonymous guest. Do not add a real identity provider, session storage, or login UI unless the owner explicitly reverses the "no real login" decision. /api/config deliberately exposes no auth flags — /api/whoami is the one endpoint that exposes the current principal.

Solution layout (Vertical Slice Architecture)

Source projects live under /src; tests under /tests.

Project Role
src/PoPunkouterSoftware.API API host + BFF. Feature slices under Features/<Area>/*Endpoints.cs (Config, Diag, Portfolio), each a Map<Area>Endpoints extension called from Program.cs. Host-level plumbing that belongs to no slice lives in Host/ (FakeAuthHandler, GlobalExceptionHandler, HealthEndpoints + its IHealthChecks, ManagementActionFilter, PortfolioIdentity, RefreshHub, RefreshSessionManager, ReportRefreshRunner). Hosts the Blazor WASM app. Namespace is flat (PoPunkouterSoftware.API for every file regardless of which Host//Features/<Area>/ folder it physically lives in) — the folder split satisfies NET_RULES' VSA layout without the using-directive churn a matching namespace hierarchy would add.
src/PoPunkouterSoftware.Client Blazor WASM (Radzen UI, mobile-first). wwwroot lives only here. The layout is flat — every component sits in the project root, named for what it is: shared UI (ErrorRetryBanner, LoadingSkeleton, DevErrorBoundary), page-specific blocks (AzurePriorityQueue, AzureResourceExplorer, AzureEvidenceDisclosures, AzureHistoryDisclosure, AzureAiSummary), relative-time formatting in RelativeTime.cs.
src/PoPunkouterSoftware.Shared Shared DTOs and the domain vocabulary (DomainVocabulary.cs) only — no behavior beyond those helpers, and no PackageReferences at all. No server/browser-only deps. Ships in the WASM bundle (IsTrimmable).
src/PoPunkouterSoftware.Infrastructure Azure services (Table Storage, ARM, pinger, incident, telemetry) plus cross-slice helpers: ReportFileCache (report file fallback + data dir), HistorySummaryMapper, AppServicePlanInventory, SecretMasking. Slices must not reference each other — shared logic goes here.

Naming schema

  • One type per file, named for the type. Two deliberate exceptions: a cohesive vocabulary of constants is named for the group (DomainVocabulary.cs, TableStorageVocabulary.cs), and a group of related DTOs is named <Concern>Models.cs (CostModels.cs, SecurityModels.cs).
  • Partial-class aspect files are <Type>.<Aspect>.cs and the aspect names the concern, not the shape: AzureReportService.Cost.cs, AzureDashboard.DerivedViews.cs. A file called .Charts.cs that holds no charts is the failure mode to avoid.
  • A file name must be greppable. If the name does not appear as a type inside the file and is not one of the two exceptions above, the name is wrong.
  • Test files mirror the source file under test (DomainVocabularyTests.cs holds ServiceHealthTests, SeverityLevelTests, …), not one file per test class.
  • Records crossing a component boundary are top-level types in their own file (PriorityQueueItem.cs, ResourceExplorerItem.cs, SafeToRemoveItem.cs) — a private nested record cannot be a Blazor component parameter.

Cross-cutting conventions

  • Routing: slices register via Map<Area>Endpoints(this WebApplication). /api/diag/* uses MapGroup("/api/diag"). /health, /healthz, /diag are off the /api group. /health is the deep probe and /healthz the static liveness ping; there is deliberately no /api/health alias.
  • Every endpoint needs a consumer. An endpoint whose only caller is its own test is dead code with a green check mark next to it. Three whole slices (GitHub, Infra, Pinger) were deleted in 2026-07 for exactly this — all tested, none reachable from the UI. Before adding a route, know what calls it.
  • Config: Directory.Build.props (net10, Nullable, TreatWarningsAsErrors, LangVersion 14) and Directory.Packages.props (Central Package Management). Add package versions only in Directory.Packages.props.
  • Resilience: typed HttpClients github and azure-arm use AddStandardResilienceHandler. health and azure-probe deliberately have no resilience — they must report real reachability, not retry through outages.
  • Named HttpClients are shared: never reassign DefaultRequestHeaders on an instance from CreateClient(name). Handler chains are pooled, so a per-call mutation leaks that header — including credentials — to every other consumer of the same named client. The github PAT is bound once, in Program.cs. Pass anything per-call on the HttpRequestMessage.
  • Caching: none beyond what the framework provides. HybridCache and the sized IMemoryCache were removed with the slices that used them; add them back deliberately if a read-through cache is needed again.
  • AI triage: AiTriageService (Infrastructure) calls the Hugging Face Inference API (google/flan-t5-base, free tier) to turn the dashboard's attention-item list into a one-paragraph summary. Killed by FeatureFlags:EnableAiSummary (off by default) and by the ai-hf typed HttpClient having deliberately no resilience pipeline — an AI outage degrades to a rule-based one-sentence fallback (AiTriageService.BuildTemplateFallback, pure/no-I/O), never a dead "unavailable" state, and never retry-storms the free quota. Consumer: the persisted per-scan summary is precomputed server-side — ReportRefreshRunner calls AiTriageService.GenerateSummaryAsync right after AzureReportService.RunAsync and attaches the AiSummaryResult (Shared) to the report before AzureReportStore.SaveAsync, so it round-trips through the existing report/file-cache paths with no new storage plumbing; it's projected onto both AzureReport.AiSummary and the compact OpsSummary.AiSummary and rendered by the Client's AzureAiSummary component on /azure. POST /api/diag/ai itself is consumed directly by that component's "Regenerate now" button, an on-demand/unpersisted re-generation that bypasses the cache. The attention-item list itself is built once by AttentionItemsBuilder (Infrastructure) and shared between DiagEndpoints.BuildOpsSummary (read path) and ReportRefreshRunner (scan-time precompute) so the two can never drift. GenerateSummaryAsync also skips the model call and reuses the previous scan's text (Source: "cached") when the attention items' SHA-256 hash is unchanged from a real (Source: "ai") previous generation. This replaces the 2026-07 "no AI integration" decision (the old azure-openai client that backed it is still gone); do not re-add a second AI client speculatively.
  • Screenshots & disk: AppScreenshotService pins PLAYWRIGHT_BROWSERS_PATH to the persistent %HOME% share and installs only the Chromium headless shell — the worker's ephemeral disk previously filled with a full Chromium download and broke every deployment ("not enough space on the disk", 2026-07-10). Kill switch: FeatureFlags:EnableScreenshots=false.
  • Retention: History/HistorySummary table rows are pruned after Retention:HistoryDays (default 30) by AzureReportStore on each save; blobs age out via the 30-day lifecycle policy in infra/main.bicep. Incidents are deliberately never pruned.
  • Telemetry budget: fixed-rate trace sampling, default 10% (ApplicationInsights:SamplingRatio). Exceptions are never lost to it — GlobalExceptionHandler logs them through ILogger and the logs pipeline is not trace-sampled.
  • Telemetry: Serilog → Console + File; Azure Monitor via OpenTelemetry (the sole App Insights pipeline). cloud_RoleName comes from the OTel resource service.name (set by reflection in the API only — never in WASM). High-frequency logs use [LoggerMessage] source-gen (see ServicePingerService).
  • Secrets: Azure Key Vault (kv-poshared, prefix PoPunkouterSoftware--) loaded at startup via System-Assigned Managed Identity; skipped under the Testing environment. DECISION: the grant is a classic access policy (get/list secrets), not the RBAC "Key Vault Secrets User" role — kv-poshared has RBAC authorization disabled and is shared across Po* apps, so switching models is an estate-wide change, not per-app. The App Service plan (asp-PoPunkouterSoftware-f1) is deliberately app-local, not in PoShared: F1 is free, and isolation keeps this app's cold starts and quota pressure away from the shared plan.
  • Storage: Azure Table Storage. Local dev runs Azurite in Docker (docker-compose.yml, container popunkoutersoftware-azurite). UseDevelopmentStorage=true in appsettings.Development.json.
  • Snoozes: the snoozes partition (SnoozeStore) lets the operator hide a finding from the priority queue for N days. Findings have no server-side identity — the RowKey is a SHA-256 hash of the client's opaque key (Table Storage forbids | etc. in RowKey), with the raw key stored as a property so it round-trips. Unprivileged — no .RequireManagementActions(), same tier as /api/diag/ai — and expiry is enforced client-side-in-effect by GetActiveAsync filtering ExpiresAtUtc in code rather than a TTL/cleanup job; expired rows are simply excluded from reads, not deleted.
  • Management actions: mutating / expensive endpoints (/api/diag/refresh, /api/diag/cancel-refresh) are gated server-side by ManagementActionFilter (.RequireManagementActions()). It enforces the same FeatureFlags:EnableManagementActions flag the UI reads from /api/config (on in Development/Testing, otherwise opt-in), plus an optional Security:ManagementApiKey checked against the X-Management-Key header. FakeAuthHandler (Host/, NET_RULES §3) additionally backs a [Authorize(Policy = "Management")] gate on POST /api/impersonate (mapped only outside Production — with no scheme registered in Production, a failed policy must deny outright rather than challenge, or ChallengeAsync() throws): reads X-Fake-User/X-Fake-Roles, throws in its constructor if instantiated under IsProduction(), and is registered in Program.cs only for Development/Testing. /api/whoami exposes the resulting principal and /api/logout is a no-op peer for the client to call (the scheme is stateless per-request; "logout" means the caller drops its own X-Fake-* headers).
  • No login UI, by design (deviation from NET_RULES §3): the header ships no Session/Logout slot and no "MOCK DATA" banner, even though the auth plumbing above and /api/config's isMockMode field exist to back them. MainLayout.razor is statically server-rendered (<Routes /> carries no render mode) — no @onclick fires and its injected HttpClient has no BaseAddress for a relative fetch, so both were built once, could never actually render, and were deliberately deleted rather than kept as chrome that only looks functional (2026-08-03). Reinstating either correctly requires a small InteractiveWebAssembly-rendered island component in the header, not a change to MainLayout itself. This is intentional and permanent unless the owner asks for the interactive-island fix.

Trim status

EnableTrimAnalyzer and PublishTrimmed are ON for .Client; the shared assembly is trimmable. All client JSON now flows through the source-generated AppJsonContext (AppJsonContext.cs), so the analyzer runs clean and the previous IL2026/IL2104 WarningsNotAsErrors suppression has been removed — the client builds trim-safe under the global TreatWarningsAsErrors. Add new client (de)serialised types to AppJsonContext with a [JsonSerializable] attribute.

Tests (/tests) — four projects, one per tier (target ratio 100/50/25/25)

One project per tier, each named for the tier it is. The fast tier is the first two.

  • PoPunkouterSoftware.Unit — strictly no-I/O (HTTP stubs, in-memory caches). RULE: DTO-mapping tests live in .Integration, never here.
  • PoPunkouterSoftware.IntegrationWebApplicationFactory + Testcontainers Azurite, including a fixture that runs Azurite INSIDE the factory with explicit teardown. Collections run sequentially (AssemblyInfo.cs) because concurrent entry-point boots race. ProductionBootTests boots the entry point under Production with every external dependency blanked — the only coverage of that environment's hosting pipeline.
  • PoPunkouterSoftware.E2EAPIApiSmokeTests, pure-HTTP against a live instance via BASE_URL (default http://localhost:8000). Also the post-deploy smoke: point BASE_URL at production. On demand, not in CI.
  • PoPunkouterSoftware.E2EUIPortfolioUiTests, Playwright. Every UI test runs BOTH mobile-portrait (390×844) and desktop-landscape (1440×1000) via the shared Viewports theory data — visual parity is a hard rule. On demand, not in CI.

CI/CD: two workflows. deploy.yml is deliberately build-and-deploy only — no test steps live in the YAML. screenshots.yml runs nightly (06:17 UTC) to capture the portfolio screenshots on a Linux runner, because the production Windows F1 sandbox cannot run Chromium. All test tiers run locally before pushing to master (fast tier: dotnet test tests/PoPunkouterSoftware.Unit then dotnet test tests/PoPunkouterSoftware.Integrationdotnet test takes ONE project per invocation; E2E: on demand, headed via HEADED=1, installed Chrome via BROWSER_CHANNEL=chrome).

Agent tooling (Claude Code)

  • User-level plugins (claude plugin list): dotnet-claude-kit (codewithmukesh), dotnet-skills@wshaddix-dotnet-skills (167 skills + 16 agents; local marketplace at ~/.claude/local-marketplaces/wshaddix-dotnet-skills), and the official dotnet/skills plugins dotnet, dotnet-aspnetcore, dotnet-blazor, dotnet-test, dotnet-data, dotnet-diag (marketplace dotnet-agent-skills).
  • Project-level skills (.claude/skills/): auto-managed by the dotnet-skills global tool (managedcode). Refresh with dotnet skills install --auto --prune --agent claude after changing package references.

Run / deploy

  • Local (Development env): F5 in VS Code runs f5-prep (kill stale dotnet → start Azurite → build) then launches on http://localhost:8000. SCRIPTS/setup.ps1 provisions WinGet/Docker/Azure prerequisites.
  • Azure (Production env): .github/workflows/deploy.yml builds + deploys to App Service app-popunkoutersoftware (OIDC login, no secrets in workflow, no test steps by design).