diff --git a/.dockerignore b/.dockerignore index e4754d3..6591e1d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,6 +8,7 @@ **/TestResults/ .git/ docs/ +design/ tests/ *.md *.txt diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..97201ce --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* whitespace=trailing-space,space-before-tab,cr-at-eol +*.sh text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ee0d84..858b50b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,9 @@ jobs: - name: Test run: dotnet test --project tests/MicroStack.Tests/MicroStack.Tests.csproj -c Release --no-build + - name: Test UI components + run: dotnet test --project tests/MicroStack.UI.Tests/MicroStack.UI.Tests.csproj -c Release --no-build + - name: Pack NuGet packages run: dotnet pack src/MicroStack.Aspire.Hosting/MicroStack.Aspire.Hosting.csproj -c Release --no-build --output ./packages @@ -113,5 +116,8 @@ jobs: npm run typecheck npm test + - name: Smoke test container + run: sh scripts/smoke-container.sh microstack:ci + - name: Report container image size run: docker images microstack diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68ac8b2..de3c937 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,6 +94,9 @@ jobs: - name: Test run: dotnet test --project tests/MicroStack.Tests/MicroStack.Tests.csproj -c Release --no-build + - name: Test UI components + run: dotnet test --project tests/MicroStack.UI.Tests/MicroStack.UI.Tests.csproj -c Release --no-build + - name: Pack NuGet packages run: dotnet pack src/MicroStack.Aspire.Hosting/MicroStack.Aspire.Hosting.csproj -c Release --no-build --output ./packages @@ -159,6 +162,9 @@ jobs: npm run typecheck npm test + - name: Smoke test container + run: sh scripts/smoke-container.sh ghcr.io/${{ github.repository_owner }}/microstack:${{ inputs.version }} + - name: Push container image to GHCR run: | docker push ghcr.io/${{ github.repository_owner }}/microstack:${{ inputs.version }} diff --git a/Dockerfile b/Dockerfile index 7c2a626..0a5a40e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,19 +2,24 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build RUN apk add --no-cache clang binutils musl-dev build-base zlib-dev zlib-static WORKDIR /src -# Copy props/solution files first for layer caching +# Copy props/project files first for layer caching COPY Directory.Build.props ./ +COPY MicroStack.slnx ./ COPY src/MicroStack/MicroStack.csproj src/MicroStack/ +COPY src/MicroStack.UI.Client/MicroStack.UI.Client.csproj src/MicroStack.UI.Client/ +COPY src/MicroStack.Admin.Contracts/MicroStack.Admin.Contracts.csproj src/MicroStack.Admin.Contracts/ RUN dotnet restore src/MicroStack/MicroStack.csproj -r linux-musl-x64 -# Copy source and publish -COPY src/MicroStack/ src/MicroStack/ -RUN dotnet publish src/MicroStack/MicroStack.csproj -c Release -r linux-musl-x64 -o /app +# Copy source and publish the API with its browser assets +COPY src/ src/ +RUN dotnet publish src/MicroStack/MicroStack.csproj -c Release -r linux-musl-x64 -o /out/microstack + +FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine +WORKDIR /app +COPY --from=build /out/microstack/ /app/ -FROM alpine:3.21 -RUN apk add --no-cache libstdc++ libgcc -COPY --from=build /app /app ENV ASPNETCORE_HTTP_PORTS=4566 +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 EXPOSE 4566 ENTRYPOINT ["/app/MicroStack"] diff --git a/MicroStack.slnx b/MicroStack.slnx index 4a010c8..3a4987c 100644 --- a/MicroStack.slnx +++ b/MicroStack.slnx @@ -1,11 +1,14 @@ + + + diff --git a/README.md b/README.md index 7ee56e0..c01ed3c 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,31 @@ dotnet run --project src/MicroStack/MicroStack.csproj curl http://localhost:4566/_microstack/health ``` +## Admin UI + +MicroStack includes a web UI on the same gateway port: + +- **AWS/API endpoint:** `http://localhost:4566` +- **UI endpoint:** `http://localhost:4566/ui/` + +An unsigned browser navigation to `http://localhost:4566/` redirects to `/ui/`. +SDK, signed, presigned, and non-HTML root requests retain AWS behavior. + +Local builds include the browser client's static assets. Native publishing builds +the client separately. The client keeps its `browser-wasm` runtime identifier +even when restore is invoked with the server's native runtime identifier. + +The UI includes: + +- **Services** — a searchable directory of all supported services +- **Resource Explorer** — read-only, account-aware inspection of retained + resources, configuration, content, and configured connections +- **Overview** — service health, resource counts, and the global reset control +- **Request Log** — recent AWS API calls (service, action, account, status, duration) + +For the separate, synthetic design reference (not the running Admin UI), see the +[Resource Explorer prototype](design/resource-explorer/README.md). + ## Usage Point any AWS SDK client at `http://localhost:4566`: diff --git a/design/resource-explorer/DESIGN.md b/design/resource-explorer/DESIGN.md new file mode 100644 index 0000000..2ddfa28 --- /dev/null +++ b/design/resource-explorer/DESIGN.md @@ -0,0 +1,182 @@ +--- +name: MicroStack resource inspection prototype +description: A familiar developer console for read-only local resource inspection. +colors: + ink: "#202c3d" + muted: "#526176" + line: "#dbe1e9" + blue: "#165dca" + blue-soft: "#edf4ff" + surface: "#ffffff" + nav: "#f7f9fc" + green: "#267048" + amber: "#795100" +typography: + directory-heading: + fontSize: "2rem" + fontWeight: 650 + body: + fontFamily: '"Segoe UI", system-ui, -apple-system, sans-serif' + fontSize: "16px" + lineHeight: 1.5 + headline: + fontSize: "1.625rem" + fontWeight: 650 + lineHeight: 1.25 + letterSpacing: "-.025em" + title: + fontSize: "1.25rem" + fontWeight: 650 + letterSpacing: "-.02em" + data: + fontFamily: 'Consolas, "SFMono-Regular", monospace' + fontSize: ".8125rem" +rounded: + control: "4px" + inspector: "7px" +spacing: + small: "8px" + medium: "16px" + panel: "24px" +components: + button-primary: + backgroundColor: "{colors.blue}" + textColor: "{colors.surface}" + rounded: "{rounded.control}" + padding: "7px 12px" +--- + +# Design System: MicroStack Resource Mockups + +## Overview + +**Creative North Star: "The local resource workbench"** + +A task-oriented console for developers moving from a service to a resource, then +its contents, configuration, and configured connections. The user chose familiar +developer-console conventions rather than an experimental visual direction. +Specific reference products were not endorsed. + +This document describes the isolated prototype, not an approved production +redesign. The user selected services home -> dedicated split inspector as the new +prototype direction to avoid an ever-growing services rail. Alternative +compositions and their comparison controls have been removed. The live Blazor +application is unchanged; data legibility and preservation of debugging context +take priority over decoration. + +## Colors + +Blue identifies navigation, selected rows, links, and active tabs. White inspection +surfaces sit beside cool-gray navigation, with subtle borders defining sections. +Ink and muted text distinguish primary data from supporting metadata. Green and +amber statuses always carry text; color alone never asserts health or delivery. +Official AWS artwork retains its own service-category colors. Those asset colors +identify services, not selection or health; do not recolor or grayscale the icons. + +## Typography + +System sans-serif avoids font downloads and fits native development tools. +Consolas/SFMono monospace is reserved for identifiers, keys, and payloads. +Service headings are 1.625rem and resource titles 1.25rem on desktop; data, +controls, and supporting labels use .75rem to .9375rem. Narrow headings reduce +to 1.375rem and 1.0625rem. Payload line height is 1.75 for nested JSON. + +UI timestamps use UTC ISO 8601 with a full date, seconds, and a `Z` suffix +(for example, `2026-09-11T10:20:00Z`), including object metadata, message lists, +activity logs, and snapshot labels. Durations remain human-readable. + +## Layout + +The services home is a searchable directory, not a dashboard of status cards. +Grouped lists occupy two columns on desktop and one below 760px, with a maximum +1400px page width. Availability text distinguishes the five clickable mockup +inspectors, unimplemented inspectors, and an explicitly synthetic disabled example. +Search includes service names, identifiers, and categories. A checkbox restricts +the list to services with example inspectors. + +The only workspace is a split inspector: a persistent 220px resource index beside +inspection content, without a services rail. All services and a compact switcher +provide cross-service access. The current service in the breadcrumb opens a +searchable dropdown: All services / selected service. Entries sort alphabetically +by displayed name and filter by short or full service name. All 40 catalog entries +appear in the scrollable list. Services without mock inspectors are labeled and +marked aria-disabled; they remain discoverable by keyboard but cannot navigate +to an unfinished inspector. The search uses an +accessible combobox and listbox, with arrow navigation, Enter to select, and Escape +to dismiss and restore focus. Tab moves from the filter to the active result; +the list has one roving tab stop. Arrow keys and Home/End move focus within the +list, Shift+Tab returns to the filter, and Tab from a result leaves the picker. +The focused result has an inset focus outline that is not clipped by list scrolling. +Typing on the closed trigger also starts a search. +No matches are announced explicitly; choosing the current service preserves its +resource selection. There is no separate switcher on the right. +Main padding is 18px 28px. +The breadcrumb and account/configured-region scope share one aligned desktop +header row, with scope on the right. They wrap naturally on narrow screens; +scope remains visible on the services home when the breadcrumb is hidden. +At 1200px and below the inspector stacks records above payloads. +At 1000px resource-index width and panel padding contract. At 760px contents +stack and Browse resources opens a focused index. +Selecting a resource returns to detail. Long identifiers wrap; payloads and wide +activity tables scroll within their own containers, not the page. + +Lists expose loaded counts and pagination. The large-list sample generates 125 +content entries in pages of 10; it is not a server-side scalability claim. + +## Elevation & Depth + +Borders and surface tones carry almost all grouping. Toast feedback has a modest +floating shadow; the service dropdown uses a restrained offset shadow to separate +it from the inspector beneath. No decorative +gradients, hero treatments, or animated charts compete with inspected data. +State examples and local refresh feedback do not depend on animation. + +## Shapes + +Controls and status tags use restrained 4px corners. The inspector uses 7px corners +on desktop and 5px on narrow screens. Data sections remain rectangular and aligned. +The existing MicroStack logo supplies product identity. + +## Components + +All 40 directory services have locally served, unmodified AWS SVG artwork from +the July 31, 2026 Architecture Icons release. Icons also appear in the searchable +switcher, current-service breadcrumb, inspector headings, and connection rows. +Sizes are 28px in the directory, 24px in results/connections, 20px in the breadcrumb, +and 32px at the service heading. Image elements preserve proportions and use empty +alt text because visible labels already identify each service. + +The two API Gateway entries, two Cognito entries, and RDS/RDS Data share family +artwork. S3 Files, CloudWatch Logs, STS, and Application Load Balancer have dedicated +resource icons. `aws-icon-sources.json` records their official archive paths. +Functional glyphs such as search, copy, and arrows remain neutral inline SVGs; +AWS artwork is never substituted for MicroStack branding. + +Service buttons use selected background and text; resource buttons also expose +pressed state. Tabs use roving tabindex with arrow, Home, and End navigation. +Controls have a visible 3px focus outline; the page provides a skip link. + +Each service has a distinct content model: S3 keys and virtual prefixes with +bounded-preview examples; SQS non-consuming sample message snapshots; DynamoDB +typed items; SNS subscriptions; EventBridge buses, rules, patterns, and targets. +Configuration and service/account activity share consistent secondary tabs. + +Connections describe configured relationships, never delivery traces. Following +one retains an explicit return trail, including selected item, tab, prefix, +filter, page, and example state. Missing and external destinations are not +clickable internal resources. Copy controls announce success or explain clipboard +denial; errors and empty/loading/stale states remain explicit. + +## Do's and Don'ts + +- Keep synthetic data, account, and configured-region scope visible. +- Keep the default services catalog on its home page, not in a growing rail. +- Preserve service -> resource -> contents as the main navigation path. +- Distinguish configuration from observations; never imply causal delivery. +- Label proposed non-consuming SQS snapshots as requiring a dedicated admin API. +- Keep binary and oversized objects metadata-only when a preview is unavailable. +- Do not equate a loaded-subset filter with a query or complete scan. +- Do not add provisioning controls, live API calls, or inferred relationships. +- Do not treat prototype navigation approval as permission to change production. + +Open product and API decisions are recorded in `notes.html` and `PRODUCT.md`. diff --git a/design/resource-explorer/PRODUCT.md b/design/resource-explorer/PRODUCT.md new file mode 100644 index 0000000..9ac8db2 --- /dev/null +++ b/design/resource-explorer/PRODUCT.md @@ -0,0 +1,85 @@ +# Product + + + +## Platform + +web + +## Users + +Developers inspecting and debugging a local MicroStack instance. Their primary +path is service -> resource -> contents, with connected resources and relevant +activity available in context. + +## Product Purpose + +MicroStack emulates AWS services locally. This prototype explores how developers +can inspect resources and contents without switching repeatedly to SDK or CLI +commands. It is a design artifact, not an extension of the running emulator. + +## Operating Context + +The existing admin application is Blazor WebAssembly on a separate UI port. +The emulator is a Native AOT .NET application. Resources may belong to different +accounts, while the configured region must not be mistaken for per-region +isolation. The prototype uses only clearly labeled synthetic data. +The UI assumes one local instance, so it needs no local-instance indicator or +instance switcher. + +## Capabilities and Constraints + +- Representative inspectors: S3, SQS, DynamoDB, SNS, and EventBridge. +- Inspect and debug, not provision or mutate resources. +- SNS subscriptions and EventBridge rules/targets provide configured connections; + they do not establish that an event matched or was delivered. +- Existing admin resource summaries cover SQS, S3, and DynamoDB only. +- Current request logs record service, action, account, timestamp, status, and + duration, not reliable resource IDs or causal trace IDs. +- Non-consuming SQS inspection is a proposed admin capability, not ReceiveMessage. +- Backend APIs, live integration, destructive actions, and production changes are + excluded from this prototype. +- Navigation should accommodate other services without invented inspectors. +- Confirmed navigation decision (11 September 2026): service-first home and a + split inspector per service. This is the selected direction, not merely a + comparison default. The searchable, grouped services home opens a resource + list beside an inspector, without a permanent services rail. +- All services navigation and a compact service switcher remain available inside + inspection. Alternative layouts and comparison controls have been removed. + This design decision does not itself authorize production implementation. +- The current-service breadcrumb opens a searchable, alphabetically sorted + switcher with arrow-key navigation, Enter selection, and Escape dismissal. + +## Brand Commitments + +MicroStack name and factual terminology remain. The user explicitly approved +replacing the Bootstrap template appearance with a purpose-designed developer +tool. The user subsequently selected the familiar developer console direction. +No specific reference products were supplied: conventional cloud-console +navigation and database-inspector patterns are working assumptions, not endorsed +references. Use a restrained light interface for reading data alongside other +development windows; this is a prototype decision, not a product requirement. + +Use official AWS service/resource icons to identify all 40 catalog entries, +with corresponding family icons for related APIs. Keep AWS artwork unmodified +and local, alongside explicit text labels. Retain the MicroStack logo as the +product identity; AWS icons do not imply AWS endorsement. + +## Evidence on Hand + +The repository has a working initial dashboard, request log, and generic resource +lists, plus a MicroStack logo. Service handlers provide related AWS operations. +Synthetic order-processing examples are illustrations, never evidence of live +resources or observed deliveries. + +## Product Principles + +- Preserve context while moving from a resource to its contents or connections. +- Separate configured state, observed requests, and inferred relationships. +- Expose partial, stale, missing, and unsupported data rather than hiding limits. +- Optimize for task clarity and scanning rather than a marketing presentation. + +## Accessibility & Inclusion + +The prototype plan includes keyboard-accessible controls, visible focus, text +status labels, readable payloads, and a focused detail view on narrow screens. diff --git a/design/resource-explorer/README.md b/design/resource-explorer/README.md new file mode 100644 index 0000000..0cfec5b --- /dev/null +++ b/design/resource-explorer/README.md @@ -0,0 +1,108 @@ +# Resource Explorer design reference + +This is the preserved, runnable prototype of the selected service-first directory +and split inspector. It is a **frozen synthetic design reference**, not the +production Admin UI or an emulator integration. All resources, payloads, counts, +account context, activity, and timestamps are fixtures. No AWS credentials, +emulator, .NET build, or live API calls are required. + +## Run from the repository + +Install a currently supported Node.js release (Node 22 or newer), then run this +from the repository root in PowerShell: + +```powershell +node .\design\resource-explorer\server.mjs +``` + +There are no npm dependencies and no install or build step. The server binds +**only to `127.0.0.1`** and chooses an available ephemeral port by default. Open +the `PROTOTYPE_URL=http://127.0.0.1:/` printed in the terminal. + +To request a specific port in PowerShell: + +```powershell +$env:PORT = '8080' +node .\design\resource-explorer\server.mjs +``` + +Stop the server with **Ctrl+C**. Remove the override with +`Remove-Item Env:PORT` to restore automatic port selection on the next launch. +An explicit `PORT=0` also selects an available port. This server serves only its +allowlisted pages and static assets; unknown paths return 404. It is a local +review tool, not a production host. + +## What is preserved + +- All 40 catalog entries and locally served AWS icons. +- Five example inspectors: S3, SQS, DynamoDB, SNS, and EventBridge. +- Searchable grouped home and alphabetically sorted breadcrumb service switcher. +- Resource-specific contents, configuration, configured connections, return + trails, service/account activity, and example-state controls. +- Responsive layouts, keyboard interactions, and UTC ISO 8601 sample timestamps. + +Start with `/`, read `/notes.html` for decisions and a guided review path, and +use `/qa.html` for side-by-side 1440px and 390px layout frames. +[PRODUCT.md](PRODUCT.md) and [DESIGN.md](DESIGN.md) retain the original product +and visual-design snapshot. Their statements about the then-current application +and unresolved implementation work are historical, not live production status. + +Production implementation has since been authorized separately, including +all-service inspection, account switching, and text/JSON previews up to 1 MiB. +That does not change this five-inspector mock or make its synthetic behavior an +approved production API contract. Production must use actual modeled data, +read-only inspection APIs, bounded previews, and appropriate sensitive-data +handling; it must not ship the fixtures, example-state selector, or demo banner. +The mock has no account discovery, real request log, resource mutation, or +delivery tracing. Configured relationships are not proof of event delivery. + +Keep this snapshot useful for deliberate future design work; it need not mirror +every production change. Make any later design revisions explicit rather than +silently replacing fixtures with emulator calls. The reference lives outside +`src` and the published docs tree, is excluded from Docker contexts by +`.dockerignore`, and is not registered in application build/publish projects. + +## Repeatable manual review + +- [ ] Search the 40-entry directory; filter to entries with an inspector and + distinguish unsupported examples from the synthetic disabled EMR entry. +- [ ] Open each of the five inspectors; change selected resources and tabs. + In SQS inspect nested JSON; in DynamoDB inspect typed attributes and filter + the loaded subset; in SNS inspect subscriptions; in EventBridge switch rules. +- [ ] In S3 navigate prefixes and inspect JSON, CSV, binary, and oversized + examples. Binary and oversized payloads should remain metadata-only. +- [ ] Follow an SQS connection to SNS, then use the explicit return link. + Check restored selection, tab, filter, page, and prefix where applicable; + also exercise browser Back/Forward and service deep links. +- [ ] Exercise the breadcrumb switcher with search, arrows, Home/End, Enter, + Escape, Tab/Shift+Tab, and focus restoration. Check the skip link and tab + keyboard navigation without a mouse. +- [ ] Review empty, loading, error, disabled, unsupported, stale, missing-link, + and large-list scenarios. Large content samples paginate 125 entries by 10 + where supported; these are examples, not real service capabilities. +- [ ] Check full-date UTC timestamps ending in `Z`, desktop and narrow layouts, + long identifiers, internal data scrolling, and readable status labels. +- [ ] Confirm all local images load, unknown URLs return 404, and browser + network activity contains no emulator or AWS API requests. + +These are repeatable review steps, not a claim that historical test results +remain a current guarantee. + +## AWS artwork provenance and usage + +The 40 files in `icons` are **unmodified Amazon Web Services artwork**, from the +**July 31, 2026 AWS Architecture Icons release**. They are third-party assets, +**not licensed under MicroStack's MIT license**. + +- Publisher, original archive URL, exact source paths, and family mappings: + [aws-icon-sources.json](aws-icon-sources.json). +- Official source and published usage guidance: + [AWS Architecture Icons](https://aws.amazon.com/architecture/icons/). + +Related API variants share their corresponding family artwork; dedicated +resource icons identify S3 Files, CloudWatch Logs, STS, and Application Load +Balancer. Preserve original colors, proportions, and provenance. AWS artwork +identifies the corresponding emulated services, not MicroStack branding or AWS +endorsement. The MicroStack logo remains the product identity. Consult AWS's +published usage guidance and applicable terms before publishing or +redistributing these assets; inclusion here does not grant additional rights. diff --git a/design/resource-explorer/app.js b/design/resource-explorer/app.js new file mode 100644 index 0000000..c6b7460 --- /dev/null +++ b/design/resource-explorer/app.js @@ -0,0 +1,488 @@ +'use strict'; + +const icons = { + search:'', + copy:'', + refresh:'', + info:'', + arrow:'', + chevron:'', + back:'', + file:'', + folder:'', + warning:'', + link:'' +}; +const icon = name => serviceIconNames.has(name)?``:``; +const esc = value => String(value ?? '').replace(/[&<>"']/g, char => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char])); +const tag = (text, tone='') => `${esc(text)}`; +const account = '000000000000'; +const arn = (service, name) => service === 's3' ? `arn:aws:s3:::${name}` : `arn:aws:${service}:us-east-1:${account}:${name}`; +const message = (id, status, order, extra={}) => ({ + name:id, status, meta:'2026-09-11T10:23:42Z · 482 B', kind:'json', + body:{eventType:'OrderPlaced', orderId:order, customerId:'cus_2048', total:{amount:129.90,currency:'EUR'}, items:[{sku:'FIELD-NOTES-A5',quantity:2}], shipping:{city:'Zürich',country:'CH'}, ...extra}, + facts:{'Content type':'application/json','Receive count':status==='In flight'?'1':'0','Message group':'Not a FIFO queue'} +}); +const link = (label, service, resource, relation, extra={}) => ({label,service,resource,relation,...extra}); +const services = { + sqs:{ + label:'SQS', name:'Simple Queue Service', plural:'Queues', singular:'Queue', content:'Messages', + description:'Inspect queue snapshots, message contents, and configured producers.', + resources:[ + {name:'orders-processing',type:'Standard queue',status:'Available', + counts:[['12','Visible'],['2','Delayed'],['1','In flight']], total:15, + items:[message('8f3a2c10 · ORD-1042','Visible','ORD-1042'),message('bc72e934 · ORD-1043','Visible','ORD-1043'),message('de19a280 · ORD-1044','In flight','ORD-1044')], + configuration:{'Queue type':'Standard','Visibility timeout':'30 seconds','Message retention':'4 days','Delivery delay':'0 seconds','Maximum message size':'256 KiB','Dead-letter queue':'orders-dlq'}, + connections:[link('order-events','sns',0,'Subscription destination'),link('order-routing','events',0,'EventBridge rule target'),link('orders-dlq','sqs',1,'Dead-letter destination')], + }, + {name:'orders-dlq',type:'Standard queue',status:'Available',counts:[['1','Visible'],['0','Delayed'],['0','In flight']],total:1, + items:[message('a29411ce · ORD-1031','Visible','ORD-1031',{retryReason:'InventoryReservationFailed'})], + configuration:{'Queue type':'Standard','Message retention':'14 days','Visibility timeout':'30 seconds'}, + connections:[link('orders-processing','sqs',0,'Configured redrive source')]}, + {name:'notifications-outbox.fifo',type:'FIFO queue',status:'Available',counts:[['0','Visible'],['0','Delayed'],['0','In flight']],total:0,items:[], + configuration:{'Queue type':'FIFO','Content-based deduplication':'Enabled','Message retention':'4 days'},connections:[]} + ] + }, + s3:{ + label:'S3',name:'Simple Storage Service',plural:'Buckets',singular:'Bucket',content:'Objects', + description:'Browse object keys and inspect metadata without downloading everything.', + resources:[ + {name:'order-exports',type:'Bucket',status:'Available',root:'2026/09/11/',total:5, + items:[ + {name:'2026/09/11/orders-001.json',kind:'json',meta:'2.4 KiB · application/json',status:'Object',body:{exportId:'exp_20260911_001',generatedAt:'2026-09-11T10:20:00Z',orders:[{orderId:'ORD-1042',state:'placed',total:129.90}],nextCursor:null},facts:{'Content type':'application/json','Size':'2.4 KiB','Last modified':'2026-09-11T10:20:00Z','ETag':'"d41d8cd98f00b204e9800998ecf8427e"'}}, + {name:'2026/09/11/manifest.csv',kind:'text',meta:'182 B · text/csv',status:'Object',body:'order_id,state,total,currency\nORD-1042,placed,129.90,EUR\nORD-1043,placed,45.00,EUR',facts:{'Content type':'text/csv','Size':'182 B'}}, + {name:'2026/09/11/invoice-1042.pdf',kind:'binary',meta:'48 KiB · application/pdf',status:'Object',facts:{'Content type':'application/pdf','Size':'48 KiB'}}, + {name:'2026/09/11/full-export.json',kind:'oversized',meta:'24 MiB · application/json',status:'Object',facts:{'Content type':'application/json','Size':'24 MiB','Preview policy':'Bounded preview required; limit undecided'}}, + {name:'README.txt',kind:'text',meta:'124 B · text/plain',status:'Object',body:'Synthetic export examples for the MicroStack resource explorer.\nThese objects are demo fixtures, not real customer records.',facts:{'Content type':'text/plain','Size':'124 B'}} + ], + configuration:{'Region':'us-east-1','Versioning':'Disabled','Object count':'5','Encryption':'Not configured','Tags':'environment=local'},connections:[]}, + {name:'microstack-assets',type:'Bucket',status:'Available',items:[{name:'brand/logo.png',kind:'binary',meta:'17 KiB · image/png',status:'Object',facts:{'Content type':'image/png','Size':'17 KiB'}}],configuration:{'Region':'us-east-1','Versioning':'Disabled'},connections:[]}, + {name:'audit-archive',type:'Bucket',status:'Available',items:[],configuration:{'Region':'us-east-1','Versioning':'Enabled'},connections:[]} + ] + }, + dynamodb:{ + label:'DynamoDB',name:'DynamoDB',plural:'Tables',singular:'Table',content:'Items', + description:'Find keys and inspect typed item values, including nested documents.', + resources:[ + {name:'Orders',type:'Table',status:'Active',total:128, + counts:[['PK','Partition key'],['SK','Sort key'],['128','Items in sample table']], + items:[ + {name:'ORDER#1042 / META',meta:'PK: ORDER#1042 · SK: META',status:'Item',kind:'json',body:{PK:{S:'ORDER#1042'},SK:{S:'META'},status:{S:'PLACED'},total:{N:'129.90'},customer:{M:{id:{S:'cus_2048'},city:{S:'Zürich'}}},expedited:{BOOL:false}},facts:{'Partition key':'ORDER#1042 (S)','Sort key':'META (S)','Display':'DynamoDB typed JSON'}}, + {name:'ORDER#1043 / META',meta:'PK: ORDER#1043 · SK: META',status:'Item',kind:'json',body:{PK:{S:'ORDER#1043'},SK:{S:'META'},status:{S:'PLACED'},total:{N:'45.00'},tags:{SS:['local','demo']}},facts:{'Partition key':'ORDER#1043 (S)','Sort key':'META (S)'}}, + {name:'ORDER#1042 / LINE#001',meta:'PK: ORDER#1042 · SK: LINE#001',status:'Item',kind:'json',body:{PK:{S:'ORDER#1042'},SK:{S:'LINE#001'},sku:{S:'FIELD-NOTES-A5'},quantity:{N:'2'}},facts:{'Partition key':'ORDER#1042 (S)','Sort key':'LINE#001 (S)'}} + ],configuration:{'Status':'ACTIVE','Partition key':'PK (String)','Sort key':'SK (String)','Billing mode':'PAY_PER_REQUEST','Item count':'128','Indexes':'0'},connections:[]}, + {name:'Customers',type:'Table',status:'Active',items:[],configuration:{'Partition key':'CustomerId (String)','Billing mode':'PAY_PER_REQUEST'},connections:[]} + ] + }, + sns:{ + label:'SNS',name:'Simple Notification Service',plural:'Topics',singular:'Topic',content:'Subscriptions', + description:'Inspect subscriptions and follow configured delivery destinations.', + resources:[ + {name:'order-events',type:'Standard topic',status:'Available',items:[], + subscriptions:[ + {name:'orders-processing',protocol:'sqs',endpoint:arn('sqs','orders-processing'),state:'Confirmed',service:'sqs',resource:0,filter:'{"eventType":["OrderPlaced"]}'}, + {name:'analytics-ingest',protocol:'https',endpoint:'https://analytics.example.test/events',state:'External endpoint',filter:'No filter policy'} + ], + configuration:{'Type':'Standard','Subscription count':'2','Display name':'Order events','Tags':'environment=local'}, + connections:[link('orders-processing','sqs',0,'SQS subscription'),link('analytics-ingest',null,null,'HTTPS subscription',{external:true})]}, + {name:'customer-updates',type:'Standard topic',status:'Available',items:[],subscriptions:[],configuration:{'Type':'Standard','Subscription count':'0'},connections:[]} + ] + }, + events:{ + label:'EventBridge',name:'EventBridge',plural:'Event buses',singular:'Event bus',content:'Rules', + description:'Inspect rule patterns and navigate the targets they are configured to use.', + resources:[ + {name:'order-routing',type:'Custom event bus',status:'Available',items:[], + rules:[ + {name:'route-new-orders',status:'Enabled',description:'OrderPlaced events from checkout',pattern:{source:['checkout'], 'detail-type':['OrderPlaced']},targets:[link('orders-processing','sqs',0,'SQS target')]}, + {name:'notify-order-updates',status:'Disabled',description:'OrderUpdated events from checkout',pattern:{source:['checkout'],'detail-type':['OrderUpdated']},targets:[link('order-events','sns',0,'SNS target')]}, + {name:'retired-integration',status:'Enabled',description:'Example with a missing destination',pattern:{source:['legacy-import']},targets:[link('legacy-orders',null,null,'SQS target',{missing:true})]} + ], + configuration:{'Bus name':'order-routing','Type':'Custom','Rules':'3','Tags':'environment=local'}, + connections:[link('orders-processing','sqs',0,'route-new-orders → SQS target'),link('order-events','sns',0,'notify-order-updates → SNS target'),link('legacy-orders',null,null,'retired-integration → SQS target',{missing:true})]}, + {name:'default',type:'Default event bus',status:'Available',items:[],rules:[],configuration:{'Bus name':'default','Rules':'0'},connections:[]} + ] + } +}; +const directoryGroups = { + 'Messaging & workflows':[['sqs','SQS'],['sns','SNS'],['events','EventBridge'],['ses','SES'],['stepfunctions','Step Functions']], + 'Storage & databases':[['s3','S3'],['s3files','S3 Files'],['efs','EFS'],['dynamodb','DynamoDB'],['rds','RDS'],['rdsdata','RDS Data'],['elasticache','ElastiCache']], + 'Compute & containers':[['lambda','Lambda'],['ec2','EC2'],['ecs','ECS'],['ecr','ECR']], + 'Networking & delivery':[['apigateway','API Gateway REST'],['apigatewayv2','API Gateway HTTP / WebSocket'],['alb','Application Load Balancer'],['appsync','AppSync'],['cloudfront','CloudFront'],['route53','Route 53'],['servicediscovery','Cloud Map']], + 'Security & identity':[['acm','Certificate Manager'],['cognitoidp','Cognito User Pools'],['cognitoidentity','Cognito Identity Pools'],['iam','IAM'],['kms','KMS'],['secretsmanager','Secrets Manager'],['sts','STS'],['waf','WAF']], + 'Analytics & streaming':[['athena','Athena'],['emr','EMR'],['firehose','Data Firehose'],['glue','Glue'],['kinesis','Kinesis']], + 'Management & observability':[['cloudformation','CloudFormation'],['cloudwatch','CloudWatch'],['logs','CloudWatch Logs'],['ssm','Systems Manager']] +}; +const directoryCount = Object.values(directoryGroups).reduce((total,entries)=>total+entries.length,0); +const serviceIconNames = new Set(Object.values(directoryGroups).flat().map(([id])=>id)); +const sortedServices = Object.values(directoryGroups).flat().map(([id,label])=>[id,{ + label,name:services[id]?.name||label,available:!!services[id] +}]).sort(([,a],[,b])=>a.label.localeCompare(b.label,'en')); +const query = new URLSearchParams(location.search); +const state = {home:query.get('view')==='home'||!query.has('service'),serviceFilter:'',inspectableOnly:false,service:services[query.get('service')]?query.get('service'):'sqs',resource:0,item:0,tab:'contents',scenario:'populated',filter:'',resourceFilter:'',page:0,prefix:'',browsing:false,trail:[]}; +let timer, toastTimer; +let servicePickerOpen=false, activeServiceIndex=0; +const svc = () => services[state.service]; +const resource = () => svc().resources[state.resource]; +const $ = selector => document.querySelector(selector); +const notice = (text, warning=false) => `
${icon(warning?'warning':'info')}

${text}

`; +function searchInput(id, placeholder, value='') { + return ``; +} +function copyButton(type, label='Copy identifier') { + return ``; +} +function jsonHtml(object) { + const text = typeof object==='string'?object:JSON.stringify(object,null,2); + return esc(text).replace(/("(?:[^&]|&(?!quot;))*?")(\s*:)?|\b(true|false|null|\d+(?:\.\d+)?)\b/g,(all,string,colon,number) => { + if(string) return `${string}${colon||''}`; + return `${number}`; + }); +} +function render() { + servicePickerOpen=false; + document.body.className = 'focused'+(state.home?' home':'')+(state.browsing?' browsing':''); + $('#service-home').hidden = !state.home; + $('#service-context').hidden = state.home; + $('.header-title').textContent = state.home?'Services':'Resource explorer'; + $('.skip').textContent = state.home?'Skip to services':'Skip to resource inspector'; + if(state.home) renderHome(); + $('#service-context').innerHTML = `
+
+ + +
`; + $('#scenario').value = state.scenario; + $('#scenario option[value="large"]').disabled = !resource().items.length; + $('#service-title').innerHTML = `
${icon(state.service)}

${svc().name}

${svc().description}

`; + renderIndex(); + renderInspector(); + const url = new URL(location.href); + url.searchParams.delete('layout'); + if(state.home) {url.searchParams.set('view','home');url.searchParams.delete('service');} + else {url.searchParams.delete('view');url.searchParams.set('service',state.service);} + history.replaceState({mockup:state},'',url); +} +function matchingServices() { + const search=$('#service-filter').value.trim().toLowerCase(); + return sortedServices.filter(([,service])=>`${service.label} ${service.name}`.toLowerCase().includes(search)); +} +function updateActiveService() { + const options=Array.from($('#service-options').children); + options.forEach((option,index)=>{ + const active=index===activeServiceIndex; + option.classList.toggle('active',active); + option.tabIndex=active?0:-1; + }); + const active=options[activeServiceIndex]; + if(active) { + $('#service-filter').setAttribute('aria-activedescendant',active.id); + active.scrollIntoView({block:'nearest'}); + } else $('#service-filter').removeAttribute('aria-activedescendant'); +} +function moveActiveService(key,focusResult=false) { + const count=matchingServices().length; + if(!count) return; + activeServiceIndex=key==='Home'?0:key==='End'?count-1:(activeServiceIndex+(key==='ArrowDown'?1:count-1))%count; + updateActiveService(); + if(focusResult) $('#service-options').children[activeServiceIndex].focus({preventScroll:true}); +} +function renderServiceOptions() { + const matches=matchingServices(); + activeServiceIndex=matches.length?0:-1; + $('#service-options').innerHTML=matches.map(([id,service])=>``).join(''); + $('#service-match-count').textContent=matches.length?`${matches.length} ${matches.length===1?'service':'services'} · Tab to results, ↑ ↓ to navigate, Enter to select`:'No matching services. Try another name.'; + updateActiveService(); +} +function openServicePicker(search='',last=false) { + servicePickerOpen=true; + $('#service-picker-panel').hidden=false; + $('#service-switcher').setAttribute('aria-expanded','true'); + $('#service-filter').setAttribute('aria-expanded','true'); + $('#service-filter').value=search; + renderServiceOptions(); + if(last) {activeServiceIndex=matchingServices().length-1;updateActiveService();} + $('#service-filter').focus({preventScroll:true}); +} +function closeServicePicker(restoreFocus=false) { + servicePickerOpen=false; + $('#service-picker-panel').hidden=true; + $('#service-switcher').setAttribute('aria-expanded','false'); + $('#service-filter').setAttribute('aria-expanded','false'); + if(restoreFocus) $('#service-switcher').focus({preventScroll:true}); +} +function chooseService(id) { + if(!services[id]) toast(id==='emr'?'EMR is disabled in this sample.':'This service does not have an inspector in the mockup yet.'); + else if(id===state.service) closeServicePicker(true); + else go(id); +} +function renderHome() { + $('#service-home').innerHTML = `

All services

Choose a service, then inspect its resources in a dedicated workspace.

+
${searchInput('service-search','Find a service',state.serviceFilter)}
+

Service names reflect the repository. Five inspectors are clickable; availability and resource counts are demo examples, not live status.

+
`; + renderDirectory(); +} +function renderDirectory() { + let count=0; + const groups=Object.entries(directoryGroups).map(([group,entries])=>{ + const matching=entries.filter(([id,name])=>(!state.inspectableOnly||services[id])&&`${group} ${id} ${name} ${services[id]?.name||''}`.toLowerCase().includes(state.serviceFilter.toLowerCase())); + count+=matching.length; + if(!matching.length) return ''; + return `

${esc(group)}${matching.length}

    ${matching.map(([id,name])=>{ + const service=services[id]; + const content=`${icon(id)}${esc(name)}${service?`${service.resources.length} sample ${service.plural.toLowerCase()}`:id==='emr'?'Disabled (sample)':'Inspector not in mockup'}${service?icon('arrow'):''}`; + return `
  • ${service?``:`
    ${content}
    `}
  • `; + }).join('')}
`; + }).join(''); + $('#directory-results').innerHTML = `

${count} of ${directoryCount} services${state.serviceFilter?' match your search':''}

${count?`
${groups}
`:statePanel('No matching services','Try a service name such as SQS, storage, or Lambda.',``,'search')}`; +} +function renderIndex() { + const matches = svc().resources.map((r,i)=>({r,i})).filter(({r})=>r.name.toLowerCase().includes(state.resourceFilter.toLowerCase())); + $('#resource-index').innerHTML = `
${svc().plural}${svc().resources.length}
+ ${searchInput('resource-search','Filter '+svc().plural.toLowerCase(),state.resourceFilter)} +
${matches.map(({r,i})=>``).join('') || '

No matching resources.

'}
+

Only resources from the sample account are shown.

`; +} +function currentArn() { + const name = state.service==='dynamodb'?'table/'+resource().name:state.service==='events'?'event-bus/'+resource().name:resource().name; + return arn(state.service,name); +} +function renderInspector() { + const r=resource(); + const tabs = [['contents',svc().content],['configuration','Configuration'],['connections','Connections'],['activity','Activity']]; + $('#inspector').innerHTML = `
+ ${state.trail.length?``:''} +

${esc(r.name)}

${esc(r.type)} ${tag(r.status,'green')}
+
+
${esc(currentArn())}${copyButton('arn')}
+
+
${tabs.map(([id,label])=>``).join('')}
+
${panel()}
`; +} +function statePanel(title,text,button='',symbol='info') { + return `
${icon(symbol)}

${title}

${text}

${button}
`; +} +function panel() { + if(state.scenario==='loading') return `

Loading resource snapshot…

${Array.from({length:6},()=>'
').join('')}

Preview state. Choose Populated to finish loading.

`; + if(state.scenario==='error') return statePanel('Unable to reach MicroStack','The resource snapshot could not be loaded. Check that your local API is running, then try again.',`

Simulated error · retry restores sample data.

`,'warning'); + if(state.scenario==='disabled') return statePanel('This service is disabled',`${svc().label} is excluded from the instance’s enabled services. An unavailable service is not an empty resource.`,``); + if(state.scenario==='unsupported') return statePanel('Inspector not available','This state represents an enabled service without an implemented resource inspector. Continue using your SDK or CLI; no empty-resource claim is made.',``); + let html=state.scenario==='stale'?notice('Snapshot is 18 minutes old. Counts and contents may have changed. ',true):''; + if(state.tab==='configuration') return html+configuration(); + if(state.tab==='connections') return html+connections(); + if(state.tab==='activity') return html+activity(); + if(state.scenario==='missing') return html+connections(true); + if(state.scenario==='empty') return html+statePanel(`No ${svc().content.toLowerCase()} in this example`,'The resource is available, but this snapshot has no entries. Create data using your application, SDK, or CLI.',``,state.service==='s3'?'folder':'file'); + if(state.service==='sns') return html+subscriptions(); + if(state.service==='events') return html+rules(); + return html+contents(); +} +function allItems() { + const original=resource().items; + if(state.scenario!=='large' || !original.length) return original; + return Array.from({length:125},(_,i)=>({...original[i%original.length], name:state.service==='s3'?`batch-${String(i+1).padStart(3,'0')}/export.json`:`${original[i%original.length].name} · sample ${String(i+1).padStart(3,'0')}`})); +} +function visibleItems() { + const items=allItems(); + if(state.service==='s3' && state.scenario!=='large') { + const folders=new Map(), entries=[]; + items.forEach((item,i)=>{ + if(!item.name.startsWith(state.prefix)) return; + const suffix=item.name.slice(state.prefix.length), slash=suffix.indexOf('/'); + if(slash>=0) { + const name=state.prefix+suffix.slice(0,slash+1); + folders.set(name,{name,kind:'folder',meta:'Virtual prefix',status:'Prefix',i:-1}); + } else entries.push({...item,i}); + }); + return [...folders.values(),...entries].filter(item=>item.name.toLowerCase().includes(state.filter.toLowerCase())); + } + return items.map((item,i)=>({...item,i})).filter(item=>item.name.toLowerCase().includes(state.filter.toLowerCase()) || JSON.stringify(item.body||'').toLowerCase().includes(state.filter.toLowerCase())); +} +function contents() { + const r=resource(), all=allItems(), matches=visibleItems(); + const counts=state.scenario==='large' && state.service==='sqs'? + [[String(all.filter(i=>i.status==='Visible').length),'Visible'],['0','Delayed'],[String(all.filter(i=>i.status==='In flight').length),'In flight']]:r.counts; + const pageCount=Math.max(1,Math.ceil(matches.length/10)); + state.page=Math.min(state.page,pageCount-1); + const records=matches.slice(state.page*10,state.page*10+10); + const selected=all[state.item]; + const disclaimer=state.service==='sqs'?'Proposed non-consuming snapshot. Viewing these sample messages does not receive, hide, or delete them. This needs a dedicated admin API.': + state.service==='dynamodb'?'Loaded sample items only. Filtering here is not a DynamoDB query or a full-table scan. Types are preserved in the item inspector.': + 'Object previews and metadata are sample fixtures. Prefixes group object keys; they are not physical folders.'; + const prefixParts=(state.scenario==='large'?'':state.prefix).split('/').filter(Boolean); + const prefixNav=state.service==='s3'?``:''; + return notice(disclaimer)+ + (counts?`
${counts.map(([n,label])=>`
${n}${label}
`).join('')}
`:'')+prefixNav+ + `
${searchInput('item-search','Filter loaded '+svc().content.toLowerCase(),state.filter)}${all.length} sample entries loaded
+
+
${svc().content}${matches.length} ${state.filter?'matching':'in view'}
+ ${records.map(item=>``).join('') || '
No matching entries.
Try a different filter or prefix.
'}
+
${selected && matches.some(x=>x.i===state.item)?payload(selected):statePanel('Select an entry','Choose a message, object, or item to inspect its contents.','','file')}
+
+ ${contextLinks()}`; +} +function payload(item) { + return `
${item.kind==='json'?'JSON':['binary','oversized'].includes(item.kind)?'Metadata':'Text'} inspector${item.body?copyButton('payload','Copy payload'):''}
+ ${item.kind==='binary'?statePanel('Preview unavailable','Binary content is not rendered in this prototype. Metadata remains available; no download or external viewer is invoked.','','file'):item.kind==='oversized'?statePanel('Object exceeds preview size','This example intentionally withholds a large payload. A production preview must be bounded; the exact size limit is still a design decision.','','file'):`
${jsonHtml(item.body)}
`} +
${Object.entries(item.facts||{}).map(([k,v])=>`
${esc(k)}
${esc(v)}
`).join('')}
`; +} +function targetButton(item,label=item.label) { + return item.service?``:`${esc(label)}`; +} +function contextLinks() { + const links=resource().connections; + if(!links.length) return ``; + return ``; +} +function configuration() { + return `

Resource configuration

${tag('Read only')}
+

Scope

This is instance configuration, not a guarantee of per-region resource isolation.

`; +} +function connections(forceMissing=false) { + const links=forceMissing?[link('legacy-orders',null,null,'Configured SQS target',{missing:true})]:resource().connections; + return notice('These links describe configured subscriptions, targets, or redrive policies. They do not prove that a message matched, was delivered, or was processed.')+ + `

Configured connections

${links.length}
`+ + (links.length?`
${links.map(l=>`
${icon(l.service||'link')}
${targetButton(l)}${esc(l.relation)}${l.missing?'The destination is not present in this sample account.':l.external?'External destination · no MicroStack inspector.':''}
${tag(l.missing?'Not found':l.external?'External':'Configured',l.missing?'amber':'')}
`).join('')}
`:statePanel('No configured connections','This sample has no supported resource relationships. Connections are never inferred from resource names.','','link')); +} +function subscriptions() { + const subs=resource().subscriptions; + return notice('Subscription configuration is shown below. A confirmed subscription is not proof of a successful delivery.')+ + `

Subscriptions

${subs.length}
`+ + (subs.length?`
${subs.map(s=>`
${icon(s.protocol==='sqs'?'sqs':'link')}
${targetButton(s,s.name)}${esc(s.protocol.toUpperCase())} · ${esc(s.endpoint)}Filter: ${esc(s.filter)}
${tag(s.state,s.state==='Confirmed'?'green':'')}
`).join('')}
`:statePanel('No subscriptions','This topic exists but has no sample subscriptions.','','sns'))+ + `

About topic inspection

SNS is not a message queue. This view inspects subscription configuration, not a retained message inbox.

`; +} +function rules() { + const rules=resource().rules; + if(!rules.length) return statePanel('No rules on this bus','The event bus exists but no sample routing rules are configured.','','events'); + const current=rules[Math.min(state.item,rules.length-1)]; + return notice('Enabled means the rule is configured to participate in routing. Event matching and target delivery are not observed in this mockup.')+ + `
Rules${rules.length} total
${rules.map((rule,i)=>``).join('')}
+
Event pattern${tag('Configuration')}
${jsonHtml(current.pattern)}
Targets${current.targets.map(t=>`

${targetButton(t)} ${t.missing?tag('Not found','amber'):tag('Configured')}${esc(t.relation)}

`).join('')}
`; +} +function activity() { + const actions={sqs:['GetQueueAttributes','SendMessage','ReceiveMessage','ListQueues'],s3:['GetObject','ListObjectsV2','HeadObject','ListBuckets'],dynamodb:['GetItem','PutItem','Query','ListTables'],sns:['GetTopicAttributes','ListSubscriptionsByTopic','Publish','ListTopics'],events:['ListTargetsByRule','DescribeRule','PutEvents','ListEventBuses']}[state.service]; + return notice(`Service/account scope, not resource history. The current log does not carry reliable resource identifiers. These sample calls belong to ${svc().label} / ${account}; none is claimed to target this selected resource.`)+ + `

Recent ${svc().label} API activity

${tag('Sample log')}
${actions.map((a,i)=>``).join('')}
Time (UTC)ActionAccountStatusDuration
2026-09-11T10:23:${42-i*5}Z${a}${account}${tag(i===2?'400':'200',i===2?'amber':'green')}${[3,8,2,1][i]} ms

Metadata only. No request bodies, response bodies, or causal delivery traces are captured.

`; +} +function beginNavigation() { + history.replaceState({mockup:state},'',location.href); + history.pushState(null,'',location.href); +} +function go(service,index=0,connected=false) { + clearTimeout(timer); + beginNavigation(); + if(connected) state.trail.push({service:state.service,resource:state.resource,item:state.item,tab:state.tab,prefix:state.prefix,filter:state.filter,resourceFilter:state.resourceFilter,page:state.page,scenario:state.scenario,name:resource().name}); + else state.trail=[]; + Object.assign(state,{home:false,service,resource:index,item:0,tab:'contents',filter:'',resourceFilter:'',page:0,scenario:'populated',browsing:false,prefix:services[service].resources[index].root||''}); + render(); + $('#main').focus({preventScroll:true}); +} +function goHome() { + clearTimeout(timer); + beginNavigation(); + Object.assign(state,{home:true,browsing:false,trail:[]}); + render(); + $('#service-search').focus({preventScroll:true}); + window.scrollTo(0,0); +} +function toast(text) { + clearTimeout(toastTimer);$('#toast').textContent=text; + toastTimer=setTimeout(()=>$('#toast').textContent='',3500); +} +document.addEventListener('click',async event=>{ + const button=event.target.closest('[data-action]'); + if(!button||button.disabled) return; + const {action,value}=button.dataset; + if(action==='home') goHome(); + else if(action==='toggle-service-picker') {if(servicePickerOpen) closeServicePicker(true);else openServicePicker();} + else if(action==='choose-service') chooseService(value); + else if(action==='open-service') {go(value);window.scrollTo(0,0);} + else if(action==='clear-service-search') {state.serviceFilter='';state.inspectableOnly=false;renderHome();$('#service-search').focus();} + else if(action==='resource') go(state.service,Number(value)); + else if(action==='connection') go(button.dataset.service,Number(button.dataset.resource),true); + else if(action==='back') {clearTimeout(timer);beginNavigation();Object.assign(state,state.trail.pop(),{browsing:false});render();$('#main').focus({preventScroll:true});} + else if(action==='tab') {state.tab=value;renderInspector();$(`#tab-${value}`).focus();} + else if(action==='item'||action==='rule') {state.item=Number(value);renderInspector();$(`[data-action="${action}"][data-value="${value}"]`)?.focus();} + else if(action==='prefix') {state.prefix=value;state.filter='';state.page=0;state.item=-1;renderInspector();} + else if(action==='page') {state.page+=Number(value);state.item=-1;renderInspector();} + else if(action==='show-resources') {state.browsing=!state.browsing;render();if(state.browsing) $('#resource-search').focus();} + else if(action==='retry') {clearTimeout(timer);state.scenario='populated';render();toast('Sample snapshot restored. No API request was made.');} + else if(action==='refresh') { + const previous=state.scenario==='loading'?'populated':state.scenario; + state.scenario='loading';render(); + timer=setTimeout(()=>{state.scenario=previous==='stale'?'populated':previous;render();toast('Sample snapshot refreshed. No live API calls.');},650); + } else if(action==='copy') { + const text=button.dataset.type==='arn'?currentArn():allItems()[state.item]?.body; + try {await navigator.clipboard.writeText(typeof text==='string'?text:JSON.stringify(text,null,2));toast(button.dataset.type==='arn'?'ARN copied':'Sample payload copied');} + catch {toast('Clipboard access is unavailable. Select the visible text to copy it manually.');} + } +}); +document.addEventListener('input',event=>{ + if(event.target.id==='service-filter') renderServiceOptions(); + if(event.target.id==='service-search') {state.serviceFilter=event.target.value;renderDirectory();} + if(event.target.id==='resource-search') { + state.resourceFilter=event.target.value; + const caret=event.target.selectionStart;renderIndex();$('#resource-search').focus(); + if(caret!==null && $('#resource-search').type!=='search') $('#resource-search').setSelectionRange(caret,caret); + } + if(event.target.id==='item-search') { + state.filter=event.target.value;state.page=0;renderInspector();$('#item-search').focus(); + } +}); +document.addEventListener('change',event=>{ + if(event.target.id==='inspectable-only') {state.inspectableOnly=event.target.checked;renderDirectory();} + if(event.target.id==='scenario') {clearTimeout(timer);state.scenario=event.target.value;state.page=0;state.filter='';state.item=0;render();} +}); +document.addEventListener('keydown',event=>{ + if(event.isComposing) return; + if(servicePickerOpen&&event.key==='Escape') {event.preventDefault();closeServicePicker(true);return;} + if(event.target.id==='service-switcher') { + if(['ArrowDown','ArrowUp'].includes(event.key)) {event.preventDefault();openServicePicker('',event.key==='ArrowUp');return;} + if(event.key.length===1&&event.key!==' '&&!event.ctrlKey&&!event.altKey&&!event.metaKey) {event.preventDefault();openServicePicker(event.key);return;} + } + if(event.target.id==='service-filter') { + const matches=matchingServices(); + if(['ArrowDown','ArrowUp'].includes(event.key)) { + event.preventDefault(); + moveActiveService(event.key); + } else if(event.key==='Enter') { + event.preventDefault(); + if(matches[activeServiceIndex]) chooseService(matches[activeServiceIndex][0]); + } + return; + } + if(event.target.matches('#service-options [role="option"]')&&['ArrowDown','ArrowUp','Home','End'].includes(event.key)) { + event.preventDefault(); + activeServiceIndex=matchingServices().findIndex(([id])=>id===event.target.dataset.value); + moveActiveService(event.key,true); + return; + } + if(event.target.matches('[role="tab"]') && ['ArrowRight','ArrowLeft','Home','End'].includes(event.key)) { + event.preventDefault(); + const tabs=['contents','configuration','connections','activity'], current=tabs.indexOf(state.tab); + state.tab=tabs[event.key==='Home'?0:event.key==='End'?3:(current+(event.key==='ArrowRight'?1:3))%4]; + renderInspector();$(`#tab-${state.tab}`).focus(); + } +}); +document.addEventListener('pointerdown',event=>{ + if(servicePickerOpen&&!event.target.closest('.service-picker')) closeServicePicker(); +}); +document.addEventListener('focusout',event=>{ + if(servicePickerOpen&&!$('.service-picker').contains(event.relatedTarget)) closeServicePicker(); +}); +window.addEventListener('popstate',event=>{ + if(!event.state?.mockup) return; + clearTimeout(timer); + Object.assign(state,event.state.mockup); + render(); + $('#main').focus({preventScroll:true}); +}); +render(); diff --git a/design/resource-explorer/aws-icon-sources.json b/design/resource-explorer/aws-icon-sources.json new file mode 100644 index 0000000..ee3a673 --- /dev/null +++ b/design/resource-explorer/aws-icon-sources.json @@ -0,0 +1,51 @@ +{ + "publisher": "Amazon Web Services", + "release": "2026-07-31", + "sourcePage": "https://aws.amazon.com/architecture/icons/", + "packageUrl": "https://d1.awsstatic.com/onedam/marketing-channels/website/public/shared/architecture-icon-release/Icon-package_07312026.5846e92413caa21490223536cc97f1269e44fa92.zip", + "usage": "Original AWS SVGs identify corresponding emulated services, not MicroStack branding. Artwork is unmodified. Consult AWS source guidance before publishing or redistributing.", + "sources": { + "sqs": "Architecture-Service-Icons_07312026/Arch_Application-Integration/32/Arch_Amazon-Simple-Queue-Service_32.svg", + "sns": "Architecture-Service-Icons_07312026/Arch_Application-Integration/32/Arch_Amazon-Simple-Notification-Service_32.svg", + "events": "Architecture-Service-Icons_07312026/Arch_Application-Integration/32/Arch_Amazon-EventBridge_32.svg", + "ses": "Architecture-Service-Icons_07312026/Arch_Business-Applications/32/Arch_Amazon-Simple-Email-Service_32.svg", + "stepfunctions": "Architecture-Service-Icons_07312026/Arch_Application-Integration/32/Arch_AWS-Step-Functions_32.svg", + "s3": "Architecture-Service-Icons_07312026/Arch_Storage/32/Arch_Amazon-Simple-Storage-Service_32.svg", + "s3files": "Resource-Icons_07312026/Res_Storage/Res_Amazon-Simple-Storage-Service_S3-Files_48.svg", + "efs": "Architecture-Service-Icons_07312026/Arch_Storage/32/Arch_Amazon-EFS_32.svg", + "dynamodb": "Architecture-Service-Icons_07312026/Arch_Databases/32/Arch_Amazon-DynamoDB_32.svg", + "rds": "Architecture-Service-Icons_07312026/Arch_Databases/32/Arch_Amazon-RDS_32.svg", + "elasticache": "Architecture-Service-Icons_07312026/Arch_Databases/32/Arch_Amazon-ElastiCache_32.svg", + "lambda": "Architecture-Service-Icons_07312026/Arch_Compute/32/Arch_AWS-Lambda_32.svg", + "ec2": "Architecture-Service-Icons_07312026/Arch_Compute/32/Arch_Amazon-EC2_32.svg", + "ecs": "Architecture-Service-Icons_07312026/Arch_Containers/32/Arch_Amazon-Elastic-Container-Service_32.svg", + "ecr": "Architecture-Service-Icons_07312026/Arch_Containers/32/Arch_Amazon-Elastic-Container-Registry_32.svg", + "apigateway": "Architecture-Service-Icons_07312026/Arch_Networking-Content-Delivery/32/Arch_Amazon-API-Gateway_32.svg", + "alb": "Resource-Icons_07312026/Res_Networking-Content-Delivery/Res_Elastic-Load-Balancing_Application-Load-Balancer_48.svg", + "appsync": "Architecture-Service-Icons_07312026/Arch_Application-Integration/32/Arch_AWS-AppSync_32.svg", + "cloudfront": "Architecture-Service-Icons_07312026/Arch_Networking-Content-Delivery/32/Arch_Amazon-CloudFront_32.svg", + "route53": "Architecture-Service-Icons_07312026/Arch_Networking-Content-Delivery/32/Arch_Amazon-Route-53_32.svg", + "servicediscovery": "Architecture-Service-Icons_07312026/Arch_Networking-Content-Delivery/32/Arch_AWS-Cloud-Map_32.svg", + "acm": "Architecture-Service-Icons_07312026/Arch_Security-Identity/32/Arch_AWS-Certificate-Manager_32.svg", + "cognitoidp": "Architecture-Service-Icons_07312026/Arch_Security-Identity/32/Arch_Amazon-Cognito_32.svg", + "iam": "Architecture-Service-Icons_07312026/Arch_Security-Identity/32/Arch_AWS-Identity-and-Access-Management_32.svg", + "kms": "Architecture-Service-Icons_07312026/Arch_Security-Identity/32/Arch_AWS-Key-Management-Service_32.svg", + "secretsmanager": "Architecture-Service-Icons_07312026/Arch_Security-Identity/32/Arch_AWS-Secrets-Manager_32.svg", + "sts": "Resource-Icons_07312026/Res_Security-Identity/Res_AWS-Identity-Access-Management_AWS-STS_48.svg", + "waf": "Architecture-Service-Icons_07312026/Arch_Security-Identity/32/Arch_AWS-WAF_32.svg", + "athena": "Architecture-Service-Icons_07312026/Arch_Analytics/32/Arch_Amazon-Athena_32.svg", + "emr": "Architecture-Service-Icons_07312026/Arch_Analytics/32/Arch_Amazon-EMR_32.svg", + "firehose": "Architecture-Service-Icons_07312026/Arch_Analytics/32/Arch_Amazon-Data-Firehose_32.svg", + "glue": "Architecture-Service-Icons_07312026/Arch_Analytics/32/Arch_AWS-Glue_32.svg", + "kinesis": "Architecture-Service-Icons_07312026/Arch_Analytics/32/Arch_Amazon-Kinesis_32.svg", + "cloudformation": "Architecture-Service-Icons_07312026/Arch_Management-Tools/32/Arch_AWS-CloudFormation_32.svg", + "cloudwatch": "Architecture-Service-Icons_07312026/Arch_Management-Tools/32/Arch_Amazon-CloudWatch_32.svg", + "logs": "Resource-Icons_07312026/Res_Management-Governance/Res_Amazon-CloudWatch_Logs_48.svg", + "ssm": "Architecture-Service-Icons_07312026/Arch_Management-Tools/32/Arch_AWS-Systems-Manager_32.svg" + }, + "familyAliases": { + "apigatewayv2": "apigateway", + "cognitoidentity": "cognitoidp", + "rdsdata": "rds" + } +} diff --git a/design/resource-explorer/icons/acm.svg b/design/resource-explorer/icons/acm.svg new file mode 100644 index 0000000..5a49b4c --- /dev/null +++ b/design/resource-explorer/icons/acm.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-Certificate-Manager_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/alb.svg b/design/resource-explorer/icons/alb.svg new file mode 100644 index 0000000..992c502 --- /dev/null +++ b/design/resource-explorer/icons/alb.svg @@ -0,0 +1,7 @@ + + + Icon-Resource/Networking-and-Content-Delivery/Res_Elastic-Load-Balancing_Application-Load-Balancer_48 + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/apigateway.svg b/design/resource-explorer/icons/apigateway.svg new file mode 100644 index 0000000..6f05362 --- /dev/null +++ b/design/resource-explorer/icons/apigateway.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/design/resource-explorer/icons/apigatewayv2.svg b/design/resource-explorer/icons/apigatewayv2.svg new file mode 100644 index 0000000..6f05362 --- /dev/null +++ b/design/resource-explorer/icons/apigatewayv2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/design/resource-explorer/icons/appsync.svg b/design/resource-explorer/icons/appsync.svg new file mode 100644 index 0000000..b00d11a --- /dev/null +++ b/design/resource-explorer/icons/appsync.svg @@ -0,0 +1,12 @@ + + + Icon-Architecture/32/Arch_AWS-AppSync_32 + + + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/athena.svg b/design/resource-explorer/icons/athena.svg new file mode 100644 index 0000000..11e0683 --- /dev/null +++ b/design/resource-explorer/icons/athena.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Athena_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/cloudformation.svg b/design/resource-explorer/icons/cloudformation.svg new file mode 100644 index 0000000..74657ae --- /dev/null +++ b/design/resource-explorer/icons/cloudformation.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-CloudFormation_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/cloudfront.svg b/design/resource-explorer/icons/cloudfront.svg new file mode 100644 index 0000000..2c16a09 --- /dev/null +++ b/design/resource-explorer/icons/cloudfront.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-CloudFront_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/cloudwatch.svg b/design/resource-explorer/icons/cloudwatch.svg new file mode 100644 index 0000000..de06cf1 --- /dev/null +++ b/design/resource-explorer/icons/cloudwatch.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-CloudWatch_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/cognitoidentity.svg b/design/resource-explorer/icons/cognitoidentity.svg new file mode 100644 index 0000000..be3d14b --- /dev/null +++ b/design/resource-explorer/icons/cognitoidentity.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Cognito_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/cognitoidp.svg b/design/resource-explorer/icons/cognitoidp.svg new file mode 100644 index 0000000..be3d14b --- /dev/null +++ b/design/resource-explorer/icons/cognitoidp.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Cognito_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/dynamodb.svg b/design/resource-explorer/icons/dynamodb.svg new file mode 100644 index 0000000..948ee42 --- /dev/null +++ b/design/resource-explorer/icons/dynamodb.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-DynamoDB_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/ec2.svg b/design/resource-explorer/icons/ec2.svg new file mode 100644 index 0000000..df01964 --- /dev/null +++ b/design/resource-explorer/icons/ec2.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-EC2_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/ecr.svg b/design/resource-explorer/icons/ecr.svg new file mode 100644 index 0000000..91729a0 --- /dev/null +++ b/design/resource-explorer/icons/ecr.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Elastic-Container-Registry_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/ecs.svg b/design/resource-explorer/icons/ecs.svg new file mode 100644 index 0000000..7738b90 --- /dev/null +++ b/design/resource-explorer/icons/ecs.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Elastic-Container-Service_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/efs.svg b/design/resource-explorer/icons/efs.svg new file mode 100644 index 0000000..e8611ff --- /dev/null +++ b/design/resource-explorer/icons/efs.svg @@ -0,0 +1,12 @@ + + + Icon-Architecture/32/Arch_Amazon-EFS_32 + + + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/elasticache.svg b/design/resource-explorer/icons/elasticache.svg new file mode 100644 index 0000000..d1465ef --- /dev/null +++ b/design/resource-explorer/icons/elasticache.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-ElastiCache_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/emr.svg b/design/resource-explorer/icons/emr.svg new file mode 100644 index 0000000..0a1916a --- /dev/null +++ b/design/resource-explorer/icons/emr.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-EMR_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/events.svg b/design/resource-explorer/icons/events.svg new file mode 100644 index 0000000..1d6fe98 --- /dev/null +++ b/design/resource-explorer/icons/events.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-EventBridge_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/firehose.svg b/design/resource-explorer/icons/firehose.svg new file mode 100644 index 0000000..965e18c --- /dev/null +++ b/design/resource-explorer/icons/firehose.svg @@ -0,0 +1,12 @@ + + + Icon-Architecture/32/Arch_Amazon-Kinesis-Data-Firehose_32 + + + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/glue.svg b/design/resource-explorer/icons/glue.svg new file mode 100644 index 0000000..5b05d46 --- /dev/null +++ b/design/resource-explorer/icons/glue.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-Glue_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/iam.svg b/design/resource-explorer/icons/iam.svg new file mode 100644 index 0000000..867ae32 --- /dev/null +++ b/design/resource-explorer/icons/iam.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-Identity-and-Access-Management_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/kinesis.svg b/design/resource-explorer/icons/kinesis.svg new file mode 100644 index 0000000..b2314b8 --- /dev/null +++ b/design/resource-explorer/icons/kinesis.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Kinesis_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/kms.svg b/design/resource-explorer/icons/kms.svg new file mode 100644 index 0000000..67dc49f --- /dev/null +++ b/design/resource-explorer/icons/kms.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-Key-Management-Service_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/lambda.svg b/design/resource-explorer/icons/lambda.svg new file mode 100644 index 0000000..b2718d1 --- /dev/null +++ b/design/resource-explorer/icons/lambda.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-Lambda_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/logs.svg b/design/resource-explorer/icons/logs.svg new file mode 100644 index 0000000..bcfb802 --- /dev/null +++ b/design/resource-explorer/icons/logs.svg @@ -0,0 +1,7 @@ + + + Icon-Resource/Management-Governance/Res_Amazon-CloudWatch_Logs_48 + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/rds.svg b/design/resource-explorer/icons/rds.svg new file mode 100644 index 0000000..61793ec --- /dev/null +++ b/design/resource-explorer/icons/rds.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-RDS_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/rdsdata.svg b/design/resource-explorer/icons/rdsdata.svg new file mode 100644 index 0000000..61793ec --- /dev/null +++ b/design/resource-explorer/icons/rdsdata.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-RDS_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/route53.svg b/design/resource-explorer/icons/route53.svg new file mode 100644 index 0000000..9134b77 --- /dev/null +++ b/design/resource-explorer/icons/route53.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Route-53_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/s3.svg b/design/resource-explorer/icons/s3.svg new file mode 100644 index 0000000..6a2edca --- /dev/null +++ b/design/resource-explorer/icons/s3.svg @@ -0,0 +1,12 @@ + + + Icon-Architecture/32/Arch_Amazon-Simple-Storage-Service_32 + + + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/s3files.svg b/design/resource-explorer/icons/s3files.svg new file mode 100644 index 0000000..927aa41 --- /dev/null +++ b/design/resource-explorer/icons/s3files.svg @@ -0,0 +1,3 @@ + + + diff --git a/design/resource-explorer/icons/secretsmanager.svg b/design/resource-explorer/icons/secretsmanager.svg new file mode 100644 index 0000000..e56f26f --- /dev/null +++ b/design/resource-explorer/icons/secretsmanager.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-Secrets-Manager_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/servicediscovery.svg b/design/resource-explorer/icons/servicediscovery.svg new file mode 100644 index 0000000..0237856 --- /dev/null +++ b/design/resource-explorer/icons/servicediscovery.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-Cloud-Map_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/ses.svg b/design/resource-explorer/icons/ses.svg new file mode 100644 index 0000000..6654e21 --- /dev/null +++ b/design/resource-explorer/icons/ses.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Simple-Email-Service_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/sns.svg b/design/resource-explorer/icons/sns.svg new file mode 100644 index 0000000..c492368 --- /dev/null +++ b/design/resource-explorer/icons/sns.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Simple-Notification-Service_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/sqs.svg b/design/resource-explorer/icons/sqs.svg new file mode 100644 index 0000000..6f6ebc9 --- /dev/null +++ b/design/resource-explorer/icons/sqs.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Simple-Queue-Service_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/ssm.svg b/design/resource-explorer/icons/ssm.svg new file mode 100644 index 0000000..f76c16c --- /dev/null +++ b/design/resource-explorer/icons/ssm.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-Systems-Manager_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/stepfunctions.svg b/design/resource-explorer/icons/stepfunctions.svg new file mode 100644 index 0000000..d1d544d --- /dev/null +++ b/design/resource-explorer/icons/stepfunctions.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-Step-Functions_32 + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/sts.svg b/design/resource-explorer/icons/sts.svg new file mode 100644 index 0000000..954e219 --- /dev/null +++ b/design/resource-explorer/icons/sts.svg @@ -0,0 +1,7 @@ + + + Icon-Resource/Security-Identity-and-Compliance/Res_AWS-Identity-Access-Management_AWS-STS_48 + + + + \ No newline at end of file diff --git a/design/resource-explorer/icons/waf.svg b/design/resource-explorer/icons/waf.svg new file mode 100644 index 0000000..a83383b --- /dev/null +++ b/design/resource-explorer/icons/waf.svg @@ -0,0 +1,12 @@ + + + Icon-Architecture/32/Arch_AWS-WAF_32 + + + + + + + + + \ No newline at end of file diff --git a/design/resource-explorer/index.html b/design/resource-explorer/index.html new file mode 100644 index 0000000..a98703e --- /dev/null +++ b/design/resource-explorer/index.html @@ -0,0 +1,55 @@ + + + + + + + MicroStack · Resource explorer mockups + + + + + +
+
Design previewSynthetic data · no live API calls
+ Design decisions +
+
+ MicroStack + Resource explorer +
+
+ +
+
+
+
+ +
Account 000000000000Configured region us-east-1
+
+ +
+
+
+
+
+
Read-only prototypeSample snapshot · 2026-09-11T10:24:00Z
+
+
+
+ + diff --git a/design/resource-explorer/logo.png b/design/resource-explorer/logo.png new file mode 100644 index 0000000..76b78d5 Binary files /dev/null and b/design/resource-explorer/logo.png differ diff --git a/design/resource-explorer/notes.html b/design/resource-explorer/notes.html new file mode 100644 index 0000000..bb0037a --- /dev/null +++ b/design/resource-explorer/notes.html @@ -0,0 +1,25 @@ + +MicroStack · Prototype decisions +
← All services

Find a service. Work inside it.

+

A read-only design study for debugging a local MicroStack instance. All resources, payloads, counts, and activity in these screens are synthetic fixtures.

+

What is settled

Service-first navigation, resource-specific contents, and configured connections were selected with the user. The visual direction is a familiar developer console—not the experimental alternatives. Specific reference products were not provided, so conventional console and database-tool patterns are a working assumption.

+

The new default: services home

The user identified a scaling problem with the resource tree: as services and resources grow, the navigation rail becomes too tall. The selected prototype direction is now a searchable, grouped services home followed by a dedicated split inspector with no permanent services rail.

+

The directory includes 40 service entries based on repository handler names. Five have clickable example inspectors. The remaining entries are explicitly outside this mockup; EMR illustrates a disabled service, not a claim about the running instance. The With an inspector filter narrows the directory to usable examples.

+

Inside a service, use All services to return home or the compact switcher to jump to another example. Resource connections still go directly to their destination and preserve a return trail. Browser Back and Forward also preserve the in-session navigation state.

+

The selected workspace

Open the SQS split inspector: resources stay on the left and inspection on the right. Browsing the entire catalog is a separate home-page task; the compact switcher keeps frequent jumps short. Alternative layouts have been removed from the mockup. This repository copy preserves the selected design; production implementation is authorized separately and does not turn this mock into an emulator integration.

+

A useful review path

  1. In SQS, select a message and inspect its nested JSON.
  2. Open Connections, follow order-events, and inspect its SNS subscriptions.
  3. Use the explicit back link to return to the queue; open EventBridge's order-routing bus.
  4. Switch rules to compare enabled, disabled, and missing-target states.
  5. In S3, follow prefix breadcrumbs and compare JSON, CSV, binary, and oversized objects.
  6. In DynamoDB, inspect typed attributes and filter the loaded sample.
  7. Use the example-state selector to explore edge cases. Large list generates 125 entries, paginated by 10, for populated S3, SQS, and DynamoDB content samples; it is unavailable for subscriptions, rules, and empty resources.
+

The interface must not overclaim

  • SQS: non-consuming inspection is a proposed dedicated admin API. Using ReceiveMessage would change visibility.
  • SNS: topics have subscriptions, not a retained message inbox. Confirmed subscriptions do not prove delivery.
  • EventBridge: enabled rules, patterns, and configured targets are not an observed event trace.
  • Activity: current logs support service/account scope. Resource-specific attribution requires new metadata; no row is claimed to belong to the selected resource.
  • Relationships: configuration provides the edges. Object and table names are not evidence of relationships.
  • Scope: account and configured region are displayed, but region isolation is not assumed.
+

Production concerns, not implemented in this mock

+ + + + + + + +
CapabilityRequired work
Resource listsAccount-scoped paginated contracts, disabled/unsupported service distinction, stable identifiers.
S3 contentsPrefix-aware object listing, bounded previews, metadata and binary/oversize handling.
SQS snapshotsNon-mutating inspection under service synchronization, visibility semantics, bounded data, snapshot timestamps.
DynamoDB itemsTyped item contracts, bounded scan/query semantics, pagination and key schema.
Messaging connectionsSNS subscriptions and EventBridge bus/rule/target admin adapters, target resolution, missing/external destination behavior.
Resource activityExplicit resource IDs from dispatch/operations; causal tracing remains separate scope.
UX resilienceCancellable reads, accessible loading/error/empty states, stale data labels, context-preserving navigation.
+

AWS service artwork

All 40 directory entries use original SVGs from the official AWS Architecture Icons, July 31, 2026 release. The same artwork appears in the service breadcrumb, searchable switcher, inspector headings, and configured connections. Assets are served locally and retain their original colors and proportions; the MicroStack logo remains the product identity.

+

API Gateway REST and HTTP/WebSocket share the API Gateway icon; both Cognito pool entries share the Cognito icon; RDS Data uses the RDS family icon. S3 Files, CloudWatch Logs, STS, and Application Load Balancer use their dedicated AWS resource icons. Original archive paths and family mappings are recorded in aws-icon-sources.json.

+

AWS artwork belongs to Amazon Web Services and identifies the corresponding emulated service, not an AWS endorsement. These third-party icons are not covered by MicroStack's MIT license. Consult AWS's published usage guidance before publishing or redistributing these assets.

+

Prototype versus production

This frozen reference retains five synthetic inspectors. Subsequent production work is separately authorized for all services, account switching, and bounded text/JSON previews up to 1 MiB. The mock's older wording about open decisions records its original design context, not current production status. Design approval alone does not establish production API contracts; implementation must reflect actual emulator capabilities and safe read-only data access.

+

This study is preserved in the repository for future design review. No emulator calls, provisioning controls, live traces, or production UI behavior are implemented by this standalone reference.

diff --git a/design/resource-explorer/qa.html b/design/resource-explorer/qa.html new file mode 100644 index 0000000..6fab909 --- /dev/null +++ b/design/resource-explorer/qa.html @@ -0,0 +1,4 @@ + +MicroStack responsive comparison + +

Prototype layout check

Desktop · 1440px

Mobile · 390px

diff --git a/design/resource-explorer/server.mjs b/design/resource-explorer/server.mjs new file mode 100644 index 0000000..aa5beda --- /dev/null +++ b/design/resource-explorer/server.mjs @@ -0,0 +1,19 @@ +import http from 'node:http'; +import {readFile,readdir} from 'node:fs/promises'; +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; +const root=fileURLToPath(new URL('.',import.meta.url)); +const iconFiles=(await readdir(path.join(root,'icons'))).filter(name=>/^[a-z0-9-]+\.svg$/.test(name)); +const allowed=new Set(['index.html','styles.css','app.js','logo.png','notes.html','qa.html',...iconFiles.map(name=>'icons/'+name)]); +const types={'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'text/javascript; charset=utf-8','.png':'image/png','.svg':'image/svg+xml'}; +const server=http.createServer(async(req,res)=>{ + const name=new URL(req.url,'http://localhost').pathname.slice(1)||'index.html'; + if(!allowed.has(name)){res.writeHead(404);res.end('Not found');return;} + try{ + const file=await readFile(path.join(root,name)); + res.writeHead(200,{'Content-Type':types[path.extname(name)],'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'}); + res.end(file); + }catch(error){console.error(error);res.writeHead(500);res.end('Prototype asset could not be read.');} +}); +server.listen(process.env.PORT?Number(process.env.PORT):0,'127.0.0.1',()=>console.log(`PROTOTYPE_URL=http://127.0.0.1:${server.address().port}/`)); +for(const signal of ['SIGINT','SIGTERM'])process.on(signal,()=>server.close(()=>process.exit(0))); diff --git a/design/resource-explorer/styles.css b/design/resource-explorer/styles.css new file mode 100644 index 0000000..0e8ca32 --- /dev/null +++ b/design/resource-explorer/styles.css @@ -0,0 +1,249 @@ +:root { + color-scheme: light; + --ink:#202c3d; --muted:#526176; --line:#dbe1e9; --blue:#165dca; + --blue-soft:#edf4ff; --surface:#fff; --nav:#f7f9fc; --green:#267048; + --amber:#795100; --danger:#ae3033; + font-family:"Segoe UI",system-ui,-apple-system,sans-serif; font-size:16px; +} +* {box-sizing:border-box} +[hidden] {display:none!important} +body {margin:0;background:#f4f6f9;color:var(--ink);line-height:1.5} +button,input,select {font:inherit} +button,a,input,select,summary {-webkit-tap-highlight-color:transparent} +button,a,select {touch-action:manipulation} +button {cursor:pointer} +a {color:var(--blue);text-underline-offset:3px} +button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible { + outline:3px solid #135cce;outline-offset:3px; +} +button:disabled {cursor:not-allowed;opacity:.6} +button:hover:not(:disabled) {filter:brightness(.975)} +code,pre {font-family:Consolas,"SFMono-Regular",monospace} +code {font-size:.875rem;overflow-wrap:anywhere} +svg {width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;flex:none} +.aws-service-icon {width:24px;height:24px;object-fit:contain;flex:none} +.directory-name .aws-service-icon {width:28px;height:28px} +.service-title-line {display:flex;align-items:center;gap:12px} +.service-title-line .aws-service-icon {width:32px;height:32px} +.service-picker-trigger .aws-service-icon {width:20px;height:20px} +.service-option-label {display:flex;align-items:center;gap:10px;min-width:0} +.skip {position:fixed;top:-70px;left:12px;background:white;padding:12px;z-index:100} +.skip:focus {top:12px} +.prototype-bar {background:#eaf0fa;color:#2e486e;padding:9px 24px;font-size:.875rem;display:flex;justify-content:space-between;gap:12px;align-items:center} +.prototype-bar>div {display:flex;align-items:center;gap:12px;flex-wrap:wrap} +.bar-divider {height:14px;width:1px;background:#acbcd2} +.prototype-bar a {white-space:nowrap;color:#284e87;text-decoration:none} +.prototype-bar a:hover {text-decoration:underline} +.app-header {display:flex;align-items:center;gap:20px;padding:16px 24px;background:white;border-bottom:1px solid var(--line)} +.brand {display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--ink);font-size:1.125rem;letter-spacing:-.02em} +.brand img {object-fit:contain} +.header-divider {height:22px;width:1px;background:var(--line)} +.header-title {color:var(--muted);font-size:.9375rem} +.design-controls {display:flex;justify-content:flex-end;align-items:center;gap:16px;padding:12px 24px;background:white;border-bottom:1px solid var(--line)} +.design-controls label {font-size:.875rem;color:var(--muted)} +.design-controls label {display:flex;align-items:center;gap:8px} +select,input {color:var(--ink);background:white;border:1px solid #aebacc;border-radius:4px;min-height:34px;padding:5px 9px;min-width:0} +select {max-width:100%;font-size:.875rem} +.shell {display:grid;grid-template-columns:188px minmax(0,1fr);min-height:calc(100vh - 170px)} +.navigation {background:var(--nav);border-right:1px solid var(--line);padding:24px 12px;display:flex;flex-direction:column;gap:14px} +.nav-title {display:flex;justify-content:space-between;align-items:center;font-weight:600;padding:0 10px;font-size:.875rem} +.nav-title span {color:var(--muted);font-size:.75rem;font-weight:400} +.service-button {background:none;border:0;width:100%;display:flex;align-items:center;gap:10px;color:#46546a;padding:11px 10px;margin-bottom:4px;text-align:left;border-radius:5px} +.service-button.selected {background:#e6eefb;color:#164f9e;font-weight:600} +.service-button:hover {background:#edf1f7} +.service-count {margin-left:auto;font-size:.75rem;color:var(--muted)} +.nav-foot {margin-top:auto;padding:32px 8px 0;display:flex;gap:9px;font-size:.75rem;color:var(--muted)} +.nav-foot small {display:block;margin-top:3px;font-size:.75rem} +.keyboard-icon {font-size:1rem} +main {min-width:0;padding:18px 28px 0} +.scope {display:flex;gap:24px;color:var(--muted);font-size:.75rem;justify-content:flex-end;margin-left:auto;flex-wrap:wrap} +.scope code {margin-left:6px;font-size:.75rem;color:#32465c} +.service-heading {display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:22px} +h1 {margin:0;font-size:1.625rem;letter-spacing:-.025em;font-weight:650;line-height:1.25} +.service-heading p {margin:5px 0 0;color:var(--muted);font-size:.875rem} +.resource-workspace {display:grid;grid-template-columns:220px minmax(0,1fr);border:1px solid var(--line);background:white;border-radius:7px;min-height:590px;overflow:hidden} +.resource-index {padding:18px 12px;border-right:1px solid var(--line);background:#fbfcfe} +.index-heading {display:flex;align-items:center;justify-content:space-between;padding:0 5px;margin-bottom:12px;font-weight:600;font-size:.875rem} +.count {color:var(--muted);background:#edf0f4;border-radius:3px;font-size:.75rem;padding:1px 6px;font-weight:400} +.search {display:flex;align-items:center;gap:8px;padding:0 10px;border:1px solid #bdc8d7;border-radius:4px;background:white} +.search svg {color:#687a92;width:16px} +.search input {border:0;padding:7px 0;width:100%;font-size:.875rem;background:transparent} +.search input:focus {outline-offset:0} +.resource-list {margin-top:12px;display:grid;gap:4px} +.resource-button {text-align:left;border:1px solid transparent;background:none;border-radius:4px;padding:10px;width:100%;min-width:0} +.resource-button strong {display:block;overflow-wrap:anywhere;font-size:.875rem;font-weight:600} +.resource-button small {display:block;margin-top:3px;color:var(--muted);font-size:.75rem} +.resource-button.selected {border-color:#c2d6f5;background:var(--blue-soft);color:#184e9b} +.resource-button:hover {background:#f0f4f9} +.index-note {font-size:.75rem;color:var(--muted);margin:18px 5px;line-height:1.6} +.inspector {min-width:0} +.resource-heading {padding:22px 24px 0} +.resource-top {display:flex;gap:12px;align-items:center;justify-content:space-between} +.resource-top>div {min-width:0} +h2 {margin:0;font-size:1.25rem;font-weight:650;letter-spacing:-.02em;overflow-wrap:anywhere} +.resource-type {display:flex;align-items:center;gap:7px;font-size:.8125rem;color:var(--muted);margin-top:5px} +.tag {display:inline-flex;align-items:center;gap:5px;color:#3e5470;font-size:.75rem;padding:2px 7px;background:#f0f3f7;border:1px solid #d6dfe9;border-radius:4px;white-space:nowrap} +.tag.green {color:#22613d;background:#edf7f0;border-color:#cce5d4} +.tag.amber {color:#765002;background:#fff5dc;border-color:#eddfb7} +.tag.blue {color:#20559d;background:#edf4ff;border-color:#cdddf5} +.arn-row {display:flex;align-items:center;gap:6px;color:var(--muted);margin:14px 0 20px} +.arn-row code {font-size:.75rem;min-width:0} +.icon-button {background:none;color:var(--muted);border:1px solid transparent;padding:5px;display:inline-flex;align-items:center;border-radius:4px} +.icon-button:hover {background:#eef3fa;border-color:var(--line)} +.button {display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:7px 12px;min-height:34px;font-size:.875rem;border:1px solid #adbed5;border-radius:4px;white-space:nowrap;background:white;color:#30475e;text-decoration:none} +.button.primary {background:var(--blue);border-color:var(--blue);color:white} +.button.secondary:hover {background:#f4f7fc} +.link-button {padding:0;background:none;border:0;color:var(--blue);text-align:left;text-decoration:none;font-size:inherit;overflow-wrap:anywhere} +.link-button:hover {text-decoration:underline} +.tabs {display:flex;gap:22px;padding:0 24px;border-bottom:1px solid var(--line);overflow:auto} +.tabs button {position:relative;background:none;border:0;padding:12px 0;color:var(--muted);font-size:.875rem;white-space:nowrap} +.tabs button[aria-selected=true] {color:var(--blue);font-weight:600;box-shadow:inset 0 -2px var(--blue)} +.tabs .tab-count {margin-left:5px;background:#edf1f6;padding:0 5px;border-radius:3px;font-size:.6875rem;color:#4c617d} +.tab-body {padding:22px 24px;min-height:420px} +.notice {font-size:.8125rem;background:#f0f5fb;border:1px solid #d8e4f2;color:#365171;padding:10px 12px;display:flex;align-items:flex-start;gap:9px;border-radius:4px;margin-bottom:18px} +.notice svg {width:16px;margin-top:2px} +.notice p {margin:0} +.notice.warning {color:#72520d;background:#fff9e9;border-color:#efdfb7} +.measurements {display:flex;gap:30px;padding:0 0 20px;flex-wrap:wrap} +.measurement {display:grid;grid-template-columns:auto auto;gap:8px;align-items:baseline} +.measurement strong {font-size:1.125rem;font-weight:600;font-variant-numeric:tabular-nums} +.measurement span {color:var(--muted);font-size:.8125rem} +.content-tools {display:flex;gap:12px;align-items:center;justify-content:space-between;margin-bottom:14px} +.content-tools .search {max-width:310px;flex:1} +.snapshot-time {font-size:.75rem;color:var(--muted);white-space:nowrap} +.data-split {display:grid;grid-template-columns:minmax(200px,.9fr) minmax(230px,1.1fr);border:1px solid var(--line);border-radius:5px;overflow:hidden} +.records {min-width:0} +.list-heading {display:flex;align-items:center;justify-content:space-between;font-size:.75rem;color:var(--muted);background:#f7f9fc;padding:10px 12px;border-bottom:1px solid var(--line)} +.record-button {display:block;padding:12px;border:0;border-bottom:1px solid #e8edf3;background:white;width:100%;text-align:left;position:relative} +.record-button.selected {background:#edf4ff;box-shadow:inset 2px 0 var(--blue)} +.record-button:hover {background:#f4f7fc} +.record-title {font: .8125rem Consolas,monospace;color:#263c56;overflow-wrap:anywhere;font-weight:600} +.record-meta {display:flex;gap:8px;align-items:center;justify-content:space-between;margin-top:7px;font-size:.75rem;color:var(--muted);flex-wrap:wrap} +.payload {border-left:1px solid var(--line);min-width:0;background:#fafcfe} +.payload-head {display:flex;gap:10px;align-items:center;justify-content:space-between;padding:9px 12px;font-size:.8125rem;border-bottom:1px solid var(--line);background:#f7f9fc} +.payload pre {margin:0;padding:16px;font-size:.8125rem;line-height:1.75;overflow:auto;max-height:330px;color:#304254} +.json-key {color:#144f94}.json-string {color:#7e4017}.json-number {color:#286944} +.payload-facts {margin:0;padding:12px 16px;border-top:1px solid var(--line);display:grid;gap:8px} +.payload-facts>div {display:grid;grid-template-columns:110px minmax(0,1fr);gap:10px;font-size:.75rem} +dt {color:var(--muted)}dd {margin:0;overflow-wrap:anywhere} +.pagination {display:flex;align-items:center;justify-content:space-between;font-size:.75rem;color:var(--muted);padding:12px 0;gap:12px;flex-wrap:wrap} +.pagination>div {display:flex;align-items:center;gap:7px} +.pagination .button {min-height:28px;padding:3px 7px;font-size:.75rem} +.context-links {margin-top:18px;border-top:1px solid var(--line);padding-top:16px} +.context-links h3 {font-size:.875rem;margin:0 0 9px;font-weight:600} +.context-links p {font-size:.8125rem;margin:6px 0;color:var(--muted)} +.inline-connection {display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.connection-list {display:grid;gap:0;border:1px solid var(--line);border-radius:5px} +.connection {display:grid;grid-template-columns:24px minmax(0,1fr) auto;gap:12px;align-items:center;padding:17px 16px;border-bottom:1px solid var(--line)} +.connection:last-child {border:0} +.connection>svg {color:#4b6d9e} +.connection strong {display:block;font-size:.9375rem} +.connection small {color:var(--muted);font-size:.8125rem;display:block;margin-top:4px} +.connection .destination {font-family:Consolas,monospace;font-size:.8125rem} +.connection small code {font-size:.75rem} +.section-heading {display:flex;align-items:center;justify-content:space-between;margin:0 0 14px} +.section-heading h3 {margin:0;font-size:1rem;font-weight:600} +.table-scroll {overflow:auto} +table {width:100%;border-collapse:collapse;font-size:.8125rem;text-align:left} +th {font-size:.75rem;font-weight:600;color:var(--muted);padding:10px;background:#f6f8fb;white-space:nowrap} +td {padding:12px 10px;border-bottom:1px solid #e5eaf1;vertical-align:top} +.metadata {margin:0;display:grid;gap:0} +.metadata>div {display:grid;grid-template-columns:160px minmax(0,1fr);gap:16px;padding:13px 0;border-bottom:1px solid #e8edf3;font-size:.875rem} +.metadata code {font-size:.8125rem} +.subsection {margin-top:24px} +.subsection h3 {font-size:.9375rem;margin:0 0 12px} +.pattern {background:#f7f9fc;border:1px solid var(--line);padding:16px;border-radius:4px;font-size:.8125rem;overflow:auto} +.state-panel {padding:54px 24px;text-align:center;max-width:620px;margin:0 auto} +.state-panel .state-symbol {color:#647c9c;background:#f0f4fa;display:inline-flex;padding:13px;border-radius:50%;margin-bottom:16px} +.state-panel .state-symbol svg {width:24px;height:24px} +.state-panel h3 {margin:0 0 10px;font-size:1.125rem} +.state-panel p {font-size:.875rem;color:var(--muted);max-width:48ch;margin:0 auto 20px} +.state-panel .muted {font-size:.75rem} +.empty-list {padding:26px 16px;font-size:.875rem;color:var(--muted)} +.skeleton {background:#e9eef4;height:13px;margin:16px 0;border-radius:3px} +.skeleton:nth-child(even) {width:70%} +.workspace-footer {display:flex;justify-content:space-between;padding:15px 0 22px;font-size:.75rem;color:var(--muted);gap:12px;flex-wrap:wrap} +.return-link {margin-bottom:15px;font-size:.8125rem;display:flex;gap:8px;align-items:center} +#mobile-resources {display:none} +#toast {position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#24394f;color:white;border-radius:5px;padding:10px 18px;font-size:.875rem;box-shadow:0 4px 16px #192b4533;z-index:90;max-width:90vw} +#toast:empty {display:none} +.notes {max-width:940px;margin:auto;padding:44px 28px 80px;background:white} +.notes h1 {margin-top:22px}.notes h2 {margin:32px 0 12px}.notes p {max-width:70ch} +.notes li {margin:8px 0}.notes table {margin:18px 0} +.notes .lead {color:var(--muted);font-size:1.0625rem} +@media(min-width:1500px) {main {padding-left:38px;padding-right:38px}.resource-workspace {grid-template-columns:260px minmax(0,1fr)}} +.home .shell,.focused .shell {display:block} +.home .navigation,.focused .navigation {display:none} +.home .design-controls,.home .service-heading,.home .resource-workspace {display:none} +.home main {max-width:1400px;margin:auto} +.directory-heading h1 {font-size:2rem} +.directory-heading p {margin:8px 0 24px;color:var(--muted)} +.directory-tools {display:flex;align-items:center;gap:24px} +.directory-tools .search {width:420px;max-width:100%} +.directory-tools label {display:flex;align-items:center;gap:8px;font-size:.875rem;color:var(--muted)} +.directory-tools input[type=checkbox] {min-height:0;width:16px;height:16px;accent-color:var(--blue)} +.directory-note {font-size:.8125rem;color:var(--muted);max-width:75ch;margin:14px 0 24px} +.directory-result-count {font-size:.8125rem;color:var(--muted);margin:0 0 18px} +.directory-grid {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:32px 40px;align-items:start} +.directory-group h2 {display:flex;align-items:center;gap:10px;font-size:1rem;margin:0 0 12px} +.directory-group ul {list-style:none;margin:0;padding:0;border-top:1px solid var(--line)} +.directory-service {display:grid;grid-template-columns:minmax(0,1fr) auto 18px;align-items:center;gap:12px;width:100%;padding:14px 10px;text-align:left;border:0;border-bottom:1px solid var(--line);background:white;color:var(--ink)} +button.directory-service:hover {background:var(--blue-soft)} +.directory-name {display:flex;align-items:center;gap:10px;min-width:0} +.directory-name strong {font-size:.875rem;font-weight:600;overflow-wrap:anywhere} +.directory-name svg,.directory-service>svg {color:var(--blue)} +.directory-detail {font-size:.75rem;color:var(--muted)} +.directory-service.unavailable {background:transparent;grid-template-columns:minmax(0,1fr) auto} +.directory-service.unavailable strong {font-weight:400} +.workspace-context {display:flex;align-items:center;justify-content:space-between;gap:12px 24px;flex-wrap:wrap;border-bottom:1px solid var(--line);padding-bottom:16px;margin-bottom:24px} +.home .workspace-context {border-bottom:0;padding-bottom:0} +.service-context {display:flex;align-items:center;gap:18px;font-size:.875rem} +.service-breadcrumb {display:flex;align-items:center;gap:12px;position:relative} +.service-picker {position:relative} +.service-picker-trigger {display:flex;align-items:center;gap:6px;padding:5px 9px;min-height:34px;font-size:.875rem;font-weight:600;color:var(--ink);background:transparent;border:1px solid transparent;border-radius:4px} +.service-picker-trigger:hover,.service-picker-trigger[aria-expanded=true] {background:var(--surface);border-color:var(--line)} +.service-picker-panel {position:absolute;top:calc(100% + 8px);left:0;width:320px;max-width:calc(100vw - 48px);padding:10px;background:var(--surface);border:1px solid var(--line);border-radius:5px;box-shadow:0 6px 20px #192b4526;z-index:30} +.service-options {max-height:300px;overflow:auto;margin-top:8px} +.service-option {display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;padding:9px 10px;text-align:left;background:transparent;border:0;border-radius:4px;color:var(--ink)} +.service-option.active {background:var(--blue-soft)} +.service-option[aria-disabled=true] {color:var(--muted);cursor:default} +.service-option:focus-visible {outline-offset:-3px} +.service-option:hover {background:#f0f3f7} +.service-option strong {display:block;font-size:.875rem;font-weight:600} +.service-option small {display:block;margin-top:2px;font-size:.75rem;color:var(--muted)} +.service-option-current {font-size:.75rem;color:var(--blue)} +.service-match-count {margin:10px 3px 2px;font-size:.75rem;color:var(--muted)} +@media(max-width:1200px) {.focused .data-split {grid-template-columns:1fr}.focused .payload {border-left:0;border-top:1px solid var(--line)}.focused .records {max-height:240px;overflow:auto}.focused .payload pre {max-height:270px}} +@media(max-width:1000px) {.design-controls {align-items:flex-start;flex-wrap:wrap}main {padding:16px 18px 0}.resource-workspace {grid-template-columns:188px minmax(0,1fr)}.resource-heading {padding:18px 18px 0}.tab-body {padding:18px}.tabs {padding:0 18px;gap:18px}.scope {gap:16px}.snapshot-time {display:none}} +@media(max-width:760px) { + .service-picker {position:static} + .directory-grid {grid-template-columns:1fr;gap:28px} + .directory-heading h1 {font-size:1.625rem} + .directory-tools {align-items:stretch;flex-direction:column;gap:12px} + .directory-service {grid-template-columns:minmax(0,1fr) 18px;gap:4px 10px} + .directory-detail {grid-row:2;grid-column:1} + .directory-service>svg {grid-column:2;grid-row:1 / 3} + .directory-service.unavailable {grid-template-columns:1fr} + .service-context {align-items:flex-start;flex-wrap:wrap;gap:12px} + .prototype-bar {padding:8px 14px;font-size:.75rem;align-items:flex-start}.prototype-bar>div {gap:4px}.bar-divider {display:none}.prototype-bar>div>span:last-child {width:100%} + .app-header {padding:12px 14px;gap:12px}.header-divider,.header-title {display:none} + .design-controls {padding:12px 14px;gap:10px}.design-controls label {width:100%;justify-content:space-between} + #services {display:flex;gap:4px;overflow:auto}.service-button {white-space:nowrap;margin:0;padding:9px;font-size:.8125rem;width:auto} + main {padding:14px 12px 0}.scope {gap:6px 16px;margin-left:0;justify-content:flex-start;font-size:.6875rem}.scope code {font-size:.6875rem;margin-left:3px} + h1 {font-size:1.375rem}.service-heading {align-items:flex-start}.service-heading p {font-size:.8125rem} + #mobile-resources {display:inline-flex;font-size:.75rem;padding:5px 8px} + .resource-workspace {display:block;border-radius:5px;min-height:450px} + .resource-index {display:none}.browsing .resource-index {display:block!important;border-right:0} + .browsing .inspector {display:none} + .resource-heading {padding:16px 14px 0}.resource-top {align-items:flex-start}.resource-top .button {font-size:.75rem;padding:6px 8px} + h2 {font-size:1.0625rem}.arn-row {margin:12px 0}.arn-row code {font-size:.6875rem} + .tabs {padding:0 14px;gap:18px}.tabs button {font-size:.8125rem}.tab-body {padding:16px 14px} + .notice {font-size:.75rem}.measurements {gap:14px;padding-bottom:16px}.measurement {gap:5px}.measurement span {font-size:.75rem} + .data-split {grid-template-columns:1fr}.payload {border-left:0;border-top:1px solid var(--line)} + .records {max-height:240px;overflow:auto}.payload pre {font-size:.75rem;max-height:290px} + .metadata>div {grid-template-columns:110px minmax(0,1fr);font-size:.8125rem;gap:8px} + .connection {grid-template-columns:18px minmax(0,1fr);padding:14px 10px;gap:8px}.connection>.tag {grid-column:2;justify-self:start} + .content-tools {gap:8px}.content-tools .button {padding:6px 8px;font-size:.75rem} + .workspace-footer {font-size:.6875rem} +} +@media(prefers-reduced-motion:reduce) {* {scroll-behavior:auto!important}} diff --git a/docs/Docs/Content/docs/configuration.md b/docs/Docs/Content/docs/configuration.md index c9a7cac..312a9a2 100644 --- a/docs/Docs/Content/docs/configuration.md +++ b/docs/Docs/Content/docs/configuration.md @@ -7,7 +7,7 @@ section: Guides # Configuration -MicroStack is configured via environment variables. All settings are consolidated into a strongly-typed `MicroStackOptions` class internally. +MicroStack is configured via environment variables. Emulator settings are consolidated into a strongly-typed `MicroStackOptions` class internally. ## Environment Variables @@ -75,3 +75,20 @@ docker run -e PERSIST_STATE=1 -v ./state:/tmp/microstack-state -p 4566:4566 ghcr ``` State is saved as JSON files in `STATE_DIR` on shutdown and restored on startup. + +## UI Connection + +The UI is served by the gateway at `/ui/` and calls the admin API on the same +origin. For a custom port, publish only the gateway port: + +```bash +docker run \ + -e GATEWAY_PORT=5000 \ + -p 127.0.0.1:5000:5000 \ + ghcr.io/damianh/microstack:latest +``` + +Open `http://localhost:5000/ui/`. `MICROSTACK_UI_PORT`, +`MICROSTACK_API_URL`, and the UI host's `ApiBaseUrl` setting are obsolete and +ignored. The admin endpoints do not grant cross-origin browser access. This is +not authentication; keep these development endpoints on a trusted local network. diff --git a/docs/Docs/Content/docs/docker.md b/docs/Docs/Content/docs/docker.md index 43d594a..d548c4d 100644 --- a/docs/Docs/Content/docs/docker.md +++ b/docs/Docs/Content/docs/docker.md @@ -7,22 +7,37 @@ section: Guides # Docker -MicroStack publishes OCI container images via `dotnet publish` — no Dockerfile required. +MicroStack supports both the repository Dockerfile and SDK OCI publishing. +The native process serves AWS APIs and the browser UI on one port. ## Pre-built Image ```bash -docker run -p 4566:4566 ghcr.io/damianh/microstack:latest +docker run -p 127.0.0.1:4566:4566 ghcr.io/damianh/microstack:latest ``` +Open `http://localhost:4566/ui/` for the UI. AWS clients use the same origin. + ## Building Locally ```bash -dotnet publish src/MicroStack/MicroStack.csproj /t:PublishContainer -c Release -docker run -p 4566:4566 microstack:latest +docker build -t microstack:latest . +docker run -p 127.0.0.1:4566:4566 microstack:latest +``` + +Alternatively, SDK publishing includes the UI under the API publish directory: + +```bash +dotnet publish src/MicroStack/MicroStack.csproj /t:PublishContainer -c Release -r linux-musl-x64 ``` -This produces a ~237MB image based on `mcr.microsoft.com/dotnet/aspnet:10.0`. +Native AOT publishing requires a matching Linux toolchain; use the Dockerfile +or a suitable Linux environment when building from Windows. Both image paths +use the .NET runtime-dependencies Alpine image and include the browser assets +served directly by the native API process. + +For an API-only publish, pass `-p:PublishAdminUi=false`. The standalone design +reference in `design/resource-explorer` is never included in production images. ## Docker Compose @@ -31,7 +46,7 @@ services: microstack: image: ghcr.io/damianh/microstack:latest ports: - - "4566:4566" + - "127.0.0.1:4566:4566" environment: - PERSIST_STATE=1 volumes: diff --git a/docs/Docs/Content/docs/internal-api.md b/docs/Docs/Content/docs/internal-api.md index 0cfb039..e0fd97b 100644 --- a/docs/Docs/Content/docs/internal-api.md +++ b/docs/Docs/Content/docs/internal-api.md @@ -1,6 +1,6 @@ --- title: Internal API -description: MicroStack's built-in health, reset, and config endpoints for test automation. +description: MicroStack's built-in health, resource inspection, reset, and config endpoints. order: 5 section: Guides --- @@ -9,6 +9,268 @@ section: Guides MicroStack exposes internal endpoints for health checking, state management, and runtime configuration. These are especially useful in CI pipelines and test suites. +## Browser Access + +The UI and admin API share the gateway origin, so admin endpoints do not grant +cross-origin browser access. CORS is not authentication and does not prevent +non-browser access or all cross-origin writes; keep the emulator on a trusted +development network. AWS service endpoints retain their existing permissive CORS +behavior for SDK clients. + +The UI is reserved at `/ui/` on the gateway hostname. An unsigned, query-free +browser navigation to `/` redirects there. Signed, presigned, SDK, non-HTML, and +service-specific-host requests retain their AWS routing behavior. + +## Resource Inspection + +The admin UI uses the versioned, read-only resource inspection API under +`/_microstack/admin/v1`. It exposes the live state retained by enabled MicroStack +service handlers; it does not create separate service instances or execute AWS +data-plane operations to discover resources. + +```bash +# Configured region, default account, and API capabilities +curl http://localhost:4566/_microstack/admin/v1/context + +# Known accounts with retained resources, plus the configured default +curl http://localhost:4566/_microstack/admin/v1/accounts + +# Declared service catalog and current availability +curl "http://localhost:4566/_microstack/admin/v1/services?accountId=000000000000" + +# First page of S3 resources +curl "http://localhost:4566/_microstack/admin/v1/services/s3/resources?accountId=000000000000&pageSize=50" +``` + +The service endpoints are: + +| Endpoint | Description | +|----------|-------------| +| `GET /services/{serviceId}/resources` | Page through root resources, optionally by resource kind | +| `GET /services/{serviceId}/resource` | Read resource metadata and available inspection capabilities | +| `GET /services/{serviceId}/children` | Page through a retained child collection | +| `GET /services/{serviceId}/content` | Read bounded text or JSON content, or metadata for binary/oversized content | +| `GET /services/{serviceId}/connections` | Read explicitly configured cross-service relationships | +| `POST /services/{serviceId}/reveal` | Reveal a supported retained sensitive value on demand | +| `GET /services/{serviceId}/activity` | Read service- and account-scoped request activity | + +Append these paths to `/_microstack/admin/v1`. Resource and child identities are +opaque JSON paths passed in the `path` query parameter. Clients should use the +paths returned by the API rather than constructing or splitting identifiers. + +Lists default to 50 entries and accept `pageSize` values from 1 through 200. +Continuation tokens are opaque and scoped to the service, account, kind, and +parent path; a token cannot be reused in another scope. The optional `accountId` +must contain exactly 12 ASCII digits and defaults to the configured account. + +### Known accounts + +`GET /_microstack/admin/v1/accounts` returns a JSON string array, for example +`["000000000000","111111111111","222222222222"]`. IDs are distinct, sorted +ordinally, and contain exactly 12 ASCII digits. The configured default is always +included, even without resources; invalid `MICROSTACK_ACCOUNT_ID` configuration +is rejected at startup. The response uses `Cache-Control: no-store`. + +Discovery reads explicit retained-resource ownership from enabled handlers, +including restored persistent state. It does not inspect request logs, remember +previously selected accounts, create resources, or scan resource payloads. +Reading an empty account does not add it. An account disappears once its last +retained resource is removed, or after reset, unless it is the configured default. +Retained execution/query resources can keep an account present; secondary +indexes, tags left behind after deletion, and empty child containers do not. +Concurrent mutations are not a transactionally simultaneous cross-service snapshot. +EC2's automatically seeded default VPC, subnets, security group, route table, and +internet gateway do not qualify on their own: the handler initializes these even +on read requests. User-created EC2 resources do qualify. + +Instance-global resources have no account ownership and do not add accounts. +These include Firehose streams, RDS Data transactions, Athena workgroups/catalogs, +EventBridge buses/partner sources, and SES sent-email history. Other account-scoped +resources in those services still participate. Discovery does not add persistence +support to handlers that do not already implement it. The UI selects known +accounts only; there is no admin account-creation endpoint. + +The canonical browser routes are `/ui/accounts/{accountId}/services` and +`/ui/accounts/{accountId}/services/{serviceId}`, with a 12-digit account ID. +Legacy `/ui/`, `/ui/resources`, and `/ui/services/{serviceId}` links redirect to +the account-scoped routes, using the legacy `account` query parameter when +present, otherwise the last explorer account in this tab or the configured default. +Redirects replace the current history entry and preserve the remaining +query-based inspection state. An unknown account in a URL prompts the user to +choose a known account rather than creating an account or querying its resources. +The account selector lives in the application header on explorer pages. With one +known account it displays plain text; with multiple accounts it switches immediately, +retaining the service but clearing resource and payload selections. Only the last +known selected account ID is saved in browser session storage, scoped to this origin +and tab; this is navigation context, not account discovery. Explicit account routes +and legacy account query parameters take precedence over saved context. A deleted +saved account requires an explicit known-account selection, not a silent fallback. + +Overview and Request log remain instance-wide and show "Instance-wide" in the header +instead of a selector. Visiting or refreshing either page does not change the +remembered explorer account: Services and the brand link return to that account. +Overview's legacy resource summary still covers only the configured default account, +as labeled; its reset affects every account. Storage failures are shown explicitly; +in-memory account navigation still works when browser storage is unavailable. +These browser routes do not change the admin API's `accountId` query parameter or +AWS request scoping. + +### Live inspection + +The UI updates automatically using metadata-only Server-Sent Events (SSE) and the +existing HTTP inspection endpoints. SSE reports that a scope may have changed; +it is not a resource mutation history or a payload stream. AWS reads may also +invalidate a view, since some reads affect retained state. Admin inspection reads +never produce a refresh loop. + +Each visible browser tab shares one event connection. Notifications are coalesced +at roughly two updates per second; a five-second reconciliation refreshes visible +data even without an event. This catches indirect changes and time-based state +such as SQS visibility transitions. These are eventually consistent snapshots, +not an atomic view across services. Large reads may take longer than the scheduling +interval; only one refresh batch runs at a time. + +The header places shared live controls beside the account selector (or the +instance-wide label on global pages). The live/pause icon is an accessible toggle; +the refresh icon updates the visible data once and its tooltip includes the last +successful refresh time. Connection/staleness states, errors and Retry remain +visible when attention is needed. A connected stream does not make a failed +snapshot current. Pausing freezes the view and suspends automatic work; a manual +refresh while paused updates it once without resuming. Hidden tabs suspend work +and catch up on return, unless explicitly paused. Full page reload starts live +updates again. Navigation and explicit reset/clear operations still work while +paused. Overview and Request log retain their instance-wide semantics. + +Background updates retain the URL, account, selection, filters, focus and scroll. +The UI shows all matching resources, child entries and configured connections +without paging controls. Lists can change as resources are added or removed; +pause for stable reading. Old browser cursor parameters are ignored and removed +on the next inspection navigation. +A deleted selected resource or account stays in the URL with an explicit missing +state, not an automatic replacement selection. + +Only displayed non-sensitive content is fetched automatically, under the existing +1 MiB preview limit. A refresh of an inspection hides explicitly revealed secrets +and cancels pending reveals. Reveal again explicitly, or pause before revealing +when the value needs to remain visible. No automatic refresh calls the reveal API. + +#### Event connection + +`GET /_microstack/admin/v1/events` opens a `text/event-stream` response. An optional +12-digit `accountId` selects account-resource invalidations; inventory, instance +and request-log signals remain available for global UI state. Without `accountId`, +resource invalidations cover all accounts (still without account identifiers or +resource data in the event). The endpoint uses +the same admin CORS policy and `Cache-Control: no-store`. + +Every new connection starts with a resynchronization signal. Clients must re-read +their visible state on connection/reconnection rather than treating `Last-Event-ID` +as a durable replay cursor. An instance epoch and monotonic sequence identify the +stream's lifetime/order; no event history is stored. Events are named `change` +and use an `id` of `epoch:sequence`. The version-1 JSON shape is: + +```json +{ + "version": 1, + "epoch": "instance-lifetime-identifier", + "sequence": 1, + "resync": true, + "resources": true, + "accounts": true, + "instance": true, + "activity": true +} +``` + +The boolean fields are invalidation categories, not resource counts or mutation +claims. Heartbeat comments every 15 seconds keep idle connections active without +invalidating data. + +Subscribers are limited to 64 per instance; capacity exhaustion returns a +retryable HTTP 503. Writes to stalled subscribers time out after 10 seconds. +Pending work is bounded. Bursts merge dirty flags rather than +queue one message per SDK operation; slow/disconnected clients never block resource +operations. Notification production does not scan resource trees or payloads. +Resource bodies, credentials, and revealed values are never included in SSE. + +### Inspector metadata and filtering + +The explorer uses the same inspection shell across all services, with +service-specific summaries where the handler retains the necessary data. +Service `kinds` include an `isRoot` flag: only root kinds belong in the resource +picker. Child kinds remain available through opaque resource paths. A detail's +`childKinds` describe its collections even when they are empty. + +Resource summaries may supply a `type` and small `summary` fields for list rows. +Details may supply their own `summary` fields and a known `connectionCount`. +Missing metadata is not a zero count or a healthy status. Fields marked +`secondary` remain available as additional metadata; sensitive-field masking +and reveal restrictions are unchanged. + +Resource and child lists accept `filter`, which matches retained names, +identifiers and ARNs, not payload bodies. The UI applies filters after a short +typing pause, or immediately on Enter or clear. Filtering resets the relevant +continuation token; it does not run an AWS Query, Scan, or ReceiveMessage. +The UI automatically follows API continuation tokens before displaying the complete +matching list, with no arbitrary item cap or Next/Restart buttons. API pagination +remains available for other clients. A failed or canceled continuation read never +replaces the view with a partial successful list. Resource keys repeated across +moving page boundaries are displayed once. Reads are not transactionally +simultaneous, so concurrent mutations can still change the list between requests. +Displayed list counts describe all returned matches; provider summary counts +describe retained state and may differ from a filtered list. + +Global and service/account request activity show the complete retained bounded log, +without a Rows selector. This does not recover older entries discarded by the +server's log retention. Content is still fetched only for the selected entry; +loading complete lists never reveals secrets or downloads every payload. + +### Service-specific inspection + +- **SQS:** queues show their retained type, settings and message-state counts. + Message inspection is non-consuming: it does not receive messages, change + visibility, increment receive counts or advance consumers. +- **S3:** prefixes are virtual groupings of object keys. Browsing preserves full + object and version identities; previews do not automatically download binary + or oversized objects. +- **DynamoDB:** item keys and JSON retain DynamoDB attribute types. Browsing + modeled items is not execution of a DynamoDB Query or Scan. +- **SNS:** the topic view inspects subscriptions, not a retained message inbox. + Subscription endpoints and confirmation/filter settings describe configuration. +- **EventBridge:** the inspector shows retained rules, patterns or schedules and + targets. It does not manufacture event history or delivery traces. + +### Configured connections + +Connections describe configuration, not evidence of successful delivery or +processing. Topic and event-bus context can include destinations configured by +their subscriptions or rules. SQS context also includes same-account SNS +subscriptions, EventBridge targets and dead-letter source queues that explicitly +reference the queue. Matching names alone never establish a relationship. + +Connections use full identities and preserve source resource paths. The UI +follows API pages in their stable order to show all configured connections. +External or missing destinations are not silently treated as +live inspectable resources. Supported reverse relationships do not constitute a +universal dependency graph across every service. Cross-provider reads are not +transactionally simultaneous. Activity remains a bounded service/account log, +not a resource-specific delivery trace. + +Text and JSON previews are limited to 1 MiB of UTF-8 data. Binary and larger +values remain metadata-only. Known secrets are masked in ordinary responses. +Only explicitly supported existing values can be revealed; reveal and content +responses use `Cache-Control: no-store`. Private cryptographic key material is +never exposed. + +Errors use a structured JSON response with a stable `code`, a human-readable +`message`. A missing resource returns `404`; invalid arguments and continuation +tokens return `400`; disabled services and unavailable inspection capabilities +return `409`. + +The API is intended for trusted local development. It has no authentication, and +account selection and secret reveal make exposing it on an untrusted network +especially unsafe. + ## Health Check ```bash diff --git a/scripts/smoke-container.sh b/scripts/smoke-container.sh new file mode 100644 index 0000000..650f83d --- /dev/null +++ b/scripts/smoke-container.sh @@ -0,0 +1,39 @@ +#!/bin/sh +set -eu + +image="${1:?Usage: smoke-container.sh IMAGE}" +api_port="${MICROSTACK_SMOKE_API_PORT:-4566}" +container_id="$(docker run --rm -d \ + -p "127.0.0.1:$api_port:4566" \ + "$image")" +cleanup() { + status=$? + trap - EXIT + if [ "$status" -ne 0 ]; then + docker logs "$container_id" >&2 || true + fi + docker rm -f "$container_id" >/dev/null 2>&1 || true + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +api="http://localhost:$api_port" +# Docker can accept and reset connections before the application starts listening. +curl --fail --silent --show-error --retry 20 --retry-all-errors --retry-delay 1 --retry-max-time 30 --max-time 5 "$api/_microstack/health" >/dev/null +curl --fail --silent --show-error --retry 20 --retry-connrefused --retry-delay 1 "$api/ui/" >/dev/null +curl --fail --silent --show-error "$api/_microstack/admin/v1/services" >/dev/null +curl --fail --silent --show-error "$api/ui/services/s3" | grep -qi '&2; exit 1 ;; +esac +redirect="$(curl --silent --show-error -D - -o /dev/null -H "Accept: text/html" "$api/")" +printf '%s' "$redirect" | grep -qi '^HTTP/.* 302' +printf '%s' "$redirect" | grep -qi '^location: /ui/' +echo "API, same-port UI, deep links, framework, icons, and browser redirect passed." diff --git a/src/MicroStack.Admin.Contracts/AdminContracts.cs b/src/MicroStack.Admin.Contracts/AdminContracts.cs new file mode 100644 index 0000000..8fe470e --- /dev/null +++ b/src/MicroStack.Admin.Contracts/AdminContracts.cs @@ -0,0 +1,69 @@ +namespace MicroStack.Admin.Contracts; + +public sealed record AdminContext(string DefaultAccount, string Region, int PreviewMaxBytes = 1_048_576); + +public sealed record AdminChangeEvent( + int Version, string Epoch, long Sequence, bool Resync, + bool Resources, bool Accounts, bool Instance, bool Activity); + +public sealed record AdminService( + string Id, string Name, string Label, string Category, string Icon, + string CanonicalHandler, string Availability, string Scope, string? Notice = null) +{ + public IReadOnlyList Kinds { get; init => field = value ?? []; } = []; +} + +public sealed record AdminResourceKind(string Id, string Label, bool IsRoot = true); + +public sealed record AdminKey(string Kind, string Id); + +public sealed record AdminResourceSummary( + AdminKey Key, string Name, string? Arn = null, string? Status = null, string Scope = "account") +{ + public string? Type { get; init; } + public IReadOnlyList Summary { get; init => field = value ?? []; } = []; +} + +public sealed record AdminField( + string Name, string? Value, bool Sensitive = false, bool CanReveal = false, string? Format = null) +{ + public bool Secondary { get; init; } +} + +public sealed record AdminConnection( + string Label, string Relation, string? TargetServiceId = null, + AdminKey[]? TargetPath = null, string? ExternalUri = null, string State = "configured") +{ + public string? SourceServiceId { get; init; } + public AdminKey[]? SourcePath { get; init; } +} + +public sealed record AdminPage +{ + public IReadOnlyList Items { get; init; } = []; + public string? NextCursor { get; init; } + public long? KnownTotal { get; init; } + public DateTimeOffset CapturedAt { get; init; } +} + +public sealed record AdminResourceDetail(AdminResourceSummary Resource) +{ + public IReadOnlyList Fields { get; init => field = value ?? []; } = []; + public bool HasChildren { get; init; } + public bool HasContent { get; init; } + public bool HasConnections { get; init; } + public IReadOnlyList ChildKinds { get; init => field = value ?? []; } = []; + public IReadOnlyList Summary { get; init => field = value ?? []; } = []; + public long? ConnectionCount { get; init; } + public IReadOnlyList RevealableFields { get; init => field = value ?? []; } = []; +} + +public sealed record AdminContent( + string Kind, string ContentType, long? Length = null, + string? Text = null, string? Reason = null, bool Sensitive = false); + +public sealed record AdminError(string Code, string Message); + +public sealed record AdminActivity( + string Service, string Action, string AccountId, + DateTimeOffset Timestamp, int StatusCode, long DurationMs); diff --git a/src/MicroStack.Admin.Contracts/AdminJsonContext.cs b/src/MicroStack.Admin.Contracts/AdminJsonContext.cs new file mode 100644 index 0000000..c03d577 --- /dev/null +++ b/src/MicroStack.Admin.Contracts/AdminJsonContext.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; + +namespace MicroStack.Admin.Contracts; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(AdminContext))] +[JsonSerializable(typeof(AdminChangeEvent))] +[JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(AdminService))] +[JsonSerializable(typeof(AdminResourceKind))] +[JsonSerializable(typeof(AdminKey))] +[JsonSerializable(typeof(AdminResourceSummary))] +[JsonSerializable(typeof(AdminField))] +[JsonSerializable(typeof(AdminConnection))] +[JsonSerializable(typeof(AdminResourceDetail))] +[JsonSerializable(typeof(AdminContent))] +[JsonSerializable(typeof(AdminError))] +[JsonSerializable(typeof(AdminActivity))] +[JsonSerializable(typeof(AdminContext[]))] +[JsonSerializable(typeof(AdminService[]))] +[JsonSerializable(typeof(AdminResourceKind[]))] +[JsonSerializable(typeof(AdminKey[]))] +[JsonSerializable(typeof(AdminResourceSummary[]))] +[JsonSerializable(typeof(AdminField[]))] +[JsonSerializable(typeof(AdminConnection[]))] +[JsonSerializable(typeof(AdminResourceDetail[]))] +[JsonSerializable(typeof(AdminContent[]))] +[JsonSerializable(typeof(AdminError[]))] +[JsonSerializable(typeof(AdminActivity[]))] +[JsonSerializable(typeof(AdminPage))] +[JsonSerializable(typeof(AdminPage))] +[JsonSerializable(typeof(AdminPage))] +[JsonSerializable(typeof(AdminPage))] +[JsonSerializable(typeof(AdminPage))] +[JsonSerializable(typeof(AdminPage))] +[JsonSerializable(typeof(AdminPage))] +[JsonSerializable(typeof(AdminPage))] +[JsonSerializable(typeof(AdminPage))] +[JsonSerializable(typeof(AdminPage))] +[JsonSerializable(typeof(AdminPage))] +public partial class AdminJsonContext : JsonSerializerContext; diff --git a/src/MicroStack.Admin.Contracts/MicroStack.Admin.Contracts.csproj b/src/MicroStack.Admin.Contracts/MicroStack.Admin.Contracts.csproj new file mode 100644 index 0000000..df90c2d --- /dev/null +++ b/src/MicroStack.Admin.Contracts/MicroStack.Admin.Contracts.csproj @@ -0,0 +1,8 @@ + + + net10.0 + false + true + false + + diff --git a/src/MicroStack.UI.Client/App.razor b/src/MicroStack.UI.Client/App.razor new file mode 100644 index 0000000..d4900ec --- /dev/null +++ b/src/MicroStack.UI.Client/App.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/MicroStack.UI.Client/Components/AccountScope.razor b/src/MicroStack.UI.Client/Components/AccountScope.razor new file mode 100644 index 0000000..beac91c --- /dev/null +++ b/src/MicroStack.UI.Client/Components/AccountScope.razor @@ -0,0 +1,37 @@ +
+ @if (Accounts.Count == 1 && Accounts[0] == Account) + { + Account @Account@(Account == DefaultAccount ? " (default)" : "") + } + else if (Accounts.Count > 0) + { + + + Accounts with retained resources and the configured default account. + } + @if (_error is not null) { @_error } +
+@code { + [Parameter] public string Account { get; set; } = ""; + [Parameter] public string DefaultAccount { get; set; } = ""; + [Parameter] public IReadOnlyList Accounts { get; set; } = []; + [Parameter] public EventCallback AccountChanged { get; set; } + private string? _error; + protected override void OnParametersSet() => _error = null; + private async Task ApplyAsync(ChangeEventArgs args) + { + var account = args.Value?.ToString(); + if (account is null || !Accounts.Contains(account)) + { + _error = "This account is no longer available. Refresh the page to update known accounts."; + return; + } + _error = null; + if (account != Account) await AccountChanged.InvokeAsync(account); + } +} diff --git a/src/MicroStack.UI.Client/Components/ConnectionList.razor b/src/MicroStack.UI.Client/Components/ConnectionList.razor new file mode 100644 index 0000000..30a3e82 --- /dev/null +++ b/src/MicroStack.UI.Client/Components/ConnectionList.razor @@ -0,0 +1,28 @@ +
+ @foreach (var connection in Connections) + { +
+
@connection.Label@connection.Relation @connection.State + @if (connection.SourcePath is { Length: > 0 } sourcePath) + { + Configuration · @sourcePath[^1].Id + } + @if (connection.ExternalUri is not null) + { +
@connection.ExternalUri
+ } +
+
+ @if (CanFollow(connection)) { Inspect destination → } + @if (Source?.Invoke(connection) is { } sourceUrl) { Inspect configuration → } +
+
+ } +
+@code { + [Parameter] public IReadOnlyList Connections { get; set; } = []; + [Parameter, EditorRequired] public Func Destination { get; set; } = default!; + [Parameter] public Func? Source { get; set; } + private static bool CanFollow(AdminConnection connection) => connection.TargetServiceId is not null && connection.TargetPath is { Length: > 0 } && + connection.State is not ("missing" or "disabled" or "external" or "inaccessible" or "unavailable"); +} diff --git a/src/MicroStack.UI.Client/Components/ContentPreview.razor b/src/MicroStack.UI.Client/Components/ContentPreview.razor new file mode 100644 index 0000000..fa6dd0c --- /dev/null +++ b/src/MicroStack.UI.Client/Components/ContentPreview.razor @@ -0,0 +1,55 @@ +
+
+ @(Title ?? "Content preview") + @if (_text is not null) { } +
+ @if (Content is null) + { + + } + else + { +
+ @Content.ContentType + @if (Content.Length is { } length) { @length.ToString("N0") bytes } + @if (!string.IsNullOrEmpty(_notice)) {

@_notice

} +
+ @if (_text is not null) {
@_text
} + } +
+@code { + [Parameter] public AdminContent? Content { get; set; } + [Parameter] public string? Title { get; set; } + [Parameter] public bool Revealed { get; set; } + private string? _text; + private string? _notice; + private AdminContent? _formattedContent; + private bool _formattedReveal; + private bool _formatted; + protected override void OnParametersSet() + { + if (_formatted && _formattedContent == Content && _formattedReveal == Revealed) return; + _formatted = true; _formattedContent = Content; _formattedReveal = Revealed; + _text = null; + _notice = null; + if (Content is null) return; + if (Content.Sensitive && !Revealed) { _notice = "Sensitive content is masked. Use an explicit Reveal action to inspect an existing value."; return; } + if (Content.Kind is not ("text" or "json") || Content.Text is null) + { _notice = Content.Reason ?? "This entry is metadata-only; no text preview is available."; return; } + if (Content.Length > 1_048_576 || System.Text.Encoding.UTF8.GetByteCount(Content.Text) > 1_048_576) + { _notice = "Content exceeds the 1 MiB preview limit. Only metadata is shown."; return; } + _text = Content.Text; + if (Content.Kind == "json" || Content.ContentType.Contains("json", StringComparison.OrdinalIgnoreCase)) + { + try + { + using var document = System.Text.Json.JsonDocument.Parse(_text); + using var buffer = new MemoryStream(); + using (var writer = new System.Text.Json.Utf8JsonWriter(buffer, new() { Indented = true })) + document.WriteTo(writer); + if (buffer.Length <= 1_048_576) _text = System.Text.Encoding.UTF8.GetString(buffer.ToArray()); + } + catch (System.Text.Json.JsonException) { _notice = "This payload is not valid JSON. Its original text is shown unchanged."; } + } + } +} diff --git a/src/MicroStack.UI.Client/Components/CopyButton.razor b/src/MicroStack.UI.Client/Components/CopyButton.razor new file mode 100644 index 0000000..2994b06 --- /dev/null +++ b/src/MicroStack.UI.Client/Components/CopyButton.razor @@ -0,0 +1,57 @@ +@inject IJSRuntime JS +@implements IDisposable + + + @_status + +@code { + [Parameter] public string? Text { get; set; } + [Parameter] public string Label { get; set; } = "Copy"; + private string? _status; + private string? _previousText; + private CancellationTokenSource? _copyCancellation; + private bool _disposed; + protected override void OnParametersSet() + { + if (_previousText == Text) return; + CancelCopy(); + _previousText = Text; + _status = null; + } + private async Task CopyAsync() + { + CancelCopy(); + if (_disposed) return; + using var cancellation = new CancellationTokenSource(); + _copyCancellation = cancellation; + var token = cancellation.Token; + _status = null; + try + { + await JS.InvokeVoidAsync("microstack.copy", token, Text); + if (token.IsCancellationRequested) return; + _status = "Copied."; + StateHasChanged(); + await Task.Delay(TimeSpan.FromSeconds(2), token); + _status = null; + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + catch (JSException) + { + if (!token.IsCancellationRequested) + _status = "Clipboard unavailable. Select the text and copy it manually."; + } + finally + { + if (ReferenceEquals(_copyCancellation, cancellation)) _copyCancellation = null; + } + } + private void CancelCopy() + { + _copyCancellation?.Cancel(); + _copyCancellation = null; + } + public void Dispose() { _disposed = true; CancelCopy(); } +} diff --git a/src/MicroStack.UI.Client/Components/DebouncedFilter.razor b/src/MicroStack.UI.Client/Components/DebouncedFilter.razor new file mode 100644 index 0000000..3f951eb --- /dev/null +++ b/src/MicroStack.UI.Client/Components/DebouncedFilter.razor @@ -0,0 +1,68 @@ +@implements IDisposable + +@code { + [Parameter] public string? Value { get; set; } + [Parameter] public string Scope { get; set; } = ""; + [Parameter] public string Label { get; set; } = "Filter resources"; + [Parameter] public string Placeholder { get; set; } = "Filter by name or identifier…"; + [Parameter] public EventCallback Changed { get; set; } + [Parameter] public TimeProvider Clock { get; set; } = TimeProvider.System; + private DebouncedAction? _debounce; + private ElementReference _input; + private string _text = "", _parameter = ""; + private string? _scope, _submitted; + + protected override void OnParametersSet() + { + _debounce ??= new(Clock); + var value = Value ?? ""; + if (_scope != Scope) + { + _debounce.Cancel(); + _scope = Scope; + _text = value; + _submitted = null; + } + else if (_parameter != value && value != _submitted) + { + _debounce.Cancel(); + _text = value; + } + _parameter = value; + } + + private Task InputAsync(ChangeEventArgs args) + { + _text = args.Value?.ToString() ?? ""; + return ApplyAsync(_text.Length == 0); + } + + private Task KeyDownAsync(KeyboardEventArgs args) => args.Key == "Enter" ? ApplyAsync(true) : Task.CompletedTask; + private async Task ClearAsync() + { + _text = ""; + var apply = ApplyAsync(true); + await _input.FocusAsync(preventScroll: true); + await apply; + } + private Task ApplyAsync(bool immediately) + { + var text = _text; + return _debounce!.RunAsync(token => InvokeAsync(async () => + { + if (token.IsCancellationRequested) return; + _submitted = text; + await Changed.InvokeAsync(text); + }), immediately); + } + public void Dispose() => _debounce?.Dispose(); +} diff --git a/src/MicroStack.UI.Client/Components/FieldList.razor b/src/MicroStack.UI.Client/Components/FieldList.razor new file mode 100644 index 0000000..dd9ad23 --- /dev/null +++ b/src/MicroStack.UI.Client/Components/FieldList.razor @@ -0,0 +1,83 @@ +@implements IDisposable +@inject AdminApiClient Api + +@foreach (var name in ExtraRevealFields) +{ +
+ @if (_revealed.TryGetValue(name, out var content)) + { + + + } + else { } +
+} +@if (_busy) {

Retrieving the selected sensitive value…

} +@if (_error is not null) { } +@if (Fields.Any(field => field.CanReveal) || RevealableFields.Count > 0) +{

Live refresh hides revealed values. Pause live updates before revealing a value to keep it open.

} +@code { + [Parameter] public IReadOnlyList Fields { get; set; } = []; + [Parameter] public IReadOnlyList RevealableFields { get; set; } = []; + [Parameter] public string Service { get; set; } = ""; + [Parameter] public string Account { get; set; } = ""; + [Parameter] public AdminKey[] Path { get; set; } = []; + [Parameter] public long Revision { get; set; } + private readonly Dictionary _revealed = []; + private CancellationTokenSource _cts = new(); + private string? _scope; + private string? _error; + private bool _busy; + private IEnumerable ExtraRevealFields => RevealableFields.Where(name => Fields.All(item => item.Name != name)); + protected override void OnParametersSet() + { + var scope = Service + Account + ExplorerLocation.EncodePath(Path) + ":" + Revision; + if (scope == _scope) return; + _cts.Cancel(); _cts.Dispose(); _cts = new(); + _scope = scope; _revealed.Clear(); _error = null; _busy = false; + } + private async Task RevealAsync(string field) + { + if (_busy) return; + var token = _cts.Token; + _busy = true; _error = null; + try + { + var value = await Api.RevealAsync(Service, Account, Path, field, token); + if (!token.IsCancellationRequested) _revealed[field] = value; + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + catch (Exception) { if (!token.IsCancellationRequested) _error = "The value could not be revealed. Retry the explicit Reveal action."; } + finally { if (!token.IsCancellationRequested) _busy = false; } + } + private void Hide(string field) { _revealed.Remove(field); _error = null; } + public void Dispose() { _cts.Cancel(); _cts.Dispose(); _revealed.Clear(); } +} diff --git a/src/MicroStack.UI.Client/Components/LiveControls.razor b/src/MicroStack.UI.Client/Components/LiveControls.razor new file mode 100644 index 0000000..665f844 --- /dev/null +++ b/src/MicroStack.UI.Client/Components/LiveControls.razor @@ -0,0 +1,87 @@ +@implements IDisposable +@inject LiveUpdateCoordinator Live +
+ @Mode + + + + @if (_live?.Error is not null || _live?.ConnectionError is not null || _actionError is not null) + { + + } + + + @if (_live?.LastSuccess is { } time) + { + Last successful refresh + } + else { No successful live refresh yet } + + @if (_live?.Hidden == true && _live.Paused == false) + { + Updates suspended while this tab is hidden. + } + @if (_live?.Error is not null || _live?.ConnectionError is not null || _actionError is not null) + { + @(_actionError ?? _live?.Error ?? _live?.ConnectionError) + } +
+@code { + private LiveUpdateCoordinator? _live; + private bool _acting, _refreshing, _disposed; + private string? _actionError; + private string Mode => _live?.Mode ?? "Connecting"; + private string ToggleTitle => $"{Mode} — {(_live?.Paused == true ? "Resume" : "Pause")} live updates"; + private string RefreshTitle => (_refreshing ? "Refreshing visible data." : "Refresh visible data once, including while paused.") + + "\n" + (_live?.LastSuccess is { } time ? $"Last successful refresh {ExplorerLocation.Utc(time)}" : "No successful live refresh yet"); + + protected override void OnInitialized() + { + _live = Live; + if (_live is not null) _live.Changed += OnChanged; + } + + private void OnChanged() + { + if (!_disposed) _ = InvokeAsync(StateHasChanged); + } + + private Task TogglePauseAsync() => ActAsync(() => _live!.Paused ? _live.ResumeAsync() : _live.PauseAsync()); + private Task RetryAsync() => ActAsync(() => _live!.RetryAsync()); + private async Task ActAsync(Func action) + { + if (_acting || _live is null) return; + _acting = true; _actionError = null; + try { await action(); } + catch (OperationCanceledException) { } + catch (Exception) { _actionError = "Live updates could not be changed. Use Retry when the connection is available."; } + finally { _acting = false; } + } + + private async Task RefreshAsync() + { + if (_refreshing || _live is null) return; + _refreshing = true; _actionError = null; + try { await _live.RefreshOnceAsync(); } + catch (OperationCanceledException) { } + catch (Exception) { _actionError = "Visible data could not be refreshed. Use Retry when the connection is available."; } + finally { _refreshing = false; } + } + + public void Dispose() + { + _disposed = true; + if (_live is not null) _live.Changed -= OnChanged; + } +} diff --git a/src/MicroStack.UI.Client/Components/LoadingState.razor b/src/MicroStack.UI.Client/Components/LoadingState.razor new file mode 100644 index 0000000..ce49614 --- /dev/null +++ b/src/MicroStack.UI.Client/Components/LoadingState.razor @@ -0,0 +1,4 @@ +
+ @Label
+
+@code { [Parameter] public string Label { get; set; } = "Loading snapshot…"; } diff --git a/src/MicroStack.UI.Client/Components/RequestActivity.razor b/src/MicroStack.UI.Client/Components/RequestActivity.razor new file mode 100644 index 0000000..3c9fb8b --- /dev/null +++ b/src/MicroStack.UI.Client/Components/RequestActivity.razor @@ -0,0 +1,181 @@ +@implements IAsyncDisposable +@inject AdminApiClient AdminApi +@inject MicroStackApiService LegacyApi +@inject LiveUpdateCoordinator Live +
+ @if (Global) + { + + } + @if (_captured is { } time) + { + Last successful refresh · @ExplorerLocation.Utc(time) + } +
+@if (!Global) {

Retained service/account activity, not a resource-specific delivery trace.

} +@if (_confirmClear) +{ + +} +@if (_error is not null) { } +@if (_entries is null && _busy) { } +else if (_entries is { Count: 0 }) {

No recorded requests in this scope.

} +else if (_entries is not null) +{ +
+ + + + @foreach (var entry in _entries) + { + + + } + +
Timestamp (UTC)ServiceActionAccountStatusDuration
@ExplorerLocation.Utc(entry.Timestamp)@entry.Service@entry.Action@entry.AccountId@entry.StatusCode@entry.DurationMs ms
+
+

@_entries.Count retained requests. The bounded log does not include older discarded requests.

+} +@code { + [Parameter] public bool Global { get; set; } + [Parameter] public string Service { get; set; } = ""; + [Parameter] public string Account { get; set; } = ""; + private readonly CancellationTokenSource _lifetime = new(); + private IAsyncDisposable? _subscription; + private LiveUpdateCoordinator? _live; + private CancellationTokenSource? _read; + private long _generation, _scopeVersion; + private IReadOnlyList? _entries; + private string? _scope; + private string? _error; + private DateTimeOffset? _captured; + private bool _busy, _confirmClear, _clearing, _disposed; + + protected override async Task OnParametersSetAsync() + { + if (_disposed) return; + var scope = $"{Global}:{Service}:{Account}"; + if (_scope == scope) return; + _scope = scope; + var scopeVersion = ++_scopeVersion; + _read?.Cancel(); + _generation++; + _entries = null; _error = null; _captured = null; _busy = false; _confirmClear = false; + var previous = _subscription; + _subscription = null; + if (previous is not null) await previous.DisposeAsync(); + if (_disposed || scopeVersion != _scopeVersion) return; + _live = Live; + await LoadExplicitAsync(); + if (_disposed || scopeVersion != _scopeVersion) return; + if (_live is not null) + { + var registration = await _live.RegisterAsync(token => InvokeAsync(async () => + { + if (scopeVersion == _scopeVersion) await LoadAsync(token); + }), Global ? null : Account); + if (_disposed || scopeVersion != _scopeVersion) + { + await registration.DisposeAsync(); + return; + } + _subscription = registration; + } + } + private async Task LoadExplicitAsync() + { + try { await LoadAsync(_lifetime.Token); } + catch (OperationCanceledException) { } + catch (Exception) { } + } + private async Task LoadAsync(CancellationToken cancellationToken) + { + if (_disposed) return; + cancellationToken.ThrowIfCancellationRequested(); + _read?.Cancel(); + _read?.Dispose(); + using var read = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token, cancellationToken); + _read = read; + var token = read.Token; + var generation = ++_generation; + var global = Global; var service = Service; var account = Account; + _busy = true; + try + { + IReadOnlyList entries; + DateTimeOffset captured; + if (global) + { + entries = (await LegacyApi.GetRequestsAsync(cancellationToken: token)) + .Select(entry => new AdminActivity(entry.Service, entry.Action, entry.AccountId, entry.Timestamp, entry.StatusCode, entry.DurationMs)).ToArray(); + captured = DateTimeOffset.UtcNow; + } + else + { + var page = await AdminApi.ActivityAsync(service, account, token); + entries = page.Items; captured = page.CapturedAt; + } + token.ThrowIfCancellationRequested(); + if (generation != _generation) return; + if (_entries is null || !_entries.SequenceEqual(entries)) _entries = entries; + _captured = captured; _error = null; + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; } + catch (Exception) + { + if (!token.IsCancellationRequested && generation == _generation) + _error = "Request metadata could not be refreshed. Check the API connection and use Refresh to retry."; + throw; + } + finally + { + if (ReferenceEquals(_read, read)) _read = null; + if (generation == _generation) + { + _busy = false; + if (!_lifetime.IsCancellationRequested) StateHasChanged(); + } + } + } + private async Task ClearAsync() + { + if (_clearing || !Global) return; + _read?.Cancel(); + _generation++; + var scope = _scope; + var token = _lifetime.Token; + _clearing = true; + var cleared = false; + try + { + await LegacyApi.DeleteRequestsAsync(token); + if (token.IsCancellationRequested || scope != _scope) return; + _read?.Cancel(); + _generation++; + _confirmClear = false; _entries = []; _captured = null; _error = null; + cleared = true; + StateHasChanged(); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + catch (Exception) { if (!token.IsCancellationRequested && scope == _scope) _error = "The global log could not be cleared. Retry when the API is available."; } + finally { _clearing = false; _busy = false; } + if (cleared && !token.IsCancellationRequested && scope == _scope) + { + if (_live is not null && !_live.Hidden) await _live.RefreshOnceAsync(); + else await LoadExplicitAsync(); + } + } + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + _generation++; + _lifetime.Cancel(); _read?.Cancel(); + if (_subscription is not null) await _subscription.DisposeAsync(); + _lifetime.Dispose(); + } +} diff --git a/src/MicroStack.UI.Client/Components/ResourceFacts.razor b/src/MicroStack.UI.Client/Components/ResourceFacts.razor new file mode 100644 index 0000000..853f070 --- /dev/null +++ b/src/MicroStack.UI.Client/Components/ResourceFacts.razor @@ -0,0 +1,18 @@ +@if (Fields.Count > 0) +{ +
+ @foreach (var field in Fields) + { +
@field.Name
+ @InspectionProfile.FieldValue(field) + @if (Copyable && !field.Sensitive) { } +
+ } +
+} +@code { + [Parameter] public IReadOnlyList Fields { get; set; } = []; + [Parameter] public bool Compact { get; set; } + [Parameter] public bool Copyable { get; set; } + [Parameter] public string Label { get; set; } = "Resource summary"; +} diff --git a/src/MicroStack.UI.Client/Components/ResourceInspector.razor b/src/MicroStack.UI.Client/Components/ResourceInspector.razor new file mode 100644 index 0000000..df32a9f --- /dev/null +++ b/src/MicroStack.UI.Client/Components/ResourceInspector.razor @@ -0,0 +1,43 @@ +
+ @if (Detail.HasContent && ShowContent) + { + + } + @if (ShowSummary) { } + @if (Primary.Count > 0 || Detail.RevealableFields.Count > 0) + { +
+ +
+ } + @if (Secondary.Count > 0) + { + + } + @ChildContent + @if ((!Detail.HasContent || !ShowContent) && Detail.Fields.Count == 0 && Detail.RevealableFields.Count == 0) + { +

@(ShowContent && Detail.HasChildren ? "Browse this resource to inspect its retained entries." : "No additional configuration is retained for this resource.")

+ } +
+@code { + [Parameter, EditorRequired] public AdminResourceDetail Detail { get; set; } = default!; + [Parameter] public AdminContent? Content { get; set; } + [Parameter] public string Service { get; set; } = ""; + [Parameter] public string Account { get; set; } = ""; + [Parameter] public AdminKey[] Path { get; set; } = []; + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public bool ShowSummary { get; set; } = true; + [Parameter] public bool SummaryShownExternally { get; set; } + [Parameter] public bool ShowContent { get; set; } = true; + [Parameter] public long Revision { get; set; } + private IReadOnlyList Summary => Detail.Summary.Count > 0 ? Detail.Summary : Detail.Resource.Summary; + private IReadOnlyList Primary => Detail.Fields.Where(fact => + (!fact.Secondary || fact.Sensitive) && !IsSummaryDuplicate(fact)).ToArray(); + private IReadOnlyList Secondary => Detail.Fields.Where(fact => fact.Secondary && !fact.Sensitive).ToArray(); + private bool IsSummaryDuplicate(AdminField fact) => (ShowSummary || SummaryShownExternally) && !fact.Sensitive && !fact.CanReveal && + Summary.Any(summary => !summary.Sensitive && summary.Name == fact.Name && summary.Value == fact.Value && summary.Format == fact.Format); +} diff --git a/src/MicroStack.UI.Client/Components/ServicePicker.razor b/src/MicroStack.UI.Client/Components/ServicePicker.razor new file mode 100644 index 0000000..cf38ecf --- /dev/null +++ b/src/MicroStack.UI.Client/Components/ServicePicker.razor @@ -0,0 +1,35 @@ +
+ + +
+@code { + [Parameter] public IReadOnlyList Services { get; set; } = []; + [Parameter] public AdminService? Selected { get; set; } + [Parameter] public EventCallback SelectedChanged { get; set; } + private string _filter = ""; + private List Matches => Services.Where(service => + $"{service.Label} {service.Name} {service.Id} {service.Category}".Contains(_filter, StringComparison.OrdinalIgnoreCase)) + .OrderBy(service => service.Label, StringComparer.OrdinalIgnoreCase).ToList(); +} diff --git a/src/MicroStack.UI.Client/Layout/MainLayout.razor b/src/MicroStack.UI.Client/Layout/MainLayout.razor new file mode 100644 index 0000000..1d944a4 --- /dev/null +++ b/src/MicroStack.UI.Client/Layout/MainLayout.razor @@ -0,0 +1,48 @@ +@inherits LayoutComponentBase +@implements IDisposable +@using Microsoft.AspNetCore.Components.Sections +@inject NavigationManager Navigation +@inject ExplorerAccountState AccountState + +
+ MicroStack + +
+
+ @if (IsInstancePage) + { Instance-wide } + else + { } +
+ +
+ +
+
+ @if (AccountState.PersistenceError is { } error) + { } + @Body +
+@code { + private bool IsInstancePage => Navigation.ToBaseRelativePath(Navigation.Uri).Split('?', '#')[0] is "overview" or "requests"; + private string ServicesUrl => (ExplorerLocation.RouteAccount(Navigation) ?? AccountState.Account) is { } account + ? ExplorerLocation.DirectoryUrl(Navigation, account) : Navigation.BaseUri; + protected override async Task OnInitializedAsync() + { + Navigation.LocationChanged += LocationChanged; + AccountState.Changed += AccountChanged; + await AccountState.RestoreAsync(); + } + private void AccountChanged() => _ = InvokeAsync(StateHasChanged); + private void LocationChanged(object? sender, Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs args) => + _ = InvokeAsync(StateHasChanged); + public void Dispose() + { + Navigation.LocationChanged -= LocationChanged; + AccountState.Changed -= AccountChanged; + } +} diff --git a/src/MicroStack.UI.Client/MicroStack.UI.Client.csproj b/src/MicroStack.UI.Client/MicroStack.UI.Client.csproj new file mode 100644 index 0000000..1b1d99e --- /dev/null +++ b/src/MicroStack.UI.Client/MicroStack.UI.Client.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + ui + + browser-wasm + browser-wasm + + + + + + + + + + diff --git a/src/MicroStack.UI.Client/Pages/LegacyResources.razor b/src/MicroStack.UI.Client/Pages/LegacyResources.razor new file mode 100644 index 0000000..a486a6a --- /dev/null +++ b/src/MicroStack.UI.Client/Pages/LegacyResources.razor @@ -0,0 +1,56 @@ +@page "/" +@page "/resources" +@page "/services/{ServiceId}" +@implements IDisposable +@inject AdminApiClient Api +@inject NavigationManager Navigation +@inject ExplorerAccountState AccountState + +Services · MicroStack +@if (_error is not null) +{ + +} + +@code { + [Parameter] public string? ServiceId { get; set; } + [SupplyParameterFromQuery(Name = "account")] public string? Account { get; set; } + private readonly CancellationTokenSource _cts = new(); + private string? _destination, _error; + protected override Task OnParametersSetAsync() => LoadAsync(); + + private async Task LoadAsync() + { + _error = null; + var original = Navigation.Uri; + try + { + if (Account is null) await AccountState.RestoreAsync(); + var account = Account ?? AccountState.Account ?? (await Api.ContextAsync(_cts.Token)).DefaultAccount; + if (_cts.IsCancellationRequested || Navigation.Uri != original) return; + if (!ExplorerLocation.ValidAccount(account)) + throw new FormatException("Account ID must contain exactly 12 digits."); + var destination = ServiceId is null ? ExplorerLocation.DirectoryUrl(Navigation, account) + : ExplorerLocation.ServiceUrl(Navigation, ServiceId, account); + _destination = Navigation.GetUriWithQueryParameters(destination + new Uri(original).Query, + new Dictionary { ["account"] = null }); + } + catch (OperationCanceledException) when (_cts.IsCancellationRequested) { } + catch (FormatException exception) { _error = exception.Message; } + catch (Exception) { _error = "The default account could not be loaded. Check the API connection and retry."; } + } + + protected override Task OnAfterRenderAsync(bool firstRender) + { + if (_destination is { } destination) + { + _destination = null; + Navigation.NavigateTo(destination, replace: true); + } + return Task.CompletedTask; + } + public void Dispose() { _cts.Cancel(); _cts.Dispose(); } +} diff --git a/src/MicroStack.UI.Client/Pages/NotFound.razor b/src/MicroStack.UI.Client/Pages/NotFound.razor new file mode 100644 index 0000000..57010be --- /dev/null +++ b/src/MicroStack.UI.Client/Pages/NotFound.razor @@ -0,0 +1,7 @@ +@page "/not-found" +@layout MainLayout + +Not found · MicroStack +

Page not found

+

This address does not match a MicroStack page.

+Browse services \ No newline at end of file diff --git a/src/MicroStack.UI.Client/Pages/Overview.razor b/src/MicroStack.UI.Client/Pages/Overview.razor new file mode 100644 index 0000000..cf09337 --- /dev/null +++ b/src/MicroStack.UI.Client/Pages/Overview.razor @@ -0,0 +1,163 @@ +@page "/overview" +@implements IAsyncDisposable +@inject MicroStackApiService Api +@inject AdminApiClient AdminApi +@inject LiveUpdateCoordinator Live +Overview · MicroStack +
+

Overview

Instance status and global operations.

+ @if (_error is not null) { } + @if (_busy && _health is null) { } + @if (_health is not null) + { + +

Service status

+
+ @foreach (var entry in _health.Services.OrderBy(entry => entry.Key, StringComparer.Ordinal)) + { } +
HandlerStatus
@entry.Key@entry.Value
+

Default-account resource summary

+

This compatible summary covers the original resource providers in the configured default account. Use Services for all-service, account-selectable inspection.

+
+ @foreach (var entry in _resources.OrderBy(entry => entry.Service, StringComparer.Ordinal)) + { } +
ProviderResources
@entry.Service@entry.Count
+ } +
+

Global reset

+

Clear all in-memory resource state for every account and service. This is not scoped to your selected account.

+ @if (_confirmReset) + { + + } + else { } +
+ @if (_message is not null) {

@_message

} +
+@code { + private readonly CancellationTokenSource _lifetime = new(); + private IAsyncDisposable? _subscription; + private LiveUpdateCoordinator? _live; + private CancellationTokenSource? _read; + private long _generation; + private HealthResponse? _health; + private AdminContext? _context; + private IReadOnlyList _resources = []; + private bool _busy, _confirmReset, _resetting, _refreshContext, _disposed; + private string? _error, _message; + protected override async Task OnInitializedAsync() + { + _live = Live; + await LoadExplicitAsync(); + if (_disposed) return; + if (_live is not null) + { + _live.Changed += LiveChanged; + var registration = await _live.RegisterAsync(token => InvokeAsync(() => LoadAsync(token, _refreshContext))); + if (_disposed) + { + await registration.DisposeAsync(); + return; + } + _subscription = registration; + } + } + + private void LiveChanged() + { + if (_live?.ConnectionError is not null) _refreshContext = true; + } + + private async Task LoadExplicitAsync() + { + try { await LoadAsync(_lifetime.Token); } + catch (OperationCanceledException) { } + catch (Exception) { } + } + private async Task LoadAsync(CancellationToken cancellationToken, bool refreshContext = false) + { + if (_disposed) return; + cancellationToken.ThrowIfCancellationRequested(); + _read?.Cancel(); + _read?.Dispose(); + using var read = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token, cancellationToken); + _read = read; + var token = read.Token; + var generation = ++_generation; + _busy = true; _error = null; + try + { + var health = await Api.GetHealthAsync(token); + var resources = await Api.GetResourcesAsync(token); + var context = _context is null || refreshContext ? await AdminApi.ContextAsync(token) : _context; + token.ThrowIfCancellationRequested(); + if (generation != _generation) return; + _health = health; _resources = resources; _context = context; + _refreshContext = false; + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; } + catch (Exception) + { + if (!token.IsCancellationRequested && generation == _generation) + _error = "Instance status could not be loaded. Check the API connection and refresh. Previously loaded data may be stale."; + throw; + } + finally + { + if (ReferenceEquals(_read, read)) _read = null; + if (generation == _generation) + { + _busy = false; + if (!_lifetime.IsCancellationRequested) StateHasChanged(); + } + } + } + private async Task ResetAsync() + { + if (_resetting) return; + _read?.Cancel(); + _generation++; + _resetting = true; _error = null; _message = null; + var reset = false; + try + { + await Api.ResetAsync(_lifetime.Token); + if (_lifetime.IsCancellationRequested) return; + _read?.Cancel(); + _generation++; + _confirmReset = false; _message = "All in-memory state was reset."; + _resources = []; _context = null; + reset = true; + StateHasChanged(); + } + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) { } + catch (Exception) { _error = "The reset could not be confirmed. Refresh instance status before retrying."; } + finally { _resetting = false; _busy = false; } + if (reset && !_lifetime.IsCancellationRequested) + { + if (_live is not null && !_live.Hidden) await _live.RefreshOnceAsync(); + else await LoadExplicitAsync(); + } + } + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + _generation++; + _lifetime.Cancel(); + _read?.Cancel(); + if (_live is not null) _live.Changed -= LiveChanged; + if (_subscription is not null) await _subscription.DisposeAsync(); + _lifetime.Dispose(); + } +} diff --git a/src/MicroStack.UI.Client/Pages/RequestLog.razor b/src/MicroStack.UI.Client/Pages/RequestLog.razor new file mode 100644 index 0000000..fb7b667 --- /dev/null +++ b/src/MicroStack.UI.Client/Pages/RequestLog.razor @@ -0,0 +1,4 @@ +@page "/requests" +Request log · MicroStack +

Request log

Instance-wide request metadata across all services and accounts. Payloads are not logged.

+ diff --git a/src/MicroStack.UI.Client/Pages/Resources.Live.cs b/src/MicroStack.UI.Client/Pages/Resources.Live.cs new file mode 100644 index 0000000..3dbe4ff --- /dev/null +++ b/src/MicroStack.UI.Client/Pages/Resources.Live.cs @@ -0,0 +1,154 @@ +using System.Net; +using MicroStack.Admin.Contracts; +using MicroStack.UI.Client.Services; + +namespace MicroStack.UI.Client.Pages; + +public partial class Resources +{ + private long _revealRevision; + private long _subscriptionVersion; + private IAsyncDisposable? _liveRegistration; + private bool _disposed; + private string? _catalogEpoch; + + internal Task RefreshLiveAsync(CancellationToken cancellationToken) => + InvokeAsync(() => ReadLiveAsync(cancellationToken)); + + private async Task ReadLiveAsync(CancellationToken cancellationToken) + { + using var read = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cts.Token); + var token = read.Token; + var generation = _generation; + var account = AccountId ?? _account; + var service = ServiceId; + var path = ExplorerLocation.DecodePath(PathQuery); + var item = ExplorerLocation.DecodePath(ItemQuery); + if (item.Length > 1) throw new FormatException("Select one child entry at a time."); + var tab = ActiveTab; + var kind = _kind; + var filter = Filter; + var childKind = ChildKind; + var childFilter = ChildFilter; + var prefix = Prefix; + token.ThrowIfCancellationRequested(); + + // A refresh revokes explicit reveals without changing navigation or debounce identity. + _revealRevision++; + StateHasChanged(); + try + { + var epoch = Live.InstanceEpoch; + var reloadCatalog = epoch != _catalogEpoch; + var context = _context is null || reloadCatalog ? await Api.ContextAsync(token) : _context; + if (!ExplorerLocation.ValidAccount(account)) + throw new FormatException("Account ID must contain exactly 12 digits. Choose a valid account."); + var accounts = await Api.AccountsAsync(token); + token.ThrowIfCancellationRequested(); + if (!accounts.Contains(account)) + { + _accounts = accounts; + _context = context; _account = account; + ClearLiveInspection(); + _resources = null; + _error = $"Account {account} has no retained resources and is not the default. Select a known account."; + return; + } + + var services = _services.Count > 0 && !reloadCatalog ? _services : await Api.ServicesAsync(account, token); + var selectedService = services.FirstOrDefault(value => value.Id == service); + if (selectedService is not null && string.IsNullOrEmpty(kind)) + kind = InspectionProfile.RootKinds(selectedService).FirstOrDefault(value => value.Id == (Kind ?? path.FirstOrDefault()?.Kind))?.Id + ?? InspectionProfile.RootKinds(selectedService).FirstOrDefault()?.Id ?? ""; + AdminPage? resources = null, children = null; + AdminResourceDetail? detail = null, selected = null; + AdminContent? content = null, selectedContent = null; + AdminPage? connections = null, selectedConnections = null; + string? error = null, childError = null; + var missing = false; + + if (service is not null) + { + if (selectedService is null) + throw new FormatException("This service is not in the instance catalog. Choose a service from the breadcrumb."); + if (selectedService.Availability != "disabled") + { + if (InspectionProfile.RootKinds(selectedService).Count > 0) + resources = await Api.ResourcesAsync(service, account, kind, filter, token); + if (path.Length > 0) + { + try + { + detail = await Api.DetailAsync(service, account, path, token); + // A previously missing/configuration-only resource may now expose content. + tab = Tab is "configuration" or "connections" or "activity" ? Tab : + detail is { HasChildren: false, HasContent: false } ? "configuration" : "contents"; + if (tab == "connections" && detail.HasConnections) + connections = await Api.ConnectionsAsync(service, account, path, token); + if (tab == "contents") + { + if (detail.HasChildren) + children = await Api.ChildrenAsync(service, account, path, childKind, childFilter, prefix, token); + if (detail.HasContent) + content = await Api.ContentAsync(service, account, path, token); + if (item.Length > 0) + { + try + { + AdminKey[] selectedPath = [.. path, .. item]; + selected = await Api.DetailAsync(service, account, selectedPath, token); + if (selected.HasContent) + selectedContent = await Api.ContentAsync(service, account, selectedPath, token); + if (selected.HasConnections) + selectedConnections = await Api.ConnectionsAsync(service, account, selectedPath, token); + } + catch (AdminApiException exception) when (exception.Status == HttpStatusCode.NotFound) + { + selected = null; selectedContent = null; selectedConnections = null; + childError = "This entry no longer exists. Select another entry or wait for it to return."; + } + } + } + } + catch (AdminApiException exception) when (exception.Status == HttpStatusCode.NotFound) + { + missing = true; + detail = null; content = null; children = null; connections = null; + error = "This resource no longer exists in the selected account. Return to the resource list or wait for it to return."; + } + } + } + } + + token.ThrowIfCancellationRequested(); + if (generation != _generation) return; + _context = context; _account = account; _kind = kind; + _catalogEpoch = epoch; + _accounts = accounts; _services = services; _service = selectedService; + _path = path; _item = item; + _resources = resources; _detail = detail; _content = content; + _children = children; _connections = connections; + _selectedDetail = selected; _selectedContent = selectedContent; _selectedConnections = selectedConnections; + _error = error; _childError = childError; _missing = missing; _selectedConnectionError = null; + _captured = resources?.CapturedAt ?? DateTimeOffset.UtcNow; + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; } + catch (Exception) + { + if (!token.IsCancellationRequested && generation == _generation) + _error = "Live inspection could not be refreshed. Previously loaded data may be stale. Use Retry in the live controls."; + throw; + } + finally + { + if (!token.IsCancellationRequested && generation == _generation) StateHasChanged(); + } + } + + private void ClearLiveInspection() + { + _detail = null; _selectedDetail = null; _content = null; _selectedContent = null; + _children = null; _connections = null; _selectedConnections = null; + _childError = null; _selectedConnectionError = null; _missing = false; + } +} diff --git a/src/MicroStack.UI.Client/Pages/Resources.razor b/src/MicroStack.UI.Client/Pages/Resources.razor new file mode 100644 index 0000000..d644f7c --- /dev/null +++ b/src/MicroStack.UI.Client/Pages/Resources.razor @@ -0,0 +1,272 @@ +@page "/accounts/{AccountId}/services" +@page "/accounts/{AccountId}/services/{ServiceId}" +@implements IAsyncDisposable +@using Microsoft.AspNetCore.Components.Sections +@inject AdminApiClient Api +@inject ExplorerAccountState AccountState +@inject LiveUpdateCoordinator Live +@inject NavigationManager Navigation +@inject IJSRuntime JS + +@(_service?.Label ?? "Services") · MicroStack + + @if (_context is not null) + { } + +@if (ServiceId is not null) +{ +
+ + @if (_service is not null) + { } +
+} +@if (_error is not null) +{ + +} +@if (ServiceId is null) +{ +
+
+

Services

+

Find a service, inspect its resources, and follow configured connections.

+
+
+ + + @if (_services.Count > 0) + {

@DirectoryServices.Count of @_services.Count services

} +
+ @if (_busy && _showLoading) { } + else if (_services.Count > 0) + { + @if (DirectoryServices.Count == 0) {

No services match. Try a service name, identifier, or category.

} +
+ @foreach (var group in DirectoryServices.GroupBy(service => service.Category)) + { +

@group.Key @group.Count()

+ } +
+ } +
+} +else if (_service is not null) +{ + @if (ExplorerLocation.ResolveReturn(Navigation, ReturnTo) is { } returnTo) { ← Return to previous inspection } +

@_service.Label

+ @if (_service.Scope != "account") {

@ScopeLabel(_service.Scope)@(IsGlobalScope(_service.Scope) ? " · shared across accounts" : "")

} + @if (!string.IsNullOrEmpty(_service.Notice)) {

@_service.Notice

} + @if (_service.Availability == "disabled") + {

Service disabled

This service is declared in the catalog but its handler is not enabled on this instance.

View instance status
} + else + { +
+
+
+ @if (RootKinds.Count > 1) + { + +
+ + +
+ } + else + { @RootKindLabel } + @(_resources?.Items.Count ?? 0) +
+ + @if (_showLoading && _resources is null) { } + @if (_resources is not null) + { +
+ @foreach (var resource in _resources.Items) + { + + @resource.Name + @if (resource.Type is not null) { @resource.Type } + + } +
+ @if (_resources.Items.Count == 0) {

No @RootKindLabel.ToLowerInvariant()@(string.IsNullOrEmpty(Filter) ? " in this scope." : " match this filter.")

} + } +

Name / identifier search · pause for stable inspection.

+
+
+ @if (_showLoading && _detail is null) { } + @if (_detail is not null) + { +
+ @if (_path.Length > 1) + { + + } +

@_detail.Resource.Name

+
@(_detail.Resource.Type ?? KindLabel(_detail.Resource.Key.Kind)) + @if (InspectionProfile.DisplayStatus(_detail.Resource) is { } status) { @status } + @if (_detail.Resource.Scope != "account") { @ScopeLabel(_detail.Resource.Scope) } +
+
+
@(_detail.Resource.Arn ?? _detail.Resource.Key.Id)
+ +
+
+ @foreach (var tab in Tabs) + { + + } +
+
+ @if (_showLoading) {

Updating snapshot…

} + @if (ActiveTab == "configuration") + { + + } + else if (ActiveTab == "connections") + { +

Explicit configured relationships only. A configured connection does not establish successful delivery.

+ @if (_connections is { Items.Count: > 0 }) + { + + } + else {

No configured connections are retained for this resource.

} + } + else if (ActiveTab == "activity") + { } + else + { + @if (ServiceId == "sqs") {

Read-only queue snapshot. Inspection does not receive messages, change visibility, or advance consumers.

} + @if (ServiceId == "dynamodb") {

Items retain DynamoDB attribute types (S, N, M, L, and other modeled types). Browsing does not execute a query or alter items.

} + @if (_detail.HasContent) + { + + } + @if (_detail.HasChildren) + { + @if (IsS3PrefixBrowser) + { + + } +
+
+ @if (ChildKinds.Count > 1) + { + + } + + @if (IsS3PrefixBrowser) + { +
+ + +
+ } +
+
+
+
+
@ChildrenLabel@(_children?.Items.Count ?? 0)
+ @if (_children is not null) + { + @foreach (var group in _children.Items.GroupBy(child => child.Key.Kind)) + { + @if (ChildKinds.Count > 1) {
@KindLabel(group.Key)
} + @foreach (var child in group) + { + +
@ChildName(child)
+
@(child.Type ?? KindLabel(child.Key.Kind)) + @if (InspectionProfile.DisplayStatus(child) is { } childStatus) { @childStatus } + @if (ServiceId == "s3" && child.Key.Kind == "prefixes") { Browse → } +
+ +
+ } + } + @if (_children.Items.Count == 0) {

@(string.IsNullOrEmpty(ChildFilter) ? $"No {ChildrenLabel.ToLowerInvariant()} are retained in this collection." : "No entries match this name or identifier. Try a different filter.")

} + } +
+
+ @if (_childError is not null) { } + @if (_selectedDetail is not null) + { +
@_selectedDetail.Resource.Name + + @if (_selectedDetail.HasChildren) { Browse @SelectedBrowseLabel → } +
+ + @if (_selectedDetail.HasConnections) + { +
+

Configured connections@(_selectedDetail.ConnectionCount is { } count ? $" ({count:N0})" : "")

+ @if (_selectedConnectionError is not null) { } + @if (_selectedConnections is { Items.Count: > 0 }) + { + + } + else if (_selectedConnections is not null) {

No configured connections are retained for this entry.

} +
+ } +
+ } + else if (_childError is null) {

Select an entry to inspect its content and metadata.

} +
+
+ } + @if (!_detail.HasChildren && !_detail.HasContent) + {

This resource retains configuration, not a browsable content collection. Inspect the Configuration tab for its modeled fields.

} + } +
+ } + else if (!_missing && !_busy) + { +

@(_service.Kinds.Count == 0 ? "No retained resources" : "Choose a resource")

+

@(_service.Kinds.Count == 0 ? "This service does not retain a resource collection. Its scope and request activity remain available." : "Select a resource from the index, or refresh after creating one through its AWS API.")

+ +
+ @if (_service.Kinds.Count == 0) {
} + } +
+
+ } +
Read-only inspection · content previews up to 1 MiB + @if (_captured is { } captured) { Snapshot · @ExplorerLocation.Utc(captured) } +
+} +else if (_busy && _showLoading) { } diff --git a/src/MicroStack.UI.Client/Pages/Resources.razor.cs b/src/MicroStack.UI.Client/Pages/Resources.razor.cs new file mode 100644 index 0000000..a3c8a66 --- /dev/null +++ b/src/MicroStack.UI.Client/Pages/Resources.razor.cs @@ -0,0 +1,400 @@ +using System.Net; +using System.Text.Json; +using MicroStack.Admin.Contracts; +using MicroStack.UI.Client.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace MicroStack.UI.Client.Pages; + +public partial class Resources +{ + [Parameter] public string? ServiceId { get; set; } + [Parameter] public string? AccountId { get; set; } + [SupplyParameterFromQuery(Name = "kind")] public string? Kind { get; set; } + [SupplyParameterFromQuery(Name = "path")] public string? PathQuery { get; set; } + [SupplyParameterFromQuery(Name = "item")] public string? ItemQuery { get; set; } + [SupplyParameterFromQuery(Name = "tab")] public string? Tab { get; set; } + [SupplyParameterFromQuery(Name = "filter")] public string? Filter { get; set; } + [SupplyParameterFromQuery(Name = "enabled")] public bool? Enabled { get; set; } + [SupplyParameterFromQuery(Name = "childKind")] public string? ChildKind { get; set; } + [SupplyParameterFromQuery(Name = "childFilter")] public string? ChildFilter { get; set; } + [SupplyParameterFromQuery(Name = "prefix")] public string? Prefix { get; set; } + [SupplyParameterFromQuery(Name = "returnTo")] public string? ReturnTo { get; set; } + private CancellationTokenSource _cts = new(); + private AdminContext? _context; + private IReadOnlyList _accounts = []; + private IReadOnlyList _services = []; + private AdminService? _service; + private AdminPage? _resources, _children; + private AdminPage? _connections, _selectedConnections; + private AdminResourceDetail? _detail, _selectedDetail; + private AdminContent? _content, _selectedContent; + private AdminKey[] _path = [], _item = []; + private string _account = "", _kind = "", _prefixFilter = "", _scopeKey = ""; + private string? _error, _childError, _selectedConnectionError, _loadedScope, _loadedItem; + private DateTimeOffset? _captured; + private bool _busy, _missing, _browsing, _showLoading, _deferSelectionRender; + private long _generation, _restoredGeneration; + private RequestState? _lastRequest; + private string? _focusTab; + private string ActiveTab => Tab is "configuration" or "connections" or "activity" ? Tab : + _detail is { HasChildren: false, HasContent: false } ? "configuration" : "contents"; + private IReadOnlyList<(string Id, string Label)> Tabs + { + get + { + List<(string, string)> tabs = []; + if (_detail is { HasChildren: true } or { HasContent: true }) tabs.Add(("contents", ChildrenLabel)); + tabs.Add(("configuration", "Configuration")); + if (_detail?.HasConnections == true) + tabs.Add(("connections", _detail.ConnectionCount is { } count ? $"Connections ({count:N0})" : "Connections")); + else if (Tab == "connections") tabs.Add(("connections", "Connections")); + tabs.Add(("activity", "Activity")); + return tabs; + } + } + private List DirectoryServices => _services.Where(service => + (Enabled != true || service.Availability != "disabled") && + $"{service.Name} {service.Label} {service.Id} {service.Category}".Contains(Filter ?? "", StringComparison.OrdinalIgnoreCase)).ToList(); + private string RootKindLabel => _service?.Kinds.FirstOrDefault(kind => kind.Id == _kind)?.Label ?? "Resources"; + private string RootFilterLabel => $"Filter {RootKindLabel.ToLowerInvariant()}"; + private string ChildFilterLabel => InspectionProfile.ChildFilterLabel(ServiceId, _detail, ChildKind); + private IReadOnlyList RootKinds => InspectionProfile.RootKinds(_service); + private IReadOnlyList ChildKinds => _detail?.ChildKinds ?? []; + private string ChildrenLabel => InspectionProfile.CollectionLabel(ServiceId, _detail); + private bool IsSelectedEventRule => ServiceId == "events" && _selectedDetail?.Resource.Key.Kind == "rules"; + private string SelectedBrowseLabel => IsSelectedEventRule + ? InspectionProfile.CollectionLabel(ServiceId, _selectedDetail) : KindLabel(_selectedDetail!.Resource.Key.Kind); + private string FilterScope => $"{_generation}:{ServiceId}:{AccountId}"; + private bool IsS3PrefixBrowser => ServiceId == "s3" && _detail?.Resource.Key.Kind is "buckets" or "prefixes"; + private string CurrentPrefix => Prefix ?? (_path.LastOrDefault()?.Kind == "prefixes" ? _path[^1].Id : ""); + private IReadOnlyList<(string Name, string Prefix)> PrefixAncestors + { + get + { + List<(string, string)> ancestors = []; + var prefix = CurrentPrefix; + var start = 0; + for (var index = 0; index < prefix.Length; index++) + { + if (prefix[index] != '/') continue; + ancestors.Add((index == start ? "/" : prefix[start..index], prefix[..(index + 1)])); + start = index + 1; + } + if (start < prefix.Length) ancestors.Add((prefix[start..], prefix)); + return ancestors; + } + } + private AdminKey[] SelectedPath => [.. _path, .. _item]; + private string HomeUrl => ExplorerLocation.DirectoryUrl(Navigation, _account); + private string OpenChildUrl => UpdateUrl(new() { ["path"] = ExplorerLocation.EncodePath(SelectedPath), ["item"] = null, ["childKind"] = null, ["childFilter"] = null, ["childCursor"] = null, ["prefix"] = null, ["tab"] = "contents" }); + + protected override async Task OnParametersSetAsync() + { + var version = ++_subscriptionVersion; + _cts.Cancel(); + var previous = _liveRegistration; + _liveRegistration = null; + if (previous is not null) await previous.DisposeAsync(); + if (_disposed || version != _subscriptionVersion) return; + await LoadAsync(); + if (_disposed || version != _subscriptionVersion || !ExplorerLocation.ValidAccount(AccountId)) return; + var registration = await Live.RegisterAsync(RefreshLiveAsync, AccountId); + if (_disposed || version != _subscriptionVersion) await registration.DisposeAsync(); + else _liveRegistration = registration; + } + protected override bool ShouldRender() => !_deferSelectionRender; + + private bool HasSensitiveInspection => + new[] { _detail, _selectedDetail }.Any(detail => detail is not null && + (detail.RevealableFields.Count > 0 || detail.Fields.Any(value => value.Sensitive || value.CanReveal))) || + _content?.Sensitive == true || _selectedContent?.Sensitive == true; + + private async Task ShowLoadingAsync(long generation, CancellationToken token) + { + try + { + await Task.Delay(TimeSpan.FromMilliseconds(150), token); + await InvokeAsync(() => + { + if (generation != _generation) return; + _deferSelectionRender = false; + _showLoading = true; + StateHasChanged(); + }); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_busy || _restoredGeneration == _generation) return; + _restoredGeneration = _generation; + await JS.InvokeVoidAsync("microstack.restore", Navigation.Uri); + if (_focusTab is not null && _detail is not null) + { + await JS.InvokeVoidAsync("microstack.focus", $"tab-{_focusTab}"); + _focusTab = null; + } + } + + private async Task LoadAsync() + { + _cts.Cancel(); _cts.Dispose(); _cts = new(); + var token = _cts.Token; + var generation = ++_generation; + _revealRevision++; + var request = new RequestState(ServiceId, AccountId, Kind, PathQuery, ItemQuery, Tab, Filter, ChildKind, ChildFilter, Prefix); + var resourceFilterOnly = _lastRequest is { } previous && request != previous && + request with { Filter = previous.Filter } == previous; + var childFilterOnly = _lastRequest is { } previousChild && request != previousChild && string.IsNullOrEmpty(ItemQuery) && + request with { ChildFilter = previousChild.ChildFilter, Item = previousChild.Item } == previousChild; + var scope = $"{ServiceId}:{AccountId}:{Kind}:{PathQuery}"; + var sameScope = _loadedScope == scope; + var sameIndexScope = _lastRequest is { } indexRequest && + indexRequest.Service == request.Service && indexRequest.Account == request.Account && + indexRequest.Kind == request.Kind && indexRequest.Filter == request.Filter; + // Keep the previous frame for fast selections, but never retain sensitive content across navigation. + _deferSelectionRender = !sameScope && sameIndexScope && !HasSensitiveInspection; + _showLoading = false; + using var loadingDelay = CancellationTokenSource.CreateLinkedTokenSource(token); + var loadingFeedback = ShowLoadingAsync(generation, loadingDelay.Token); + _loadedScope = scope; + _scopeKey = $"{scope}:{ItemQuery}"; + _busy = true; _error = null; _childError = null; _selectedConnectionError = null; _missing = false; + if (!sameScope) + { + _detail = null; _content = null; _children = null; _connections = null; _captured = null; + if (!sameIndexScope) _resources = null; + _browsing = false; + } + if (!sameScope || _loadedItem != ItemQuery) + { + _selectedDetail = null; _selectedContent = null; _selectedConnections = null; + } + _loadedItem = ItemQuery; + _prefixFilter = Prefix ?? ""; + try + { + if (sameScope && _service is not null && ServiceId is not null && (resourceFilterOnly || childFilterOnly)) + { + if (resourceFilterOnly) + { + var resources = await Api.ResourcesAsync(ServiceId, _account, _kind, Filter, token); + if (token.IsCancellationRequested) return; + _resources = resources; _captured = resources.CapturedAt; + } + else if (_detail?.HasChildren == true) + { + _item = []; + var children = await Api.ChildrenAsync(ServiceId, _account, _path, ChildKind, ChildFilter, Prefix, token); + if (token.IsCancellationRequested) return; + _children = children; _captured = children.CapturedAt; + } + return; + } + var context = _context ?? await Api.ContextAsync(token); + var account = AccountId ?? context.DefaultAccount; + if (token.IsCancellationRequested) return; + _context = context; _account = account; + if (!ExplorerLocation.ValidAccount(account)) throw new FormatException("Account ID must contain exactly 12 digits. Choose a valid account."); + if (!sameIndexScope || request == _lastRequest || _accounts.Count == 0) + { + var accounts = await Api.AccountsAsync(token); + if (token.IsCancellationRequested) return; + _accounts = accounts; + } + if (!_accounts.Contains(account)) + { + _detail = null; _selectedDetail = null; _content = null; _selectedContent = null; + _resources = null; _children = null; _connections = null; _selectedConnections = null; + throw new FormatException($"Account {account} has no retained resources and is not the default. Select a known account."); + } + await AccountState.RememberAsync(account); + if (token.IsCancellationRequested) return; + var path = ExplorerLocation.DecodePath(PathQuery); + var item = ExplorerLocation.DecodePath(ItemQuery); + if (item.Length > 1) throw new FormatException("Select one child entry at a time."); + var reuseIndex = sameIndexScope && request != _lastRequest && _resources is not null && + (Kind ?? path.FirstOrDefault()?.Kind ?? _kind) == _kind; + var services = reuseIndex ? _services : await Api.ServicesAsync(account, token); + if (token.IsCancellationRequested) return; + _context = context; _account = account; _path = path; _item = item; _services = services; + _service = services.FirstOrDefault(service => service.Id == ServiceId); + if (ServiceId is null) return; + if (_service is null) throw new FormatException("This service is not in the instance catalog. Choose a service from the breadcrumb."); + if (_service.Availability == "disabled") return; + var requestedKind = Kind ?? path.FirstOrDefault()?.Kind; + _kind = RootKinds.FirstOrDefault(kind => kind.Id == requestedKind)?.Id ?? RootKinds.FirstOrDefault()?.Id ?? ""; + if (RootKinds.Count > 0 && !reuseIndex) + { + var resources = await Api.ResourcesAsync(ServiceId, account, _kind, Filter, token); + if (token.IsCancellationRequested) return; + _resources = resources; _captured = resources.CapturedAt; + } + if (path.Length == 0) + { + _browsing = true; + return; + } + var detail = await Api.DetailAsync(ServiceId, account, path, token); + if (token.IsCancellationRequested) return; + _detail = detail; + if (!detail.HasConnections) _connections = null; + _browsing = false; + if (ActiveTab == "connections" && detail.HasConnections) + { + var connections = await Api.ConnectionsAsync(ServiceId, account, path, token); + if (!token.IsCancellationRequested) { _connections = connections; _captured = connections.CapturedAt; } + } + if (ActiveTab != "contents") return; + if (detail.HasChildren) + { + var children = await Api.ChildrenAsync(ServiceId, account, path, ChildKind, ChildFilter, Prefix, token); + if (token.IsCancellationRequested) return; + _children = children; _captured = children.CapturedAt; + } + if (detail.HasContent) + { + var content = await Api.ContentAsync(ServiceId, account, path, token); + if (token.IsCancellationRequested) return; + _content = content; + } + if (item.Length > 0) await LoadSelectedAsync(token); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + catch (AdminApiException exception) + { + if (token.IsCancellationRequested) return; + _missing = exception.Status == HttpStatusCode.NotFound; + if (_missing) { _detail = null; _selectedDetail = null; _content = null; _selectedContent = null; _selectedConnections = null; } + _error = _missing ? "This resource no longer exists in the selected account. Return to the resource list or refresh." : exception.Message; + } + catch (Exception exception) when (exception is JsonException or FormatException) + { if (!token.IsCancellationRequested) _error = exception is FormatException ? exception.Message : "The API returned an invalid inspection response. Refresh to retry."; } + catch (Exception) { if (!token.IsCancellationRequested) _error = "The inspection API is unavailable. Check the API connection, then retry this snapshot."; } + finally + { + loadingDelay.Cancel(); + await loadingFeedback; + if (generation == _generation) + { + _busy = false; + _showLoading = false; + _deferSelectionRender = false; + if (!token.IsCancellationRequested && _error is null) _lastRequest = request; + } + } + } + + private async Task LoadSelectedAsync(CancellationToken token) + { + try + { + var selected = await Api.DetailAsync(ServiceId!, _account, SelectedPath, token); + if (token.IsCancellationRequested) return; + _selectedDetail = selected; + if (selected.HasContent) + { + var content = await Api.ContentAsync(ServiceId!, _account, SelectedPath, token); + if (token.IsCancellationRequested) return; + _selectedContent = content; + } + if (selected.HasConnections) + { + try + { + var connections = await Api.ConnectionsAsync(ServiceId!, _account, SelectedPath, token); + if (!token.IsCancellationRequested) _selectedConnections = connections; + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + catch (Exception) { if (!token.IsCancellationRequested) _selectedConnectionError = "Connections could not be loaded. Refresh the snapshot to retry."; } + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + catch (AdminApiException exception) when (exception.Status == HttpStatusCode.NotFound) + { + if (!token.IsCancellationRequested) + { + _selectedDetail = null; _selectedContent = null; _selectedConnections = null; + _childError = "This entry no longer exists. Refresh the collection and select another entry."; + } + } + catch (Exception) { if (!token.IsCancellationRequested) _childError = "This entry could not be loaded. Refresh the snapshot to retry."; } + } + + private string UpdateUrl(Dictionary changes) + { + changes["cursor"] = null; changes["childCursor"] = null; changes["connectionCursor"] = null; + return Navigation.GetUriWithQueryParameters(changes); + } + private void Update(Dictionary changes, bool replace = false) => Navigation.NavigateTo(UpdateUrl(changes), replace: replace); + private string ServiceUrl(string service) => ExplorerLocation.ServiceUrl(Navigation, service, _account); + private void SwitchService(string service) { if (service != ServiceId) Navigation.NavigateTo(ServiceUrl(service)); } + private void SwitchAccount(string account) + { + _cts.Cancel(); + _detail = null; _selectedDetail = null; _content = null; _selectedContent = null; _resources = null; _children = null; _connections = null; _selectedConnections = null; + Navigation.NavigateTo(ServiceId is null ? ExplorerLocation.DirectoryUrl(Navigation, account) + : ExplorerLocation.ServiceUrl(Navigation, ServiceId, account)); + } + private void FilterServices(ChangeEventArgs args) => Update(new() { ["filter"] = args.Value?.ToString() }, replace: true); + private void FilterEnabled(ChangeEventArgs args) => Update(new() { ["enabled"] = args.Value is true ? true : null }, replace: true); + private void ChangeKind(ChangeEventArgs args) => Update(new() { ["kind"] = args.Value?.ToString(), ["path"] = null, ["item"] = null, ["cursor"] = null, ["filter"] = null, ["childKind"] = null, ["childFilter"] = null, ["childCursor"] = null, ["prefix"] = null }); + private void ApplyResourceFilter(string value) => Update(new() { ["filter"] = value, ["cursor"] = null }, replace: true); + private void ApplyChildFilter(string value) => Update(new() { ["childFilter"] = value, ["childCursor"] = null, ["item"] = null }, replace: true); + private void ApplyPrefix() => Update(new() { ["prefix"] = _prefixFilter, ["childCursor"] = null, ["item"] = null }); + private void ChangeChildKind(ChangeEventArgs args) => Update(new() { ["childKind"] = args.Value?.ToString(), ["childCursor"] = null, ["item"] = null }); + private void ChangeTab(string tab) { _focusTab = tab; Update(new() { ["tab"] = tab }); } + private void ClearSelection() => Update(new() { ["path"] = null, ["item"] = null, ["cursor"] = null, ["childCursor"] = null }); + private string RootUrl(AdminKey key) => UpdateUrl(new() { ["path"] = ExplorerLocation.EncodePath([key]), ["item"] = null, ["tab"] = "contents", ["childKind"] = null, ["childFilter"] = null, ["childCursor"] = null, ["connectionCursor"] = null, ["prefix"] = null }); + private string ChildUrl(AdminKey key) => ServiceId == "s3" && key.Kind == "prefixes" + ? UpdateUrl(new() { ["path"] = ExplorerLocation.EncodePath([_path[0], key]), ["item"] = null, ["prefix"] = null, ["childFilter"] = null, ["childCursor"] = null, ["childKind"] = null }) + : UpdateUrl(new() { ["item"] = ExplorerLocation.EncodePath([key]) }); + private string PrefixUrl(string prefix) => UpdateUrl(new() { ["path"] = ExplorerLocation.EncodePath([_path[0]]), ["prefix"] = prefix, ["item"] = null, ["childFilter"] = null, ["childCursor"] = null, ["childKind"] = null, ["tab"] = "contents" }); + private string ChildName(AdminResourceSummary child) => IsS3PrefixBrowser && CurrentPrefix.Length > 0 && child.Name.StartsWith(CurrentPrefix, StringComparison.Ordinal) + ? child.Name[CurrentPrefix.Length..] : child.Name; + private string AncestorUrl(int index) => UpdateUrl(new() { ["path"] = ExplorerLocation.EncodePath(_path[..(index + 1)]), ["item"] = null, ["tab"] = "contents", ["childKind"] = null, ["childFilter"] = null, ["childCursor"] = null, ["prefix"] = null }); + private bool IsRootSelected(AdminKey key) => _path.Length > 0 && _path[0] == key; + private bool IsChildSelected(AdminKey key) => _item.Length == 1 && _item[0] == key; + private static bool IsGlobalScope(string scope) => scope is "global" or "instance" or "instance-global"; + private static string ScopeLabel(string scope) => scope switch + { + "global" or "instance" or "instance-global" => "Instance-global data", + "account" => "Account-scoped data", + _ => scope + }; + private static string AvailabilityLabel(AdminService service) => service.Availability == "disabled" ? "Disabled" : service.Kinds.Count == 0 ? "Enabled · no retained resources" : "Enabled"; + private string KindLabel(string kind) => ChildKinds.FirstOrDefault(candidate => candidate.Id == kind)?.Label ?? + _service?.Kinds.FirstOrDefault(candidate => candidate.Id == kind)?.Label ?? + System.Globalization.CultureInfo.InvariantCulture.TextInfo.ToTitleCase(kind.Replace('-', ' ').Replace('_', ' ')); + private string ConnectionUrl(AdminConnection connection) + { + var local = "/" + Navigation.ToBaseRelativePath(Navigation.Uri); + return ExplorerLocation.ServiceUrl(Navigation, connection.TargetServiceId!, _account, connection.TargetPath, local); + } + private string? ConnectionSourceUrl(AdminConnection connection) + { + if (connection.SourceServiceId is null || connection.SourcePath is not { Length: > 0 } sourcePath) return null; + if (connection.SourceServiceId == ServiceId && (sourcePath.SequenceEqual(_path) || sourcePath.SequenceEqual(SelectedPath))) return null; + if (connection.SourceServiceId == connection.TargetServiceId && connection.TargetPath is { } targetPath && sourcePath.SequenceEqual(targetPath)) return null; + return ExplorerLocation.ServiceUrl(Navigation, connection.SourceServiceId, _account, sourcePath, "/" + Navigation.ToBaseRelativePath(Navigation.Uri)); + } + private async Task ToggleBrowse() + { + _browsing = !_browsing; + await JS.InvokeVoidAsync("microstack.focus", _browsing ? "resource-index" : "resource-title"); + } + public async ValueTask DisposeAsync() + { + _disposed = true; + _subscriptionVersion++; + _cts.Cancel(); + if (_liveRegistration is not null) await _liveRegistration.DisposeAsync(); + _cts.Dispose(); + } + + private sealed record RequestState(string? Service, string? Account, string? Kind, string? Path, string? Item, string? Tab, + string? Filter, string? ChildKind, string? ChildFilter, string? Prefix); +} diff --git a/src/MicroStack.UI.Client/Program.cs b/src/MicroStack.UI.Client/Program.cs new file mode 100644 index 0000000..aba5862 --- /dev/null +++ b/src/MicroStack.UI.Client/Program.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using MicroStack.UI.Client; +using MicroStack.UI.Client.Services; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +var applicationBaseUri = new Uri(builder.HostEnvironment.BaseAddress); +var apiBaseUri = new Uri(applicationBaseUri.GetLeftPart(UriPartial.Authority) + "/"); +builder.Services.AddScoped(_ => new HttpClient { BaseAddress = apiBaseUri }); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +await builder.Build().RunAsync(); diff --git a/src/MicroStack.UI.Client/Properties/launchSettings.json b/src/MicroStack.UI.Client/Properties/launchSettings.json new file mode 100644 index 0000000..58dd3b8 --- /dev/null +++ b/src/MicroStack.UI.Client/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5237", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7087;http://localhost:5237", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/MicroStack.UI.Client/Services/AdminApiClient.cs b/src/MicroStack.UI.Client/Services/AdminApiClient.cs new file mode 100644 index 0000000..78d97c8 --- /dev/null +++ b/src/MicroStack.UI.Client/Services/AdminApiClient.cs @@ -0,0 +1,98 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using MicroStack.Admin.Contracts; + +namespace MicroStack.UI.Client.Services; + +public sealed class AdminApiException(HttpStatusCode status, string code, string message) : Exception(message) +{ + public HttpStatusCode Status { get; } = status; + public string Code { get; } = code; +} + +public sealed class AdminApiClient(HttpClient http) +{ + private const string Root = "/_microstack/admin/v1"; + public Task ContextAsync(CancellationToken ct) => GetAsync(Root + "/context", AdminJsonContext.Default.AdminContext, ct); + public Task AccountsAsync(CancellationToken ct) => GetAsync(Root + "/accounts", AdminJsonContext.Default.StringArray, ct); + public Task ServicesAsync(string account, CancellationToken ct) => + GetAsync(Url("/services", account), AdminJsonContext.Default.AdminServiceArray, ct); + public Task> ResourcesAsync(string service, string account, string? kind, string? filter, CancellationToken ct) => + GetAllAsync(cursor => Url($"/services/{Uri.EscapeDataString(service)}/resources", account, kind: kind, filter: filter, cursor: cursor), + AdminJsonContext.Default.AdminPageAdminResourceSummary, ct, item => item.Key); + public Task DetailAsync(string service, string account, AdminKey[] path, CancellationToken ct) => + GetAsync(Url($"/services/{Uri.EscapeDataString(service)}/resource", account, path), AdminJsonContext.Default.AdminResourceDetail, ct); + public Task> ChildrenAsync(string service, string account, AdminKey[] path, string? kind, string? filter, string? prefix, CancellationToken ct) => + GetAllAsync(cursor => Url($"/services/{Uri.EscapeDataString(service)}/children", account, path, kind, filter, cursor, prefix), + AdminJsonContext.Default.AdminPageAdminResourceSummary, ct, item => item.Key); + public Task ContentAsync(string service, string account, AdminKey[] path, CancellationToken ct) => + GetAsync(Url($"/services/{Uri.EscapeDataString(service)}/content", account, path), AdminJsonContext.Default.AdminContent, ct); + public Task> ConnectionsAsync(string service, string account, AdminKey[] path, CancellationToken ct) => + GetAllAsync(cursor => Url($"/services/{Uri.EscapeDataString(service)}/connections", account, path, cursor: cursor), + AdminJsonContext.Default.AdminPageAdminConnection, ct); + public Task> ActivityAsync(string service, string account, CancellationToken ct) => + GetAllAsync(cursor => Url($"/services/{Uri.EscapeDataString(service)}/activity", account, cursor: cursor), AdminJsonContext.Default.AdminPageAdminActivity, ct); + public async Task RevealAsync(string service, string account, AdminKey[] path, string field, CancellationToken ct) + { + var url = Url($"/services/{Uri.EscapeDataString(service)}/reveal", account, path) + "&field=" + Uri.EscapeDataString(field); + using var response = await http.PostAsync(url, null, ct); + return await ReadAsync(response, AdminJsonContext.Default.AdminContent, ct); + } + + private async Task GetAsync(string url, JsonTypeInfo type, CancellationToken ct) + { + using var response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); + return await ReadAsync(response, type, ct); + } + + private async Task> GetAllAsync(Func url, JsonTypeInfo> type, + CancellationToken ct, Func? keySelector = null) + { + List items = []; + HashSet cursors = new(StringComparer.Ordinal); + HashSet keys = []; + string? cursor = null; + DateTimeOffset captured; + do + { + ct.ThrowIfCancellationRequested(); + var page = await GetAsync(url(cursor), type, ct); + ct.ThrowIfCancellationRequested(); + // A moving collection may repeat a resource at a page boundary. + foreach (var item in page.Items) + if (keySelector is null || keys.Add(keySelector(item))) items.Add(item); + captured = page.CapturedAt; + cursor = page.NextCursor; + if (!string.IsNullOrEmpty(cursor) && !cursors.Add(cursor)) + throw new JsonException("The API repeated a continuation cursor. The complete list could not be loaded."); + } while (!string.IsNullOrEmpty(cursor)); + return new AdminPage { Items = items, KnownTotal = items.Count, CapturedAt = captured }; + } + + private static async Task ReadAsync(HttpResponseMessage response, JsonTypeInfo type, CancellationToken ct) + { + if (!response.IsSuccessStatusCode) + { + AdminError? error = null; + try { error = await response.Content.ReadFromJsonAsync(AdminJsonContext.Default.AdminError, ct); } + catch (JsonException) { } + throw new AdminApiException(response.StatusCode, error?.Code ?? "request_failed", + error?.Message ?? $"The API returned HTTP {(int)response.StatusCode}. Refresh to try again."); + } + return await response.Content.ReadFromJsonAsync(type, ct) + ?? throw new JsonException("The API returned no response data."); + } + + private static string Url(string route, string account, AdminKey[]? path = null, string? kind = null, string? filter = null, string? cursor = null, string? prefix = null) + { + var values = new Dictionary + { + ["accountId"] = account, ["path"] = path is null ? null : ExplorerLocation.EncodePath(path), + ["kind"] = kind, ["filter"] = filter, ["cursor"] = cursor, ["prefix"] = prefix, ["pageSize"] = "200" + }; + return Root + route + "?" + string.Join("&", values.Where(pair => !string.IsNullOrEmpty(pair.Value)) + .Select(pair => pair.Key + "=" + Uri.EscapeDataString(pair.Value!))); + } +} diff --git a/src/MicroStack.UI.Client/Services/DebouncedAction.cs b/src/MicroStack.UI.Client/Services/DebouncedAction.cs new file mode 100644 index 0000000..82cddc0 --- /dev/null +++ b/src/MicroStack.UI.Client/Services/DebouncedAction.cs @@ -0,0 +1,35 @@ +namespace MicroStack.UI.Client.Services; + +public sealed class DebouncedAction(TimeProvider? timeProvider = null) : IDisposable +{ + private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; + private CancellationTokenSource? _pending; + private bool _disposed; + + public async Task RunAsync(Func action, bool immediately = false) + { + Cancel(); + if (_disposed) return; + var pending = _pending = new(); + var token = pending.Token; + try + { + if (!immediately) await Task.Delay(TimeSpan.FromMilliseconds(250), _timeProvider, token); + if (!token.IsCancellationRequested) await action(token); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + finally + { + if (ReferenceEquals(_pending, pending)) _pending = null; + pending.Dispose(); + } + } + + public void Cancel() + { + _pending?.Cancel(); + _pending = null; + } + + public void Dispose() { _disposed = true; Cancel(); } +} diff --git a/src/MicroStack.UI.Client/Services/ExplorerAccountState.cs b/src/MicroStack.UI.Client/Services/ExplorerAccountState.cs new file mode 100644 index 0000000..2f46ea1 --- /dev/null +++ b/src/MicroStack.UI.Client/Services/ExplorerAccountState.cs @@ -0,0 +1,50 @@ +using Microsoft.JSInterop; + +namespace MicroStack.UI.Client.Services; + +public sealed class ExplorerAccountState(IJSRuntime js) +{ + internal const string StorageKey = "microstack.explorer.account"; + private Task? _restore; + public string? Account { get; private set; } + public string? PersistenceError { get; private set; } + public event Action? Changed; + + public Task RestoreAsync() => _restore ??= RestoreCoreAsync(); + + private async Task RestoreCoreAsync() + { + try + { + var saved = await js.InvokeAsync("sessionStorage.getItem", StorageKey); + // An explicit route selected while storage was loading takes precedence. + if (Account is not null || saved is null) return; + if (ExplorerLocation.ValidAccount(saved)) Account = saved; + else PersistenceError = "The saved explorer account is invalid. Choose a known account in Services."; + } + catch (JSException) + { + PersistenceError = "The saved explorer account could not be restored. Choose an account in Services."; + } + Changed?.Invoke(); + } + + public async Task RememberAsync(string account) + { + if (!ExplorerLocation.ValidAccount(account)) + throw new ArgumentException("Account ID must contain exactly 12 ASCII digits.", nameof(account)); + if (Account == account) return; + Account = account; + Changed?.Invoke(); + try + { + await js.InvokeVoidAsync("sessionStorage.setItem", StorageKey, account); + PersistenceError = null; + } + catch (JSException) + { + PersistenceError = "Account selection cannot be saved in this tab. It will be remembered only until the page is reloaded."; + } + Changed?.Invoke(); + } +} diff --git a/src/MicroStack.UI.Client/Services/ExplorerLocation.cs b/src/MicroStack.UI.Client/Services/ExplorerLocation.cs new file mode 100644 index 0000000..bffb72f --- /dev/null +++ b/src/MicroStack.UI.Client/Services/ExplorerLocation.cs @@ -0,0 +1,59 @@ +using System.Globalization; +using System.Text.Json; +using MicroStack.Admin.Contracts; +using Microsoft.AspNetCore.Components; + +namespace MicroStack.UI.Client.Services; + +public static class ExplorerLocation +{ + public static bool ValidAccount(string? value) => + value is { Length: 12 } && value.All(c => c is >= '0' and <= '9'); + + public static string Utc(DateTimeOffset value) => + value.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.FFFFFFF'Z'", CultureInfo.InvariantCulture); + + public static string EncodePath(AdminKey[] path) => + JsonSerializer.Serialize(path, AdminJsonContext.Default.AdminKeyArray); + + public static AdminKey[] DecodePath(string? value) + { + if (string.IsNullOrEmpty(value)) return []; + var keys = JsonSerializer.Deserialize(value, AdminJsonContext.Default.AdminKeyArray) + ?? throw new FormatException("The resource path is invalid."); + if (keys.Length > 32 || keys.Any(key => key is null || string.IsNullOrEmpty(key.Kind) || string.IsNullOrEmpty(key.Id))) + throw new FormatException("The resource path is invalid."); + return keys; + } + + public static string DirectoryUrl(NavigationManager navigation, string account) => + navigation.BaseUri + "accounts/" + Uri.EscapeDataString(account) + "/services"; + + public static string ServiceUrl(NavigationManager navigation, string serviceId, string account, AdminKey[]? path = null, string? returnTo = null) => + navigation.GetUriWithQueryParameters(DirectoryUrl(navigation, account) + "/" + Uri.EscapeDataString(serviceId), + new Dictionary { ["path"] = path is { Length: > 0 } ? EncodePath(path) : null, ["returnTo"] = SafeReturn(returnTo) }); + + public static string? RouteAccount(NavigationManager navigation) + { + var segments = navigation.ToBaseRelativePath(navigation.Uri).Split('?')[0].Split('/'); + return segments.Length >= 3 && segments[0] == "accounts" && segments[2] == "services" && ValidAccount(segments[1]) + ? segments[1] : null; + } + + public static string? SafeReturn(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 16_384 || !value.StartsWith('/') || value.StartsWith("//") || value.Contains('\\')) + return null; + var route = value.Split('?')[0]; + var segments = route.Split('/'); + var accountRoute = segments.Length is 4 or 5 && segments[1] == "accounts" && + ValidAccount(segments[2]) && segments[3] == "services" && + (segments.Length == 4 || segments[4].Length > 0 && segments[4] is not "." and not ".." && !segments[4].Contains('%')); + return route is "/" or "/resources" || route.StartsWith("/services/", StringComparison.Ordinal) || accountRoute ? value : null; + } + + public static string? ResolveReturn(NavigationManager navigation, string? value) => + SafeReturn(value) is { } safe + ? new Uri(new Uri(navigation.BaseUri), safe.TrimStart('/')).AbsoluteUri + : null; +} diff --git a/src/MicroStack.UI.Client/Services/InspectionProfile.cs b/src/MicroStack.UI.Client/Services/InspectionProfile.cs new file mode 100644 index 0000000..70e03df --- /dev/null +++ b/src/MicroStack.UI.Client/Services/InspectionProfile.cs @@ -0,0 +1,50 @@ +using System.Globalization; +using MicroStack.Admin.Contracts; + +namespace MicroStack.UI.Client.Services; + +public static class InspectionProfile +{ + public static IReadOnlyList RootKinds(AdminService? service) => + service?.Kinds.Where(kind => kind.IsRoot).ToArray() ?? []; + + public static string ContentTitle(string service, string kind) => (service, kind) switch + { + ("sqs", "messages") => "Message body", + ("s3", "objects" or "versions") => "Object preview", + ("dynamodb", "items") => "Item · DynamoDB JSON", + ("events", "rules") => "Event pattern / schedule", + _ => "Content preview" + }; + + public static string CollectionLabel(string? service, AdminResourceDetail? detail) + { + if (detail?.HasChildren != true) return "Content"; + if (service == "s3" && detail.Resource.Key.Kind is "buckets" or "prefixes") return "Objects"; + return detail.ChildKinds.Count == 1 ? detail.ChildKinds[0].Label : "Entries"; + } + + public static string? DisplayStatus(AdminResourceSummary resource) => + !string.IsNullOrWhiteSpace(resource.Type) && string.Equals(resource.Status, "available", StringComparison.OrdinalIgnoreCase) + ? null : resource.Status; + + public static string ChildFilterLabel(string? service, AdminResourceDetail? detail, string? selectedKind) + { + var kind = detail?.ChildKinds.FirstOrDefault(candidate => candidate.Id == selectedKind) ?? + (detail?.ChildKinds.Count == 1 ? detail.ChildKinds[0] : null); + if (service == "sqs" && kind?.Id == "messages") return "Filter message IDs"; + if (service == "s3" && detail?.Resource.Key.Kind is "buckets" or "prefixes") return "Filter object keys or prefix names"; + return kind is null ? "Filter entries by name or identifier" : $"Filter {kind.Label.ToLowerInvariant()} by name or identifier"; + } + + public static string FieldValue(AdminField field) + { + if (field.Sensitive) return "••••••••"; + if (field.Format is "timestamp" or "datetime" or "date-time" && DateTimeOffset.TryParse(field.Value, + CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var timestamp)) + return ExplorerLocation.Utc(timestamp); + if (field.Format == "bytes" && long.TryParse(field.Value, CultureInfo.InvariantCulture, out var bytes)) + return $"{bytes.ToString("N0", CultureInfo.InvariantCulture)} bytes"; + return field.Value ?? "Not set"; + } +} diff --git a/src/MicroStack.UI.Client/Services/LiveUpdateCoordinator.cs b/src/MicroStack.UI.Client/Services/LiveUpdateCoordinator.cs new file mode 100644 index 0000000..63435b4 --- /dev/null +++ b/src/MicroStack.UI.Client/Services/LiveUpdateCoordinator.cs @@ -0,0 +1,347 @@ +using System.Text.Json; +using Microsoft.JSInterop; + +namespace MicroStack.UI.Client.Services; + +/// One bounded refresh scheduler and browser event stream for the current tab. +public sealed class LiveUpdateCoordinator(IJSRuntime js, TimeProvider? timeProvider = null) : IAsyncDisposable +{ + private readonly TimeProvider _clock = timeProvider ?? TimeProvider.System; + private readonly SemaphoreSlim _lifecycle = new(1, 1); + private readonly object _sync = new(); + private readonly List _registrations = []; + private CancellationTokenSource _generation = new(); + private IJSObjectReference? _module; + private IJSObjectReference? _bridge; + private DotNetObjectReference? _reference; + private Task _worker = Task.CompletedTask; + private bool _working, _dirty, _manualDirty, _disposed, _open, _started, _failedProtocol; + private string? _account; + private long _transportGeneration; + private DateTimeOffset? _lastStart; + private string? _epoch; + private long _sequence; + + public event Action? Changed; + public string? InstanceEpoch => _epoch; + public bool Paused { get; private set; } + public bool Hidden { get; private set; } + public DateTimeOffset? LastSuccess { get; private set; } + public string? Error { get; private set; } + public string? ConnectionError { get; private set; } + internal Task PendingRefresh { get { lock (_sync) return _worker; } } + public string Mode => Paused || Hidden ? "Paused" + : Error is not null || _failedProtocol ? "Stale" + : _open && LastSuccess is not null ? "Live" + : ConnectionError is not null ? "Reconnecting" : "Connecting"; + private bool Automatic => !Paused && !Hidden && !_disposed; + + // Register after the page's independent navigation load is ready to accept refreshes. + public async Task RegisterAsync(Func refresh, string? accountId = null) + { + ArgumentNullException.ThrowIfNull(refresh); + if (accountId is not null && !ExplorerLocation.ValidAccount(accountId)) + throw new ArgumentException("Account ID must contain exactly 12 ASCII digits.", nameof(accountId)); + await _lifecycle.WaitAsync(); + try + { + ObjectDisposedException.ThrowIf(_disposed, this); + var registration = new Registration(this, refresh, accountId); + lock (_sync) _registrations.Add(registration); + await ReconfigureAsync(); + return registration; + } + finally { _lifecycle.Release(); } + } + + public Task PauseAsync() => SetPausedAsync(true); + public Task ResumeAsync() => SetPausedAsync(false); + public Task Pause() => PauseAsync(); + public Task Resume() => ResumeAsync(); + + private async Task SetPausedAsync(bool paused) + { + await _lifecycle.WaitAsync(); + try + { + if (_disposed || Paused == paused) return; + Paused = paused; + await ReconfigureAsync(); + } + finally { _lifecycle.Release(); } + } + + public Task RefreshOnceAsync() => QueueRefresh(manual: true); + + public async Task RetryAsync() + { + await _lifecycle.WaitAsync(); + try + { + if (_disposed) return; + _failedProtocol = false; + await StopStreamAsync(); + await ReconfigureAsync(catchup: false); + } + finally { _lifecycle.Release(); } + await RefreshOnceAsync(); + } + + [JSInvokable] + public async Task OnVisibilityChanged(bool visible) + { + await _lifecycle.WaitAsync(); + try + { + if (_disposed || Hidden == !visible) return; + Hidden = !visible; + await ReconfigureAsync(); + } + finally { _lifecycle.Release(); } + } + + [JSInvokable] + public Task OnConnectionChanged(long generation, bool open) + { + if (_disposed || generation != _transportGeneration || !Automatic) return Task.CompletedTask; + _open = open; + ConnectionError = open ? null : "The live connection was interrupted. Reconnecting automatically."; + Changed?.Invoke(); + if (open) _ = QueueRefresh(); + return Task.CompletedTask; + } + + [JSInvokable] + public async Task OnChange(long generation, string json) + { + if (_disposed || generation != _transportGeneration || !Automatic) return; + try + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + if (root.GetProperty("version").GetInt32() != 1) throw new JsonException(); + var epoch = root.GetProperty("epoch").GetString(); + var sequence = root.GetProperty("sequence").GetInt64(); + if (string.IsNullOrWhiteSpace(epoch) || sequence < 0) throw new JsonException(); + var relevant = root.GetProperty("resync").GetBoolean(); + foreach (var category in new[] { "resources", "accounts", "instance", "activity" }) + relevant |= root.GetProperty(category).GetBoolean(); + // Gaps and restarts are resync hints, never a claim that events can be replayed. + relevant |= _epoch != epoch || sequence != _sequence + 1; + _epoch = epoch; + _sequence = sequence; + if (relevant) _ = QueueRefresh(); + } + catch (Exception exception) when (exception is JsonException or InvalidOperationException or KeyNotFoundException or FormatException) + { + await _lifecycle.WaitAsync(); + try + { + if (_disposed || generation != _transportGeneration) return; + _failedProtocol = true; + await StopStreamAsync(); + ConnectionError = "The live server sent an unsupported or malformed notification. Retry to reconnect."; + Changed?.Invoke(); + } + finally { _lifecycle.Release(); } + } + } + + private async Task ReconfigureAsync(bool catchup = true) + { + Invalidate(); + string? account; + bool hasRegistrations; + lock (_sync) + { + account = _registrations.FirstOrDefault()?.Account; + // Filter only when every consumer observes the same account. Global or mixed + // consumers need the union of resource hints, without account identifiers. + if (_registrations.Any(item => item.Account != account)) account = null; + hasRegistrations = _registrations.Count != 0; + } + if (!Automatic || !hasRegistrations || _account != account) await StopStreamAsync(); + _account = account; + if (Automatic && hasRegistrations) + { + try + { + if (_bridge is null) + { + _module ??= await js.InvokeAsync("import", "./live.js"); + if (_module is null) throw new InvalidOperationException("The live module is unavailable."); + _reference ??= DotNetObjectReference.Create(this); + _bridge = await _module.InvokeAsync("create", _reference); + if (_bridge is null) throw new InvalidOperationException("The live transport is unavailable."); + Hidden = !await _bridge.InvokeAsync("isVisible"); + } + if (Automatic && !_started && !_failedProtocol) + { + _open = false; + ConnectionError = null; + await _bridge.InvokeVoidAsync("start", _account, ++_transportGeneration); + _started = true; + } + } + catch (Exception exception) when (exception is JSException or InvalidOperationException) + { + ConnectionError = "The live connection could not start. Retry to reconnect."; + } + if (Automatic) + { + _ = ReconcileAsync(_generation.Token); + if (catchup) _ = QueueRefresh(); + } + } + Changed?.Invoke(); + } + + private void Invalidate() + { + CancellationTokenSource old; + lock (_sync) + { + old = _generation; + _generation = new(); + _dirty = _manualDirty = false; + } + old.Cancel(); + old.Dispose(); + } + + private async Task ReconcileAsync(CancellationToken token) + { + try + { + while (true) + { + await Task.Delay(TimeSpan.FromSeconds(5), _clock, token); + _ = QueueRefresh(); + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + } + + private Task QueueRefresh(bool manual = false) + { + lock (_sync) + { + if (_disposed || _registrations.Count == 0 || Hidden || (!Automatic && !manual)) + return Task.CompletedTask; + _dirty = true; + _manualDirty |= manual; + if (!_working) + { + _working = true; + _worker = RefreshLoopAsync(); + } + return _worker; + } + } + + private async Task RefreshLoopAsync() + { + await Task.Yield(); + while (true) + { + CancellationToken token; + Registration[] registrations; + lock (_sync) + { + if (_disposed || !_dirty || Hidden || (!Automatic && !_manualDirty)) + { + _working = false; + return; + } + token = _generation.Token; + registrations = _registrations.ToArray(); + _dirty = _manualDirty = false; + } + try + { + if (_lastStart is { } lastStart) + { + var remaining = TimeSpan.FromMilliseconds(500) - (_clock.GetUtcNow() - lastStart); + if (remaining > TimeSpan.Zero) await Task.Delay(remaining, _clock, token); + } + token.ThrowIfCancellationRequested(); + _lastStart = _clock.GetUtcNow(); + string? error = null; + foreach (var registration in registrations) + { + token.ThrowIfCancellationRequested(); + try { await registration.Refresh(token); } + catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; } + catch (Exception) { error = "The visible snapshot could not refresh. Retry to catch up."; } + } + token.ThrowIfCancellationRequested(); + Error = error; + if (error is null) LastSuccess = _clock.GetUtcNow(); + Changed?.Invoke(); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + } + } + + private async Task StopStreamAsync() + { + ++_transportGeneration; + _open = _started = false; + _epoch = null; + _sequence = 0; + if (_bridge is not null) + { + try { await _bridge.InvokeVoidAsync("stop"); } + catch (JSException) { } + } + } + + private async ValueTask RemoveAsync(Registration registration) + { + await _lifecycle.WaitAsync(); + try + { + if (_disposed) return; + lock (_sync) _registrations.Remove(registration); + await ReconfigureAsync(); + } + finally { _lifecycle.Release(); } + } + + public async ValueTask DisposeAsync() + { + await _lifecycle.WaitAsync(); + try + { + if (_disposed) return; + _disposed = true; + Invalidate(); + lock (_sync) _registrations.Clear(); + await StopStreamAsync(); + if (_bridge is not null) + { + try { await _bridge.InvokeVoidAsync("dispose"); } + catch (JSException) { } + await _bridge.DisposeAsync(); + } + if (_module is not null) await _module.DisposeAsync(); + _reference?.Dispose(); + _generation.Dispose(); + Changed = null; + } + finally { _lifecycle.Release(); } + } + + private sealed class Registration(LiveUpdateCoordinator owner, Func refresh, string? account) : IAsyncDisposable + { + private bool _disposed; + public Func Refresh { get; } = refresh; + public string? Account { get; } = account; + public ValueTask DisposeAsync() + { + if (_disposed) return ValueTask.CompletedTask; + _disposed = true; + return owner.RemoveAsync(this); + } + } +} diff --git a/src/MicroStack.UI.Client/Services/MicroStackApiService.cs b/src/MicroStack.UI.Client/Services/MicroStackApiService.cs new file mode 100644 index 0000000..6c5d14e --- /dev/null +++ b/src/MicroStack.UI.Client/Services/MicroStackApiService.cs @@ -0,0 +1,49 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MicroStack.UI.Client.Services; + +internal sealed class MicroStackApiService(HttpClient httpClient) +{ + public async Task GetHealthAsync(CancellationToken cancellationToken = default) + { + return await httpClient.GetFromJsonAsync("/_microstack/health", LegacyJsonContext.Default.HealthResponse, cancellationToken) + ?? throw new JsonException("The API returned no health data."); + } + + public async Task> GetRequestsAsync(int limit = 1000, CancellationToken cancellationToken = default) + { + return await httpClient.GetFromJsonAsync($"/_microstack/requests?limit={limit}", LegacyJsonContext.Default.RequestLogEntryArray, cancellationToken) + ?? throw new JsonException("The API returned no request data."); + } + + public async Task DeleteRequestsAsync(CancellationToken cancellationToken = default) + { + using var response = await httpClient.DeleteAsync("/_microstack/requests", cancellationToken); + response.EnsureSuccessStatusCode(); + } + + public async Task> GetResourcesAsync(CancellationToken cancellationToken = default) + { + return await httpClient.GetFromJsonAsync("/_microstack/resources", LegacyJsonContext.Default.ResourceSummaryArray, cancellationToken) + ?? throw new JsonException("The API returned no resource data."); + } + + public async Task ResetAsync(CancellationToken cancellationToken = default) + { + using var response = await httpClient.PostAsync("/_microstack/reset", content: null, cancellationToken); + response.EnsureSuccessStatusCode(); + } +} + +internal sealed record HealthResponse(Dictionary Services, string Edition, string Version); +internal sealed record RequestLogEntry(string Service, string Action, string AccountId, DateTimeOffset Timestamp, int StatusCode, long DurationMs); +internal sealed record ResourceSummary(string Service, int Count, IReadOnlyList Items); +internal sealed record ResourceItem(string Name, string Arn, Dictionary? Attributes); + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(HealthResponse))] +[JsonSerializable(typeof(RequestLogEntry[]))] +[JsonSerializable(typeof(ResourceSummary[]))] +internal partial class LegacyJsonContext : JsonSerializerContext; diff --git a/src/MicroStack.UI.Client/_Imports.razor b/src/MicroStack.UI.Client/_Imports.razor new file mode 100644 index 0000000..44d70a0 --- /dev/null +++ b/src/MicroStack.UI.Client/_Imports.razor @@ -0,0 +1,13 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using MicroStack.UI.Client +@using MicroStack.UI.Client.Layout +@using MicroStack.UI.Client.Services +@using MicroStack.UI.Client.Components +@using MicroStack.Admin.Contracts diff --git a/src/MicroStack.UI.Client/wwwroot/appsettings.json b/src/MicroStack.UI.Client/wwwroot/appsettings.json new file mode 100644 index 0000000..69a88e3 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/appsettings.json @@ -0,0 +1 @@ +{} diff --git a/src/MicroStack.UI.Client/wwwroot/css/app.css b/src/MicroStack.UI.Client/wwwroot/css/app.css new file mode 100644 index 0000000..cc3898e --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/css/app.css @@ -0,0 +1,292 @@ +:root { + color-scheme: light; + --ink:#202c3d; --muted:#526176; --line:#dbe1e9; --blue:#165dca; + --blue-soft:#edf4ff; --surface:#fff; --nav:#f7f9fc; --green:#267048; --amber:#795100; --danger:#ae3033; + font-family:"Segoe UI",system-ui,-apple-system,sans-serif; font-size:16px; +} +* {box-sizing:border-box} +[hidden] {display:none!important} +body {margin:0;background:#f4f6f9;color:var(--ink);line-height:1.5} +button,input,select {font:inherit} +button,a,input,select,summary {-webkit-tap-highlight-color:transparent} +button,a,select {touch-action:manipulation} +button {cursor:pointer} +a {color:var(--blue);text-underline-offset:3px} +button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible {outline:3px solid #135cce;outline-offset:3px} +button:disabled {cursor:not-allowed;opacity:.6} +button:hover:not(:disabled) {filter:brightness(.975)} +code,pre {font-family:Consolas,"SFMono-Regular",monospace} +code {font-size:.875rem;overflow-wrap:anywhere} +svg {width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;flex:none} +.aws-service-icon {width:24px;height:24px;object-fit:contain;flex:none} +.directory-name .aws-service-icon {width:28px;height:28px} +.service-picker-trigger .aws-service-icon {width:20px;height:20px} +.skip {position:fixed;top:-70px;left:12px;background:white;padding:12px;z-index:100} +.skip:focus {top:12px} +.app-header {display:flex;align-items:center;gap:20px;padding:16px 24px;background:white;border-bottom:1px solid var(--line);flex-wrap:wrap} +.brand {display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--ink);font-size:1.125rem;letter-spacing:-.02em} +.brand img {object-fit:contain} +.header-divider {height:22px;width:1px;background:var(--line)} +.header-title {color:var(--muted);font-size:.9375rem} +.header-tools {display:flex;align-items:center;gap:12px;flex-wrap:wrap;min-width:0;max-width:100%} +.header-context {min-width:0;max-width:100%} +.header-context .scope {margin-left:0;justify-content:flex-start;gap:8px} +.global-nav {display:flex;gap:20px;margin-left:auto;font-size:.875rem} +.global-nav a {color:var(--muted);text-decoration:none;padding:4px 0} +.global-nav a.active {color:var(--blue);box-shadow:0 2px var(--blue)} +.global-nav a:hover {text-decoration:underline} +.live-controls {display:flex;align-items:center;gap:8px;flex-wrap:wrap;max-width:100%;font-size:.8125rem;color:var(--muted)} +.live-status {font-weight:600;color:var(--ink)} +.live-actions {display:flex;align-items:center;gap:4px} +.live-controls .button {font-size:.75rem;min-height:30px;padding:4px 9px} +.live-controls .icon-button {display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;padding:7px;background:transparent;border-color:transparent;flex:none} +.live-controls .icon-button:hover:not(:disabled) {background:var(--blue-soft)} +.live-toggle[aria-pressed=true] {color:var(--green)} +.live-error {flex-basis:100%;color:var(--danger);margin:0} +select,input {color:var(--ink);background:white;border:1px solid #aebacc;border-radius:4px;min-height:34px;padding:5px 9px;min-width:0} +input::placeholder {color:var(--muted);opacity:1} +select {max-width:100%;font-size:.875rem} +main {min-width:0;padding:18px 28px 28px;max-width:1800px;margin:auto} +.scope {display:flex;gap:16px;color:var(--muted);font-size:.75rem;justify-content:flex-end;margin-left:auto;align-items:center;flex-wrap:wrap} +.scope code {margin-left:6px;font-size:.75rem;color:#32465c} +.scope select {font: .75rem Consolas,monospace;max-width:100%} +.scope select:focus-visible {outline:2px solid var(--blue);outline-offset:0} +.field-error {color:var(--danger);font-size:.8125rem} +.workspace-context {display:flex;align-items:center;justify-content:space-between;gap:12px 24px;flex-wrap:wrap;border-bottom:1px solid var(--line);padding-bottom:16px;margin-bottom:24px} +.service-breadcrumb {display:flex;align-items:center;gap:12px;position:relative;font-size:.875rem;flex-wrap:wrap} +.service-picker {position:relative} +.service-picker-trigger {display:flex;align-items:center;gap:6px;padding:5px 9px;min-height:34px;font-size:.875rem;font-weight:600;color:var(--ink);background:transparent;border:1px solid transparent;border-radius:4px} +.service-picker-chevron {width:14px;height:14px} +.service-picker-trigger:hover,.service-picker-trigger[aria-expanded=true] {background:var(--surface);border-color:var(--line)} +.service-picker-panel {position:absolute;top:calc(100% + 8px);left:0;width:320px;max-width:calc(100vw - 48px);padding:10px;background:var(--surface);border:1px solid var(--line);border-radius:5px;box-shadow:0 6px 20px #192b4526;z-index:30} +.service-options {max-height:300px;overflow:auto;margin-top:8px} +.service-option {display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;padding:9px 10px;text-align:left;background:transparent;border:0;border-radius:4px;color:var(--ink)} +.service-option.active {background:var(--blue-soft)} +.service-option:focus-visible {outline-offset:-3px} +.service-option:hover {background:#f0f3f7} +.service-option-label {display:flex;align-items:center;gap:10px;min-width:0} +.service-option strong {display:block;font-size:.875rem;font-weight:600} +.service-option small {display:block;margin-top:2px;font-size:.75rem;color:var(--muted)} +.service-option-current {font-size:.75rem;color:var(--blue)} +.service-match-count {margin:10px 3px 2px;font-size:.75rem;color:var(--muted)} +.directory {max-width:1344px;margin:auto} +.directory-heading {display:grid;grid-template-columns:minmax(0,1fr);gap:8px;margin-bottom:24px} +.directory-heading h1 {font-size:2rem} +.directory-heading p {grid-column:1 / -1;margin:0;color:var(--muted)} +.directory-tools {display:flex;align-items:center;gap:12px 24px;flex-wrap:wrap;margin-bottom:24px} +.directory-tools .search {width:420px;max-width:100%} +.directory-tools label {display:flex;align-items:center;gap:8px;font-size:.875rem;color:var(--muted)} +.directory-tools input[type=checkbox] {min-height:0;width:16px;height:16px;accent-color:var(--blue)} +.directory-result-count {font-size:.8125rem;color:var(--muted);margin:0 0 0 auto} +.directory-grid {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:32px 40px;align-items:start} +.directory-group h2 {display:flex;align-items:center;gap:10px;font-size:1rem;margin:0 0 12px} +.directory-group ul {list-style:none;margin:0;padding:0;border-top:1px solid var(--line)} +.directory-service {display:grid;grid-template-columns:minmax(0,1fr) auto 18px;align-items:center;gap:12px;width:100%;padding:14px 10px;text-align:left;border:0;border-bottom:1px solid var(--line);background:white;color:var(--ink);text-decoration:none} +.directory-service:hover {background:var(--blue-soft)} +.directory-name {display:flex;align-items:center;gap:10px;min-width:0} +.directory-name strong {font-size:.875rem;font-weight:600;overflow-wrap:anywhere} +.directory-detail {font-size:.75rem;color:var(--muted)} +.service-heading {display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:22px} +h1 {margin:0;font-size:1.625rem;letter-spacing:-.025em;font-weight:650;line-height:1.25} +h2 {margin:0;font-size:1.25rem;font-weight:650;letter-spacing:-.02em;overflow-wrap:anywhere} +h3 {font-size:1rem;font-weight:600} +.service-heading p {margin:5px 0 0;color:var(--muted);font-size:.875rem} +.resource-workspace {display:grid;grid-template-columns:220px minmax(0,1fr);border:1px solid var(--line);background:white;border-radius:7px;min-height:590px} +.resource-index {padding:18px 12px;border-right:1px solid var(--line);background:#fbfcfe;min-width:0;border-radius:7px 0 0 7px} +.index-heading {display:flex;align-items:center;gap:8px;min-height:32px;padding:0 5px;margin-bottom:8px;font-weight:600;font-size:.875rem} +.kind-picker {display:flex;position:relative;min-width:0} +.kind-select {appearance:none;field-sizing:content;max-width:100%;min-height:32px;padding:3px 24px 3px 4px;font:inherit;text-overflow:ellipsis;background:transparent;border-color:transparent} +.kind-select:hover {background:var(--blue-soft)} +.kind-picker svg {position:absolute;right:5px;top:50%;transform:translateY(-50%);width:14px;height:14px;pointer-events:none} +.index-heading .count {flex:none} +.count {color:var(--muted);background:#edf0f4;border-radius:3px;font-size:.75rem;padding:1px 6px;font-weight:400} +.search {display:flex;align-items:center;gap:8px;padding:0 10px;border:1px solid #bdc8d7;border-radius:4px;background:white} +.search:focus-within {border-color:var(--blue);outline:1px solid var(--blue);outline-offset:0} +.search svg {color:#687a92;width:16px} +.search input {border:0;padding:7px 0;width:100%;font-size:.875rem;background:transparent} +.search input:focus {outline:none} +.resource-list {margin-top:12px;display:grid;gap:4px;max-height:65vh;overflow:auto} +.resource-button {text-align:left;border:1px solid transparent;background:none;border-radius:4px;padding:10px;width:100%;min-width:0;text-decoration:none;color:var(--ink)} +.resource-button strong {display:block;overflow-wrap:anywhere;font-size:.875rem;font-weight:600} +.resource-button small {display:block;margin-top:3px;color:var(--muted);font-size:.75rem;overflow-wrap:anywhere} +.resource-button.selected {border-color:#c2d6f5;background:var(--blue-soft);color:#184e9b} +.resource-button:hover {background:#f0f4f9} +.index-note {font-size:.75rem;color:var(--muted);margin:18px 5px;line-height:1.6} +.inspector {min-width:0} +.resource-heading {padding:22px 24px 0} +.resource-top {display:flex;gap:12px;align-items:center;justify-content:space-between} +.resource-top>div {min-width:0} +.resource-type {display:flex;align-items:center;gap:7px;font-size:.8125rem;color:var(--muted);margin-top:5px;flex-wrap:wrap} +.tag {display:inline-flex;align-items:center;gap:5px;color:#3e5470;font-size:.75rem;padding:2px 7px;background:#f0f3f7;border:1px solid #d6dfe9;border-radius:4px;overflow-wrap:anywhere} +.tag.green {color:#22613d;background:#edf7f0;border-color:#cce5d4} +.tag.amber {color:#765002;background:#fff5dc;border-color:#eddfb7} +.tag.danger {color:#8f272a;background:#fff0f0;border-color:#e8c7c8} +.arn-row {display:flex;align-items:center;gap:8px;color:var(--muted);margin:14px 0 20px} +.arn-row code {font-size:.75rem;min-width:0} +.button {display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:7px 12px;min-height:34px;font-size:.875rem;border:1px solid #adbed5;border-radius:4px;background:white;color:#30475e;text-decoration:none} +.button.primary {background:var(--blue);border-color:var(--blue);color:white} +.button.danger {color:var(--danger);border-color:#c69697} +.button.secondary:hover {background:#f4f7fc} +.link-button {padding:0;background:none;border:0;color:var(--blue);text-align:left;text-decoration:none;font-size:inherit;overflow-wrap:anywhere} +.link-button:hover {text-decoration:underline} +.tabs {display:flex;gap:22px;padding:0 24px;border-bottom:1px solid var(--line);overflow:auto} +.tabs button {position:relative;background:none;border:0;padding:12px 0;color:var(--muted);font-size:.875rem;white-space:nowrap} +.tabs button[aria-selected=true] {color:var(--blue);font-weight:600;box-shadow:inset 0 -2px var(--blue)} +.tab-body {padding:22px 24px;min-height:420px} +.notice {font-size:.8125rem;background:#f0f5fb;border:1px solid #d8e4f2;color:#365171;padding:10px 12px;border-radius:4px;margin-bottom:18px;overflow-wrap:anywhere} +.notice p {margin:0} +.notice.warning {color:#72520d;background:#fff9e9;border-color:#efdfb7} +.notice.error {color:#8f272a;background:#fff2f2;border-color:#e7c9cb} +.notice .button {margin-top:10px} +.content-tools {display:flex;gap:12px;align-items:center;justify-content:space-between;margin-bottom:14px;flex-wrap:wrap} +.content-tools .search {max-width:310px;flex:1} +.snapshot-time {font-size:.75rem;color:var(--muted)} +.data-split {display:grid;grid-template-columns:minmax(200px,.9fr) minmax(230px,1.1fr);border:1px solid var(--line);border-radius:5px;overflow:hidden} +.records {min-width:0;max-height:560px;overflow:auto} +.list-heading {display:flex;align-items:center;justify-content:space-between;font-size:.75rem;color:var(--muted);background:#f7f9fc;padding:10px 12px;border-bottom:1px solid var(--line)} +.record-button {display:block;padding:12px;border:0;border-bottom:1px solid #e8edf3;background:white;width:100%;text-align:left;text-decoration:none;color:var(--ink)} +.record-button.selected {background:#edf4ff;box-shadow:inset 2px 0 var(--blue)} +.record-button:hover {background:#f4f7fc} +.record-title {font:.8125rem Consolas,monospace;color:#263c56;overflow-wrap:anywhere;font-weight:600} +.record-meta {display:flex;gap:8px;align-items:center;justify-content:space-between;margin-top:7px;font-size:.75rem;color:var(--muted);flex-wrap:wrap;overflow-wrap:anywhere} +.payload {border-left:1px solid var(--line);min-width:0;background:#fafcfe} +.payload-head {display:flex;gap:10px;align-items:center;justify-content:space-between;padding:9px 12px;font-size:.8125rem;border-bottom:1px solid var(--line);background:#f7f9fc;flex-wrap:wrap} +.payload pre,.code-block {margin:0;padding:16px;font-size:.8125rem;line-height:1.75;overflow:auto;max-height:440px;color:#304254;white-space:pre;background:#fafcfe} +.payload-body {padding:16px} +.connection-list {display:grid;border:1px solid var(--line);border-radius:5px} +.connection {display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;padding:17px 16px;border-bottom:1px solid var(--line)} +.connection:last-child {border:0} +.connection strong {display:block;font-size:.9375rem} +.connection small {color:var(--muted);font-size:.8125rem;display:block;margin-top:4px} +.connection code {font-size:.8125rem} +.section-heading {display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 0 14px;flex-wrap:wrap} +.section-heading h3 {margin:0;font-size:1rem;font-weight:600} +.table-scroll {overflow:auto;max-width:100%;border:1px solid var(--line);background:white} +table {width:100%;border-collapse:collapse;font-size:.8125rem;text-align:left} +th {font-size:.75rem;font-weight:600;color:var(--muted);padding:10px;background:#f6f8fb;white-space:nowrap} +td {padding:12px 10px;border-bottom:1px solid #e5eaf1;vertical-align:top} +td.time {white-space:nowrap;font-family:Consolas,monospace;font-size:.75rem} +.metadata {margin:0;display:grid;gap:0} +.metadata>div {display:grid;grid-template-columns:160px minmax(0,1fr);gap:16px;padding:13px 0;border-bottom:1px solid #e8edf3;font-size:.875rem} +.metadata code {font-size:.8125rem} +dt {color:var(--muted)}dd {margin:0;overflow-wrap:anywhere;min-width:0} +.metadata pre {max-width:100%;overflow:auto;max-height:320px;font-size:.8125rem;margin:0} +.subsection {margin-top:24px} +.state-panel {padding:54px 24px;text-align:center;max-width:620px;margin:0 auto} +.state-panel h3 {margin:0 0 10px;font-size:1.125rem} +.state-panel p {font-size:.875rem;color:var(--muted);max-width:48ch;margin:0 auto 20px} +.empty-list {padding:26px 16px;font-size:.875rem;color:var(--muted)} +.skeleton {background:#e9eef4;height:13px;margin:16px 0;border-radius:3px} +.skeleton:nth-child(even) {width:70%} +.workspace-footer {display:flex;justify-content:space-between;padding:15px 0 22px;font-size:.75rem;color:var(--muted);gap:12px;flex-wrap:wrap} +.return-link {margin-bottom:15px;font-size:.8125rem;display:flex;gap:8px;align-items:center;flex-wrap:wrap} +.mobile-resources {display:none} +.toolbar {display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:18px 0} +.overview {max-width:1100px} +.overview-facts {background:white;border:1px solid var(--line);padding:8px 20px;margin:22px 0} +.copy-status {font-size:.75rem;color:var(--muted)} +.copy-control {display:inline-flex;align-items:center;gap:6px;position:relative;flex:none} +.icon-button {width:30px;min-height:30px;padding:5px;border-color:transparent;background:transparent} +.icon-button:hover {background:var(--blue-soft);border-color:var(--line)} +.icon-button svg {width:16px;height:16px} +.copy-control .copy-status:not(:empty) {position:absolute;right:0;top:100%;z-index:5;min-width:95px;max-width:240px;width:max-content;padding:5px 8px;border:1px solid var(--line);border-radius:4px;background:var(--surface);box-shadow:0 3px 9px #192b451a;white-space:normal} +.field-value {display:flex;align-items:flex-start;justify-content:space-between;gap:8px;min-width:0} +.field-value>code,.field-value>pre {min-width:0} +.field-value>.copy-control {margin-top:-4px} +.resource-facts {display:flex;flex-wrap:wrap;gap:8px 24px;margin:0;padding:12px 0;border-top:1px solid var(--line);font-size:.75rem} +.resource-facts>div {min-width:0;max-width:100%} +.resource-facts dt {font-size:.75rem} +.resource-facts dd {font-size:.8125rem;font-weight:600;font-variant-numeric:tabular-nums;overflow-wrap:anywhere} +.resource-facts dd:has(.copy-control) {display:flex;align-items:center;gap:4px} +.resource-facts.compact {gap:5px 12px;border:0;padding:6px 0 0} +.resource-facts.compact>div {display:flex;gap:5px;flex-wrap:wrap} +.resource-facts.compact dd {font-size:.75rem;font-weight:400} +.resource-workspace .scope-exception,.scope-exception {color:var(--amber)} +.resource-workspace .metadata>div {grid-template-columns:minmax(110px,30%) minmax(0,1fr);gap:12px;padding:8px 0;font-size:.8125rem} +.resource-workspace .metadata>div:last-child {border-bottom:0} +.resource-workspace .arn-row {margin:10px 0 14px} +.resource-workspace .resource-heading {padding-top:18px} +.resource-workspace .resource-top>.button {font-size:.75rem;white-space:nowrap} +.resource-workspace .tab-body {padding-top:16px} +.entry-inspector>.payload {border:0;background:transparent} +.payload-head>strong {min-width:0;overflow-wrap:anywhere;flex:1} +.entry-inspector>.resource-facts {padding:12px} +.entry-inspector .payload-body {padding:8px 12px} +.entry-inspector .payload-body .notice {margin:8px 0} +.entry-facts {padding:10px 12px} +.configuration-first>.entry-facts {padding:0} +.secondary-metadata {margin:0 12px 12px;border-top:1px solid var(--line);font-size:.8125rem} +.secondary-metadata>summary {padding:10px 0;cursor:pointer;color:var(--muted)} +.secondary-metadata>summary:hover {color:var(--blue)} +.entry-connections {padding:12px;border-top:1px solid var(--line)} +.entry-connections h3 {margin:0 0 10px;font-size:.875rem} +.entry-connections .connection {grid-template-columns:1fr;gap:8px;padding:10px 12px} +.entry-connections .connection .button {justify-self:start;font-size:.75rem} +.entry-connections .index-note {margin:8px 0} +.connection .field-value {margin-top:5px} +.connection>div {min-width:0;overflow-wrap:anywhere} +.connection-actions {display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.live-filter .filter-clear {display:inline-flex;align-items:center;justify-content:center;width:28px;height:30px;flex:none;border:0;border-radius:3px;color:var(--muted);background:transparent;padding:4px} +.live-filter .filter-clear:hover {background:var(--blue-soft);color:var(--blue)} +.live-filter input::-webkit-search-cancel-button {-webkit-appearance:none} +.collection-toolbar {width:100%;margin:0;gap:10px} +.collection-toolbar>.search {min-width:180px;max-width:360px;flex:1} +.prefix-form {display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:.8125rem} +.prefix-form input {width:180px} +.prefix-breadcrumb {display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 12px;font-size:.8125rem;overflow-wrap:anywhere} +.snapshot-updating {margin:0 0 10px;color:var(--muted);font-size:.75rem} +.resource-workspace .content-tools {margin-bottom:12px} +.resource-workspace .record-button {padding:10px 12px} +.resource-workspace .record-meta {margin-top:4px} +@media(max-width:760px) { + .resource-workspace .metadata>div {grid-template-columns:1fr;gap:4px} + .resource-workspace .resource-top {flex-wrap:wrap} + .resource-facts {gap:8px 16px} + .collection-toolbar>.search {max-width:none;flex-basis:100%} + .prefix-form {width:100%} + .prefix-form input {width:0;flex:1} + .copy-control .copy-status:not(:empty) {max-width:200px} +} +.startup {max-width:440px;margin:15vh auto;padding:24px} +.sr-only {position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} +#blazor-error-ui {display:none;background:#fff9e9;color:#72520d;bottom:0;left:0;padding:12px 24px;position:fixed;width:100%;z-index:1000;border-top:1px solid #efdfb7} +@media(min-width:1500px) {main {padding-left:38px;padding-right:38px}.resource-workspace {grid-template-columns:260px minmax(0,1fr)}} +@media(max-width:1200px) {.data-split {grid-template-columns:1fr}.payload {border-left:0;border-top:1px solid var(--line)}.records {max-height:300px;overflow:auto}} +@media(max-width:1000px) {main {padding:16px 18px 28px}.resource-workspace {grid-template-columns:188px minmax(0,1fr)}.resource-heading {padding:18px 18px 0}.tab-body {padding:18px}.tabs {padding:0 18px;gap:18px}} +@media(max-width:760px) { + .directory-grid {grid-template-columns:1fr;gap:28px} + .directory-heading {grid-template-columns:minmax(0,1fr);gap:12px} + .directory-heading h1 {font-size:1.625rem} + .directory-tools .search {width:100%} + .directory-result-count {margin-left:0} + .directory-service {grid-template-columns:minmax(0,1fr) 18px;gap:4px 10px} + .directory-detail {grid-row:2;grid-column:1} + .directory-service>span:last-child {grid-column:2;grid-row:1 / 3} + .app-header {display:grid;grid-template-columns:minmax(0,1fr) auto;padding:12px 14px;gap:12px} + .header-divider {display:none} + .header-tools {grid-row:2;grid-column:1 / -1;gap:8px} + .app-header .global-nav {grid-row:1;grid-column:2} + .global-nav {gap:12px;font-size:.8125rem} + main {padding:14px 12px 28px} + .scope {margin-left:0;justify-content:flex-start;gap:6px 16px} + .workspace-context {gap:12px;margin-bottom:18px} + h1 {font-size:1.375rem}.service-heading {align-items:flex-start}.service-heading p {font-size:.8125rem} + .mobile-resources {display:inline-flex;font-size:.75rem;padding:5px 8px} + .resource-workspace {display:block;border-radius:5px;min-height:450px} + .resource-index {display:none} + .browsing .resource-index {display:block;border-right:0} + .browsing .inspector {display:none} + .resource-heading {padding:16px 14px 0} + .resource-top {align-items:flex-start} + h2 {font-size:1.0625rem} + .tabs {padding:0 14px;gap:18px}.tabs button {font-size:.8125rem}.tab-body {padding:16px 14px} + .data-split {grid-template-columns:1fr} + .metadata>div {grid-template-columns:1fr;gap:4px} + .service-picker-panel {max-width:calc(100vw - 36px);left:-80px} + .connection {grid-template-columns:1fr} + .resource-list {max-height:60vh} +} +@media(max-width:480px) { + .app-header .global-nav {grid-row:3;grid-column:1 / -1;margin-left:0} + .header-context .scope label {position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} +} diff --git a/src/MicroStack.UI.Client/wwwroot/favicon.png b/src/MicroStack.UI.Client/wwwroot/favicon.png new file mode 100644 index 0000000..8422b59 Binary files /dev/null and b/src/MicroStack.UI.Client/wwwroot/favicon.png differ diff --git a/src/MicroStack.UI.Client/wwwroot/icon-192.png b/src/MicroStack.UI.Client/wwwroot/icon-192.png new file mode 100644 index 0000000..166f56d Binary files /dev/null and b/src/MicroStack.UI.Client/wwwroot/icon-192.png differ diff --git a/src/MicroStack.UI.Client/wwwroot/icons/acm.svg b/src/MicroStack.UI.Client/wwwroot/icons/acm.svg new file mode 100644 index 0000000..5a49b4c --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/acm.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-Certificate-Manager_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/alb.svg b/src/MicroStack.UI.Client/wwwroot/icons/alb.svg new file mode 100644 index 0000000..992c502 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/alb.svg @@ -0,0 +1,7 @@ + + + Icon-Resource/Networking-and-Content-Delivery/Res_Elastic-Load-Balancing_Application-Load-Balancer_48 + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/apigateway.svg b/src/MicroStack.UI.Client/wwwroot/icons/apigateway.svg new file mode 100644 index 0000000..6f05362 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/apigateway.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/MicroStack.UI.Client/wwwroot/icons/apigatewayv2.svg b/src/MicroStack.UI.Client/wwwroot/icons/apigatewayv2.svg new file mode 100644 index 0000000..6f05362 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/apigatewayv2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/MicroStack.UI.Client/wwwroot/icons/appsync.svg b/src/MicroStack.UI.Client/wwwroot/icons/appsync.svg new file mode 100644 index 0000000..b00d11a --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/appsync.svg @@ -0,0 +1,12 @@ + + + Icon-Architecture/32/Arch_AWS-AppSync_32 + + + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/athena.svg b/src/MicroStack.UI.Client/wwwroot/icons/athena.svg new file mode 100644 index 0000000..11e0683 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/athena.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Athena_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/cloudformation.svg b/src/MicroStack.UI.Client/wwwroot/icons/cloudformation.svg new file mode 100644 index 0000000..74657ae --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/cloudformation.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-CloudFormation_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/cloudfront.svg b/src/MicroStack.UI.Client/wwwroot/icons/cloudfront.svg new file mode 100644 index 0000000..2c16a09 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/cloudfront.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-CloudFront_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/cloudwatch.svg b/src/MicroStack.UI.Client/wwwroot/icons/cloudwatch.svg new file mode 100644 index 0000000..de06cf1 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/cloudwatch.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-CloudWatch_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/cognitoidentity.svg b/src/MicroStack.UI.Client/wwwroot/icons/cognitoidentity.svg new file mode 100644 index 0000000..be3d14b --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/cognitoidentity.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Cognito_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/cognitoidp.svg b/src/MicroStack.UI.Client/wwwroot/icons/cognitoidp.svg new file mode 100644 index 0000000..be3d14b --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/cognitoidp.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Cognito_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/dynamodb.svg b/src/MicroStack.UI.Client/wwwroot/icons/dynamodb.svg new file mode 100644 index 0000000..948ee42 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/dynamodb.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-DynamoDB_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/ec2.svg b/src/MicroStack.UI.Client/wwwroot/icons/ec2.svg new file mode 100644 index 0000000..df01964 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/ec2.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-EC2_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/ecr.svg b/src/MicroStack.UI.Client/wwwroot/icons/ecr.svg new file mode 100644 index 0000000..91729a0 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/ecr.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Elastic-Container-Registry_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/ecs.svg b/src/MicroStack.UI.Client/wwwroot/icons/ecs.svg new file mode 100644 index 0000000..7738b90 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/ecs.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Elastic-Container-Service_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/efs.svg b/src/MicroStack.UI.Client/wwwroot/icons/efs.svg new file mode 100644 index 0000000..e8611ff --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/efs.svg @@ -0,0 +1,12 @@ + + + Icon-Architecture/32/Arch_Amazon-EFS_32 + + + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/elasticache.svg b/src/MicroStack.UI.Client/wwwroot/icons/elasticache.svg new file mode 100644 index 0000000..d1465ef --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/elasticache.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-ElastiCache_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/emr.svg b/src/MicroStack.UI.Client/wwwroot/icons/emr.svg new file mode 100644 index 0000000..0a1916a --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/emr.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-EMR_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/events.svg b/src/MicroStack.UI.Client/wwwroot/icons/events.svg new file mode 100644 index 0000000..1d6fe98 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/events.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-EventBridge_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/firehose.svg b/src/MicroStack.UI.Client/wwwroot/icons/firehose.svg new file mode 100644 index 0000000..965e18c --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/firehose.svg @@ -0,0 +1,12 @@ + + + Icon-Architecture/32/Arch_Amazon-Kinesis-Data-Firehose_32 + + + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/glue.svg b/src/MicroStack.UI.Client/wwwroot/icons/glue.svg new file mode 100644 index 0000000..5b05d46 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/glue.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-Glue_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/iam.svg b/src/MicroStack.UI.Client/wwwroot/icons/iam.svg new file mode 100644 index 0000000..867ae32 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/iam.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-Identity-and-Access-Management_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/kinesis.svg b/src/MicroStack.UI.Client/wwwroot/icons/kinesis.svg new file mode 100644 index 0000000..b2314b8 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/kinesis.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Kinesis_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/kms.svg b/src/MicroStack.UI.Client/wwwroot/icons/kms.svg new file mode 100644 index 0000000..67dc49f --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/kms.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-Key-Management-Service_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/lambda.svg b/src/MicroStack.UI.Client/wwwroot/icons/lambda.svg new file mode 100644 index 0000000..b2718d1 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/lambda.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-Lambda_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/logs.svg b/src/MicroStack.UI.Client/wwwroot/icons/logs.svg new file mode 100644 index 0000000..bcfb802 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/logs.svg @@ -0,0 +1,7 @@ + + + Icon-Resource/Management-Governance/Res_Amazon-CloudWatch_Logs_48 + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/rds.svg b/src/MicroStack.UI.Client/wwwroot/icons/rds.svg new file mode 100644 index 0000000..61793ec --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/rds.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-RDS_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/rdsdata.svg b/src/MicroStack.UI.Client/wwwroot/icons/rdsdata.svg new file mode 100644 index 0000000..61793ec --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/rdsdata.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-RDS_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/route53.svg b/src/MicroStack.UI.Client/wwwroot/icons/route53.svg new file mode 100644 index 0000000..9134b77 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/route53.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Route-53_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/s3.svg b/src/MicroStack.UI.Client/wwwroot/icons/s3.svg new file mode 100644 index 0000000..6a2edca --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/s3.svg @@ -0,0 +1,12 @@ + + + Icon-Architecture/32/Arch_Amazon-Simple-Storage-Service_32 + + + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/s3files.svg b/src/MicroStack.UI.Client/wwwroot/icons/s3files.svg new file mode 100644 index 0000000..927aa41 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/s3files.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/MicroStack.UI.Client/wwwroot/icons/secretsmanager.svg b/src/MicroStack.UI.Client/wwwroot/icons/secretsmanager.svg new file mode 100644 index 0000000..e56f26f --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/secretsmanager.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-Secrets-Manager_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/servicediscovery.svg b/src/MicroStack.UI.Client/wwwroot/icons/servicediscovery.svg new file mode 100644 index 0000000..0237856 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/servicediscovery.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-Cloud-Map_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/ses.svg b/src/MicroStack.UI.Client/wwwroot/icons/ses.svg new file mode 100644 index 0000000..6654e21 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/ses.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Simple-Email-Service_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/sns.svg b/src/MicroStack.UI.Client/wwwroot/icons/sns.svg new file mode 100644 index 0000000..c492368 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/sns.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Simple-Notification-Service_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/sqs.svg b/src/MicroStack.UI.Client/wwwroot/icons/sqs.svg new file mode 100644 index 0000000..6f6ebc9 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/sqs.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_Amazon-Simple-Queue-Service_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/ssm.svg b/src/MicroStack.UI.Client/wwwroot/icons/ssm.svg new file mode 100644 index 0000000..f76c16c --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/ssm.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-Systems-Manager_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/stepfunctions.svg b/src/MicroStack.UI.Client/wwwroot/icons/stepfunctions.svg new file mode 100644 index 0000000..d1d544d --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/stepfunctions.svg @@ -0,0 +1,10 @@ + + + Icon-Architecture/32/Arch_AWS-Step-Functions_32 + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/sts.svg b/src/MicroStack.UI.Client/wwwroot/icons/sts.svg new file mode 100644 index 0000000..954e219 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/sts.svg @@ -0,0 +1,7 @@ + + + Icon-Resource/Security-Identity-and-Compliance/Res_AWS-Identity-Access-Management_AWS-STS_48 + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/icons/waf.svg b/src/MicroStack.UI.Client/wwwroot/icons/waf.svg new file mode 100644 index 0000000..a83383b --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/icons/waf.svg @@ -0,0 +1,12 @@ + + + Icon-Architecture/32/Arch_AWS-WAF_32 + + + + + + + + + \ No newline at end of file diff --git a/src/MicroStack.UI.Client/wwwroot/index.html b/src/MicroStack.UI.Client/wwwroot/index.html new file mode 100644 index 0000000..87cfd71 --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/index.html @@ -0,0 +1,28 @@ + + + + + + + MicroStack · Services + + + + + + + + +
+
Loading MicroStack…
+
+ +
+ An unhandled error has occurred. + Reload +
+ + + + + diff --git a/src/MicroStack.UI.Client/wwwroot/live.js b/src/MicroStack.UI.Client/wwwroot/live.js new file mode 100644 index 0000000..eac4e7d --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/live.js @@ -0,0 +1,45 @@ +export function create(receiver) { + let source = null; + let disposed = false; + const notify = (method, ...args) => { + if (!disposed) receiver.invokeMethodAsync(method, ...args).catch(() => {}); + }; + const stop = () => { + if (source) { + source.onopen = source.onerror = null; + source.close(); + source = null; + } + }; + const visibility = () => { + if (document.hidden) stop(); + notify("OnVisibilityChanged", !document.hidden); + }; + document.addEventListener("visibilitychange", visibility); + return { + isVisible: () => !document.hidden, + start(accountId, generation) { + stop(); + if (disposed || document.hidden) return; + const url = new URL("/_microstack/admin/v1/events", window.location.origin); + if (accountId) url.searchParams.set("accountId", accountId); + const current = new EventSource(url); + source = current; + current.onopen = () => { + if (source === current) notify("OnConnectionChanged", generation, true); + }; + current.onerror = () => { + if (source === current) notify("OnConnectionChanged", generation, false); + }; + current.addEventListener("change", event => { + if (source === current) notify("OnChange", generation, event.data); + }); + }, + stop, + dispose() { + disposed = true; + stop(); + document.removeEventListener("visibilitychange", visibility); + } + }; +} diff --git a/src/MicroStack.UI.Client/wwwroot/logo.png b/src/MicroStack.UI.Client/wwwroot/logo.png new file mode 100644 index 0000000..76b78d5 Binary files /dev/null and b/src/MicroStack.UI.Client/wwwroot/logo.png differ diff --git a/src/MicroStack.UI.Client/wwwroot/workspace.js b/src/MicroStack.UI.Client/wwwroot/workspace.js new file mode 100644 index 0000000..62aa18c --- /dev/null +++ b/src/MicroStack.UI.Client/wwwroot/workspace.js @@ -0,0 +1,119 @@ +window.microstack = (() => { + let openPicker; + const scrollPositions = new Map(); + let currentUrl = location.href; + function capture() { + scrollPositions.set(currentUrl, { + window: window.scrollY, + index: document.querySelector('.resource-list')?.scrollTop ?? 0, + records: document.querySelector('.records')?.scrollTop ?? 0 + }); + if (scrollPositions.size > 100) scrollPositions.delete(scrollPositions.keys().next().value); + } + const options = picker => Array.from(picker.querySelectorAll('[role="option"]')); + function activate(picker, index, focus) { + const items = options(picker); + const current = items[Math.max(0, Math.min(index, items.length - 1))]; + items.forEach(item => item.tabIndex = item === current ? 0 : -1); + const input = picker.querySelector('[role="combobox"]'); + if (current) { + input.setAttribute('aria-activedescendant', current.id); + if (focus) current.focus(); + current.scrollIntoView({ block: 'nearest' }); + } else input.removeAttribute('aria-activedescendant'); + } + function close(picker, restore) { + if (!picker) return; + picker.querySelector('[data-picker-panel]').hidden = true; + picker.querySelector('[data-picker-trigger]').setAttribute('aria-expanded', 'false'); + picker.querySelector('[role="combobox"]').setAttribute('aria-expanded', 'false'); + if (restore) picker.querySelector('[data-picker-trigger]').focus(); + if (openPicker === picker) openPicker = null; + } + function open(picker) { + if (openPicker && openPicker !== picker) close(openPicker, false); + openPicker = picker; + picker.querySelector('[data-picker-panel]').hidden = false; + picker.querySelector('[data-picker-trigger]').setAttribute('aria-expanded', 'true'); + picker.querySelector('[role="combobox"]').setAttribute('aria-expanded', 'true'); + activate(picker, 0, false); + picker.querySelector('[role="combobox"]').focus(); + } + document.addEventListener('click', event => { + capture(); + const link = event.target.closest('a[href]'); + if (link && link.origin === location.origin && link.pathname === location.pathname && + new URL(link.href).searchParams.get('account') === new URL(currentUrl).searchParams.get('account') && + !scrollPositions.has(link.href)) + scrollPositions.set(link.href, scrollPositions.get(currentUrl)); + const picker = event.target.closest('[data-service-picker]'); + if (event.target.closest('[data-picker-trigger]')) { + if (openPicker === picker) close(picker, true); else open(picker); + } else if (event.target.closest('[role="option"]') && picker) close(picker, true); + else if (openPicker && !openPicker.contains(event.target)) close(openPicker, !event.target.closest('a,button,input,select,textarea,[tabindex]')); + }); + document.addEventListener('focusin', event => { + if (openPicker && !openPicker.contains(event.target)) close(openPicker, false); + }); + window.addEventListener('popstate', capture); + document.addEventListener('keydown', event => { + const picker = event.target.closest('[data-service-picker]'); + const trigger = event.target.closest('[data-picker-trigger]'); + if (trigger && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) { + event.preventDefault(); open(picker); return; + } + if (trigger && event.key.length === 1 && !event.ctrlKey && !event.metaKey && event.key !== ' ') { + event.preventDefault(); open(picker); + const input = picker.querySelector('input'); + input.value = event.key; + input.dispatchEvent(new Event('input', { bubbles: true })); + return; + } + if (picker && openPicker === picker) { + if (event.key === 'Escape') { event.preventDefault(); close(picker, true); return; } + const items = options(picker); + const isInput = event.target.matches('input'); + let index = items.indexOf(event.target); + if (isInput) index = items.findIndex(item => item.id === event.target.getAttribute('aria-activedescendant')); + if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key) && (!isInput || event.key.startsWith('Arrow'))) { + event.preventDefault(); + activate(picker, event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : + event.key === 'ArrowDown' ? Math.min(index + 1, items.length - 1) : Math.max(index - 1, 0), !isInput); + } else if (event.key === 'Enter' && isInput) { + event.preventDefault(); items[Math.max(index, 0)]?.click(); + } + } + const tab = event.target.closest('[role="tab"]'); + if (tab && ['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) { + event.preventDefault(); + const tabs = Array.from(tab.parentElement.querySelectorAll('[role="tab"]')); + const index = tabs.indexOf(tab); + const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : + (index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length; + tabs[next].focus(); tabs[next].click(); + } + }); + new MutationObserver(() => { + if (openPicker && !document.contains(openPicker)) { openPicker = null; return; } + if (openPicker && !options(openPicker).some(item => item.tabIndex === 0)) activate(openPicker, 0, false); + }).observe(document.documentElement, { childList: true, subtree: true }); + return { + copy: text => navigator.clipboard.writeText(text), + focus: id => document.getElementById(id)?.focus(), + restore: url => { + const previous = new URL(currentUrl); + const next = new URL(url); + if (!scrollPositions.has(url) && previous.pathname === next.pathname && + previous.searchParams.get('account') === next.searchParams.get('account') && scrollPositions.has(currentUrl)) + scrollPositions.set(url, scrollPositions.get(currentUrl)); + currentUrl = url; + const position = scrollPositions.get(url); + if (!position) return; + const index = document.querySelector('.resource-list'); + const records = document.querySelector('.records'); + if (index) index.scrollTop = position.index; + if (records) records.scrollTop = position.records; + window.scrollTo({ top: position.window, behavior: 'instant' }); + } + }; +})(); diff --git a/src/MicroStack/Internal/AccountContext.cs b/src/MicroStack/Internal/AccountContext.cs index a776a87..88046f1 100644 --- a/src/MicroStack/Internal/AccountContext.cs +++ b/src/MicroStack/Internal/AccountContext.cs @@ -40,6 +40,30 @@ internal static void SetFromAccessKey(string? accessKeyId) internal static string GetAccountId() => _accountId.Value ?? _defaultAccountId; + internal static IDisposable BeginScope(string accountId) + { + ArgumentException.ThrowIfNullOrEmpty(accountId); + if (!TwelveDigitRegex().IsMatch(accountId)) + throw new ArgumentException("Account IDs must contain exactly 12 digits.", nameof(accountId)); + + var previous = _accountId.Value; + _accountId.Value = accountId; + return new Scope(previous); + } + /// Resets to default (used in tests). internal static void Reset() => _accountId.Value = null; + + private sealed class Scope(string? previous) : IDisposable + { + private bool _disposed; + + public void Dispose() + { + if (_disposed) + return; + _accountId.Value = previous; + _disposed = true; + } + } } diff --git a/src/MicroStack/Internal/AccountScopedDictionary.cs b/src/MicroStack/Internal/AccountScopedDictionary.cs index 1f0ea81..e0c9346 100644 --- a/src/MicroStack/Internal/AccountScopedDictionary.cs +++ b/src/MicroStack/Internal/AccountScopedDictionary.cs @@ -63,6 +63,17 @@ internal bool TryAdd(TKey key, TValue value) => _data.Where(kv => IsMine(kv.Key)) .Select(kv => new KeyValuePair(kv.Key.Key, kv.Value)); + /// + /// Enumerates retained owners without taking a persistence snapshot or entering + /// an account scope. A typed predicate can exclude empty resource containers. + /// + internal IEnumerable GetAccountIds( + Func? hasResource = null, Func? includeKey = null) => + _data.Where(entry => (includeKey is null || includeKey(entry.Key.Key)) + && (hasResource is null || hasResource(entry.Value))) + .Select(entry => entry.Key.AccountId) + .Distinct(StringComparer.Ordinal); + /// Clear ALL accounts' data (used by service Reset). internal void Clear() => _data.Clear(); diff --git a/src/MicroStack/Internal/Admin/AdminApi.cs b/src/MicroStack/Internal/Admin/AdminApi.cs new file mode 100644 index 0000000..5183dc7 --- /dev/null +++ b/src/MicroStack/Internal/Admin/AdminApi.cs @@ -0,0 +1,377 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using MicroStack.Admin.Contracts; + +namespace MicroStack.Internal.Admin; + +internal static class AdminApi +{ + private const string Root = "/_microstack/admin/v1"; + + internal static void MapAdminApi( + this WebApplication app, ServiceRegistry registry, RequestLog requestLog, + MicroStackOptions options, string corsPolicy) + { + app.MapGet(Root + "/events", AdminEvents.Stream).RequireCors(corsPolicy); + + app.MapGet(Root + "/context", () => + Ok(new AdminContext(options.DefaultAccountId, options.Region), AdminJsonContext.Default.AdminContext)) + .RequireCors(corsPolicy); + + app.MapGet(Root + "/accounts", (HttpContext context) => + { + context.Response.Headers.CacheControl = "no-store"; + return Ok(registry.GetKnownAccountIds(options.DefaultAccountId), AdminJsonContext.Default.StringArray); + }).RequireCors(corsPolicy); + + app.MapGet(Root + "/services", (HttpContext context) => + WithAccount(context, options, _ => Ok( + AdminCatalog.Entries.Select(entry => AdminCatalog.Describe(entry, registry)).ToArray(), + AdminJsonContext.Default.AdminServiceArray))) + .RequireCors(corsPolicy); + + app.MapGet(Root + "/services/{service}/resources", (HttpContext context, string service) => + WithSource(context, service, registry, options, (entry, source, account) => + { + if (!TryPage(context, Scope(entry.Id, account, [], "resources", + Query(context, "kind"), Query(context, "filter")), out var page, out var error)) + return error; + + var kind = Query(context, "kind"); + var filter = Query(context, "filter"); + IEnumerable resources = Order(source.GetAdminResources(entry.Id)); + if (!string.IsNullOrEmpty(kind)) + resources = resources.Where(node => + string.Equals(node.Resource.Key.Kind, kind, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrEmpty(filter)) + resources = resources.Where(node => Matches(node.Resource, filter)); + return Page(resources.Select(node => node.Resource), page); + })) + .RequireCors(corsPolicy); + + app.MapGet(Root + "/services/{service}/resource", (HttpContext context, string service) => + WithNode(context, service, registry, options, (node, scope) => + { + var connections = new AdminRelationshipResolver(registry, scope.Account) + .Read(node, scope.Service, scope.Path); + var hasConnections = node.ReadConnections is not null || connections.Count > 0; + return Ok(new AdminResourceDetail(node.Resource) + { + Fields = node.ReadFields?.Invoke() ?? [], + Summary = node.ReadSummary?.Invoke() ?? [], + ChildKinds = node.ChildKinds, + HasChildren = node.ReadChildren is not null, + HasContent = node.ReadContent is not null, + HasConnections = hasConnections, + ConnectionCount = hasConnections ? connections.Count : null, + RevealableFields = node.RevealableFields + }, AdminJsonContext.Default.AdminResourceDetail); + })) + .RequireCors(corsPolicy); + + app.MapGet(Root + "/services/{service}/children", (HttpContext context, string service) => + WithNode(context, service, registry, options, (node, scope) => + { + if (node.ReadChildren is null) + return Error(409, "capability_unavailable", "This resource does not expose child resources."); + if (!TryPage(context, Scope(scope.Service, scope.Account, scope.Path, "children", + Query(context, "kind"), Query(context, "filter"), Query(context, "prefix")), + out var page, out var error)) + return error; + + var kind = Query(context, "kind"); + var filter = Query(context, "filter"); + var prefix = Query(context, "prefix"); + IEnumerable children = Order(node.ReadChildren()); + if (!string.IsNullOrEmpty(kind)) + children = children.Where(child => + string.Equals(child.Resource.Key.Kind, kind, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrEmpty(filter)) + children = children.Where(child => Matches(child.Resource, filter)); + if (!string.IsNullOrEmpty(prefix)) + children = children.Where(child => + child.Resource.Key.Id.StartsWith(prefix, StringComparison.Ordinal)); + return Page(children.Select(child => child.Resource), page); + })) + .RequireCors(corsPolicy); + + app.MapGet(Root + "/services/{service}/content", (HttpContext context, string service) => + WithNode(context, service, registry, options, (node, _) => + { + if (node.ReadContent is null) + return Error(409, "capability_unavailable", "This resource does not expose content."); + context.Response.Headers.CacheControl = "no-store"; + return Ok(node.ReadContent(), AdminJsonContext.Default.AdminContent); + })) + .RequireCors(corsPolicy); + + app.MapGet(Root + "/services/{service}/connections", (HttpContext context, string service) => + WithNode(context, service, registry, options, (node, scope) => + { + if (!TryPage(context, Scope(scope.Service, scope.Account, scope.Path, "connections"), + out var page, out var error)) + return error; + var values = new AdminRelationshipResolver(registry, scope.Account) + .Read(node, scope.Service, scope.Path); + if (node.ReadConnections is null && values.Count == 0) + return Error(409, "capability_unavailable", "This resource does not expose connections."); + return ConnectionPage(values, page); + })) + .RequireCors(corsPolicy); + + app.MapPost(Root + "/services/{service}/reveal", (HttpContext context, string service) => + WithNode(context, service, registry, options, (node, _) => + { + context.Response.Headers.CacheControl = "no-store"; + var field = Query(context, "field"); + if (string.IsNullOrWhiteSpace(field)) + return Error(400, "invalid_field", "A field is required."); + if (node.RevealField is null || !node.RevealableFields.Contains(field, StringComparer.Ordinal)) + return Error(409, "capability_unavailable", "This field cannot be revealed."); + return Ok(node.RevealField(field), AdminJsonContext.Default.AdminContent); + })) + .RequireCors(corsPolicy); + + app.MapGet(Root + "/services/{service}/activity", (HttpContext context, string service) => + WithAccount(context, options, account => + { + var entry = AdminCatalog.Find(service); + if (entry is null) + return Error(404, "service_not_found", "The requested service is not in the admin catalog."); + if (registry.Resolve(entry.CanonicalHandler) is null) + return Error(409, "service_disabled", "The requested service is disabled."); + if (!TryPage(context, Scope(entry.Id, account, [], "activity"), out var page, out var error)) + return error; + + // Read the bounded log, then filter service/account before applying the page limit. + var entries = requestLog.GetEntries() + .Where(item => string.Equals(item.Service, entry.CanonicalHandler, + StringComparison.OrdinalIgnoreCase) + && string.Equals(item.AccountId, account, StringComparison.Ordinal)) + .Skip(page.Offset).Take(page.Size + 1) + .Select(item => new AdminActivity(item.Service, item.Action, item.AccountId, + item.Timestamp, item.StatusCode, item.DurationMs)) + .ToArray(); + return ActivityPage(entries, page); + })) + .RequireCors(corsPolicy); + } + + private static IResult WithSource( + HttpContext context, string service, ServiceRegistry registry, MicroStackOptions options, + Func action) => + WithAccount(context, options, account => + { + var entry = AdminCatalog.Find(service); + if (entry is null) + return Error(404, "service_not_found", "The requested service is not in the admin catalog."); + var handler = registry.Resolve(entry.CanonicalHandler); + if (handler is null) + return Error(409, "service_disabled", "The requested service is disabled."); + if (handler is not IAdminResourceSource source) + return Error(409, "capability_unavailable", "Inspection is unavailable for this service."); + return action(entry, source, account); + }); + + private static IResult WithNode( + HttpContext context, string service, ServiceRegistry registry, MicroStackOptions options, + Func action) => + WithSource(context, service, registry, options, (entry, source, account) => + { + if (!TryPath(context, out var path, out var error)) + return error; + var node = Resolve(source, entry.Id, path); + return node is null + ? Error(404, "resource_not_found", "The requested resource was not found.") + : action(node, new(entry.Id, account, path)); + }); + + private static IResult WithAccount( + HttpContext context, MicroStackOptions options, Func action) + { + var account = context.Request.Query.ContainsKey("accountId") + ? Query(context, "accountId") + : options.DefaultAccountId; + if (account.Length != 12 || account.Any(character => character is < '0' or > '9')) + return Error(400, "invalid_account", "Account IDs must contain exactly 12 digits."); + using var scope = AccountContext.BeginScope(account); + return action(account); + } + + private static AdminNode? Resolve(IAdminResourceSource source, string service, AdminKey[] path) + { + IEnumerable level = source.GetAdminResources(service); + AdminNode? current = null; + for (var index = 0; index < path.Length; index++) + { + var key = path[index]; + current = level.FirstOrDefault(node => + string.Equals(node.Resource.Key.Kind, key.Kind, StringComparison.Ordinal) + && string.Equals(node.Resource.Key.Id, key.Id, StringComparison.Ordinal)); + if (current is null) + return null; + if (index < path.Length - 1) + level = current.ReadChildren?.Invoke() ?? []; + } + return current; + } + + private static bool TryPath(HttpContext context, out AdminKey[] path, out IResult error) + { + path = []; + error = null!; + var encoded = Query(context, "path"); + if (string.IsNullOrEmpty(encoded)) + { + error = Error(400, "invalid_path", "A non-empty resource path is required."); + return false; + } + try + { + path = JsonSerializer.Deserialize(encoded, AdminJsonContext.Default.AdminKeyArray) ?? []; + } + catch (JsonException) + { + error = Error(400, "invalid_path", "The resource path is invalid."); + return false; + } + if (path.Length is 0 or > 32 || path.Any(key => + string.IsNullOrEmpty(key.Kind) || string.IsNullOrEmpty(key.Id) + || key.Kind.Length > 256 || key.Id.Length > 16_384)) + { + error = Error(400, "invalid_path", "The resource path is invalid."); + return false; + } + return true; + } + + private static bool TryPage(HttpContext context, string scope, out PageRequest page, out IResult error) + { + page = default; + error = null!; + var sizeText = Query(context, "pageSize"); + if (!string.IsNullOrEmpty(sizeText) + && (!int.TryParse(sizeText, out var parsed) || parsed is < 1 or > 200)) + { + error = Error(400, "invalid_limit", "pageSize must be between 1 and 200."); + return false; + } + var size = string.IsNullOrEmpty(sizeText) ? 50 : int.Parse(sizeText); + var cursor = Query(context, "cursor"); + var offset = 0; + if (!string.IsNullOrEmpty(cursor) && !Cursor.TryDecode(cursor, scope, out offset)) + { + error = Error(400, "invalid_cursor", "The cursor is invalid for this request."); + return false; + } + page = new(offset, size, scope); + return true; + } + + private static IResult Page(IEnumerable values, PageRequest page) + { + var items = values.Skip(page.Offset).Take(page.Size + 1).ToArray(); + var hasMore = items.Length > page.Size; + return Ok(new AdminPage + { + Items = hasMore ? items[..page.Size] : items, + NextCursor = hasMore ? Cursor.Encode(page.Offset + page.Size, page.Scope) : null, + CapturedAt = DateTimeOffset.UtcNow + }, AdminJsonContext.Default.AdminPageAdminResourceSummary); + } + + private static IResult ConnectionPage(IReadOnlyList values, PageRequest page) + { + var items = values.Skip(page.Offset).Take(page.Size + 1).ToArray(); + var hasMore = items.Length > page.Size; + return Ok(new AdminPage + { + Items = hasMore ? items[..page.Size] : items, + NextCursor = hasMore ? Cursor.Encode(page.Offset + page.Size, page.Scope) : null, + KnownTotal = values.Count, + CapturedAt = DateTimeOffset.UtcNow + }, AdminJsonContext.Default.AdminPageAdminConnection); + } + + private static IResult ActivityPage(AdminActivity[] items, PageRequest page) + { + var hasMore = items.Length > page.Size; + return Ok(new AdminPage + { + Items = hasMore ? items[..page.Size] : items, + NextCursor = hasMore ? Cursor.Encode(page.Offset + page.Size, page.Scope) : null, + CapturedAt = DateTimeOffset.UtcNow + }, AdminJsonContext.Default.AdminPageAdminActivity); + } + + private static IOrderedEnumerable Order(IEnumerable nodes) => + nodes.OrderBy(node => node.Resource.Key.Kind, StringComparer.Ordinal) + .ThenBy(node => node.Resource.Key.Id, StringComparer.Ordinal); + + private static bool Matches(AdminResourceSummary resource, string filter) => + resource.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) + || resource.Key.Id.Contains(filter, StringComparison.OrdinalIgnoreCase) + || (resource.Arn?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false); + + private static string Scope(string service, string account, AdminKey[] path, string operation, + params string[] qualifiers) + { + var builder = new StringBuilder().Append(service).Append('\0').Append(account).Append('\0') + .Append(operation); + foreach (var key in path) + builder.Append('\0').Append(key.Kind.Length).Append(':').Append(key.Kind) + .Append(key.Id.Length).Append(':').Append(key.Id); + foreach (var qualifier in qualifiers) + builder.Append('\0').Append(qualifier); + return builder.ToString(); + } + + private static string Query(HttpContext context, string name) => context.Request.Query[name].ToString(); + + private static IResult Ok(T value, System.Text.Json.Serialization.Metadata.JsonTypeInfo type) => + Results.Json(value, type); + + private static IResult Error(int status, string code, string message) => + Results.Json(new AdminError(code, message), AdminJsonContext.Default.AdminError, statusCode: status); + + private readonly record struct NodeScope(string Service, string Account, AdminKey[] Path); + private readonly record struct PageRequest(int Offset, int Size, string Scope); + + private static class Cursor + { + internal static string Encode(int offset, string scope) + { + Span bytes = stackalloc byte[20]; + BinaryPrimitives.WriteInt32BigEndian(bytes, offset); + Span hash = stackalloc byte[32]; + SHA256.HashData(Encoding.UTF8.GetBytes(scope), hash); + hash[..16].CopyTo(bytes[4..]); + return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + internal static bool TryDecode(string value, string scope, out int offset) + { + offset = 0; + try + { + var normalized = value.Replace('-', '+').Replace('_', '/'); + normalized += new string('=', (4 - normalized.Length % 4) % 4); + var bytes = Convert.FromBase64String(normalized); + if (bytes.Length != 20) + return false; + Span expected = stackalloc byte[32]; + SHA256.HashData(Encoding.UTF8.GetBytes(scope), expected); + if (!CryptographicOperations.FixedTimeEquals(bytes.AsSpan(4), expected[..16])) + return false; + offset = BinaryPrimitives.ReadInt32BigEndian(bytes); + return offset >= 0; + } + catch (FormatException) + { + return false; + } + } + } +} diff --git a/src/MicroStack/Internal/Admin/AdminCatalog.cs b/src/MicroStack/Internal/Admin/AdminCatalog.cs new file mode 100644 index 0000000..b62dc50 --- /dev/null +++ b/src/MicroStack/Internal/Admin/AdminCatalog.cs @@ -0,0 +1,81 @@ +using MicroStack.Admin.Contracts; + +namespace MicroStack.Internal.Admin; + +internal sealed record AdminCatalogEntry( + string Id, string Name, string Label, string Category, string Icon, + string CanonicalHandler, string? Variant = null, string Scope = "account"); + +internal static class AdminCatalog +{ + internal static readonly IReadOnlyList Entries = + [ + E("sqs", "Simple Queue Service", "SQS", "Messaging & workflows", "sqs"), + E("sns", "Simple Notification Service", "SNS", "Messaging & workflows", "sns"), + E("events", "EventBridge", "EventBridge", "Messaging & workflows", "events"), + E("ses", "SES", "SES", "Messaging & workflows", "ses"), + E("stepfunctions", "Step Functions", "Step Functions", "Messaging & workflows", "stepfunctions", "states"), + + E("s3", "Simple Storage Service", "S3", "Storage & databases", "s3"), + E("s3files", "S3 Files", "S3 Files", "Storage & databases", "s3files"), + E("efs", "EFS", "EFS", "Storage & databases", "efs", "elasticfilesystem"), + E("dynamodb", "DynamoDB", "DynamoDB", "Storage & databases", "dynamodb"), + E("rds", "RDS", "RDS", "Storage & databases", "rds"), + E("rdsdata", "RDS Data", "RDS Data", "Storage & databases", "rdsdata", "rds-data"), + E("elasticache", "ElastiCache", "ElastiCache", "Storage & databases", "elasticache"), + + E("lambda", "Lambda", "Lambda", "Compute & containers", "lambda"), + E("ec2", "EC2", "EC2", "Compute & containers", "ec2"), + E("ecs", "ECS", "ECS", "Compute & containers", "ecs"), + E("ecr", "ECR", "ECR", "Compute & containers", "ecr"), + + E("apigateway", "API Gateway REST", "API Gateway REST", "Networking & delivery", "apigateway", variant: "rest"), + E("apigatewayv2", "API Gateway HTTP / WebSocket", "API Gateway HTTP / WebSocket", "Networking & delivery", "apigatewayv2", "apigateway", "v2"), + E("alb", "Application Load Balancer", "Application Load Balancer", "Networking & delivery", "alb", "elasticloadbalancing"), + E("appsync", "AppSync", "AppSync", "Networking & delivery", "appsync"), + E("cloudfront", "CloudFront", "CloudFront", "Networking & delivery", "cloudfront"), + E("route53", "Route 53", "Route 53", "Networking & delivery", "route53"), + E("servicediscovery", "Cloud Map", "Cloud Map", "Networking & delivery", "servicediscovery"), + + E("acm", "Certificate Manager", "Certificate Manager", "Security & identity", "acm"), + E("cognitoidp", "Cognito User Pools", "Cognito User Pools", "Security & identity", "cognitoidp", "cognito-idp"), + E("cognitoidentity", "Cognito Identity Pools", "Cognito Identity Pools", "Security & identity", "cognitoidentity", "cognito-identity"), + E("iam", "IAM", "IAM", "Security & identity", "iam"), + E("kms", "KMS", "KMS", "Security & identity", "kms"), + E("secretsmanager", "Secrets Manager", "Secrets Manager", "Security & identity", "secretsmanager"), + E("sts", "STS", "STS", "Security & identity", "sts"), + E("waf", "WAF", "WAF", "Security & identity", "waf", "wafv2"), + + E("athena", "Athena", "Athena", "Analytics & streaming", "athena"), + E("emr", "EMR", "EMR", "Analytics & streaming", "emr", "elasticmapreduce"), + E("firehose", "Data Firehose", "Data Firehose", "Analytics & streaming", "firehose", scope: "global"), + E("glue", "Glue", "Glue", "Analytics & streaming", "glue"), + E("kinesis", "Kinesis", "Kinesis", "Analytics & streaming", "kinesis"), + + E("cloudformation", "CloudFormation", "CloudFormation", "Management & observability", "cloudformation"), + E("cloudwatch", "CloudWatch", "CloudWatch", "Management & observability", "cloudwatch", "monitoring"), + E("logs", "CloudWatch Logs", "CloudWatch Logs", "Management & observability", "logs"), + E("ssm", "Systems Manager", "Systems Manager", "Management & observability", "ssm") + ]; + + internal static AdminCatalogEntry? Find(string id) => + Entries.FirstOrDefault(entry => string.Equals(entry.Id, id, StringComparison.OrdinalIgnoreCase)); + + internal static AdminService Describe(AdminCatalogEntry entry, ServiceRegistry registry) + { + var handler = registry.Resolve(entry.CanonicalHandler); + var source = handler as IAdminResourceSource; + var available = handler is not null; + return new(entry.Id, entry.Name, entry.Label, entry.Category, entry.Icon, + entry.CanonicalHandler, available ? "available" : "disabled", entry.Scope, + available ? source?.GetAdminNotice(entry.Id) : "This service is disabled.") + { + Kinds = source?.GetAdminResourceKinds(entry.Id) ?? [] + }; + } + + private static AdminCatalogEntry E( + string id, string name, string label, string category, string icon, + string? canonical = null, string? variant = null, string scope = "account") => + new(id, name, label, category, icon, canonical ?? id, variant, scope); +} diff --git a/src/MicroStack/Internal/Admin/AdminChangeHub.cs b/src/MicroStack/Internal/Admin/AdminChangeHub.cs new file mode 100644 index 0000000..4f82b3b --- /dev/null +++ b/src/MicroStack/Internal/Admin/AdminChangeHub.cs @@ -0,0 +1,111 @@ +using System.Threading.Channels; +using MicroStack.Admin.Contracts; + +namespace MicroStack.Internal.Admin; + +[Flags] +internal enum AdminDirty +{ + None = 0, Resources = 1, Accounts = 2, Instance = 4, Activity = 8, All = 15 +} + +internal sealed class AdminChangeHub : IDisposable +{ + internal const int MaxSubscribers = 64; + private readonly Lock _lock = new(); + private readonly HashSet _subscriptions = []; + private readonly string _epoch = Guid.NewGuid().ToString("N"); + private long _sequence; + private bool _disposed; + + internal Subscription? Subscribe(string? accountId) + { + lock (_lock) + { + if (_disposed || _subscriptions.Count >= MaxSubscribers) + return null; + var subscription = new Subscription(this, accountId); + _subscriptions.Add(subscription); + return subscription; + } + } + + internal void DispatchCompleted(string service, string accountId) + { + // These handlers have shared stores as well as account-owned resources. + // Unknown handlers have unknown dependency scope, so broaden rather than drop hints. + var global = service is "athena" or "events" or "firehose" + || !AdminCatalog.Entries.Any(entry => entry.CanonicalHandler == service); + Publish(AdminDirty.Resources | AdminDirty.Accounts, global ? null : accountId); + } + + internal void Publish(AdminDirty dirty, string? accountId = null) + { + lock (_lock) + { + if (_disposed) + return; + var sequence = ++_sequence; + foreach (var subscription in _subscriptions) + { + var filtered = accountId is not null && subscription.AccountId is not null + && accountId != subscription.AccountId + ? dirty & ~AdminDirty.Resources : dirty; + if (filtered == AdminDirty.None) + continue; + subscription.Dirty |= filtered; + subscription.Sequence = sequence; + subscription.Wakeup.Writer.TryWrite(true); + } + } + } + + private AdminChangeEvent Take(Subscription subscription, bool resync) + { + lock (_lock) + { + var dirty = resync ? AdminDirty.All : subscription.Dirty; + var sequence = resync ? _sequence : subscription.Sequence; + subscription.Dirty = AdminDirty.None; + subscription.Wakeup.Reader.TryRead(out _); + return new(1, _epoch, sequence, resync, + dirty.HasFlag(AdminDirty.Resources), dirty.HasFlag(AdminDirty.Accounts), + dirty.HasFlag(AdminDirty.Instance), dirty.HasFlag(AdminDirty.Activity)); + } + } + + private void Remove(Subscription subscription) + { + lock (_lock) + { + _subscriptions.Remove(subscription); + subscription.Wakeup.Writer.TryComplete(); + } + } + + public void Dispose() + { + lock (_lock) + { + _disposed = true; + foreach (var subscription in _subscriptions) + subscription.Wakeup.Writer.TryComplete(); + _subscriptions.Clear(); + } + } + + internal sealed class Subscription(AdminChangeHub owner, string? accountId) : IDisposable + { + internal string? AccountId { get; } = accountId; + internal AdminDirty Dirty { get; set; } + internal long Sequence { get; set; } + internal Channel Wakeup { get; } = Channel.CreateBounded(new BoundedChannelOptions(1) + { + FullMode = BoundedChannelFullMode.DropWrite, + SingleReader = true, + AllowSynchronousContinuations = false + }); + internal AdminChangeEvent Take(bool resync = false) => owner.Take(this, resync); + public void Dispose() => owner.Remove(this); + } +} diff --git a/src/MicroStack/Internal/Admin/AdminData.cs b/src/MicroStack/Internal/Admin/AdminData.cs new file mode 100644 index 0000000..c2a9579 --- /dev/null +++ b/src/MicroStack/Internal/Admin/AdminData.cs @@ -0,0 +1,191 @@ +using System.Buffers; +using System.Collections; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using MicroStack.Admin.Contracts; + +namespace MicroStack.Internal.Admin; + +internal static class AdminData +{ + public const int PreviewMaxBytes = 1_048_576; + public const string MaskedValue = "••••••••"; + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + + public static string IsoUtc(DateTimeOffset value) => + value.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.FFFFFFF'Z'", CultureInfo.InvariantCulture); + + public static string IsoUtc(DateTime value) => + value.Kind == DateTimeKind.Unspecified + ? throw new ArgumentException("An explicit UTC or local timestamp is required.", nameof(value)) + : IsoUtc(new DateTimeOffset(value)); + + public static AdminField Field( + string name, string? value, bool sensitive = false, bool canReveal = false, string? format = null, + bool secondary = false) => + new(name, sensitive ? MaskedValue : value, sensitive, canReveal, format) { Secondary = secondary }; + + public static AdminNode Node( + string kind, string id, string name, string? arn = null, string? status = null, string scope = "account", + string? type = null, IReadOnlyList? summary = null) => + new(new(new(kind, id), name, arn, status, scope) { Type = type, Summary = summary ?? [] }); + + public static AdminContent Text(string text, string contentType = "text/plain", bool sensitive = false) + { + var length = StrictUtf8.GetByteCount(text); + return length > PreviewMaxBytes + ? Oversized(contentType, length, sensitive) + : new("text", contentType, length, text, Sensitive: sensitive); + } + + public static AdminContent Text( + ReadOnlySpan utf8, string contentType = "text/plain", bool sensitive = false) => + utf8.Length > PreviewMaxBytes + ? Oversized(contentType, utf8.Length, sensitive) + : new("text", contentType, utf8.Length, StrictUtf8.GetString(utf8), Sensitive: sensitive); + + public static AdminContent Binary( + long? length, string contentType = "application/octet-stream", bool sensitive = false) => + new("binary", contentType, length, Reason: "Binary content is metadata-only.", Sensitive: sensitive); + + public static AdminContent Unavailable( + string reason, string contentType = "application/octet-stream", long? length = null, bool sensitive = false) => + new("unavailable", contentType, length, Reason: reason, Sensitive: sensitive); + + public static AdminContent Oversized( + string contentType, long? length = null, bool sensitive = false) => + new("oversized", contentType, length, Reason: "Content exceeds the 1 MiB preview limit.", Sensitive: sensitive); + + public static AdminContent Json(object? value, bool sensitive = false) => + Json(writer => WriteValue(writer, value, 0), sensitive); + + public static AdminContent Json(T value, JsonTypeInfo typeInfo, bool sensitive = false) => + Json(writer => JsonSerializer.Serialize(writer, value, typeInfo), sensitive); + + public static AdminContent Json(Action write, bool sensitive = false) + { + var buffer = new PreviewBuffer(); + try + { + using (var writer = new Utf8JsonWriter(buffer)) + { + write(writer); + writer.Flush(); + } + // Also rejects incomplete output from a custom projection callback. + using var document = JsonDocument.Parse(buffer.WrittenMemory); + return new("json", "application/json", buffer.WrittenMemory.Length, + StrictUtf8.GetString(buffer.WrittenMemory.Span), Sensitive: sensitive); + } + catch (PreviewLimitException) + { + return Oversized("application/json", sensitive: sensitive); + } + } + + public static AdminContent JsonText(string json, bool sensitive = false) + { + var length = StrictUtf8.GetByteCount(json); + if (length > PreviewMaxBytes) + return Oversized("application/json", length, sensitive); + using var document = JsonDocument.Parse(json); + return new("json", "application/json", length, json, Sensitive: sensitive); + } + + private static void WriteValue(Utf8JsonWriter writer, object? value, int depth) + { + if (depth > 64) + throw new JsonException("The explicit admin projection exceeds the maximum depth."); + switch (value) + { + case null: writer.WriteNullValue(); break; + case JsonElement element: element.WriteTo(writer); break; + case string text: writer.WriteStringValue(text); break; + case bool boolean: writer.WriteBooleanValue(boolean); break; + case byte number: writer.WriteNumberValue(number); break; + case sbyte number: writer.WriteNumberValue(number); break; + case short number: writer.WriteNumberValue(number); break; + case ushort number: writer.WriteNumberValue(number); break; + case int number: writer.WriteNumberValue(number); break; + case uint number: writer.WriteNumberValue(number); break; + case long number: writer.WriteNumberValue(number); break; + case ulong number: writer.WriteNumberValue(number); break; + case float number: writer.WriteNumberValue(number); break; + case double number: writer.WriteNumberValue(number); break; + case decimal number: writer.WriteNumberValue(number); break; + case IReadOnlyDictionary dictionary: + writer.WriteStartObject(); + foreach (var (key, item) in dictionary) + { + writer.WritePropertyName(key); + WriteValue(writer, item, depth + 1); + } + writer.WriteEndObject(); + break; + case IReadOnlyDictionary dictionary: + writer.WriteStartObject(); + foreach (var (key, item) in dictionary) + writer.WriteString(key, item); + writer.WriteEndObject(); + break; + case IDictionary dictionary: + writer.WriteStartObject(); + foreach (DictionaryEntry entry in dictionary) + { + if (entry.Key is not string key) + throw new NotSupportedException("Admin JSON dictionary keys must be strings."); + writer.WritePropertyName(key); + WriteValue(writer, entry.Value, depth + 1); + } + writer.WriteEndObject(); + break; + case Array array: + writer.WriteStartArray(); + foreach (var item in array) + WriteValue(writer, item, depth + 1); + writer.WriteEndArray(); + break; + default: + throw new NotSupportedException( + "Admin JSON requires explicit primitives, string-keyed dictionaries, arrays, JsonElement, or JsonTypeInfo."); + } + } + + private sealed class PreviewLimitException : Exception; + + private sealed class PreviewBuffer : IBufferWriter + { + // Utf8JsonWriter reserves up to six bytes per UTF-16 character before + // escaping. Permit that bounded reservation; enforce the exact cap on Advance. + private const int MaxReservation = (PreviewMaxBytes + 1) * 6 + 4096; + private byte[] _buffer = new byte[4096]; + private int _written; + public ReadOnlyMemory WrittenMemory => _buffer.AsMemory(0, _written); + + public void Advance(int count) + { + if (count < 0 || count > _buffer.Length - _written) + throw new ArgumentOutOfRangeException(nameof(count)); + if (count > PreviewMaxBytes - _written) + throw new PreviewLimitException(); + _written += count; + } + + public Memory GetMemory(int sizeHint = 0) + { + if (sizeHint < 0) + throw new ArgumentOutOfRangeException(nameof(sizeHint)); + sizeHint = Math.Max(1, sizeHint); + if (sizeHint > MaxReservation - _written) + throw new PreviewLimitException(); + var required = _written + sizeHint; + if (required > _buffer.Length) + Array.Resize(ref _buffer, Math.Min(MaxReservation, Math.Max(required, _buffer.Length * 2))); + return _buffer.AsMemory(_written); + } + + public Span GetSpan(int sizeHint = 0) => GetMemory(sizeHint).Span; + } +} diff --git a/src/MicroStack/Internal/Admin/AdminEvents.cs b/src/MicroStack/Internal/Admin/AdminEvents.cs new file mode 100644 index 0000000..ac97444 --- /dev/null +++ b/src/MicroStack/Internal/Admin/AdminEvents.cs @@ -0,0 +1,86 @@ +using System.Text.Json; +using MicroStack.Admin.Contracts; +using Microsoft.AspNetCore.Http.Features; + +namespace MicroStack.Internal.Admin; + +internal static class AdminEvents +{ + internal static async Task Stream(HttpContext context, AdminChangeHub hub, + IHostApplicationLifetime lifetime) + { + string? account = null; + if (context.Request.Query.TryGetValue("accountId", out var values)) + { + account = values.ToString(); + if (values.Count != 1 || account.Length != 12 || account.Any(c => c is < '0' or > '9')) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsJsonAsync( + new AdminError("invalid_account", "Account IDs must contain exactly 12 digits."), + AdminJsonContext.Default.AdminError, cancellationToken: context.RequestAborted); + return; + } + } + + using var subscription = hub.Subscribe(account); + if (subscription is null) + { + context.Response.StatusCode = 503; + context.Response.Headers.RetryAfter = "5"; + await context.Response.WriteAsJsonAsync( + new AdminError("events_capacity", "Live updates are at capacity. Retry shortly."), + AdminJsonContext.Default.AdminError, cancellationToken: context.RequestAborted); + return; + } + + context.Response.ContentType = "text/event-stream"; + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers["X-Accel-Buffering"] = "no"; + context.Features.Get()?.DisableBuffering(); + using var stopped = CancellationTokenSource.CreateLinkedTokenSource( + context.RequestAborted, lifetime.ApplicationStopping); + try + { + await WriteEvent(subscription.Take(resync: true)); + while (!stopped.IsCancellationRequested) + { + using var heartbeat = CancellationTokenSource.CreateLinkedTokenSource(stopped.Token); + heartbeat.CancelAfter(TimeSpan.FromSeconds(15)); + bool ready; + try + { + ready = await subscription.Wakeup.Reader.WaitToReadAsync(heartbeat.Token); + } + catch (OperationCanceledException) when (!stopped.IsCancellationRequested) + { + await Write(": heartbeat\n\n"); + continue; + } + if (!ready) + break; + await Task.Delay(TimeSpan.FromMilliseconds(500), stopped.Token); + await WriteEvent(subscription.Take()); + } + } + catch (OperationCanceledException) + { + context.Abort(); + } + catch (IOException) + { + context.Abort(); + } + + Task WriteEvent(AdminChangeEvent change) => Write( + $"id: {change.Epoch}:{change.Sequence}\nevent: change\ndata: {JsonSerializer.Serialize(change, AdminJsonContext.Default.AdminChangeEvent)}\n\n"); + + async Task Write(string text) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stopped.Token); + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + await context.Response.WriteAsync(text, timeout.Token); + await context.Response.Body.FlushAsync(timeout.Token); + } + } +} diff --git a/src/MicroStack/Internal/Admin/AdminNode.cs b/src/MicroStack/Internal/Admin/AdminNode.cs new file mode 100644 index 0000000..f78cfc7 --- /dev/null +++ b/src/MicroStack/Internal/Admin/AdminNode.cs @@ -0,0 +1,15 @@ +using MicroStack.Admin.Contracts; + +namespace MicroStack.Internal.Admin; + +internal sealed record AdminNode(AdminResourceSummary Resource) +{ + public Func>? ReadFields { get; init; } + public Func>? ReadSummary { get; init; } + public IReadOnlyList ChildKinds { get; init; } = []; + public Func>? ReadChildren { get; init; } + public Func>? ReadConnections { get; init; } + public Func? ReadContent { get; init; } + public Func? RevealField { get; init; } + public IReadOnlyList RevealableFields { get; init; } = []; +} diff --git a/src/MicroStack/Internal/Admin/AdminRelationshipResolver.cs b/src/MicroStack/Internal/Admin/AdminRelationshipResolver.cs new file mode 100644 index 0000000..7f83830 --- /dev/null +++ b/src/MicroStack/Internal/Admin/AdminRelationshipResolver.cs @@ -0,0 +1,156 @@ +using MicroStack.Admin.Contracts; + +namespace MicroStack.Internal.Admin; + +internal interface IAdminRelationshipSource +{ + AdminRelationshipSnapshot GetAdminRelationshipSnapshot(); +} + +internal sealed record AdminRelationshipResource( + AdminKey[] Path, string Name, string? Status = null, string? Arn = null); + +internal sealed record AdminConfiguredRelationship( + string SourceServiceId, AdminKey[] SourcePath, string SourceName, AdminConnection Connection); + +internal sealed record AdminRelationshipSnapshot( + IReadOnlyList Resources, + IReadOnlyList Relationships); + +// Capture each provider independently. Only retained configuration is inspected; +// these snapshots are neither a delivery history nor an atomic cross-service view. +internal sealed class AdminRelationshipResolver(ServiceRegistry registry, string account) +{ + private readonly Dictionary _snapshots = new(StringComparer.Ordinal); + + public IReadOnlyList Read(AdminNode node, string service, AdminKey[] path) + { + var relationships = new List(); + var configuredHierarchy = service switch + { + "sqs" => path[0].Kind == "queues" && path.Length == 1, + "sns" => path[0].Kind == "topics", + "events" => path[0].Kind == "event-buses", + _ => false + }; + if (configuredHierarchy) + { + foreach (var edge in Snapshot(service).Relationships) + { + if (!SameAccount(edge.SourcePath) || !IsPrefix(path, edge.SourcePath) + || edge.Connection.Relation == "belongs-to" && !SamePath(path, edge.SourcePath)) + continue; + relationships.Add(Resolve(edge.Connection with + { + Label = SamePath(path, edge.SourcePath) ? edge.Connection.Label + : $"{edge.SourceName}: {edge.Connection.Label}", + SourceServiceId = edge.SourceServiceId, + SourcePath = edge.SourcePath + })); + } + + if (service == "sqs") + { + foreach (var producer in new[] { "sns", "events", "sqs" }) + foreach (var edge in Snapshot(producer).Relationships) + { + if (!SameAccount(edge.SourcePath) || edge.Connection.TargetServiceId != "sqs" + || edge.Connection.TargetPath is not { } target || !SamePath(path, target)) + continue; + relationships.Add(Resolve(new(edge.SourceName, + edge.Connection.Relation == "redrive" ? "redrive-source" : "configured-source", + producer, edge.SourcePath) + { + SourceServiceId = edge.SourceServiceId, + SourcePath = edge.SourcePath + })); + } + } + } + else + { + var direct = node.ReadConnections?.Invoke() ?? []; + relationships.AddRange(service is "sqs" or "sns" or "events" ? direct.Select(Resolve) : direct); + } + + return relationships.DistinctBy(Identity, StringComparer.Ordinal) + .OrderBy(connection => connection.Label, StringComparer.Ordinal) + .ThenBy(connection => connection.Relation, StringComparer.Ordinal) + .ThenBy(Identity, StringComparer.Ordinal).ToArray(); + } + + private AdminRelationshipSnapshot Snapshot(string service) + { + if (_snapshots.TryGetValue(service, out var snapshot)) + return snapshot; + var entry = AdminCatalog.Find(service); + var handler = entry is null ? null : registry.Resolve(entry.CanonicalHandler); + snapshot = handler is IAdminRelationshipSource source + ? source.GetAdminRelationshipSnapshot() : new([], []); + _snapshots.Add(service, snapshot); + return snapshot; + } + + private AdminConnection Resolve(AdminConnection connection) + { + if (connection.ExternalUri is not null) + return connection with { State = "external" }; + if (connection.TargetServiceId is not { } targetService || connection.TargetPath is not { Length: > 0 } path) + return connection with + { + State = connection.State == "external" || IsOtherAccount(connection.Label) ? "external" : "unavailable" + }; + var entry = AdminCatalog.Find(targetService) + ?? AdminCatalog.Entries.FirstOrDefault(item => + string.Equals(item.CanonicalHandler, + ServiceRegistry.Aliases.GetValueOrDefault(targetService, targetService), StringComparison.Ordinal)); + if (entry is null) + return connection with { State = "unavailable" }; + connection = connection with { TargetServiceId = entry.Id }; + if (path.Any(key => IsOtherAccount(key.Id))) + return connection with { State = "external" }; + var handler = registry.Resolve(entry.CanonicalHandler); + if (handler is null) + return connection with { State = "disabled" }; + if (handler is IAdminRelationshipSource) + { + var match = Snapshot(entry.Id).Resources.FirstOrDefault(resource => SamePath(path, resource.Path) + || path.Length == 1 && resource.Path.Length == 1 && path[0].Kind == resource.Path[0].Kind + && path[0].Id == resource.Arn); + var disabled = Snapshot(entry.Id).Resources.Any(resource => + IsPrefix(resource.Path, match?.Path ?? path) && IsDisabled(resource.Status)); + return connection with + { + TargetPath = match?.Path ?? path, + State = match is null ? "missing" : disabled ? "disabled" : "configured" + }; + } + return connection with { State = "unavailable" }; + } + + private bool SameAccount(AdminKey[] path) => !path.Any(key => IsOtherAccount(key.Id)); + + private bool IsOtherAccount(string identity) + { + var parts = identity.Split(':', 6); + return parts.Length == 6 && parts[0] == "arn" && parts[4].Length != 0 && parts[4] != account; + } + + private static bool IsDisabled(string? status) => + string.Equals(status, "disabled", StringComparison.OrdinalIgnoreCase) + || string.Equals(status, "inactive", StringComparison.OrdinalIgnoreCase); + + private static bool SamePath(AdminKey[] left, AdminKey[] right) => + left.Length == right.Length && IsPrefix(left, right); + + private static bool IsPrefix(AdminKey[] prefix, AdminKey[] path) => + prefix.Length <= path.Length && prefix.Where((key, index) => key != path[index]).Any() == false; + + private static string PathIdentity(AdminKey[]? path) => path is null ? "" + : string.Concat(path.Select(key => $"{key.Kind.Length}:{key.Kind}{key.Id.Length}:{key.Id}")); + + private static string Identity(AdminConnection connection) => + $"{connection.Relation.Length}:{connection.Relation}|{connection.TargetServiceId}|{PathIdentity(connection.TargetPath)}" + + $"|{connection.ExternalUri}|{connection.SourceServiceId}|{PathIdentity(connection.SourcePath)}" + + (connection.SourcePath is null ? $"|{connection.Label}" : ""); +} diff --git a/src/MicroStack/Internal/Admin/IAdminResourceSource.cs b/src/MicroStack/Internal/Admin/IAdminResourceSource.cs new file mode 100644 index 0000000..a1e42d7 --- /dev/null +++ b/src/MicroStack/Internal/Admin/IAdminResourceSource.cs @@ -0,0 +1,10 @@ +using MicroStack.Admin.Contracts; + +namespace MicroStack.Internal.Admin; + +internal interface IAdminResourceSource : IKnownAccountSource +{ + IReadOnlyList GetAdminResourceKinds(string serviceId); + IEnumerable GetAdminResources(string serviceId); + string? GetAdminNotice(string serviceId) => null; +} diff --git a/src/MicroStack/Internal/AdminUiHosting.cs b/src/MicroStack/Internal/AdminUiHosting.cs new file mode 100644 index 0000000..ac4fb33 --- /dev/null +++ b/src/MicroStack/Internal/AdminUiHosting.cs @@ -0,0 +1,137 @@ +using Microsoft.Net.Http.Headers; + +namespace MicroStack.Internal; + +internal static class AdminUiHosting +{ + internal const string PathPrefix = "/ui"; + + internal static bool IsGatewayHost(HttpRequest request, MicroStackOptions options) + { + var host = request.Host.Host; + return host.Equals(options.Host, StringComparison.OrdinalIgnoreCase) + || host.Equals("localhost", StringComparison.OrdinalIgnoreCase) + || host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase) + || host.Equals("::1", StringComparison.OrdinalIgnoreCase); + } + + internal static bool IsUiRequest(HttpContext context, MicroStackOptions options) => + IsGatewayHost(context.Request, options) + && context.Request.Path.StartsWithSegments(PathPrefix); + + internal static bool ShouldRedirectRoot(HttpRequest request, MicroStackOptions options) + { + if (!HttpMethods.IsGet(request.Method) + || request.Path != "/" + || request.QueryString.HasValue + || !IsGatewayHost(request, options) + || request.ContentLength is > 0 + || request.Headers.ContainsKey(HeaderNames.TransferEncoding) + || request.Headers.ContainsKey(HeaderNames.Authorization) + || request.Headers.ContainsKey("X-Amz-Target") + || request.Headers.Keys.Any(key => key.StartsWith("X-Amz-", StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + var acceptsHtml = request.GetTypedHeaders().Accept?.Any(value => + value.MediaType.Equals("text/html", StringComparison.OrdinalIgnoreCase) + && value.Quality.GetValueOrDefault(1) > 0) == true; + if (!acceptsHtml) + return false; + + var fetchMode = request.Headers["Sec-Fetch-Mode"].ToString(); + var fetchDestination = request.Headers["Sec-Fetch-Dest"].ToString(); + return (fetchMode.Length == 0 || fetchMode.Equals("navigate", StringComparison.OrdinalIgnoreCase)) + && (fetchDestination.Length == 0 || fetchDestination.Equals("document", StringComparison.OrdinalIgnoreCase)); + } + + internal static bool Map(WebApplication app, MicroStackOptions options) + { + if (!app.Environment.WebRootFileProvider.GetFileInfo("ui/index.html").Exists) + return false; + + app.Use(async (context, next) => + { + if (ShouldRedirectRoot(context.Request, options)) + { + context.Response.StatusCode = StatusCodes.Status302Found; + context.Response.Headers.Location = "/ui/"; + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers.Vary = $"{HeaderNames.Accept}, Sec-Fetch-Mode, Sec-Fetch-Dest"; + return; + } + + await next(context); + }); + + const string originalPathKey = "MicroStack.AdminUi.OriginalPath"; + app.Use(async (context, next) => + { + // Static-file middleware has no host predicate; hide service-host paths until AWS routing. + if (context.Request.Path.StartsWithSegments(PathPrefix) + && !IsGatewayHost(context.Request, options)) + { + context.Items[originalPathKey] = context.Request.Path; + context.Request.Path = "/__microstack_aws_passthrough" + context.Request.Path; + } + + await next(context); + }); + + app.UseBlazorFrameworkFiles(PathPrefix); + app.UseStaticFiles(); + + app.Use(async (context, next) => + { + if (context.Items.Remove(originalPathKey, out var originalPath)) + context.Request.Path = (PathString)originalPath!; + + if (IsUiRequest(context, options)) + { + if (context.Request.Path == PathPrefix) + { + context.Response.StatusCode = StatusCodes.Status308PermanentRedirect; + context.Response.Headers.Location = PathPrefix + "/" + context.Request.QueryString; + return; + } + + if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method)) + { + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + context.Response.Headers.Allow = "GET, HEAD"; + return; + } + + if (Path.HasExtension(context.Request.Path.Value)) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + await ServeIndex(context); + return; + } + + await next(context); + }); + + return true; + } + + private static async Task ServeIndex(HttpContext context) + { + var environment = context.RequestServices.GetRequiredService(); + var index = environment.WebRootFileProvider.GetFileInfo("ui/index.html"); + if (!index.Exists) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await context.Response.WriteAsync("The MicroStack UI assets are unavailable.", context.RequestAborted); + return; + } + + context.Response.ContentType = "text/html; charset=utf-8"; + context.Response.Headers.CacheControl = "no-cache"; + await context.Response.SendFileAsync(index, context.RequestAborted); + } +} diff --git a/src/MicroStack/Internal/AwsRequestMiddleware.cs b/src/MicroStack/Internal/AwsRequestMiddleware.cs index 53caf54..c7361af 100644 --- a/src/MicroStack/Internal/AwsRequestMiddleware.cs +++ b/src/MicroStack/Internal/AwsRequestMiddleware.cs @@ -1,4 +1,5 @@ using System.Web; +using MicroStack.Internal.Admin; namespace MicroStack.Internal; @@ -19,6 +20,7 @@ internal sealed class AwsRequestMiddleware private readonly AwsServiceRouter _router; private readonly ServiceRegistry _registry; private readonly ILogger _logger; + private readonly AdminChangeHub? _changes; private static readonly string _host = MicroStackOptions.Instance.Host; @@ -43,12 +45,14 @@ public AwsRequestMiddleware( RequestDelegate next, AwsServiceRouter router, ServiceRegistry registry, - ILogger logger) + ILogger logger, + AdminChangeHub? changes = null) { _next = next; _router = router; _registry = registry; _logger = logger; + _changes = changes; } // Must be public: ASP.NET Core UseMiddleware convention requires a public Invoke/InvokeAsync method. @@ -231,6 +235,7 @@ private async Task DispatchToService(HttpContext context, ServiceRequest request } else { + var accountId = AccountContext.GetAccountId(); try { response = await handler.HandleAsync(request); @@ -240,6 +245,10 @@ private async Task DispatchToService(HttpContext context, ServiceRequest request _logger.LogError(ex, "Error handling request for service '{Service}'", serviceName); response = ServiceResponse.Empty(500); } + finally + { + _changes?.DispatchCompleted(handler.ServiceName, accountId); + } } await WriteResponse(context.Response, response); diff --git a/src/MicroStack/Internal/IKnownAccountSource.cs b/src/MicroStack/Internal/IKnownAccountSource.cs new file mode 100644 index 0000000..c87c955 --- /dev/null +++ b/src/MicroStack/Internal/IKnownAccountSource.cs @@ -0,0 +1,11 @@ +namespace MicroStack.Internal; + +/// +/// Enumerates owners of retained resources, independently of the current request. +/// Implementations must not create defaults, inspect payloads, or infer ownership +/// for instance-global state. Secondary indexes and empty containers are not resources. +/// +internal interface IKnownAccountSource +{ + IEnumerable GetKnownAccountIds() => []; +} diff --git a/src/MicroStack/Internal/IResourceProvider.cs b/src/MicroStack/Internal/IResourceProvider.cs new file mode 100644 index 0000000..6110c71 --- /dev/null +++ b/src/MicroStack/Internal/IResourceProvider.cs @@ -0,0 +1,16 @@ +namespace MicroStack.Internal; + +internal interface IResourceProvider +{ + ResourceSummary GetResources(); +} + +internal sealed record ResourceSummary( + string Service, + int Count, + IReadOnlyList Items); + +internal sealed record ResourceItem( + string Name, + string Arn, + Dictionary? Attributes); diff --git a/src/MicroStack/Internal/MicroStackJsonContext.cs b/src/MicroStack/Internal/MicroStackJsonContext.cs index 8ab1d77..7bd71e2 100644 --- a/src/MicroStack/Internal/MicroStackJsonContext.cs +++ b/src/MicroStack/Internal/MicroStackJsonContext.cs @@ -4,6 +4,7 @@ using MicroStack.Services.Ssm; using MicroStack.Services.Sns; using MicroStack.Services.Sqs; +using MicroStack.Admin.Contracts; namespace MicroStack.Internal; @@ -24,6 +25,21 @@ namespace MicroStack.Internal; [JsonSerializable(typeof(HealthResponse))] [JsonSerializable(typeof(ResetResponse))] [JsonSerializable(typeof(ConfigResponse))] +[JsonSerializable(typeof(RequestLogClearResponse))] +[JsonSerializable(typeof(AdminContext))] +[JsonSerializable(typeof(AdminService[]))] +[JsonSerializable(typeof(AdminResourceDetail))] +[JsonSerializable(typeof(AdminContent))] +[JsonSerializable(typeof(AdminError))] +[JsonSerializable(typeof(AdminPage))] +[JsonSerializable(typeof(AdminPage))] +[JsonSerializable(typeof(AdminPage))] +// Request log endpoint +[JsonSerializable(typeof(List))] +// Resource explorer endpoint +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ResourceSummary))] +[JsonSerializable(typeof(ResourceItem))] // AwsResponseHelpers error type [JsonSerializable(typeof(AwsJsonError))] // SecretsManager persistence diff --git a/src/MicroStack/Internal/MicroStackOptions.cs b/src/MicroStack/Internal/MicroStackOptions.cs index 8a43d2e..a8a1c44 100644 --- a/src/MicroStack/Internal/MicroStackOptions.cs +++ b/src/MicroStack/Internal/MicroStackOptions.cs @@ -25,7 +25,18 @@ internal sealed class MicroStackOptions internal string Region { get; set; } = "us-east-1"; - internal string DefaultAccountId { get; set; } = "000000000000"; + private string _defaultAccountId = "000000000000"; + + internal string DefaultAccountId + { + get => _defaultAccountId; + set + { + if (value is null || value.Length != 12 || value.Any(c => c is < '0' or > '9')) + throw new ArgumentException("The default account ID must contain exactly 12 ASCII digits.", nameof(value)); + _defaultAccountId = value; + } + } internal bool PersistState { get; set; } diff --git a/src/MicroStack/Internal/RequestLog.cs b/src/MicroStack/Internal/RequestLog.cs new file mode 100644 index 0000000..4232023 --- /dev/null +++ b/src/MicroStack/Internal/RequestLog.cs @@ -0,0 +1,79 @@ +using MicroStack.Internal.Admin; + +namespace MicroStack.Internal; + +internal sealed record RequestLogEntry( + string Service, + string Action, + string AccountId, + DateTimeOffset Timestamp, + int StatusCode, + long DurationMs); + +/// +/// In-memory fixed-size request log for admin diagnostics. +/// +internal sealed class RequestLog +{ + private readonly RequestLogEntry[] _entries; + private int _nextIndex; + private int _count; + private readonly Lock _lock = new(); + private readonly AdminChangeHub? _changes; + + internal RequestLog(int capacity = 1000, AdminChangeHub? changes = null) + { + if (capacity <= 0) + throw new ArgumentOutOfRangeException(nameof(capacity), "Capacity must be greater than zero."); + + _entries = new RequestLogEntry[capacity]; + _changes = changes; + } + + internal void Add(RequestLogEntry entry) + { + lock (_lock) + { + _entries[_nextIndex] = entry; + _nextIndex = (_nextIndex + 1) % _entries.Length; + if (_count < _entries.Length) + _count++; + } + _changes?.Publish(AdminDirty.Activity); + } + + internal List GetEntries(int limit = 1000) + { + if (limit <= 0) + return []; + + lock (_lock) + { + if (_count == 0) + return []; + + var take = Math.Min(limit, _count); + var result = new List(take); + var newestIndex = (_nextIndex - 1 + _entries.Length) % _entries.Length; + + for (var i = 0; i < take; i++) + { + var index = (newestIndex - i + _entries.Length) % _entries.Length; + result.Add(_entries[index]); + } + + return result; + } + } + + internal void Clear() + { + lock (_lock) + { + Array.Clear(_entries); + _nextIndex = 0; + _count = 0; + } + _changes?.Publish(AdminDirty.Activity); + } +} diff --git a/src/MicroStack/Internal/RequestLogMiddleware.cs b/src/MicroStack/Internal/RequestLogMiddleware.cs new file mode 100644 index 0000000..049827e --- /dev/null +++ b/src/MicroStack/Internal/RequestLogMiddleware.cs @@ -0,0 +1,81 @@ +using System.Diagnostics; + +namespace MicroStack.Internal; + +internal sealed class RequestLogMiddleware +{ + private readonly RequestDelegate _next; + private readonly AwsServiceRouter _router; + private readonly RequestLog _requestLog; + + public RequestLogMiddleware(RequestDelegate next, AwsServiceRouter router, RequestLog requestLog) + { + _next = next; + _router = router; + _requestLog = requestLog; + } + + public async Task InvokeAsync(HttpContext context) + { + if (!ShouldLog(context.Request)) + { + await _next(context); + return; + } + + var timestamp = DateTimeOffset.UtcNow; + var sw = Stopwatch.StartNew(); + await _next(context); + sw.Stop(); + + var request = context.Request; + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, values) in request.Headers) + headers[key] = values.ToString(); + + var queryParams = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, values) in request.Query) + queryParams[key] = values.Select(v => v ?? string.Empty).ToArray(); + + var service = _router.DetectService(new ServiceRequest( + request.Method.ToUpperInvariant(), + request.Path.Value ?? "/", + headers, + [], + queryParams)); + + _requestLog.Add(new RequestLogEntry( + service, + ExtractAction(request), + AccountContext.GetAccountId(), + timestamp, + context.Response.StatusCode, + sw.ElapsedMilliseconds)); + } + + private static bool ShouldLog(HttpRequest request) + { + var path = request.Path.Value ?? "/"; + if (request.Method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)) + return false; + + return !path.StartsWith("/_microstack/", StringComparison.OrdinalIgnoreCase) + && !path.Equals("/health", StringComparison.OrdinalIgnoreCase) + && !path.Equals("/_localstack/health", StringComparison.OrdinalIgnoreCase); + } + + private static string ExtractAction(HttpRequest request) + { + var target = request.Headers["x-amz-target"].ToString(); + if (!string.IsNullOrEmpty(target)) + { + var dot = target.LastIndexOf('.'); + return dot >= 0 && dot < target.Length - 1 + ? target[(dot + 1)..] + : target; + } + + var action = request.Query["Action"].ToString(); + return string.IsNullOrEmpty(action) ? "-" : action; + } +} diff --git a/src/MicroStack/Internal/ServiceRegistry.cs b/src/MicroStack/Internal/ServiceRegistry.cs index d98a92f..7737912 100644 --- a/src/MicroStack/Internal/ServiceRegistry.cs +++ b/src/MicroStack/Internal/ServiceRegistry.cs @@ -72,6 +72,15 @@ internal void Register(IServiceHandler handler) internal IReadOnlyCollection All => _handlers.Values; + internal string[] GetKnownAccountIds(string defaultAccountId) => + _handlers.Values.OfType() + .SelectMany(source => source.GetKnownAccountIds()) + .Append(defaultAccountId) + .Where(account => account is { Length: 12 } && account.All(c => c is >= '0' and <= '9')) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + internal void ResetAll() { foreach (var handler in _handlers.Values) diff --git a/src/MicroStack/Internal/TimeHelpers.cs b/src/MicroStack/Internal/TimeHelpers.cs index ea13d89..b650727 100644 --- a/src/MicroStack/Internal/TimeHelpers.cs +++ b/src/MicroStack/Internal/TimeHelpers.cs @@ -1,3 +1,5 @@ +using System.Globalization; + namespace MicroStack.Internal; /// @@ -8,17 +10,21 @@ internal static class TimeHelpers { /// Current UTC time in AWS ISO 8601 format: 2024-01-15T12:34:56.789Z internal static string NowIso() => - DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fff") + "Z"; + DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fff'Z'", CultureInfo.InvariantCulture); /// Current UTC time in RFC 7231 format for HTTP headers: Mon, 15 Jan 2024 12:34:56 GMT internal static string NowRfc7231() => - DateTime.UtcNow.ToString("ddd, dd MMM yyyy HH:mm:ss") + " GMT"; + DateTime.UtcNow.ToString("R", CultureInfo.InvariantCulture); /// Convert an ISO 8601 timestamp to RFC 7231 format. internal static string IsoToRfc7231(string isoStr) { - if (DateTimeOffset.TryParse(isoStr, out var dt)) - return dt.UtcDateTime.ToString("ddd, dd MMM yyyy HH:mm:ss") + " GMT"; + if (DateTimeOffset.TryParse( + isoStr, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, + out var dt)) + return dt.UtcDateTime.ToString("R", CultureInfo.InvariantCulture); return isoStr; } diff --git a/src/MicroStack/MicroStack.csproj b/src/MicroStack/MicroStack.csproj index 8bc10e1..ade7732 100644 --- a/src/MicroStack/MicroStack.csproj +++ b/src/MicroStack/MicroStack.csproj @@ -16,9 +16,8 @@ - - docker.io/alpine:3.21 + mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine microstack $(MinVerVersion);latest @@ -28,10 +27,43 @@ + + + + <_AdminUiStaging>$([MSBuild]::NormalizeDirectory('$(IntermediateOutputPath)', 'admin-ui-publish')) + + + + + <_AdminUiFiles Include="$(_AdminUiStaging)wwwroot\ui\**\*" /> + + + + + + + + + + + diff --git a/src/MicroStack/Program.cs b/src/MicroStack/Program.cs index 5c0cfcc..fdd1cf7 100644 --- a/src/MicroStack/Program.cs +++ b/src/MicroStack/Program.cs @@ -37,8 +37,10 @@ using MicroStack.Services.CloudFormation; using MicroStack.Services.Cognito; using MicroStack.Services.S3Files; +using MicroStack.Internal.Admin; var builder = WebApplication.CreateBuilder(args); +builder.WebHost.UseStaticWebAssets(); // Configure JSON serialization for minimal API endpoints (required for native AOT) builder.Services.ConfigureHttpJsonOptions(jsonOptions => @@ -50,7 +52,12 @@ var options = MicroStackOptions.BindFromEnvironment(); builder.Services.AddSingleton(options); -builder.WebHost.UseUrls($"http://0.0.0.0:{options.GatewayPort}"); +const string adminCorsPolicy = "MicroStackAdmin"; +builder.Services.AddCors(cors => cors.AddPolicy(adminCorsPolicy, policy => + policy.SetIsOriginAllowed(_ => false).WithMethods("GET", "POST", "DELETE").AllowAnyHeader())); + +if (!builder.Environment.IsEnvironment("Testing")) + builder.WebHost.UseUrls($"http://0.0.0.0:{options.GatewayPort}"); // Configure static services that can't use constructor injection AccountContext.Configure(options); @@ -60,6 +67,8 @@ // so we use factory lambdas instead of letting DI locate a public constructor. builder.Services.AddSingleton(); builder.Services.AddSingleton(_ => new ServiceRegistry(options)); +builder.Services.AddSingleton(_ => new AdminChangeHub()); +builder.Services.AddSingleton(sp => new RequestLog(changes: sp.GetRequiredService())); builder.Services.AddSingleton(sp => new StatePersistence( sp.GetRequiredService>(), sp.GetRequiredService(), @@ -67,9 +76,14 @@ var app = builder.Build(); -// Restore persisted state on startup +var adminUiEnabled = AdminUiHosting.Map(app, options); +foreach (var obsoleteSetting in new[] { "MICROSTACK_UI_PORT", "MICROSTACK_API_URL" }) +{ + if (Environment.GetEnvironmentVariable(obsoleteSetting) is not null) + app.Logger.LogWarning("{Setting} is ignored because the UI is served from the gateway at /ui/.", obsoleteSetting); +} + var persistence = app.Services.GetRequiredService(); -persistence.RestoreAll(); // Wire up admin endpoints before the main AWS middleware var registry = app.Services.GetRequiredService(); @@ -87,7 +101,8 @@ registry.Register(new SecretsManagerServiceHandler()); registry.Register(new SsmServiceHandler()); registry.Register(new KmsServiceHandler()); -var lambdaHandler = new LambdaServiceHandler(sqsHandler, ddbHandler); +var lambdaHandler = new LambdaServiceHandler(sqsHandler, ddbHandler, + app.Services.GetRequiredService()); registry.Register(lambdaHandler); registry.Register(new ApiGatewayV2ServiceHandler(lambdaHandler)); var sfnHandler = new StepFunctionsServiceHandler(lambdaHandler, registry); @@ -122,6 +137,11 @@ registry.Register(new CloudFormationServiceHandler(registry)); registry.Register(new S3FilesServiceHandler()); +// Restore only after registration so persisted resources are loaded into the live handlers. +persistence.RestoreAll(); + +app.MapAdminApi(registry, app.Services.GetRequiredService(), options, adminCorsPolicy); + // Health endpoint (multiple aliases for LocalStack compatibility) foreach (var healthPath in new[] { "/_microstack/health", "/health", "/_localstack/health" }) { @@ -129,16 +149,23 @@ { var services = registry.GetServiceStatus(); return Results.Ok(new HealthResponse(services, "light", "0.1.0")); - }); + }).RequireCors(adminCorsPolicy); } // Reset endpoint app.MapPost("/_microstack/reset", () => { - registry.ResetAll(); - persistence.DeleteAll(); - return Results.Ok(new ResetResponse("ok")); -}); + try + { + registry.ResetAll(); + persistence.DeleteAll(); + return Results.Ok(new ResetResponse("ok")); + } + finally + { + app.Services.GetRequiredService().Publish(AdminDirty.All); + } +}).RequireCors(adminCorsPolicy); // Config endpoint (stub — populated when services implement it) app.MapPost("/_microstack/config", async (HttpContext ctx) => @@ -156,7 +183,15 @@ && sfnEl.TryGetProperty("_sfn_mock_config", out var mockEl)) { var mockConfig = DictionaryObjectJsonConverter.DeserializeElementDeep(mockEl); - sfnHandler.SetMockConfig(mockConfig); + try + { + sfnHandler.SetMockConfig(mockConfig); + } + finally + { + app.Services.GetRequiredService() + .Publish(AdminDirty.Resources | AdminDirty.Instance); + } applied["stepfunctions._sfn_mock_config"] = "applied"; } } @@ -167,11 +202,66 @@ } return Results.Ok(new ConfigResponse(applied)); -}); +}).RequireCors(adminCorsPolicy); + +// Request log endpoint +app.MapGet("/_microstack/requests", (HttpContext ctx) => +{ + var limitText = ctx.Request.Query["limit"].ToString(); + var limit = int.TryParse(limitText, out var parsed) ? parsed : 1000; + var requestLog = ctx.RequestServices.GetRequiredService(); + return Results.Ok(requestLog.GetEntries(limit)); +}).RequireCors(adminCorsPolicy); + +// Request log clear endpoint +app.MapDelete("/_microstack/requests", (RequestLog requestLog) => +{ + requestLog.Clear(); + return Results.Ok(new RequestLogClearResponse(true)); +}).RequireCors(adminCorsPolicy); + +// Resource explorer endpoint +app.MapGet("/_microstack/resources", (ServiceRegistry serviceRegistry) => +{ + var resources = serviceRegistry.All + .OfType() + .Select(provider => provider.GetResources()) + .OrderBy(summary => summary.Service, StringComparer.Ordinal) + .ToList(); + return Results.Ok(resources); +}).RequireCors(adminCorsPolicy); // Enable routing so endpoint matching runs before our AWS middleware. // This ensures admin endpoints (health, reset, config) take priority. app.UseRouting(); +app.UseCors(); + +// Keep failures from the inspection API machine-readable without hiding provider faults. +app.Use(async (ctx, next) => +{ + try + { + await next(ctx); + } + catch (OperationCanceledException) when (ctx.RequestAborted.IsCancellationRequested) + { + throw; + } + catch (Exception exception) when (ctx.Request.Path.StartsWithSegments("/_microstack/admin/v1")) + { + var logger = ctx.RequestServices.GetRequiredService>(); + logger.LogError(exception, "Admin API request failed for {Path}", ctx.Request.Path); + if (ctx.Response.HasStarted) + throw; + ctx.Response.Clear(); + ctx.Response.StatusCode = StatusCodes.Status500InternalServerError; + await ctx.Response.WriteAsJsonAsync( + new MicroStack.Admin.Contracts.AdminError( + "internal_error", "The admin request failed. Refresh and try again."), + MicroStack.Admin.Contracts.AdminJsonContext.Default.AdminError, + cancellationToken: ctx.RequestAborted); + } +}); // Handle OPTIONS (CORS pre-flight) before the routing layer can emit a 405. // AWS SDKs send OPTIONS pre-flight; we must return CORS headers for all paths. @@ -190,6 +280,7 @@ }); // Main AWS request middleware +app.UseMiddleware(); app.UseMiddleware(); // Activate the mapped endpoints (health, reset, config) registered above. @@ -215,6 +306,8 @@ logger.LogInformation(banner); logger.LogInformation("Services enabled: {ServiceCount}", serviceCount); + if (adminUiEnabled) + logger.LogInformation("UI available at: http://{Host}:{GatewayPort}/ui/", options.Host, options.GatewayPort); }); app.Run(); @@ -226,3 +319,4 @@ public partial class Program { } internal sealed record HealthResponse(Dictionary Services, string Edition, string Version); internal sealed record ResetResponse(string Reset); internal sealed record ConfigResponse(Dictionary Applied); +internal sealed record RequestLogClearResponse(bool Cleared); diff --git a/src/MicroStack/Services/Acm/AcmServiceHandler.Admin.cs b/src/MicroStack/Services/Acm/AcmServiceHandler.Admin.cs new file mode 100644 index 0000000..c38ddce --- /dev/null +++ b/src/MicroStack/Services/Acm/AcmServiceHandler.Admin.cs @@ -0,0 +1,48 @@ +using MicroStack.Admin.Contracts; +using MicroStack.Internal.Admin; + +namespace MicroStack.Services.Acm; + +internal sealed partial class AcmServiceHandler +{ + private static readonly AdminResourceKind[] AdminKinds = [new("certificate", "Certificates")]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => AdminKinds; + + public IEnumerable GetAdminResources(string serviceId) + { + lock (_lock) + { + return _certificates.Items + .OrderBy(x => x.Value.DomainName, StringComparer.Ordinal) + .Select(x => CertificateNode(x.Key, x.Value)) + .ToArray(); + } + } + + private AdminNode CertificateNode(string arn, AcmCertificate snapshot) => + AdminData.Node("certificate", arn, snapshot.DomainName, arn, snapshot.Status) with + { + ReadFields = () => + { + lock (_lock) + { + if (!_certificates.TryGetValue(arn, out var cert)) return []; + return + [ + AdminData.Field("Domain name", cert.DomainName), + AdminData.Field("Type", cert.Type), + AdminData.Field("Validation method", cert.ValidationMethod), + AdminData.Field("Created", cert.CreatedAt, format: "datetime"), + AdminData.Field("Issued", cert.IssuedAt, format: "datetime"), + AdminData.Field("Not before", cert.NotBefore, format: "datetime"), + AdminData.Field("Not after", cert.NotAfter, format: "datetime"), + AdminData.Field("Subject alternative names", string.Join(", ", cert.SubjectAlternativeNames)), + AdminData.Field("In use by", string.Join(", ", cert.InUseBy)), + AdminData.Field("Tags", string.Join(", ", cert.Tags.Select(t => + $"{t.GetValueOrDefault("Key")}={t.GetValueOrDefault("Value")}"))) + ]; + } + } + }; +} diff --git a/src/MicroStack/Services/Acm/AcmServiceHandler.cs b/src/MicroStack/Services/Acm/AcmServiceHandler.cs index 7e509ba..30cc401 100644 --- a/src/MicroStack/Services/Acm/AcmServiceHandler.cs +++ b/src/MicroStack/Services/Acm/AcmServiceHandler.cs @@ -13,7 +13,7 @@ namespace MicroStack.Services.Acm; /// AddTagsToCertificate, RemoveTagsFromCertificate, ListTagsForCertificate, /// UpdateCertificateOptions, RenewCertificate, ResendValidationEmail. /// -internal sealed class AcmServiceHandler : IServiceHandler +internal sealed partial class AcmServiceHandler : IServiceHandler, Internal.Admin.IAdminResourceSource { private readonly AccountScopedDictionary _certificates = new(); // keyed by ARN private readonly Lock _lock = new(); @@ -24,6 +24,8 @@ internal sealed class AcmServiceHandler : IServiceHandler public string ServiceName => "acm"; + public IEnumerable GetKnownAccountIds() => _certificates.GetAccountIds(); + public Task HandleAsync(ServiceRequest request) { var target = request.GetHeader("x-amz-target") ?? ""; diff --git a/src/MicroStack/Services/Alb/AlbAdminResources.cs b/src/MicroStack/Services/Alb/AlbAdminResources.cs new file mode 100644 index 0000000..ef0a9b3 --- /dev/null +++ b/src/MicroStack/Services/Alb/AlbAdminResources.cs @@ -0,0 +1,166 @@ +using MicroStack.Admin.Contracts; +using MicroStack.Internal; +using MicroStack.Internal.Admin; +using MicroStack.Services.ApiGateway; + +namespace MicroStack.Services.Alb; + +internal sealed partial class AlbServiceHandler +{ + private static readonly AdminResourceKind[] AdminKinds = + [ + new("load-balancers", "Load balancers"), new("listeners", "Listeners") { IsRoot = false }, + new("rules", "Rules") { IsRoot = false }, new("target-groups", "Target groups"), + new("targets", "Targets") { IsRoot = false }, + ]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + serviceId == "alb" || serviceId == ServiceName ? AdminKinds : []; + + public IEnumerable GetAdminResources(string serviceId) + { + if (serviceId != "alb" && serviceId != ServiceName) + return []; + lock (_lock) + { + return + [ + .. _lbs.Items.ToArray().Select(CreateLoadBalancerNode), + .. _tgs.Items.ToArray().Select(CreateTargetGroupNode), + ]; + } + } + + private AdminNode CreateLoadBalancerNode(KeyValuePair> pair) + { + var (arn, lb) = pair; + var node = ObjectNode("load-balancers", arn, + NetworkingAdminData.Get(lb, "LoadBalancerName", arn), lb, arn, + NetworkingAdminData.Get(lb, "State")); + return node with + { + ReadContent = () => ResourceConfiguration(arn, lb, _lbAttrs), + ChildKinds = [AdminKinds[1]], + ReadChildren = () => + { + lock (_lock) + return _listeners.Items + .Where(x => NetworkingAdminData.Get(x.Value, "LoadBalancerArn") == arn) + .Select(CreateListenerNode).ToArray(); + }, + }; + } + + private AdminNode CreateListenerNode(KeyValuePair> pair) + { + var (arn, listener) = pair; + var protocol = NetworkingAdminData.Get(listener, "Protocol"); + var port = NetworkingAdminData.Get(listener, "Port"); + return ObjectNode("listeners", arn, $"{protocol}:{port}", listener, arn) with + { + ChildKinds = [AdminKinds[2]], + ReadChildren = () => + { + lock (_lock) + return _rules.Items + .Where(x => NetworkingAdminData.Get(x.Value, "ListenerArn") == arn) + .Select(CreateRuleNode).ToArray(); + }, + ReadConnections = () => + [ + new("Load balancer", "listener-of", ServiceName, + [new("load-balancers", NetworkingAdminData.Get(listener, "LoadBalancerArn"))]), + .. TargetGroupConnections(listener, "DefaultActions"), + ], + }; + } + + private AdminNode CreateRuleNode(KeyValuePair> pair) + { + var (arn, rule) = pair; + return ObjectNode("rules", arn, + $"Priority {NetworkingAdminData.Get(rule, "Priority")}", rule, arn) with + { + ReadConnections = () => + [ + new("Listener", "rule-of", ServiceName, + [new("listeners", NetworkingAdminData.Get(rule, "ListenerArn"))]), + .. TargetGroupConnections(rule, "Actions"), + ], + }; + } + + private AdminNode CreateTargetGroupNode(KeyValuePair> pair) + { + var (arn, targetGroup) = pair; + var node = ObjectNode("target-groups", arn, + NetworkingAdminData.Get(targetGroup, "TargetGroupName", arn), targetGroup, arn); + return node with + { + ReadContent = () => ResourceConfiguration(arn, targetGroup, _tgAttrs), + ChildKinds = [AdminKinds[4]], + ReadChildren = () => + { + lock (_lock) + { + if (!_targets.TryGetValue(arn, out var targets)) + return []; + return targets.ToArray().Select(target => + { + var id = NetworkingAdminData.Get(target, "Id"); + var port = NetworkingAdminData.Get(target, "Port"); + return ObjectNode("targets", NetworkingAdminData.Composite(id, port), + string.IsNullOrEmpty(port) ? id : $"{id}:{port}", target); + }).ToArray(); + } + }, + ReadConnections = () => + { + var arns = targetGroup.TryGetValue("LoadBalancerArns", out var value) + && value is IEnumerable items ? items.ToArray() : []; + return arns.Select(lbArn => new AdminConnection( + "Load balancer", "registered-with", ServiceName, + [new("load-balancers", lbArn)])).ToArray(); + }, + }; + } + + private AdminContent ResourceConfiguration( + string arn, Dictionary resource, + AccountScopedDictionary>> attributes) + { + lock (_lock) + { + var value = new Dictionary(resource + .Select(x => new KeyValuePair(x.Key, x.Value))); + if (attributes.TryGetValue(arn, out var attrs)) + value["Attributes"] = attrs.ToArray(); + if (_tags.TryGetValue(arn, out var tags)) + value["Tags"] = tags.ToArray(); + return NetworkingAdminData.Json(value); + } + } + + private static IReadOnlyList TargetGroupConnections( + Dictionary source, string property) + { + if (!source.TryGetValue(property, out var actionsValue) + || actionsValue is not IEnumerable> actions) + return []; + return actions + .Where(action => action.TryGetValue("TargetGroupArn", out var value) && value is string) + .Select(action => new AdminConnection("Target group", "forwards-to", "elasticloadbalancing", + [new("target-groups", (string)action["TargetGroupArn"])])) + .ToArray(); + } + + private static AdminNode ObjectNode( + string kind, string id, string name, Dictionary value, + string? arn = null, string? status = null) => + AdminData.Node(kind, id, name, arn, status) with + { + ReadFields = () => NetworkingAdminData.Fields( + value.Select(x => new KeyValuePair(x.Key, x.Value))), + ReadContent = () => NetworkingAdminData.Json(value), + }; +} diff --git a/src/MicroStack/Services/Alb/AlbServiceHandler.cs b/src/MicroStack/Services/Alb/AlbServiceHandler.cs index 7961388..8f87e8c 100644 --- a/src/MicroStack/Services/Alb/AlbServiceHandler.cs +++ b/src/MicroStack/Services/Alb/AlbServiceHandler.cs @@ -2,13 +2,18 @@ using System.Text; using System.Web; using MicroStack.Internal; +using MicroStack.Internal.Admin; namespace MicroStack.Services.Alb; -internal sealed class AlbServiceHandler : IServiceHandler +internal sealed partial class AlbServiceHandler : IServiceHandler, IAdminResourceSource { public string ServiceName => "elasticloadbalancing"; + public IEnumerable GetKnownAccountIds() => + _lbs.GetAccountIds().Concat(_tgs.GetAccountIds()) + .Concat(_listeners.GetAccountIds()).Concat(_rules.GetAccountIds()); + private const string Ns = "http://elasticloadbalancing.amazonaws.com/doc/2015-12-01/"; private static string Region => diff --git a/src/MicroStack/Services/ApiGateway/ApiGatewayAdminResources.cs b/src/MicroStack/Services/ApiGateway/ApiGatewayAdminResources.cs new file mode 100644 index 0000000..fe73b0c --- /dev/null +++ b/src/MicroStack/Services/ApiGateway/ApiGatewayAdminResources.cs @@ -0,0 +1,288 @@ +using MicroStack.Admin.Contracts; +using MicroStack.Internal; +using MicroStack.Internal.Admin; + +namespace MicroStack.Services.ApiGateway; + +internal sealed partial class ApiGatewayV2ServiceHandler +{ + private static readonly AdminResourceKind[] V2Kinds = + [ + new("apis", "APIs"), new("routes", "Routes") { IsRoot = false }, + new("integrations", "Integrations") { IsRoot = false }, + new("stages", "Stages") { IsRoot = false }, new("deployments", "Deployments") { IsRoot = false }, + new("authorizers", "Authorizers") { IsRoot = false }, + ]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + serviceId switch + { + "apigateway" => _v1Handler.GetAdminResourceKinds(), + "apigatewayv2" => V2Kinds, + _ => [], + }; + + public IEnumerable GetAdminResources(string serviceId) + { + lock (_lock) + { + return serviceId switch + { + "apigateway" => _v1Handler.GetAdminResources(), + "apigatewayv2" => _apis.Items.ToArray().Select(CreateV2ApiNode).ToArray(), + _ => [], + }; + } + } + + private AdminNode CreateV2ApiNode(KeyValuePair> pair) + { + var (id, api) = pair; + return AdminData.Node("apis", id, NetworkingAdminData.Get(api, "name", id), + ApiArn(id), NetworkingAdminData.Get(api, "protocolType")) + with + { + ReadFields = () => NetworkingAdminData.Fields(api), + ReadContent = () => + { + var value = new Dictionary(api); + if (_apiTags.TryGetValue(ApiArn(id), out var tags)) + value["tags"] = tags; + return NetworkingAdminData.Json(value); + }, + ChildKinds = V2Kinds[1..], + ReadChildren = () => + [ + .. ChildNodes(_routes, id, "routes", "routeId", "routeKey"), + .. ChildNodes(_integrations, id, "integrations", "integrationId", "integrationType"), + .. ChildNodes(_stages, id, "stages", "stageName", "stageName"), + .. ChildNodes(_deployments, id, "deployments", "deploymentId", "deploymentId"), + .. ChildNodes(_authorizers, id, "authorizers", "authorizerId", "name"), + ], + }; + } + + private static IEnumerable ChildNodes( + AccountScopedDictionary>> source, + string apiId, string kind, string idName, string nameName) + { + if (!source.TryGetValue(apiId, out var values)) + return []; + return values.ToArray().Select(pair => + { + var item = pair.Value; + var id = NetworkingAdminData.Get(item, idName, pair.Key); + var node = AdminData.Node(kind, id, NetworkingAdminData.Get(item, nameName, id)); + var connections = V2Connections(kind, item); + return node with + { + ReadFields = () => NetworkingAdminData.Fields(item, "authorizerCredentialsArn"), + ReadContent = () => NetworkingAdminData.Json(item, "authorizerCredentialsArn"), + ReadConnections = connections.Count == 0 ? null : () => connections, + }; + }).ToArray(); + } + + private static IReadOnlyList V2Connections( + string kind, Dictionary item) + { + var connections = new List(); + if (kind == "routes") + { + var target = NetworkingAdminData.Get(item, "target"); + if (target.StartsWith("integrations/", StringComparison.Ordinal)) + connections.Add(new("Integration", "targets", "apigatewayv2", + [new("integrations", target["integrations/".Length..])])); + var authorizerId = NetworkingAdminData.Get(item, "authorizerId"); + if (!string.IsNullOrEmpty(authorizerId)) + connections.Add(new("Authorizer", "authorized-by", "apigatewayv2", + [new("authorizers", authorizerId)])); + } + else if (kind == "stages") + { + var deploymentId = NetworkingAdminData.Get(item, "deploymentId"); + if (!string.IsNullOrEmpty(deploymentId)) + connections.Add(new("Deployment", "deploys", "apigatewayv2", + [new("deployments", deploymentId)])); + } + return connections; + } +} + +internal sealed partial class ApiGatewayV1ServiceHandler +{ + private static readonly AdminResourceKind[] V1Kinds = + [ + new("rest-apis", "REST APIs"), new("resources", "Resources") { IsRoot = false }, + new("methods", "Methods") { IsRoot = false }, + new("method-responses", "Method responses") { IsRoot = false }, + new("integrations", "Integrations") { IsRoot = false }, + new("integration-responses", "Integration responses") { IsRoot = false }, + new("stages", "Stages") { IsRoot = false }, new("deployments", "Deployments") { IsRoot = false }, + new("authorizers", "Authorizers") { IsRoot = false }, new("models", "Models") { IsRoot = false }, + new("api-keys", "API keys"), + new("usage-plans", "Usage plans"), new("usage-plan-keys", "Usage plan keys") { IsRoot = false }, + new("domain-names", "Domain names"), new("base-path-mappings", "Base path mappings") { IsRoot = false }, + ]; + + internal IReadOnlyList GetAdminResourceKinds() => V1Kinds; + + internal IEnumerable GetAdminResources() => + [ + .. _restApis.Items.ToArray().Select(CreateRestApiNode), + .. _apiKeys.Items.ToArray().Select(pair => DictionaryNode("api-keys", pair.Key, + NetworkingAdminData.Get(pair.Value, "name", pair.Key), pair.Value, sensitive: true)), + .. _usagePlans.Items.ToArray().Select(CreateUsagePlanNode), + .. _domainNames.Items.ToArray().Select(CreateDomainNode), + ]; + + private AdminNode CreateRestApiNode(KeyValuePair> pair) + { + var (id, api) = pair; + var node = DictionaryNode("rest-apis", id, NetworkingAdminData.Get(api, "name", id), api, + RestApiArn(id)); + return node with + { + ReadContent = () => + { + var value = new Dictionary(api); + if (_v1Tags.TryGetValue(RestApiArn(id), out var tags)) + value["tags"] = tags; + return NetworkingAdminData.Json(value); + }, + ChildKinds = [V1Kinds[1], V1Kinds[6], V1Kinds[7], V1Kinds[8], V1Kinds[9]], + ReadChildren = () => + [ + .. ResourceNodes(id), + .. V1Children(_stages, id, "stages", "stageName", "stageName"), + .. V1Children(_deployments, id, "deployments", "id", "id"), + .. V1Children(_authorizers, id, "authorizers", "id", "name"), + .. V1Children(_models, id, "models", "id", "name"), + ], + }; + } + + private IEnumerable ResourceNodes(string apiId) + { + if (!_resources.TryGetValue(apiId, out var resources)) + return []; + return resources.ToArray().Select(pair => + { + var resource = pair.Value; + return DictionaryNode("resources", pair.Key, + NetworkingAdminData.Get(resource, "path", pair.Key), resource) with + { + ChildKinds = [V1Kinds[2]], + ReadChildren = () => MethodNodes(resource), + }; + }).ToArray(); + } + + private static IEnumerable MethodNodes(Dictionary resource) + { + if (!resource.TryGetValue("resourceMethods", out var methodsValue) + || methodsValue is not Dictionary methods) + return []; + return methods.ToArray().Where(x => x.Value is Dictionary) + .Select(pair => + { + var method = (Dictionary)pair.Value!; + var children = new List(); + if (method.TryGetValue("methodResponses", out var responsesValue) + && responsesValue is Dictionary responses) + { + children.AddRange(responses.ToArray() + .Where(x => x.Value is Dictionary) + .Select(x => DictionaryNode("method-responses", x.Key, x.Key, + (Dictionary)x.Value!))); + } + if (method.TryGetValue("methodIntegration", out var integrationValue) + && integrationValue is Dictionary integration) + { + children.Add(DictionaryNode("integrations", pair.Key, pair.Key, integration) + with + { + ChildKinds = [V1Kinds[5]], + ReadChildren = () => + { + if (!integration.TryGetValue("integrationResponses", out var value) + || value is not Dictionary integrationResponses) + return []; + return integrationResponses.ToArray() + .Where(x => x.Value is Dictionary) + .Select(x => DictionaryNode("integration-responses", x.Key, x.Key, + (Dictionary)x.Value!)) + .ToArray(); + }, + }); + } + var authorizerId = NetworkingAdminData.Get(method, "authorizerId"); + return DictionaryNode("methods", pair.Key, pair.Key, method) with + { + ChildKinds = [V1Kinds[3], V1Kinds[4]], + ReadChildren = () => children, + ReadConnections = string.IsNullOrEmpty(authorizerId) ? null : () => + [ + new("Authorizer", "authorized-by", "apigateway", + [new("authorizers", authorizerId)]), + ], + }; + }).ToArray(); + } + + private AdminNode CreateUsagePlanNode(KeyValuePair> pair) => + DictionaryNode("usage-plans", pair.Key, NetworkingAdminData.Get(pair.Value, "name", pair.Key), pair.Value) + with + { + ChildKinds = [V1Kinds[12]], + ReadChildren = () => V1Children(_usagePlanKeys, pair.Key, "usage-plan-keys", "id", "name", true), + }; + + private AdminNode CreateDomainNode(KeyValuePair> pair) => + DictionaryNode("domain-names", pair.Key, + NetworkingAdminData.Get(pair.Value, "domainName", pair.Key), pair.Value) with + { + ChildKinds = [V1Kinds[14]], + ReadChildren = () => V1Children(_basePathMappings, pair.Key, + "base-path-mappings", "basePath", "basePath"), + }; + + private static IEnumerable V1Children( + AccountScopedDictionary>> source, + string parentId, string kind, string idName, string nameName, bool sensitive = false) + { + if (!source.TryGetValue(parentId, out var values)) + return []; + return values.ToArray().Select(pair => + { + var node = DictionaryNode(kind, + NetworkingAdminData.Get(pair.Value, idName, pair.Key), + NetworkingAdminData.Get(pair.Value, nameName, pair.Key), pair.Value, sensitive: sensitive); + var deploymentId = kind == "stages" + ? NetworkingAdminData.Get(pair.Value, "deploymentId") + : ""; + return node with + { + ReadConnections = string.IsNullOrEmpty(deploymentId) ? node.ReadConnections : () => + [ + new("Deployment", "deploys", "apigateway", + [new("deployments", deploymentId)]), + ], + }; + }).ToArray(); + } + + private static AdminNode DictionaryNode( + string kind, string id, string name, Dictionary value, + string? arn = null, bool sensitive = false) + { + var sensitiveNames = sensitive + ? new[] { "value", "id", "credentials", "authorizerCredentialsArn" } + : new[] { "credentials", "authorizerCredentialsArn" }; + return AdminData.Node(kind, id, name, arn) with + { + ReadFields = () => NetworkingAdminData.Fields(value, sensitiveNames), + ReadContent = sensitive ? null : () => NetworkingAdminData.Json(value, sensitiveNames), + }; + } +} diff --git a/src/MicroStack/Services/ApiGateway/ApiGatewayV1ServiceHandler.cs b/src/MicroStack/Services/ApiGateway/ApiGatewayV1ServiceHandler.cs index 7e63937..8568861 100644 --- a/src/MicroStack/Services/ApiGateway/ApiGatewayV1ServiceHandler.cs +++ b/src/MicroStack/Services/ApiGateway/ApiGatewayV1ServiceHandler.cs @@ -17,7 +17,7 @@ namespace MicroStack.Services.ApiGateway; /// /// Port of ministack/services/apigateway_v1.py. /// -internal sealed class ApiGatewayV1ServiceHandler +internal sealed partial class ApiGatewayV1ServiceHandler { private readonly LambdaServiceHandler _lambdaHandler; @@ -48,6 +48,10 @@ internal ApiGatewayV1ServiceHandler(LambdaServiceHandler lambdaHandler) /// Returns true if this handler owns the given API ID (for execute-api dispatch). internal bool OwnsApiId(string apiId) => _restApis.ContainsKey(apiId); + internal IEnumerable GetKnownAccountIds() => + _restApis.GetAccountIds().Concat(_apiKeys.GetAccountIds()) + .Concat(_usagePlans.GetAccountIds()).Concat(_domainNames.GetAccountIds()); + internal void Reset() { _restApis.Clear(); diff --git a/src/MicroStack/Services/ApiGateway/ApiGatewayV2ServiceHandler.cs b/src/MicroStack/Services/ApiGateway/ApiGatewayV2ServiceHandler.cs index 2e0d6c9..d8bf996 100644 --- a/src/MicroStack/Services/ApiGateway/ApiGatewayV2ServiceHandler.cs +++ b/src/MicroStack/Services/ApiGateway/ApiGatewayV2ServiceHandler.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using MicroStack.Internal; +using MicroStack.Internal.Admin; using MicroStack.Services.Lambda; namespace MicroStack.Services.ApiGateway; @@ -17,10 +18,11 @@ namespace MicroStack.Services.ApiGateway; /// /// Port of ministack/services/apigateway.py. /// -internal sealed partial class ApiGatewayV2ServiceHandler : IServiceHandler +internal sealed partial class ApiGatewayV2ServiceHandler : IServiceHandler, IAdminResourceSource { private readonly LambdaServiceHandler _lambdaHandler; private readonly ApiGatewayV1ServiceHandler _v1Handler; + private readonly Lock _lock = new(); // -- State ------------------------------------------------------------------ @@ -51,46 +53,55 @@ internal ApiGatewayV2ServiceHandler(LambdaServiceHandler lambdaHandler) public string ServiceName => "apigateway"; + public IEnumerable GetKnownAccountIds() => + _apis.GetAccountIds().Concat(_v1Handler.GetKnownAccountIds()); + public Task HandleAsync(ServiceRequest request) { - var host = request.GetHeader("host") ?? ""; - var executeMatch = ExecuteApiRegex().Match(host); - if (executeMatch.Success) + lock (_lock) { - var apiId = executeMatch.Groups[1].Value; - // Route to v1 handler if the API ID belongs to a REST API - if (_v1Handler.OwnsApiId(apiId)) + var host = request.GetHeader("host") ?? ""; + var executeMatch = ExecuteApiRegex().Match(host); + if (executeMatch.Success) { - return Task.FromResult(_v1Handler.HandleExecute(apiId, request)); + var apiId = executeMatch.Groups[1].Value; + // Route to v1 handler if the API ID belongs to a REST API + if (_v1Handler.OwnsApiId(apiId)) + { + return Task.FromResult(_v1Handler.HandleExecute(apiId, request)); + } + return Task.FromResult(HandleExecute(apiId, request)); } - return Task.FromResult(HandleExecute(apiId, request)); - } - // Route v1 control plane paths to the v1 handler - var pathLower = request.Path.TrimStart('/').ToLowerInvariant(); - if (pathLower.StartsWith("restapis", StringComparison.Ordinal) - || pathLower.StartsWith("apikeys", StringComparison.Ordinal) - || pathLower.StartsWith("usageplans", StringComparison.Ordinal) - || pathLower.StartsWith("domainnames", StringComparison.Ordinal) - || (pathLower.StartsWith("tags/", StringComparison.Ordinal) - && pathLower.Contains("restapis", StringComparison.Ordinal))) - { - return Task.FromResult(_v1Handler.HandleControlPlane(request)); - } + // Route v1 control plane paths to the v1 handler + var pathLower = request.Path.TrimStart('/').ToLowerInvariant(); + if (pathLower.StartsWith("restapis", StringComparison.Ordinal) + || pathLower.StartsWith("apikeys", StringComparison.Ordinal) + || pathLower.StartsWith("usageplans", StringComparison.Ordinal) + || pathLower.StartsWith("domainnames", StringComparison.Ordinal) + || (pathLower.StartsWith("tags/", StringComparison.Ordinal) + && pathLower.Contains("restapis", StringComparison.Ordinal))) + { + return Task.FromResult(_v1Handler.HandleControlPlane(request)); + } - return Task.FromResult(HandleControlPlane(request)); + return Task.FromResult(HandleControlPlane(request)); + } } public void Reset() { - _apis.Clear(); - _routes.Clear(); - _integrations.Clear(); - _stages.Clear(); - _deployments.Clear(); - _authorizers.Clear(); - _apiTags.Clear(); - _v1Handler.Reset(); + lock (_lock) + { + _apis.Clear(); + _routes.Clear(); + _integrations.Clear(); + _stages.Clear(); + _deployments.Clear(); + _authorizers.Clear(); + _apiTags.Clear(); + _v1Handler.Reset(); + } } public JsonElement? GetState() => null; diff --git a/src/MicroStack/Services/ApiGateway/NetworkingAdminData.cs b/src/MicroStack/Services/ApiGateway/NetworkingAdminData.cs new file mode 100644 index 0000000..267b029 --- /dev/null +++ b/src/MicroStack/Services/ApiGateway/NetworkingAdminData.cs @@ -0,0 +1,102 @@ +using System.Collections; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using MicroStack.Admin.Contracts; +using MicroStack.Internal.Admin; + +namespace MicroStack.Services.ApiGateway; + +internal static class NetworkingAdminData +{ + internal static string Text(object? value) => value switch + { + null => "", + bool b => b ? "true" : "false", + string s => s, + DateTime dt => AdminData.IsoUtc(dt), + DateTimeOffset dto => AdminData.IsoUtc(dto), + IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() ?? "", + }; + + internal static IReadOnlyList Fields( + IEnumerable> values, + params string[] sensitiveNames) + { + var sensitive = new HashSet(sensitiveNames, StringComparer.OrdinalIgnoreCase); + return values + .Where(kv => kv.Value is null or string or bool or byte or sbyte or short or ushort + or int or uint or long or ulong or float or double or decimal or DateTime or DateTimeOffset) + .OrderBy(kv => kv.Key, StringComparer.Ordinal) + .Select(kv => AdminData.Field(kv.Key, Text(kv.Value), sensitive.Contains(kv.Key))) + .ToArray(); + } + + internal static AdminContent Json(object? value, params string[] sensitiveNames) + { + var sensitive = new HashSet(sensitiveNames, StringComparer.OrdinalIgnoreCase); + return AdminData.Json(writer => Write(writer, value, sensitive, null)); + } + + private static void Write( + Utf8JsonWriter writer, object? value, HashSet sensitive, string? propertyName) + { + if (propertyName is not null && sensitive.Contains(propertyName)) + { + writer.WriteStringValue(AdminData.MaskedValue); + return; + } + + switch (value) + { + case null: writer.WriteNullValue(); return; + case string text: writer.WriteStringValue(text); return; + case bool boolean: writer.WriteBooleanValue(boolean); return; + case byte number: writer.WriteNumberValue(number); return; + case sbyte number: writer.WriteNumberValue(number); return; + case short number: writer.WriteNumberValue(number); return; + case ushort number: writer.WriteNumberValue(number); return; + case int number: writer.WriteNumberValue(number); return; + case uint number: writer.WriteNumberValue(number); return; + case long number: writer.WriteNumberValue(number); return; + case ulong number: writer.WriteNumberValue(number); return; + case float number: writer.WriteNumberValue(number); return; + case double number: writer.WriteNumberValue(number); return; + case decimal number: writer.WriteNumberValue(number); return; + case DateTime date: writer.WriteStringValue(AdminData.IsoUtc(date)); return; + case DateTimeOffset date: writer.WriteStringValue(AdminData.IsoUtc(date)); return; + case IDictionary dictionary: + writer.WriteStartObject(); + foreach (DictionaryEntry entry in dictionary) + { + if (entry.Key is not string key) + continue; + writer.WritePropertyName(key); + Write(writer, entry.Value, sensitive, key); + } + writer.WriteEndObject(); + return; + case IEnumerable enumerable: + writer.WriteStartArray(); + foreach (var item in enumerable) + Write(writer, item, sensitive, null); + writer.WriteEndArray(); + return; + default: + writer.WriteStringValue(Text(value)); + return; + } + } + + internal static string Get( + IReadOnlyDictionary value, string key, string fallback = "") => + value.TryGetValue(key, out var item) ? Text(item) : fallback; + + internal static string Composite(params string[] values) => + string.Join("/", values.Select(Uri.EscapeDataString)); + + internal static string SecretId(string value) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..16].ToLowerInvariant(); +} diff --git a/src/MicroStack/Services/AppSync/AppSyncAdminResources.cs b/src/MicroStack/Services/AppSync/AppSyncAdminResources.cs new file mode 100644 index 0000000..83f9359 --- /dev/null +++ b/src/MicroStack/Services/AppSync/AppSyncAdminResources.cs @@ -0,0 +1,123 @@ +using MicroStack.Admin.Contracts; +using MicroStack.Internal; +using MicroStack.Internal.Admin; +using MicroStack.Services.ApiGateway; + +namespace MicroStack.Services.AppSync; + +internal sealed partial class AppSyncServiceHandler +{ + private static readonly AdminResourceKind[] AdminKinds = + [ + new("graphql-apis", "GraphQL APIs"), new("api-keys", "API keys") { IsRoot = false }, + new("data-sources", "Data sources") { IsRoot = false }, new("types", "Schema types") { IsRoot = false }, + new("resolvers", "Resolvers") { IsRoot = false }, + ]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + serviceId == ServiceName ? AdminKinds : []; + + public IEnumerable GetAdminResources(string serviceId) + { + if (serviceId != ServiceName) + return []; + lock (_lock) + return _apis.Items.ToArray().Select(CreateApiNode).ToArray(); + } + + private AdminNode CreateApiNode(KeyValuePair> pair) + { + var (apiId, api) = pair; + return DictionaryNode("graphql-apis", apiId, + NetworkingAdminData.Get(api, "name", apiId), api, + NetworkingAdminData.Get(api, "arn")) with + { + ChildKinds = AdminKinds[1..], + ReadChildren = () => + { + lock (_lock) + { + return + [ + .. ApiKeyNodes(apiId), + .. DictionaryChildren(_dataSources, apiId, "data-sources", "name", "name"), + .. TypeNodes(apiId), + .. ResolverNodes(apiId), + ]; + } + }, + }; + } + + private IEnumerable ApiKeyNodes(string apiId) + { + if (!_apiKeys.TryGetValue(apiId, out var values)) + return []; + return values.ToArray().Select(pair => + AdminData.Node("api-keys", NetworkingAdminData.SecretId(pair.Key), "API key") with + { + ReadFields = () => NetworkingAdminData.Fields(pair.Value, "id"), + }).ToArray(); + } + + private IEnumerable TypeNodes(string apiId) + { + if (!_types.TryGetValue(apiId, out var values)) + return []; + return values.ToArray().Select(pair => + { + var type = pair.Value; + return DictionaryNode("types", pair.Key, + NetworkingAdminData.Get(type, "name", pair.Key), type, + NetworkingAdminData.Get(type, "arn")) with + { + ReadContent = () => AdminData.Text( + NetworkingAdminData.Get(type, "definition"), "application/graphql"), + }; + }).ToArray(); + } + + private IEnumerable ResolverNodes(string apiId) + { + if (!_resolvers.TryGetValue(apiId, out var typeMap)) + return []; + return typeMap.ToArray().SelectMany(typePair => + typePair.Value.ToArray().Select(pair => + { + var resolver = pair.Value; + var id = NetworkingAdminData.Composite(typePair.Key, pair.Key); + var dataSource = NetworkingAdminData.Get(resolver, "dataSourceName"); + return DictionaryNode("resolvers", id, $"{typePair.Key}.{pair.Key}", resolver, + NetworkingAdminData.Get(resolver, "resolverArn")) with + { + ReadConnections = string.IsNullOrEmpty(dataSource) ? null : () => + [ + new("Data source", "uses", ServiceName, + [new("data-sources", dataSource)]), + ], + }; + })).ToArray(); + } + + private static IEnumerable DictionaryChildren( + AccountScopedDictionary>> source, + string apiId, string kind, string idName, string nameName) + { + if (!source.TryGetValue(apiId, out var values)) + return []; + return values.ToArray().Select(pair => DictionaryNode(kind, + NetworkingAdminData.Get(pair.Value, idName, pair.Key), + NetworkingAdminData.Get(pair.Value, nameName, pair.Key), pair.Value, + NetworkingAdminData.Get(pair.Value, "dataSourceArn"))).ToArray(); + } + + private static AdminNode DictionaryNode( + string kind, string id, string name, Dictionary value, string? arn = null) => + AdminData.Node(kind, id, name, string.IsNullOrEmpty(arn) ? null : arn) with + { + ReadFields = () => NetworkingAdminData.Fields(value, + "apiKey", "clientSecret", "secret", "token", "password"), + ReadContent = () => NetworkingAdminData.Json(value, + "apiKey", "clientSecret", "secret", "token", "password"), + }; +} diff --git a/src/MicroStack/Services/AppSync/AppSyncServiceHandler.cs b/src/MicroStack/Services/AppSync/AppSyncServiceHandler.cs index 01d5ca5..e34c2ec 100644 --- a/src/MicroStack/Services/AppSync/AppSyncServiceHandler.cs +++ b/src/MicroStack/Services/AppSync/AppSyncServiceHandler.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using MicroStack.Internal; +using MicroStack.Internal.Admin; namespace MicroStack.Services.AppSync; @@ -18,7 +19,7 @@ namespace MicroStack.Services.AppSync; /// Types: CreateType, ListTypes, GetType /// Tags: TagResource, UntagResource, ListTagsForResource /// -internal sealed partial class AppSyncServiceHandler : IServiceHandler +internal sealed partial class AppSyncServiceHandler : IServiceHandler, IAdminResourceSource { private readonly Lock _lock = new(); @@ -41,6 +42,8 @@ internal sealed partial class AppSyncServiceHandler : IServiceHandler public string ServiceName => "appsync"; + public IEnumerable GetKnownAccountIds() => _apis.GetAccountIds(); + public Task HandleAsync(ServiceRequest request) { var response = HandleRequest(request); diff --git a/src/MicroStack/Services/Athena/AnalyticsAdminData.cs b/src/MicroStack/Services/Athena/AnalyticsAdminData.cs new file mode 100644 index 0000000..28f554d --- /dev/null +++ b/src/MicroStack/Services/Athena/AnalyticsAdminData.cs @@ -0,0 +1,126 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using MicroStack.Admin.Contracts; + +namespace MicroStack.Internal.Admin; + +internal static class AnalyticsAdminData +{ + internal static string? String(IReadOnlyDictionary value, string key) => + value.TryGetValue(key, out var item) ? Convert.ToString(item, CultureInfo.InvariantCulture) : null; + + internal static IReadOnlyDictionary Dict( + IReadOnlyDictionary value, string key) => + value.TryGetValue(key, out var item) && item is IReadOnlyDictionary dictionary + ? dictionary + : new Dictionary(); + + internal static IEnumerable> Dicts(object? value) => + value is IEnumerable> dictionaries + ? dictionaries + : value is IEnumerable objects + ? objects.OfType>() + : []; + + internal static string? Epoch(object? value) + { + if (value is null || !double.TryParse( + Convert.ToString(value, CultureInfo.InvariantCulture), + NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds)) + return null; + return AdminData.IsoUtc(DateTimeOffset.FromUnixTimeMilliseconds((long)(seconds * 1000))); + } + + internal static IReadOnlyDictionary Project(params (string Name, object? Value)[] values) => + values.Where(x => x.Value is not null) + .ToDictionary(x => x.Name, x => Masked(x.Name) ? AdminData.MaskedValue : x.Value, + StringComparer.Ordinal); + + internal static AdminContent Json(IReadOnlyDictionary value) => + AdminData.Json(writer => Write(writer, value)); + + internal static AdminContent DecodedBase64(string data) + { + try + { + var bytes = Convert.FromBase64String(data); + try + { + return AdminData.Text(bytes); + } + catch (DecoderFallbackException) + { + return AdminData.Binary(bytes.Length); + } + } + catch (FormatException) + { + return AdminData.Text(data); + } + } + + internal static AdminConnection? S3Connection(string label, string? uri) + { + if (string.IsNullOrWhiteSpace(uri)) + return null; + if (!Uri.TryCreate(uri, UriKind.Absolute, out var parsed) || + !string.Equals(parsed.Scheme, "s3", StringComparison.OrdinalIgnoreCase)) + return new(label, "configured-destination", ExternalUri: uri); + var bucket = parsed.Host; + return string.IsNullOrEmpty(bucket) + ? null + : new(label, "configured-destination", "s3", + [new AdminKey("bucket", bucket)]); + } + + private static bool Masked(string name) => + name.Contains("password", StringComparison.OrdinalIgnoreCase) || + name.Contains("secret", StringComparison.OrdinalIgnoreCase) || + name.Contains("credential", StringComparison.OrdinalIgnoreCase) || + name.Contains("privatekey", StringComparison.OrdinalIgnoreCase) || + name.Contains("accesskey", StringComparison.OrdinalIgnoreCase) || + name.Contains("apikey", StringComparison.OrdinalIgnoreCase) || + name.Contains("token", StringComparison.OrdinalIgnoreCase) || + name.Contains("authorization", StringComparison.OrdinalIgnoreCase); + + private static void Write(Utf8JsonWriter writer, object? value) + { + switch (value) + { + case null: + writer.WriteNullValue(); + break; + case IReadOnlyDictionary dictionary: + writer.WriteStartObject(); + foreach (var (key, item) in dictionary.OrderBy(x => x.Key, StringComparer.Ordinal)) + { + writer.WritePropertyName(key); + if (Masked(key)) + writer.WriteStringValue(AdminData.MaskedValue); + else + Write(writer, item); + } + writer.WriteEndObject(); + break; + case IEnumerable items when value is not string: + writer.WriteStartArray(); + foreach (var item in items) + Write(writer, item); + writer.WriteEndArray(); + break; + case string text: + writer.WriteStringValue(text); + break; + case bool boolean: + writer.WriteBooleanValue(boolean); + break; + case byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal: + writer.WriteRawValue(Convert.ToString(value, CultureInfo.InvariantCulture)!); + break; + default: + writer.WriteStringValue(Convert.ToString(value, CultureInfo.InvariantCulture)); + break; + } + } +} diff --git a/src/MicroStack/Services/Athena/AthenaServiceHandler.cs b/src/MicroStack/Services/Athena/AthenaServiceHandler.cs index 4d41ec5..51815f9 100644 --- a/src/MicroStack/Services/Athena/AthenaServiceHandler.cs +++ b/src/MicroStack/Services/Athena/AthenaServiceHandler.cs @@ -1,5 +1,8 @@ +using System.Globalization; using System.Text.Json; +using MicroStack.Admin.Contracts; using MicroStack.Internal; +using MicroStack.Internal.Admin; namespace MicroStack.Services.Athena; @@ -17,7 +20,7 @@ namespace MicroStack.Services.Athena; /// ListTableMetadata, GetTableMetadata, ListDatabases, GetDatabase, /// TagResource, UntagResource, ListTagsForResource. /// -internal sealed class AthenaServiceHandler : IServiceHandler +internal sealed class AthenaServiceHandler : IServiceHandler, IAdminResourceSource { private readonly Lock _lock = new(); @@ -70,6 +73,10 @@ internal sealed class AthenaServiceHandler : IServiceHandler public string ServiceName => "athena"; + public IEnumerable GetKnownAccountIds() => + _executions.GetAccountIds().Concat(_namedQueries.GetAccountIds()) + .Concat(_preparedStatements.GetAccountIds()); + public Task HandleAsync(ServiceRequest request) { var target = request.GetHeader("x-amz-target") ?? ""; @@ -160,6 +167,155 @@ public void Reset() public void RestoreState(JsonElement state) { } + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + [ + new("query-execution", "Query executions"), + new("workgroup", "Workgroups"), + new("named-query", "Named queries"), + new("data-catalog", "Data catalogs"), + new("prepared-statement", "Prepared statements"), + ]; + + public IEnumerable GetAdminResources(string serviceId) + { + lock (_lock) + { + var nodes = new List(); + nodes.AddRange(_executions.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => ExecutionNode(x.Key, x.Value))); + nodes.AddRange(_workgroups.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => DictionaryNode("workgroup", x.Key, x.Value, "global"))); + nodes.AddRange(_namedQueries.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => DictionaryNode("named-query", x.Key, x.Value, "account"))); + nodes.AddRange(_dataCatalogs.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => DictionaryNode("data-catalog", x.Key, x.Value, "global"))); + nodes.AddRange(_preparedStatements.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => DictionaryNode("prepared-statement", x.Key, x.Value, "account"))); + return nodes; + } + } + + private AdminNode ExecutionNode(string id, Dictionary execution) + { + var status = AnalyticsAdminData.Dict(execution, "Status"); + var node = AdminData.Node("query-execution", id, + AnalyticsAdminData.String(execution, "Query") ?? id, + status: AnalyticsAdminData.String(status, "State")); + return node with + { + ReadFields = () => + { + lock (_lock) + { + var context = AnalyticsAdminData.Dict(execution, "QueryExecutionContext"); + var stats = AnalyticsAdminData.Dict(execution, "Statistics"); + return + [ + AdminData.Field("id", id), + AdminData.Field("query", AnalyticsAdminData.String(execution, "Query")), + AdminData.Field("database", AnalyticsAdminData.String(context, "Database")), + AdminData.Field("catalog", AnalyticsAdminData.String(context, "Catalog")), + AdminData.Field("submittedAt", AnalyticsAdminData.Epoch(status.GetValueOrDefault("SubmissionDateTime")), format: "timestamp"), + AdminData.Field("completedAt", AnalyticsAdminData.Epoch(status.GetValueOrDefault("CompletionDateTime")), format: "timestamp"), + AdminData.Field("dataScannedBytes", AnalyticsAdminData.String(stats, "DataScannedInBytes")), + ]; + } + }, + ChildKinds = [new("result-row", "Result rows") { IsRoot = false }], + ReadChildren = () => + { + lock (_lock) + { + var columns = execution.TryGetValue("_resultColumns", out var c) && c is List names ? names : []; + var rows = execution.TryGetValue("_resultRows", out var r) && r is List values ? values : []; + return rows.Select((row, index) => + { + var rowNode = AdminData.Node("result-row", index.ToString(CultureInfo.InvariantCulture), + $"Row {index + 1}"); + return rowNode with + { + ReadContent = () => + { + lock (_lock) + { + var cells = row as List ?? []; + var result = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < cells.Count; i++) + result[i < columns.Count ? columns[i] : $"column{i + 1}"] = cells[i]; + return AnalyticsAdminData.Json(result); + } + }, + }; + }).ToArray(); + } + }, + ReadConnections = () => + { + lock (_lock) + { + var config = AnalyticsAdminData.Dict(execution, "ResultConfiguration"); + var link = AnalyticsAdminData.S3Connection("Output location", + AnalyticsAdminData.String(config, "OutputLocation")); + return link is null ? [] : [link]; + } + }, + }; + } + + private AdminNode DictionaryNode( + string kind, string id, Dictionary value, string scope) + { + var name = AnalyticsAdminData.String(value, "Name") ?? + AnalyticsAdminData.String(value, "NamedQueryId") ?? id; + var status = AnalyticsAdminData.String(value, "State") ?? + AnalyticsAdminData.String(value, "Type"); + return AdminData.Node(kind, id, name, status: status, scope: scope) with + { + ReadFields = () => + { + lock (_lock) + { + return value.OrderBy(x => x.Key, StringComparer.Ordinal) + .Where(x => x.Value is null or string or bool or ValueType) + .Select(x => AdminData.Field(x.Key, + x.Key.Contains("Time", StringComparison.OrdinalIgnoreCase) + ? AnalyticsAdminData.Epoch(x.Value) ?? AnalyticsAdminData.String(value, x.Key) + : AnalyticsAdminData.String(value, x.Key), + format: x.Key.Contains("Time", StringComparison.OrdinalIgnoreCase) ? "timestamp" : null)) + .ToArray(); + } + }, + ReadContent = () => + { + lock (_lock) + { + var selected = value.Where(x => x.Key is "Configuration" or "Parameters") + .ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal); + return AnalyticsAdminData.Json(selected); + } + }, + ReadConnections = () => + { + lock (_lock) + { + var connections = new List(); + var workgroup = AnalyticsAdminData.String(value, "WorkGroup") ?? + AnalyticsAdminData.String(value, "WorkGroupName"); + if (!string.IsNullOrEmpty(workgroup)) + connections.Add(new("Workgroup", "configured-workgroup", "athena", + [new AdminKey("workgroup", workgroup)])); + var configuration = AnalyticsAdminData.Dict(value, "Configuration"); + var resultConfiguration = AnalyticsAdminData.Dict(configuration, "ResultConfiguration"); + var output = AnalyticsAdminData.S3Connection("Output location", + AnalyticsAdminData.String(resultConfiguration, "OutputLocation")); + if (output is not null) + connections.Add(output); + return connections; + } + }, + }; + } + // -- JSON helpers ---------------------------------------------------------- private static string? GetString(JsonElement el, string prop) diff --git a/src/MicroStack/Services/CloudFormation/CloudFormationServiceHandler.Admin.cs b/src/MicroStack/Services/CloudFormation/CloudFormationServiceHandler.Admin.cs new file mode 100644 index 0000000..b9d955d --- /dev/null +++ b/src/MicroStack/Services/CloudFormation/CloudFormationServiceHandler.Admin.cs @@ -0,0 +1,148 @@ +using MicroStack.Admin.Contracts; +using MicroStack.Internal.Admin; + +namespace MicroStack.Services.CloudFormation; + +internal sealed partial class CloudFormationServiceHandler +{ + private static readonly AdminResourceKind[] AdminKinds = [new("stack", "Stacks")]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + serviceId == "cloudformation" ? AdminKinds : []; + + public IEnumerable GetAdminResources(string serviceId) + { + if (serviceId != "cloudformation") return []; + lock (_lock) + { + return _stacks.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => StackNode(x.Key, GetString(x.Value.GetValueOrDefault("StackId")), + GetString(x.Value.GetValueOrDefault("StackStatus")))).ToArray(); + } + } + + private AdminNode StackNode(string name, string arn, string status) => + AdminData.Node("stack", name, name, arn, status) with + { + ReadFields = () => ReadStackFields(name), + ChildKinds = + [ + new("resource", "Resources") { IsRoot = false }, + new("event", "Events") { IsRoot = false }, + new("output", "Outputs") { IsRoot = false }, + new("template", "Templates") { IsRoot = false }, + ], + ReadChildren = () => ReadStackChildren(name), + }; + + private IReadOnlyList ReadStackFields(string name) + { + lock (_lock) + { + if (!_stacks.TryGetValue(name, out var stack)) return []; + return + [ + AdminData.Field("Stack ID", GetString(stack.GetValueOrDefault("StackId"))), + AdminData.Field("Status", GetString(stack.GetValueOrDefault("StackStatus"))), + AdminData.Field("Status reason", GetString(stack.GetValueOrDefault("StackStatusReason"))), + AdminData.Field("Description", GetString(stack.GetValueOrDefault("Description"))), + AdminData.Field("Created", GetString(stack.GetValueOrDefault("CreationTime")), format: "datetime"), + AdminData.Field("Last updated", GetString(stack.GetValueOrDefault("LastUpdatedTime")), format: "datetime"), + ]; + } + } + + private IEnumerable ReadStackChildren(string name) + { + lock (_lock) + { + if (!_stacks.TryGetValue(name, out var stack)) return []; + var children = new List(); + foreach (var (logicalId, value) in GetDict(stack.GetValueOrDefault("_resources")) + .OrderBy(x => x.Key, StringComparer.Ordinal)) + { + var resource = GetDict(value); + var snapshot = ProjectDictionary(resource); + var physicalId = GetString(resource.GetValueOrDefault("PhysicalResourceId")); + var type = GetString(resource.GetValueOrDefault("ResourceType")); + var resourceStatus = GetString(resource.GetValueOrDefault("ResourceStatus")); + children.Add(AdminData.Node("resource", logicalId, logicalId, status: resourceStatus) with + { + ReadFields = () => + [ + AdminData.Field("Logical ID", logicalId), + AdminData.Field("Physical ID", physicalId), + AdminData.Field("Type", type), + AdminData.Field("Status", resourceStatus), + ], + ReadContent = () => AdminData.Json(snapshot), + }); + } + + var stackId = GetString(stack.GetValueOrDefault("StackId")); + if (_stackEvents.TryGetValue(stackId, out var events)) + { + foreach (var ev in events.OrderByDescending(x => x.GetValueOrDefault("Timestamp"), StringComparer.Ordinal)) + { + var snapshot = ev.ToDictionary(x => x.Key, x => (object?)x.Value, StringComparer.Ordinal); + var id = ev.GetValueOrDefault("EventId", ""); + var logicalId = ev.GetValueOrDefault("LogicalResourceId", id); + var eventStatus = ev.GetValueOrDefault("ResourceStatus"); + var timestamp = ev.GetValueOrDefault("Timestamp"); + var type = ev.GetValueOrDefault("ResourceType"); + var reason = ev.GetValueOrDefault("ResourceStatusReason"); + children.Add(AdminData.Node("event", id, logicalId, status: eventStatus) with + { + ReadFields = () => + [ + AdminData.Field("Timestamp", timestamp, format: "datetime"), + AdminData.Field("Resource type", type), + AdminData.Field("Reason", reason), + ], + ReadContent = () => AdminData.Json(snapshot), + }); + } + } + + foreach (var value in GetList(stack.GetValueOrDefault("Outputs"))) + { + var output = GetDict(value); + var key = GetString(output.GetValueOrDefault("OutputKey")); + var outputValue = GetString(output.GetValueOrDefault("OutputValue")); + var description = GetString(output.GetValueOrDefault("Description")); + var export = GetString(output.GetValueOrDefault("ExportName")); + var snapshot = ProjectDictionary(output); + children.Add(AdminData.Node("output", key, key) with + { + ReadFields = () => + [ + AdminData.Field("Value", outputValue), + AdminData.Field("Description", description), + AdminData.Field("Export", export), + ], + ReadContent = () => AdminData.Json(snapshot), + }); + } + + var templateBody = GetString(stack.GetValueOrDefault("_template_body")); + children.Add(AdminData.Node("template", "template", "Template") with + { + ReadContent = () => AdminData.JsonText(templateBody), + }); + return children; + } + } + + private static Dictionary ProjectDictionary(Dictionary source) => + source.Where(x => !x.Key.StartsWith('_')) + .ToDictionary(x => x.Key, x => ProjectValue(x.Value), StringComparer.Ordinal); + + private static object? ProjectValue(object? value) => value switch + { + Dictionary dictionary => ProjectDictionary(dictionary), + Dictionary dictionary => + dictionary.ToDictionary(x => x.Key, x => (object?)x.Value, StringComparer.Ordinal), + List list => list.Select(ProjectValue).ToArray(), + _ => value, + }; +} diff --git a/src/MicroStack/Services/CloudFormation/CloudFormationServiceHandler.cs b/src/MicroStack/Services/CloudFormation/CloudFormationServiceHandler.cs index 4cc1cd5..9422d93 100644 --- a/src/MicroStack/Services/CloudFormation/CloudFormationServiceHandler.cs +++ b/src/MicroStack/Services/CloudFormation/CloudFormationServiceHandler.cs @@ -3,6 +3,7 @@ using System.Text.RegularExpressions; using System.Web; using MicroStack.Internal; +using MicroStack.Internal.Admin; namespace MicroStack.Services.CloudFormation; @@ -10,10 +11,13 @@ namespace MicroStack.Services.CloudFormation; /// CloudFormation service handler — Query/XML protocol. /// Orchestrates other MicroStack service handlers to provision resources from templates. /// -internal sealed partial class CloudFormationServiceHandler : IServiceHandler +internal sealed partial class CloudFormationServiceHandler : IServiceHandler, IAdminResourceSource { public string ServiceName => "cloudformation"; + public IEnumerable GetKnownAccountIds() => + _stacks.GetAccountIds().Concat(_changeSets.GetAccountIds()); + private const string CfnNs = "http://cloudformation.amazonaws.com/doc/2010-05-15/"; private static string Region => diff --git a/src/MicroStack/Services/CloudFront/CloudFrontAdminResources.cs b/src/MicroStack/Services/CloudFront/CloudFrontAdminResources.cs new file mode 100644 index 0000000..4ee51af --- /dev/null +++ b/src/MicroStack/Services/CloudFront/CloudFrontAdminResources.cs @@ -0,0 +1,75 @@ +using MicroStack.Admin.Contracts; +using MicroStack.Internal.Admin; +using MicroStack.Services.ApiGateway; + +namespace MicroStack.Services.CloudFront; + +internal sealed partial class CloudFrontServiceHandler +{ + private static readonly AdminResourceKind[] AdminKinds = + [ + new("distributions", "Distributions"), new("invalidations", "Invalidations") { IsRoot = false }, + ]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + serviceId == ServiceName ? AdminKinds : []; + + public IEnumerable GetAdminResources(string serviceId) + { + if (serviceId != ServiceName) + return []; + lock (_lock) + return _distributions.Items.ToArray().Select(CreateDistributionNode).ToArray(); + } + + private AdminNode CreateDistributionNode(KeyValuePair pair) + { + var distribution = pair.Value; + return AdminData.Node("distributions", distribution.Id, + string.IsNullOrEmpty(distribution.DomainName) ? distribution.Id : distribution.DomainName, + distribution.Arn, distribution.Status) with + { + ReadFields = () => + [ + AdminData.Field("Id", distribution.Id), + AdminData.Field("DomainName", distribution.DomainName), + AdminData.Field("Status", distribution.Status), + AdminData.Field("Enabled", distribution.Enabled ? "true" : "false"), + AdminData.Field("ETag", distribution.ETag), + AdminData.Field("LastModifiedTime", distribution.LastModifiedTime), + ], + ReadContent = () => AdminData.Text(distribution.ConfigXml, "application/xml"), + ChildKinds = [AdminKinds[1]], + ReadChildren = () => + { + lock (_lock) + { + if (!_invalidations.TryGetValue(distribution.Id, out var invalidations)) + return []; + return invalidations.ToArray().Select(CreateInvalidationNode).ToArray(); + } + }, + }; + } + + private static AdminNode CreateInvalidationNode(CfInvalidation invalidation) => + AdminData.Node("invalidations", invalidation.Id, invalidation.Id, status: invalidation.Status) with + { + ReadFields = () => + [ + AdminData.Field("Id", invalidation.Id), + AdminData.Field("Status", invalidation.Status), + AdminData.Field("CreateTime", invalidation.CreateTime), + AdminData.Field("CallerReference", invalidation.CallerReference), + AdminData.Field("Paths", string.Join(", ", invalidation.PathItems)), + ], + ReadContent = () => NetworkingAdminData.Json(new Dictionary + { + ["Id"] = invalidation.Id, + ["Status"] = invalidation.Status, + ["CreateTime"] = invalidation.CreateTime, + ["CallerReference"] = invalidation.CallerReference, + ["Paths"] = invalidation.PathItems, + }), + }; +} diff --git a/src/MicroStack/Services/CloudFront/CloudFrontServiceHandler.cs b/src/MicroStack/Services/CloudFront/CloudFrontServiceHandler.cs index 47bcfee..f97d342 100644 --- a/src/MicroStack/Services/CloudFront/CloudFrontServiceHandler.cs +++ b/src/MicroStack/Services/CloudFront/CloudFrontServiceHandler.cs @@ -3,6 +3,7 @@ using System.Text.RegularExpressions; using System.Xml.Linq; using MicroStack.Internal; +using MicroStack.Internal.Admin; namespace MicroStack.Services.CloudFront; @@ -17,7 +18,7 @@ namespace MicroStack.Services.CloudFront; /// Invalidations: CreateInvalidation, ListInvalidations, GetInvalidation /// Tags: TagResource, ListTagsForResource, UntagResource /// -internal sealed partial class CloudFrontServiceHandler : IServiceHandler +internal sealed partial class CloudFrontServiceHandler : IServiceHandler, IAdminResourceSource { private readonly Lock _lock = new(); @@ -56,6 +57,8 @@ internal sealed partial class CloudFrontServiceHandler : IServiceHandler public string ServiceName => "cloudfront"; + public IEnumerable GetKnownAccountIds() => _distributions.GetAccountIds(); + public Task HandleAsync(ServiceRequest request) { var (status, headers, body) = HandleRequest(request); diff --git a/src/MicroStack/Services/CloudWatch/CloudWatchServiceHandler.Admin.cs b/src/MicroStack/Services/CloudWatch/CloudWatchServiceHandler.Admin.cs new file mode 100644 index 0000000..8586ce4 --- /dev/null +++ b/src/MicroStack/Services/CloudWatch/CloudWatchServiceHandler.Admin.cs @@ -0,0 +1,159 @@ +using System.Globalization; +using System.Text; +using MicroStack.Admin.Contracts; +using MicroStack.Internal; +using MicroStack.Internal.Admin; + +namespace MicroStack.Services.CloudWatch; + +internal sealed partial class CloudWatchServiceHandler +{ + private static readonly AdminResourceKind[] AdminKinds = + [ + new("metric", "Metrics"), new("alarm", "Alarms"), new("dashboard", "Dashboards"), + ]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + serviceId == "cloudwatch" ? AdminKinds : []; + + public IEnumerable GetAdminResources(string serviceId) + { + if (serviceId != "cloudwatch") return []; + lock (_lock) + { + var nodes = new List(); + nodes.AddRange(_metrics.Items.OrderBy(x => x.Key, StringComparer.Ordinal).Select(x => + { + ParseMetricKey(x.Key, out var ns, out var name, out var dimensions); + return MetricNode(x.Key, ns, name, dimensions); + })); + nodes.AddRange(_alarms.Items.OrderBy(x => x.Key, StringComparer.Ordinal).Select(x => AlarmNode(x.Key, false))); + nodes.AddRange(_compositeAlarms.Items.OrderBy(x => x.Key, StringComparer.Ordinal).Select(x => AlarmNode(x.Key, true))); + nodes.AddRange(_dashboards.Items.OrderBy(x => x.Key, StringComparer.Ordinal).Select(x => DashboardNode(x.Key))); + return nodes; + } + } + + private static string AdminId(string value) => + Convert.ToBase64String(Encoding.UTF8.GetBytes(value)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private AdminNode MetricNode(string key, string metricNamespace, string name, string dimensions) => + AdminData.Node("metric", AdminId(key), $"{metricNamespace} / {name}") with + { + ReadFields = () => + [ + AdminData.Field("Namespace", metricNamespace), + AdminData.Field("Metric name", name), + AdminData.Field("Dimensions", dimensions), + ], + ChildKinds = [new("datapoint", "Datapoints") { IsRoot = false }], + ReadChildren = () => ReadMetricPoints(key), + }; + + private IEnumerable ReadMetricPoints(string key) + { + lock (_lock) + { + if (!_metrics.TryGetValue(key, out var points)) return []; + return points.Select((point, index) => + { + var timestamp = DateTimeOffset.FromUnixTimeMilliseconds((long)(point.Timestamp * 1000)); + var dimensions = point.Dimensions.ToDictionary(x => x.Key, x => (object?)x.Value); + var fields = point.Dimensions.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => AdminData.Field(x.Key, x.Value)).Prepend(AdminData.Field("Unit", point.Unit)).ToArray(); + return AdminData.Node("datapoint", $"{index}:{point.Timestamp:R}", AdminData.IsoUtc(timestamp), + status: point.Value.ToString("R", CultureInfo.InvariantCulture)) with + { + ReadFields = () => fields, + ReadContent = () => AdminData.Json(new Dictionary + { + ["timestamp"] = AdminData.IsoUtc(timestamp), ["value"] = point.Value, + ["unit"] = point.Unit, ["dimensions"] = dimensions, + }), + }; + }).ToArray(); + } + } + + private AdminNode AlarmNode(string name, bool composite) + { + string arn; + string state; + lock (_lock) + { + var alarm = composite ? _compositeAlarms[name] : _alarms[name]; + arn = Str(alarm, "AlarmArn"); + state = Str(alarm, "StateValue"); + } + return AdminData.Node("alarm", (composite ? "composite:" : "metric:") + name, name, + arn, state) with + { + ReadFields = () => ReadAlarmFields(name, composite), + ReadConnections = () => ReadAlarmConnections(name, composite), + }; + } + + private IReadOnlyList ReadAlarmFields(string name, bool composite) + { + lock (_lock) + { + if (!(composite ? _compositeAlarms : _alarms).TryGetValue(name, out var alarm)) return []; + return alarm.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => AdminData.Field(x.Key, Scalar(x.Value))).ToArray(); + } + } + + private IReadOnlyList ReadAlarmConnections(string name, bool composite) + { + lock (_lock) + { + if (composite || !_alarms.TryGetValue(name, out var alarm)) return []; + var metricName = Str(alarm, "MetricName"); + var metricNamespace = Str(alarm, "Namespace"); + var match = _metrics.Keys.FirstOrDefault(key => + { + ParseMetricKey(key, out var ns, out var mn, out _); + return ns == metricNamespace && mn == metricName; + }); + return match is null ? [] : + [ + new("Metric", "configured", "cloudwatch", [new("metric", AdminId(match))]), + ]; + } + } + + private AdminNode DashboardNode(string name) => + AdminData.Node("dashboard", name, name, + $"arn:aws:cloudwatch::{AccountContext.GetAccountId()}:dashboard/{name}") with + { + ReadFields = () => + { + lock (_lock) + { + if (!_dashboards.TryGetValue(name, out var dashboard)) return []; + return + [ + AdminData.Field("Last modified", Epoch(dashboard.GetValueOrDefault("LastModified")), format: "datetime"), + AdminData.Field("Size", Scalar(dashboard.GetValueOrDefault("Size")), format: "bytes"), + ]; + } + }, + ReadContent = () => + { + lock (_lock) + return _dashboards.TryGetValue(name, out var dashboard) + ? AdminData.JsonText(Str(dashboard, "DashboardBody")) + : AdminData.Unavailable("Dashboard no longer exists.", "application/json"); + }, + }; + + private static string? Scalar(object? value) => value switch + { + null => null, string text => text, bool flag => flag ? "true" : "false", + IFormattable valueWithFormat => valueWithFormat.ToString(null, CultureInfo.InvariantCulture), + IEnumerable strings => string.Join(", ", strings), _ => value.ToString(), + }; + + private static string? Epoch(object? value) => + value is double epoch ? AdminData.IsoUtc(DateTimeOffset.FromUnixTimeMilliseconds((long)(epoch * 1000))) : Scalar(value); +} diff --git a/src/MicroStack/Services/CloudWatch/CloudWatchServiceHandler.cs b/src/MicroStack/Services/CloudWatch/CloudWatchServiceHandler.cs index afa9457..a59d659 100644 --- a/src/MicroStack/Services/CloudWatch/CloudWatchServiceHandler.cs +++ b/src/MicroStack/Services/CloudWatch/CloudWatchServiceHandler.cs @@ -5,6 +5,7 @@ using System.Text.RegularExpressions; using System.Web; using MicroStack.Internal; +using MicroStack.Internal.Admin; namespace MicroStack.Services.CloudWatch; @@ -21,10 +22,18 @@ namespace MicroStack.Services.CloudWatch; /// TagResource, UntagResource, ListTagsForResource, /// PutDashboard, GetDashboard, DeleteDashboards, ListDashboards. /// -internal sealed partial class CloudWatchServiceHandler : IServiceHandler +internal sealed partial class CloudWatchServiceHandler : IServiceHandler, IAdminResourceSource { public string ServiceName => "monitoring"; + public IEnumerable GetKnownAccountIds() + { + lock (_lock) + return _metrics.GetAccountIds(points => points.Count > 0) + .Concat(_alarms.GetAccountIds()).Concat(_compositeAlarms.GetAccountIds()) + .Concat(_dashboards.GetAccountIds()).ToArray(); + } + private static string Region => MicroStackOptions.Instance.Region; diff --git a/src/MicroStack/Services/CloudWatchLogs/CloudWatchLogsServiceHandler.Admin.cs b/src/MicroStack/Services/CloudWatchLogs/CloudWatchLogsServiceHandler.Admin.cs new file mode 100644 index 0000000..12bd390 --- /dev/null +++ b/src/MicroStack/Services/CloudWatchLogs/CloudWatchLogsServiceHandler.Admin.cs @@ -0,0 +1,185 @@ +using MicroStack.Admin.Contracts; +using MicroStack.Internal.Admin; + +namespace MicroStack.Services.CloudWatchLogs; + +internal sealed partial class CloudWatchLogsServiceHandler +{ + private static readonly AdminResourceKind[] AdminKinds = + [ + new("log-group", "Log groups"), new("destination", "Destinations"), + ]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + serviceId == "logs" ? AdminKinds : []; + + public IEnumerable GetAdminResources(string serviceId) + { + if (serviceId != "logs") return []; + lock (_lock) + { + return _logGroups.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => GroupNode(x.Key, x.Value.Arn)) + .Concat(_destinations.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => DestinationNode(x.Key, x.Value.Arn))).ToArray(); + } + } + + private AdminNode GroupNode(string name, string arn) => + AdminData.Node("log-group", name, name, arn) with + { + ReadFields = () => ReadGroupFields(name), + ChildKinds = + [ + new("log-stream", "Log streams") { IsRoot = false }, + new("subscription-filter", "Subscription filters") { IsRoot = false }, + new("metric-filter", "Metric filters") { IsRoot = false }, + new("tag", "Tags") { IsRoot = false }, + ], + ReadChildren = () => ReadGroupChildren(name), + }; + + private IReadOnlyList ReadGroupFields(string name) + { + lock (_lock) + { + if (!_logGroups.TryGetValue(name, out var group)) return []; + return + [ + AdminData.Field("Created", Millis(group.CreationTime), format: "datetime"), + AdminData.Field("Retention days", group.RetentionInDays?.ToString()), + AdminData.Field("Stored bytes", + group.Streams.Values.Sum(s => s.Events.Sum(e => (long)e.Message.Length)).ToString(), format: "bytes"), + ]; + } + } + + private IEnumerable ReadGroupChildren(string name) + { + lock (_lock) + { + if (!_logGroups.TryGetValue(name, out var group)) return []; + var nodes = group.Streams.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => StreamNode(name, x.Key)).ToList(); + nodes.AddRange(group.SubscriptionFilters.Values.OrderBy(x => x.FilterName, StringComparer.Ordinal) + .Select(SubscriptionNode)); + nodes.AddRange(_metricFilters.Values.Where(x => x.LogGroupName == name) + .OrderBy(x => x.FilterName, StringComparer.Ordinal).Select(MetricFilterNode)); + nodes.AddRange(group.Tags.OrderBy(x => x.Key, StringComparer.Ordinal).Select(x => TagNode(x.Key, x.Value))); + return nodes; + } + } + + private AdminNode StreamNode(string groupName, string streamName) => + AdminData.Node("log-stream", streamName, streamName) with + { + ReadFields = () => + { + lock (_lock) + { + if (!_logGroups.TryGetValue(groupName, out var group) || + !group.Streams.TryGetValue(streamName, out var stream)) return []; + return + [ + AdminData.Field("Created", Millis(stream.CreationTime), format: "datetime"), + AdminData.Field("First event", Millis(stream.FirstEventTimestamp), format: "datetime"), + AdminData.Field("Last event", Millis(stream.LastEventTimestamp), format: "datetime"), + AdminData.Field("Last ingestion", Millis(stream.LastIngestionTime), format: "datetime"), + AdminData.Field("Event count", stream.Events.Count.ToString()), + ]; + } + }, + ChildKinds = [new("log-event", "Log events") { IsRoot = false }], + ReadChildren = () => ReadEvents(groupName, streamName), + }; + + private IEnumerable ReadEvents(string groupName, string streamName) + { + lock (_lock) + { + if (!_logGroups.TryGetValue(groupName, out var group) || + !group.Streams.TryGetValue(streamName, out var stream)) return []; + return stream.Events.Select((entry, index) => + AdminData.Node("log-event", $"{entry.Timestamp}:{entry.IngestionTime}:{index}", + Millis(entry.Timestamp) ?? entry.Timestamp.ToString()) with + { + ReadFields = () => + [ + AdminData.Field("Timestamp", Millis(entry.Timestamp), format: "datetime"), + AdminData.Field("Ingestion time", Millis(entry.IngestionTime), format: "datetime"), + ], + ReadContent = () => AdminData.Text(entry.Message), + }).ToArray(); + } + } + + private AdminNode SubscriptionNode(SubscriptionFilter filter) => + AdminData.Node("subscription-filter", filter.FilterName, filter.FilterName, status: "configured") with + { + ReadFields = () => + [ + AdminData.Field("Pattern", filter.FilterPattern), + AdminData.Field("Destination ARN", filter.DestinationArn), + AdminData.Field("Role ARN", filter.RoleArn), + AdminData.Field("Distribution", filter.Distribution), + AdminData.Field("Created", Millis(filter.CreationTime), format: "datetime"), + ], + ReadConnections = () => + { + lock (_lock) + { + if (string.IsNullOrEmpty(filter.DestinationArn)) return []; + var destination = _destinations.Items.FirstOrDefault(x => x.Value.Arn == filter.DestinationArn); + return destination.Value is not null + ? [new("Destination", "configured", "logs", [new("destination", destination.Key)])] + : [new(filter.DestinationArn, "configured")]; + } + }, + }; + + private static AdminNode MetricFilterNode(MetricFilter filter) => + AdminData.Node("metric-filter", filter.FilterName, filter.FilterName, status: "configured") with + { + ReadFields = () => + [ + AdminData.Field("Pattern", filter.FilterPattern), + AdminData.Field("Created", Millis(filter.CreationTime), format: "datetime"), + ], + ReadContent = () => AdminData.Json(new Dictionary + { + ["metricTransformations"] = filter.MetricTransformations + .Select(x => (object?)x.ToDictionary(y => y.Key, y => y.Value)).ToArray(), + }), + }; + + private static AdminNode TagNode(string key, string value) => + AdminData.Node("tag", key, key) with { ReadFields = () => [AdminData.Field("Value", value)] }; + + private AdminNode DestinationNode(string name, string arn) => + AdminData.Node("destination", name, name, arn) with + { + ReadFields = () => + { + lock (_lock) + { + if (!_destinations.TryGetValue(name, out var destination)) return []; + return + [ + AdminData.Field("Target ARN", destination.TargetArn), + AdminData.Field("Role ARN", destination.RoleArn), + AdminData.Field("Created", Millis(destination.CreationTime), format: "datetime"), + ]; + } + }, + ReadConnections = () => + { + lock (_lock) + return _destinations.TryGetValue(name, out var destination) && + !string.IsNullOrEmpty(destination.TargetArn) + ? [new(destination.TargetArn, "configured")] : []; + }, + }; + + private static string? Millis(long? value) => + value is null ? null : AdminData.IsoUtc(DateTimeOffset.FromUnixTimeMilliseconds(value.Value)); +} diff --git a/src/MicroStack/Services/CloudWatchLogs/CloudWatchLogsServiceHandler.cs b/src/MicroStack/Services/CloudWatchLogs/CloudWatchLogsServiceHandler.cs index 1d871fb..e7d373d 100644 --- a/src/MicroStack/Services/CloudWatchLogs/CloudWatchLogsServiceHandler.cs +++ b/src/MicroStack/Services/CloudWatchLogs/CloudWatchLogsServiceHandler.cs @@ -1,6 +1,7 @@ using System.Text; using System.Text.Json; using MicroStack.Internal; +using MicroStack.Internal.Admin; namespace MicroStack.Services.CloudWatchLogs; @@ -20,7 +21,7 @@ namespace MicroStack.Services.CloudWatchLogs; /// PutDestination, DescribeDestinations, PutDestinationPolicy, DeleteDestination, /// StartQuery, GetQueryResults, StopQuery. /// -internal sealed class CloudWatchLogsServiceHandler : IServiceHandler +internal sealed partial class CloudWatchLogsServiceHandler : IServiceHandler, IAdminResourceSource { private readonly AccountScopedDictionary _logGroups = new(); // keyed by group name private readonly AccountScopedDictionary _destinations = new(); // keyed by dest name @@ -40,6 +41,9 @@ internal sealed class CloudWatchLogsServiceHandler : IServiceHandler public string ServiceName => "logs"; + public IEnumerable GetKnownAccountIds() => + _logGroups.GetAccountIds().Concat(_destinations.GetAccountIds()).Concat(_queries.GetAccountIds()); + public Task HandleAsync(ServiceRequest request) { var target = request.GetHeader("x-amz-target") ?? ""; diff --git a/src/MicroStack/Services/Cognito/CognitoIdentityServiceHandler.Admin.cs b/src/MicroStack/Services/Cognito/CognitoIdentityServiceHandler.Admin.cs new file mode 100644 index 0000000..ffeb818 --- /dev/null +++ b/src/MicroStack/Services/Cognito/CognitoIdentityServiceHandler.Admin.cs @@ -0,0 +1,141 @@ +using System.Globalization; +using MicroStack.Admin.Contracts; +using MicroStack.Internal.Admin; + +namespace MicroStack.Services.Cognito; + +internal sealed partial class CognitoIdentityServiceHandler +{ + private static readonly AdminResourceKind[] AdminKinds = + [ + new("identity-pool", "Identity pools"), + new("identity", "Identities") { IsRoot = false } + ]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => AdminKinds; + + public IEnumerable GetAdminResources(string serviceId) + { + lock (_lock) + { + return _identityPools.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => IdentityPoolNode(x.Key, x.Value)).ToArray(); + } + } + + private AdminNode IdentityPoolNode(string poolId, Dictionary snapshot) => + AdminData.Node("identity-pool", poolId, + Text(snapshot, "IdentityPoolName") ?? poolId, status: "Active") with + { + ReadFields = () => + { + lock (_lock) + { + if (!_identityPools.TryGetValue(poolId, out var pool)) return []; + return + [ + AdminData.Field("Allow unauthenticated identities", Scalar(pool, "AllowUnauthenticatedIdentities")), + AdminData.Field("Allow classic flow", Scalar(pool, "AllowClassicFlow")), + AdminData.Field("Developer provider name", Text(pool, "DeveloperProviderName")), + AdminData.Field("Supported login providers", Map(pool, "SupportedLoginProviders")), + AdminData.Field("OpenID Connect provider ARNs", Strings(pool, "OpenIdConnectProviderARNs")), + AdminData.Field("SAML provider ARNs", Strings(pool, "SamlProviderARNs")), + AdminData.Field("Tags", Map(pool, "IdentityPoolTags")), + AdminData.Field("Roles", Map(pool, "_roles")) + ]; + } + }, + ChildKinds = [AdminKinds[1]], + ReadChildren = () => + { + lock (_lock) + { + if (!_identityPools.TryGetValue(poolId, out var pool) || + pool.GetValueOrDefault("_identities") is not + Dictionary> identities) + return []; + return identities.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => IdentityNode(poolId, x.Key, x.Value)).ToArray(); + } + }, + ReadConnections = () => + { + lock (_lock) + { + if (!_identityPools.TryGetValue(poolId, out var pool)) return []; + var ids = new HashSet(StringComparer.Ordinal); + if (pool.GetValueOrDefault("CognitoIdentityProviders") is List providers) + { + foreach (var provider in providers.OfType>()) + if (provider.GetValueOrDefault("ProviderName") is string name) + foreach (var id in _idp.UserPools.Keys.Where(name.Contains)) + ids.Add(id); + } + var links = ids.Where(_idp.UserPools.ContainsKey).OrderBy(x => x, StringComparer.Ordinal) + .Select(id => new AdminConnection(id, "identity-provider", "cognitoidp", + [new AdminKey("user-pool", id)])).ToList(); + if (pool.GetValueOrDefault("_roles") is Dictionary roles) + { + links.AddRange(roles.OrderBy(x => x.Key, StringComparer.Ordinal).Select(role => + { + var roleName = role.Value[(role.Value.LastIndexOf('/') + 1)..]; + return new AdminConnection($"{role.Key} role", "role", "iam", + [new AdminKey("role", roleName)]); + })); + } + return links; + } + } + }; + + private AdminNode IdentityNode(string poolId, string identityId, Dictionary snapshot) => + AdminData.Node("identity", identityId, identityId, status: "Active") with + { + ReadFields = () => + { + lock (_lock) + { + if (!TryIdentity(poolId, identityId, out var identity)) return []; + return + [ + AdminData.Field("Created", Epoch(identity, "CreationDate"), format: "datetime"), + AdminData.Field("Last modified", Epoch(identity, "LastModifiedDate"), format: "datetime"), + AdminData.Field("Logins", Map(identity, "Logins")) + ]; + } + } + }; + + private bool TryIdentity(string poolId, string identityId, + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Dictionary? identity) + { + identity = null; + return _identityPools.TryGetValue(poolId, out var pool) && + pool.GetValueOrDefault("_identities") is + Dictionary> identities && + identities.TryGetValue(identityId, out identity); + } + + private static string? Text(IReadOnlyDictionary value, string key) => + value.TryGetValue(key, out var item) ? item as string : null; + + private static string? Scalar(IReadOnlyDictionary value, string key) => + value.TryGetValue(key, out var item) && item is not null + ? Convert.ToString(item, CultureInfo.InvariantCulture) + : null; + + private static string? Epoch(IReadOnlyDictionary value, string key) => + value.TryGetValue(key, out var item) && item is double seconds + ? AdminData.IsoUtc(DateTimeOffset.FromUnixTimeMilliseconds((long)(seconds * 1000))) + : null; + + private static string Strings(IReadOnlyDictionary value, string key) => + value.TryGetValue(key, out var item) && item is List values + ? string.Join(", ", values) + : ""; + + private static string Map(IReadOnlyDictionary value, string key) => + value.TryGetValue(key, out var item) && item is Dictionary map + ? string.Join(", ", map.Select(x => $"{x.Key}={x.Value}")) + : ""; +} diff --git a/src/MicroStack/Services/Cognito/CognitoIdentityServiceHandler.cs b/src/MicroStack/Services/Cognito/CognitoIdentityServiceHandler.cs index dfefd7b..f339472 100644 --- a/src/MicroStack/Services/Cognito/CognitoIdentityServiceHandler.cs +++ b/src/MicroStack/Services/Cognito/CognitoIdentityServiceHandler.cs @@ -10,7 +10,7 @@ namespace MicroStack.Services.Cognito; /// /// Port of ministack/services/cognito.py (Identity Pool portion). /// -internal sealed class CognitoIdentityServiceHandler : IServiceHandler +internal sealed partial class CognitoIdentityServiceHandler : IServiceHandler, Internal.Admin.IAdminResourceSource { private readonly CognitoIdpServiceHandler _idp; private readonly Lock _lock = new(); @@ -29,6 +29,8 @@ internal CognitoIdentityServiceHandler(CognitoIdpServiceHandler idpHandler) // ── IServiceHandler ──────────────────────────────────────────────────── public string ServiceName => "cognito-identity"; + public IEnumerable GetKnownAccountIds() => _identityPools.GetAccountIds(); + public Task HandleAsync(ServiceRequest request) { var target = request.GetHeader("x-amz-target") ?? ""; diff --git a/src/MicroStack/Services/Cognito/CognitoIdpServiceHandler.Admin.cs b/src/MicroStack/Services/Cognito/CognitoIdpServiceHandler.Admin.cs new file mode 100644 index 0000000..bfe2ec7 --- /dev/null +++ b/src/MicroStack/Services/Cognito/CognitoIdpServiceHandler.Admin.cs @@ -0,0 +1,216 @@ +using System.Globalization; +using MicroStack.Admin.Contracts; +using MicroStack.Internal.Admin; + +namespace MicroStack.Services.Cognito; + +internal sealed partial class CognitoIdpServiceHandler +{ + private static readonly AdminResourceKind[] AdminKinds = + [ + new("user-pool", "User pools"), + new("user-pool-client", "User pool clients") { IsRoot = false }, + new("user", "Users") { IsRoot = false }, + new("group", "Groups") { IsRoot = false } + ]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => AdminKinds; + + public IEnumerable GetAdminResources(string serviceId) + { + lock (_lock) + { + return UserPools.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => UserPoolNode(x.Key, x.Value)).ToArray(); + } + } + + private AdminNode UserPoolNode(string poolId, Dictionary snapshot) => + AdminData.Node("user-pool", poolId, String(snapshot, "Name") ?? poolId, + String(snapshot, "Arn"), "Active") with + { + ReadFields = () => + { + lock (_lock) + { + if (!UserPools.TryGetValue(poolId, out var pool)) return []; + return + [ + AdminData.Field("Created", Epoch(pool, "CreationDate"), format: "datetime"), + AdminData.Field("Last modified", Epoch(pool, "LastModifiedDate"), format: "datetime"), + AdminData.Field("MFA configuration", String(pool, "MfaConfiguration")), + AdminData.Field("Estimated users", Scalar(pool, "EstimatedNumberOfUsers")), + AdminData.Field("Domain", String(pool, "Domain")), + AdminData.Field("Auto-verified attributes", Strings(pool, "AutoVerifiedAttributes")), + AdminData.Field("Alias attributes", Strings(pool, "AliasAttributes")), + AdminData.Field("Username attributes", Strings(pool, "UsernameAttributes")), + AdminData.Field("Tags", StringMap(pool, "UserPoolTags")) + ]; + } + }, + ChildKinds = AdminKinds[1..], + ReadChildren = () => + { + lock (_lock) + { + if (!UserPools.TryGetValue(poolId, out var pool)) return []; + var children = GetClients(pool).OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => ClientNode(poolId, x.Key, x.Value)).ToList(); + children.AddRange(GetUsers(pool).OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => UserNode(poolId, x.Key, x.Value))); + children.AddRange(GetGroups(pool).OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => GroupNode(poolId, x.Key, x.Value))); + return children; + } + } + }; + + private AdminNode ClientNode(string poolId, string clientId, Dictionary snapshot) + { + var hasSecret = snapshot.TryGetValue("ClientSecret", out var secret) && secret is string; + return AdminData.Node("user-pool-client", clientId, + String(snapshot, "ClientName") ?? clientId, status: "Active") with + { + ReadFields = () => + { + lock (_lock) + { + if (!TryChild(poolId, "_clients", clientId, out var client)) return []; + var secretExists = client.TryGetValue("ClientSecret", out var value) && value is string; + return + [ + AdminData.Field("Client ID", clientId), + AdminData.Field("Created", Epoch(client, "CreationDate"), format: "datetime"), + AdminData.Field("Last modified", Epoch(client, "LastModifiedDate"), format: "datetime"), + AdminData.Field("Refresh token validity", Scalar(client, "RefreshTokenValidity")), + AdminData.Field("Access token validity", Scalar(client, "AccessTokenValidity")), + AdminData.Field("ID token validity", Scalar(client, "IdTokenValidity")), + AdminData.Field("Client secret", value as string, sensitive: true, canReveal: secretExists), + AdminData.Field("Explicit auth flows", Strings(client, "ExplicitAuthFlows")), + AdminData.Field("OAuth flows", Strings(client, "AllowedOAuthFlows")), + AdminData.Field("OAuth scopes", Strings(client, "AllowedOAuthScopes")) + ]; + } + }, + RevealableFields = hasSecret ? ["ClientSecret"] : [], + RevealField = field => + { + lock (_lock) + { + return field == "ClientSecret" && + TryChild(poolId, "_clients", clientId, out var client) && + client.TryGetValue("ClientSecret", out var value) && + value is string text + ? AdminData.Text(text, sensitive: true) + : AdminData.Unavailable("The requested value is not retained or revealable.", sensitive: true); + } + } + }; + } + + private AdminNode UserNode(string poolId, string username, Dictionary snapshot) => + AdminData.Node("user", username, username, status: String(snapshot, "UserStatus")) with + { + ReadFields = () => + { + lock (_lock) + { + if (!TryChild(poolId, "_users", username, out var user)) return []; + return + [ + AdminData.Field("Enabled", Scalar(user, "Enabled")), + AdminData.Field("Created", Epoch(user, "UserCreateDate"), format: "datetime"), + AdminData.Field("Last modified", Epoch(user, "UserLastModifiedDate"), format: "datetime"), + AdminData.Field("Groups", Strings(user, "_groups")), + AdminData.Field("Password", null, sensitive: true) + ]; + } + }, + ReadConnections = () => + { + lock (_lock) + { + if (!TryChild(poolId, "_users", username, out var user)) return []; + return List(user, "_groups").Where(group => + TryChild(poolId, "_groups", group, out _)) + .Select(group => new AdminConnection(group, "member-of", "cognitoidp", + [new AdminKey("user-pool", poolId), new AdminKey("group", group)])) + .ToArray(); + } + } + }; + + private AdminNode GroupNode(string poolId, string name, Dictionary snapshot) => + AdminData.Node("group", name, name, status: "Active") with + { + ReadFields = () => + { + lock (_lock) + { + if (!TryChild(poolId, "_groups", name, out var group)) return []; + return + [ + AdminData.Field("Description", String(group, "Description")), + AdminData.Field("Role ARN", String(group, "RoleArn")), + AdminData.Field("Precedence", Scalar(group, "Precedence")), + AdminData.Field("Created", Epoch(group, "CreationDate"), format: "datetime"), + AdminData.Field("Last modified", Epoch(group, "LastModifiedDate"), format: "datetime"), + AdminData.Field("Members", Strings(group, "_members")) + ]; + } + }, + ReadConnections = () => + { + lock (_lock) + { + if (!TryChild(poolId, "_groups", name, out var group)) return []; + var links = List(group, "_members").Where(user => + TryChild(poolId, "_users", user, out _)) + .Select(user => new AdminConnection(user, "member", "cognitoidp", + [new AdminKey("user-pool", poolId), new AdminKey("user", user)])) + .ToList(); + if (String(group, "RoleArn") is { Length: > 0 } arn) + { + var roleName = arn[(arn.LastIndexOf('/') + 1)..]; + links.Add(new AdminConnection(roleName, "role", "iam", + [new AdminKey("role", roleName)])); + } + return links; + } + } + }; + + private bool TryChild(string poolId, string collection, string key, + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Dictionary? value) + { + value = null; + return UserPools.TryGetValue(poolId, out var pool) && + pool.TryGetValue(collection, out var raw) && + raw is Dictionary> children && + children.TryGetValue(key, out value); + } + + private static string? String(IReadOnlyDictionary value, string key) => + value.TryGetValue(key, out var item) ? item as string : null; + + private static string? Scalar(IReadOnlyDictionary value, string key) => + value.TryGetValue(key, out var item) && item is not null + ? Convert.ToString(item, CultureInfo.InvariantCulture) + : null; + + private static string? Epoch(IReadOnlyDictionary value, string key) => + value.TryGetValue(key, out var item) && item is double seconds + ? AdminData.IsoUtc(DateTimeOffset.FromUnixTimeMilliseconds((long)(seconds * 1000))) + : null; + + private static List List(IReadOnlyDictionary value, string key) => + value.TryGetValue(key, out var item) && item is List list ? list : []; + + private static string Strings(IReadOnlyDictionary value, string key) => + string.Join(", ", List(value, key)); + + private static string StringMap(IReadOnlyDictionary value, string key) => + value.TryGetValue(key, out var item) && item is Dictionary map + ? string.Join(", ", map.Select(x => $"{x.Key}={x.Value}")) + : ""; +} diff --git a/src/MicroStack/Services/Cognito/CognitoIdpServiceHandler.cs b/src/MicroStack/Services/Cognito/CognitoIdpServiceHandler.cs index 74f2733..4f92f83 100644 --- a/src/MicroStack/Services/Cognito/CognitoIdpServiceHandler.cs +++ b/src/MicroStack/Services/Cognito/CognitoIdpServiceHandler.cs @@ -12,7 +12,7 @@ namespace MicroStack.Services.Cognito; /// /// Port of ministack/services/cognito.py (IDP portion). /// -internal sealed partial class CognitoIdpServiceHandler : IServiceHandler +internal sealed partial class CognitoIdpServiceHandler : IServiceHandler, Internal.Admin.IAdminResourceSource { private readonly Lock _lock = new(); @@ -37,6 +37,8 @@ internal CognitoIdpServiceHandler() // ── IServiceHandler ──────────────────────────────────────────────────── public string ServiceName => "cognito-idp"; + public IEnumerable GetKnownAccountIds() => UserPools.GetAccountIds(); + public Task HandleAsync(ServiceRequest request) { var path = request.Path; diff --git a/src/MicroStack/Services/DynamoDb/DynamoDbServiceHandler.cs b/src/MicroStack/Services/DynamoDb/DynamoDbServiceHandler.cs index 5a6324a..17dcf7b 100644 --- a/src/MicroStack/Services/DynamoDb/DynamoDbServiceHandler.cs +++ b/src/MicroStack/Services/DynamoDb/DynamoDbServiceHandler.cs @@ -2,6 +2,8 @@ using System.Text.Json; using System.Text.Json.Nodes; using MicroStack.Internal; +using MicroStack.Internal.Admin; +using MicroStack.Admin.Contracts; namespace MicroStack.Services.DynamoDb; @@ -10,7 +12,7 @@ namespace MicroStack.Services.DynamoDb; /// /// Port of ministack/services/dynamodb.py. /// -internal sealed class DynamoDbServiceHandler : IServiceHandler +internal sealed class DynamoDbServiceHandler : IServiceHandler, IResourceProvider, IAdminResourceSource { // ── State ──────────────────────────────────────────────────────────────────── @@ -29,6 +31,8 @@ internal sealed class DynamoDbServiceHandler : IServiceHandler public string ServiceName => "dynamodb"; + public IEnumerable GetKnownAccountIds() => _tables.GetAccountIds(); + public Task HandleAsync(ServiceRequest request) { var target = request.GetHeader("x-amz-target") ?? ""; @@ -106,6 +110,235 @@ public void Reset() public void RestoreState(JsonElement state) { } // persistence not implemented in Phase 1 + public ResourceSummary GetResources() + { + lock (_lock) + { + var items = _tables.Items + .Select(kv => new ResourceItem( + kv.Key, + kv.Value.TableArn, + new Dictionary(StringComparer.Ordinal) + { + ["ItemCount"] = kv.Value.ItemCount.ToString(), + ["TableStatus"] = kv.Value.TableStatus, + })) + .OrderBy(item => item.Name, StringComparer.Ordinal) + .ToList(); + + return new ResourceSummary("dynamodb", items.Count, items); + } + } + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + [ + new("tables", "Tables"), + ]; + + public IEnumerable GetAdminResources(string serviceId) + { + lock (_lock) + { + return _tables.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => TableAdminNode(x.Key)).ToArray(); + } + } + + private AdminNode TableAdminNode(string tableName) + { + if (!_tables.TryGetValue(tableName, out var table)) + return AdminData.Node("tables", tableName, tableName); + return AdminData.Node("tables", tableName, tableName, table.TableArn, table.TableStatus, + type: "Table", summary: DynamoTableSummary(table)) with + { + ChildKinds = [new("items", "Items") { IsRoot = false }], + ReadSummary = () => + { + lock (_lock) + return _tables.TryGetValue(tableName, out var current) ? DynamoTableSummary(current) : []; + }, + ReadFields = () => + { + lock (_lock) + { + if (!_tables.TryGetValue(tableName, out var current)) + return []; + var ttl = _ttlSettings.TryGetValue(tableName, out var setting) ? setting : null; + return + [ + AdminData.Field("TableId", current.TableId), + AdminData.Field("Status", current.TableStatus), + AdminData.Field("ItemCount", RetainedDynamoItemCount(current).ToString()), + AdminData.Field("TableSizeBytes", current.TableSizeBytes.ToString()), + AdminData.Field("PartitionKey", current.PkName), + AdminData.Field("SortKey", current.SkName), + AdminData.Field("PartitionKeyType", DynamoKeyType(current, current.PkName)), + AdminData.Field("SortKeyType", DynamoKeyType(current, current.SkName)), + AdminData.Field("KeySchema", current.KeySchema.ToJsonString(), format: "json"), + AdminData.Field("AttributeDefinitions", current.AttributeDefinitions.ToJsonString(), format: "json"), + AdminData.Field("CreationDateTime", + AdminData.IsoUtc(DateTimeOffset.UnixEpoch.AddSeconds(current.CreationDateTime)), format: "datetime"), + AdminData.Field("BillingMode", current.BillingModeSummary?["BillingMode"]?.ToString()), + AdminData.Field("ProvisionedThroughput", current.ProvisionedThroughput.ToJsonString(), format: "json"), + AdminData.Field("StreamSpecification", current.StreamSpecification?.ToJsonString(), format: "json"), + AdminData.Field("SSEDescription", current.SseDescription?.ToJsonString(), format: "json"), + AdminData.Field("GlobalSecondaryIndexes", current.Gsis.ToJsonString(), format: "json"), + AdminData.Field("LocalSecondaryIndexes", current.Lsis.ToJsonString(), format: "json"), + AdminData.Field("TimeToLiveStatus", ttl?.Status), + AdminData.Field("TimeToLiveAttribute", ttl?.AttributeName), + AdminData.Field("PointInTimeRecoveryEnabled", + (_pitrSettings.TryGetValue(tableName, out var enabled) && enabled).ToString()), + AdminData.Field("AccountId", AccountContext.GetAccountId()), + .. current.Attributes.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => AdminData.Field($"Attributes.{x.Key}", x.Value, IsSensitiveDynamoName(x.Key))), + .. (_tags.TryGetValue(current.TableArn, out var tags) ? tags : []) + .OrderBy(x => x["Key"]?.ToString(), StringComparer.Ordinal) + .Select(x => AdminData.Field($"Tags.{x["Key"]}", x["Value"]?.ToString(), + IsSensitiveDynamoName(x["Key"]?.ToString() ?? ""))), + ]; + } + }, + ReadChildren = () => ReadDynamoItems(tableName), + }; + } + + private static int RetainedDynamoItemCount(DdbTable table) => + table.Items.Values.Sum(partition => partition.Count); + + private static string? DynamoKeyType(DdbTable table, string? name) => + name is null ? null : table.AttributeDefinitions.OfType() + .FirstOrDefault(x => x["AttributeName"]?.ToString() == name)?["AttributeType"]?.ToString(); + + private static IReadOnlyList DynamoTableSummary(DdbTable table) + { + var fields = new List(); + if (table.PkName is not null) + fields.Add(AdminData.Field("PartitionKey", DynamoKeyDescription(table, table.PkName))); + if (table.SkName is not null) + fields.Add(AdminData.Field("SortKey", DynamoKeyDescription(table, table.SkName))); + fields.Add(AdminData.Field("ItemCount", RetainedDynamoItemCount(table).ToString())); + return fields.Where(field => field.Value is not { Length: > 4096 }).ToArray(); + } + + private static string DynamoKeyDescription(DdbTable table, string name) => + DynamoKeyType(table, name) is { } type ? $"{name} ({type})" : name; + + private static IReadOnlyList DynamoItemSummary(DdbTable table, JsonObject item) + { + var fields = new List(); + foreach (var name in new[] { table.PkName, table.SkName }) + { + if (name is null) + continue; + var value = item[name]?.ToJsonString(); + if (value is { Length: <= 4096 }) + fields.Add(AdminData.Field(name, value, IsSensitiveDynamoName(name), format: "dynamodb-attribute")); + } + return fields; + } + + private static bool IsSensitiveDynamoName(string name) => + name.Contains("password", StringComparison.OrdinalIgnoreCase) || + name.Contains("secret", StringComparison.OrdinalIgnoreCase) || + name.Contains("token", StringComparison.OrdinalIgnoreCase) || + name.Contains("credential", StringComparison.OrdinalIgnoreCase) || + name.Contains("authorization", StringComparison.OrdinalIgnoreCase); + + private IReadOnlyList ReadDynamoItems(string tableName) + { + lock (_lock) + { + if (!_tables.TryGetValue(tableName, out var table)) + return []; + var nodes = new List(); + foreach (var partition in table.Items) + foreach (var itemEntry in partition.Value) + { + var item = itemEntry.Value; + if (table.PkName is null || item[table.PkName] is not JsonNode pk) + continue; + var sk = table.SkName is null ? null : item[table.SkName]; + var id = DynamoItemId(pk, sk); + var name = DynamoItemName(table, pk, sk); + nodes.Add(AdminData.Node("items", id, name, type: "Item", summary: DynamoItemSummary(table, item)) with + { + ReadSummary = () => ReadDynamoItemSummary(tableName, id), + ReadFields = () => ReadDynamoItemFields(tableName, id), + ReadContent = () => ReadDynamoItemContent(tableName, id), + }); + } + return nodes.OrderBy(x => x.Resource.Key.Id, StringComparer.Ordinal).ToArray(); + } + } + + private IReadOnlyList ReadDynamoItemSummary(string tableName, string itemId) + { + lock (_lock) + { + var found = FindDynamoItem(tableName, itemId); + return found is null ? [] : DynamoItemSummary(found.Value.Table, found.Value.Item); + } + } + + private IReadOnlyList ReadDynamoItemFields(string tableName, string itemId) + { + lock (_lock) + { + var found = FindDynamoItem(tableName, itemId); + if (found is null) + return []; + var (table, item) = found.Value; + var fields = new List(); + if (table.PkName is not null) + fields.Add(AdminData.Field(table.PkName, item[table.PkName]?.ToJsonString(), + IsSensitiveDynamoName(table.PkName), format: "dynamodb-attribute")); + if (table.SkName is not null) + fields.Add(AdminData.Field(table.SkName, item[table.SkName]?.ToJsonString(), + IsSensitiveDynamoName(table.SkName), format: "dynamodb-attribute")); + fields.Add(AdminData.Field("AttributeCount", item.Count.ToString(), secondary: true)); + return fields; + } + } + + private AdminContent ReadDynamoItemContent(string tableName, string itemId) + { + lock (_lock) + { + var found = FindDynamoItem(tableName, itemId); + return found is null + ? AdminData.Unavailable("Item no longer exists.", "application/json") + : AdminData.JsonText(found.Value.Item.ToJsonString()); + } + } + + private (DdbTable Table, JsonObject Item)? FindDynamoItem(string tableName, string itemId) + { + if (!_tables.TryGetValue(tableName, out var table)) + return null; + foreach (var partition in table.Items.Values) + foreach (var item in partition.Values) + { + if (table.PkName is null || item[table.PkName] is not JsonNode pk) + continue; + var sk = table.SkName is null ? null : item[table.SkName]; + if (DynamoItemId(pk, sk) == itemId) + return (table, item); + } + return null; + } + + private static string DynamoItemId(JsonNode pk, JsonNode? sk) + { + var key = new JsonArray(pk.DeepClone(), sk?.DeepClone()).ToJsonString(); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(key)) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + private static string DynamoItemName(DdbTable table, JsonNode pk, JsonNode? sk) => + table.SkName is null + ? $"{table.PkName}={pk.ToJsonString()}" + : $"{table.PkName}={pk.ToJsonString()}, {table.SkName}={sk?.ToJsonString()}"; + // ── Internal stream record access (used by Lambda ESM) ────────────────────── /// Dequeue stream records for a table (called by Lambda ESM poller). diff --git a/src/MicroStack/Services/Ec2/AdminProjection.cs b/src/MicroStack/Services/Ec2/AdminProjection.cs new file mode 100644 index 0000000..9507cfc --- /dev/null +++ b/src/MicroStack/Services/Ec2/AdminProjection.cs @@ -0,0 +1,54 @@ +using System.Collections; +using System.Globalization; +using MicroStack.Admin.Contracts; + +namespace MicroStack.Internal.Admin; + +internal static class AdminProjection +{ + internal static IReadOnlyDictionary Snapshot( + IDictionary source, Func? redact = null, Func? omit = null) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (DictionaryEntry entry in source) + { + if (entry.Key is not string key || omit?.Invoke(key) == true) + continue; + result[key] = redact?.Invoke(key) == true + ? AdminData.MaskedValue + : SnapshotValue(entry.Value, redact, omit); + } + return result; + } + + internal static AdminContent Content(IReadOnlyDictionary snapshot) => + AdminData.Json(snapshot); + + internal static IReadOnlyList Fields( + IReadOnlyDictionary snapshot, params string[] names) => + names.Where(snapshot.ContainsKey) + .Select(name => AdminData.Field(name, Scalar(snapshot[name]))) + .ToArray(); + + internal static string? Scalar(object? value) => value switch + { + null => null, + string text => text, + bool boolean => boolean ? "true" : "false", + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => null, + }; + + private static object? SnapshotValue(object? value, Func? redact, Func? omit) => + value switch + { + null or string or bool or byte or sbyte or short or ushort or int or uint or long or ulong + or float or double or decimal => value, + DateTime date => AdminData.IsoUtc(date), + DateTimeOffset date => AdminData.IsoUtc(date), + IDictionary dictionary => Snapshot(dictionary, redact, omit), + IEnumerable sequence => sequence.Cast() + .Select(item => SnapshotValue(item, redact, omit)).ToArray(), + _ => value.ToString(), + }; +} diff --git a/src/MicroStack/Services/Ec2/Ec2ServiceHandler.cs b/src/MicroStack/Services/Ec2/Ec2ServiceHandler.cs index 3280ff2..7904535 100644 --- a/src/MicroStack/Services/Ec2/Ec2ServiceHandler.cs +++ b/src/MicroStack/Services/Ec2/Ec2ServiceHandler.cs @@ -2,13 +2,33 @@ using System.Text; using System.Web; using MicroStack.Internal; +using MicroStack.Internal.Admin; +using MicroStack.Admin.Contracts; namespace MicroStack.Services.Ec2; -internal sealed class Ec2ServiceHandler : IServiceHandler +internal sealed class Ec2ServiceHandler : IServiceHandler, IAdminResourceSource { public string ServiceName => "ec2"; + public IEnumerable GetKnownAccountIds() => + _instances.GetAccountIds() + .Concat(_securityGroups.GetAccountIds(includeKey: key => key != DefaultSgId)) + .Concat(_keyPairs.GetAccountIds()) + .Concat(_vpcs.GetAccountIds(includeKey: key => key != DefaultVpcId)) + .Concat(_subnets.GetAccountIds(includeKey: key => + key is not (DefaultSubnetId or DefaultSubnetIdB or DefaultSubnetIdC))) + .Concat(_internetGateways.GetAccountIds(includeKey: key => key != DefaultIgwId)) + .Concat(_addresses.GetAccountIds()) + .Concat(_routeTables.GetAccountIds(includeKey: key => key != DefaultRtbId)) + .Concat(_networkInterfaces.GetAccountIds()).Concat(_vpcEndpoints.GetAccountIds()) + .Concat(_volumes.GetAccountIds()).Concat(_snapshots.GetAccountIds()) + .Concat(_natGateways.GetAccountIds()).Concat(_networkAcls.GetAccountIds()) + .Concat(_flowLogs.GetAccountIds()).Concat(_vpcPeering.GetAccountIds()) + .Concat(_dhcpOptions.GetAccountIds()).Concat(_egressIgws.GetAccountIds()) + .Concat(_prefixLists.GetAccountIds()).Concat(_vpnGateways.GetAccountIds()) + .Concat(_customerGateways.GetAccountIds()).Concat(_launchTemplates.GetAccountIds()); + private const string Ec2Ns = "http://ec2.amazonaws.com/doc/2016-11-15/"; private const string DefaultVpcId = "vpc-00000001"; private const string DefaultSubnetId = "subnet-00000001"; @@ -166,6 +186,174 @@ public void Reset() } } + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + serviceId == ServiceName ? + [ + new("instances", "Instances"), + new("security-groups", "Security groups"), + new("key-pairs", "Key pairs"), + new("vpcs", "VPCs"), + new("subnets", "Subnets"), + new("internet-gateways", "Internet gateways"), + new("elastic-ips", "Elastic IP addresses"), + new("route-tables", "Route tables"), + new("network-interfaces", "Network interfaces"), + new("vpc-endpoints", "VPC endpoints"), + new("volumes", "Volumes"), + new("snapshots", "Snapshots"), + new("nat-gateways", "NAT gateways"), + new("network-acls", "Network ACLs"), + new("flow-logs", "Flow logs"), + new("vpc-peerings", "VPC peerings"), + new("dhcp-options", "DHCP options"), + new("egress-internet-gateways", "Egress-only internet gateways"), + new("prefix-lists", "Prefix lists"), + new("vpn-gateways", "VPN gateways"), + new("customer-gateways", "Customer gateways"), + new("launch-templates", "Launch templates"), + new("launch-template-versions", "Launch template versions") { IsRoot = false }, + ] : []; + + public IEnumerable GetAdminResources(string serviceId) + { + if (serviceId != ServiceName) + return []; + lock (_lock) + { + var nodes = new List(); + Add(nodes, _instances, "instances", "InstanceId", "InstanceId", "State"); + Add(nodes, _securityGroups, "security-groups", "GroupId", "GroupName"); + Add(nodes, _keyPairs, "key-pairs", "KeyPairId", "KeyName", omitPrivateMaterial: true); + Add(nodes, _vpcs, "vpcs", "VpcId", "VpcId", "State"); + Add(nodes, _subnets, "subnets", "SubnetId", "SubnetId", "State"); + Add(nodes, _internetGateways, "internet-gateways", "InternetGatewayId", "InternetGatewayId"); + Add(nodes, _addresses, "elastic-ips", "AllocationId", "PublicIp"); + Add(nodes, _routeTables, "route-tables", "RouteTableId", "RouteTableId"); + Add(nodes, _networkInterfaces, "network-interfaces", "NetworkInterfaceId", "NetworkInterfaceId", "Status"); + Add(nodes, _vpcEndpoints, "vpc-endpoints", "VpcEndpointId", "ServiceName", "State"); + Add(nodes, _volumes, "volumes", "VolumeId", "VolumeId", "State"); + Add(nodes, _snapshots, "snapshots", "SnapshotId", "SnapshotId", "State"); + Add(nodes, _natGateways, "nat-gateways", "NatGatewayId", "NatGatewayId", "State"); + Add(nodes, _networkAcls, "network-acls", "NetworkAclId", "NetworkAclId"); + Add(nodes, _flowLogs, "flow-logs", "FlowLogId", "FlowLogId", "FlowLogStatus"); + Add(nodes, _vpcPeering, "vpc-peerings", "VpcPeeringConnectionId", "VpcPeeringConnectionId", "Status"); + Add(nodes, _dhcpOptions, "dhcp-options", "DhcpOptionsId", "DhcpOptionsId"); + Add(nodes, _egressIgws, "egress-internet-gateways", "EgressOnlyInternetGatewayId", + "EgressOnlyInternetGatewayId"); + Add(nodes, _prefixLists, "prefix-lists", "PrefixListId", "PrefixListName", "State"); + Add(nodes, _vpnGateways, "vpn-gateways", "VpnGatewayId", "VpnGatewayId", "State"); + Add(nodes, _customerGateways, "customer-gateways", "CustomerGatewayId", "CustomerGatewayId", "State"); + AddLaunchTemplates(nodes); + return nodes.OrderBy(node => node.Resource.Key.Kind, StringComparer.Ordinal) + .ThenBy(node => node.Resource.Key.Id, StringComparer.Ordinal).ToArray(); + } + } + + private void Add( + List nodes, + AccountScopedDictionary> source, + string kind, string idField, string nameField, string? statusField = null, + bool omitPrivateMaterial = false) + { + foreach (var (storedKey, value) in source.Items) + { + var snapshot = AdminProjection.Snapshot(value, omit: key => + omitPrivateMaterial && IsPrivateKeyMaterial(key)); + var id = AdminProjection.Scalar(snapshot.GetValueOrDefault(idField)) ?? storedKey; + if (_tags.TryGetValue(id, out var tags) && tags.Count > 0) + { + var withTags = new Dictionary(snapshot, StringComparer.Ordinal) + { + ["Tags"] = AdminProjection.Snapshot( + new Dictionary { ["Items"] = tags }), + }; + snapshot = withTags; + } + var name = AdminProjection.Scalar(snapshot.GetValueOrDefault(nameField)) ?? id; + var status = statusField is null ? null : State(snapshot.GetValueOrDefault(statusField)); + nodes.Add(AdminData.Node(kind, id, name, status: status) with + { + ReadFields = () => snapshot.Select(pair => + AdminData.Field(pair.Key, AdminProjection.Scalar(pair.Value))) + .Where(field => field.Value is not null).Take(16).ToArray(), + ReadContent = () => AdminProjection.Content(snapshot), + ReadConnections = () => Ec2Connections(snapshot), + }); + } + } + + private void AddLaunchTemplates(List nodes) + { + foreach (var (storedKey, value) in _launchTemplates.Items) + { + var snapshot = AdminProjection.Snapshot(value, omit: key => key == "Versions"); + var id = AdminProjection.Scalar(snapshot.GetValueOrDefault("LaunchTemplateId")) ?? storedKey; + var name = AdminProjection.Scalar(snapshot.GetValueOrDefault("LaunchTemplateName")) ?? id; + var children = new List(); + if (value.GetValueOrDefault("Versions") is List> versions) + { + foreach (var version in versions) + { + var versionSnapshot = AdminProjection.Snapshot(version, omit: IsPrivateKeyMaterial); + var number = AdminProjection.Scalar(versionSnapshot.GetValueOrDefault("VersionNumber")) + ?? children.Count.ToString(); + children.Add(AdminData.Node("launch-template-versions", number, number) with + { + ReadFields = () => AdminProjection.Fields(versionSnapshot, + "VersionNumber", "VersionDescription", "CreateTime", "DefaultVersion"), + ReadContent = () => AdminProjection.Content(versionSnapshot), + ReadConnections = () => + [new("Launch template", "version-of", "ec2", [new("launch-templates", id)])], + }); + } + } + nodes.Add(AdminData.Node("launch-templates", id, name) with + { + ReadFields = () => AdminProjection.Fields(snapshot, + "LaunchTemplateId", "LaunchTemplateName", "CreateTime", "LatestVersionNumber", + "DefaultVersionNumber"), + ReadContent = () => AdminProjection.Content(snapshot), + ChildKinds = [new("launch-template-versions", "Launch template versions") { IsRoot = false }], + ReadChildren = () => children, + }); + } + } + + private static IReadOnlyList Ec2Connections( + IReadOnlyDictionary snapshot) + { + var links = new List(); + Link("VPC", "belongs-to", "vpcs", "VpcId"); + Link("Subnet", "placed-in", "subnets", "SubnetId"); + Link("Instance", "attached-to", "instances", "InstanceId"); + Link("Volume", "created-from", "volumes", "VolumeId"); + Link("Snapshot", "created-from", "snapshots", "SnapshotId"); + Link("Route table", "uses", "route-tables", "RouteTableId"); + Link("Internet gateway", "uses", "internet-gateways", "InternetGatewayId"); + Link("NAT gateway", "uses", "nat-gateways", "NatGatewayId"); + return links; + + void Link(string label, string relation, string kind, string field) + { + var id = AdminProjection.Scalar(snapshot.GetValueOrDefault(field)); + if (!string.IsNullOrEmpty(id)) + links.Add(new(label, relation, "ec2", [new(kind, id)])); + } + } + + private static bool IsPrivateKeyMaterial(string key) => + key.Equals("KeyMaterial", StringComparison.OrdinalIgnoreCase) + || key.Equals("PrivateKey", StringComparison.OrdinalIgnoreCase) + || key.Equals("PrivateKeyMaterial", StringComparison.OrdinalIgnoreCase); + + private static string? State(object? value) => value switch + { + IDictionary state => state.TryGetValue("Name", out var name) ? name?.ToString() : null, + IReadOnlyDictionary state => + AdminProjection.Scalar(state.GetValueOrDefault("Name")), + _ => AdminProjection.Scalar(value), + }; + public JsonElement? GetState() => null; public void RestoreState(JsonElement state) { } diff --git a/src/MicroStack/Services/Ecr/EcrServiceHandler.cs b/src/MicroStack/Services/Ecr/EcrServiceHandler.cs index bae5d8f..031c612 100644 --- a/src/MicroStack/Services/Ecr/EcrServiceHandler.cs +++ b/src/MicroStack/Services/Ecr/EcrServiceHandler.cs @@ -2,6 +2,8 @@ using System.Text; using System.Text.Json; using MicroStack.Internal; +using MicroStack.Internal.Admin; +using MicroStack.Admin.Contracts; namespace MicroStack.Services.Ecr; @@ -21,10 +23,12 @@ namespace MicroStack.Services.Ecr; /// BatchCheckLayerAvailability, InitiateLayerUpload, /// UploadLayerPart, CompleteLayerUpload. /// -internal sealed class EcrServiceHandler : IServiceHandler +internal sealed class EcrServiceHandler : IServiceHandler, IAdminResourceSource { public string ServiceName => "ecr"; + public IEnumerable GetKnownAccountIds() => _repositories.GetAccountIds(); + private static string Region => MicroStackOptions.Instance.Region; @@ -52,6 +56,7 @@ public Task HandleAsync(ServiceRequest request) using var doc = JsonDocument.Parse(request.Body); data = doc.RootElement.Clone(); } + catch (JsonException) { return Task.FromResult( @@ -68,6 +73,88 @@ public Task HandleAsync(ServiceRequest request) return Task.FromResult(response); } + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + serviceId == ServiceName ? + [ + new("repositories", "Repositories"), + new("images", "Images") { IsRoot = false }, + new("lifecycle-policies", "Lifecycle policies") { IsRoot = false }, + new("repository-policies", "Repository policies") { IsRoot = false }, + ] : []; + + public IEnumerable GetAdminResources(string serviceId) + { + if (serviceId != ServiceName) + return []; + lock (_lock) + { + var nodes = new List(); + foreach (var (name, repository) in _repositories.Items.OrderBy(item => item.Key, StringComparer.Ordinal)) + { + var snapshot = AdminProjection.Snapshot(repository); + var children = new List(); + if (_images.TryGetValue(name, out var images)) + children.AddRange(images.Select(image => ImageNode(name, image)) + .OrderBy(node => node.Resource.Key.Id, StringComparer.Ordinal)); + if (_lifecyclePolicies.TryGetValue(name, out var lifecycle)) + children.Add(PolicyNode(name, "lifecycle-policies", "Lifecycle policy", lifecycle)); + if (_repoPolicies.TryGetValue(name, out var policy)) + children.Add(PolicyNode(name, "repository-policies", "Repository policy", policy)); + nodes.Add(AdminData.Node("repositories", name, name, + AdminProjection.Scalar(snapshot.GetValueOrDefault("repositoryArn"))) with + { + ReadFields = () => AdminProjection.Fields(snapshot, + "repositoryUri", "createdAt", "imageTagMutability", "registryId"), + ReadContent = () => AdminProjection.Content(snapshot), + ChildKinds = + [ + new("images", "Images") { IsRoot = false }, + new("lifecycle-policies", "Lifecycle policies") { IsRoot = false }, + new("repository-policies", "Repository policies") { IsRoot = false }, + ], + ReadChildren = () => children, + }); + } + return nodes; + } + } + + private static AdminNode ImageNode(string repositoryName, Dictionary image) + { + var snapshot = AdminProjection.Snapshot(image, omit: key => + key.Equals("layerData", StringComparison.OrdinalIgnoreCase)); + var digest = AdminProjection.Scalar(snapshot.GetValueOrDefault("imageDigest")) + ?? (snapshot.GetValueOrDefault("imageId") as IReadOnlyDictionary)? + .GetValueOrDefault("imageDigest")?.ToString() ?? "unknown"; + return AdminData.Node("images", digest, digest) with + { + ReadFields = () => AdminProjection.Fields(snapshot, + "imageDigest", "imageManifestMediaType", "imagePushedAt"), + ReadContent = () => AdminProjection.Content(snapshot), + ReadConnections = () => + [new("Repository", "stored-in", "ecr", [new("repositories", repositoryName)])], + }; + } + + private static AdminNode PolicyNode(string repositoryName, string kind, string label, string json) + { + AdminContent content; + try + { + content = AdminData.JsonText(json); + } + catch (JsonException) + { + content = AdminData.Text(json, "application/json"); + } + return AdminData.Node(kind, repositoryName, label) with + { + ReadContent = () => content, + ReadConnections = () => + [new("Repository", "configures", "ecr", [new("repositories", repositoryName)])], + }; + } + public void Reset() { lock (_lock) diff --git a/src/MicroStack/Services/Ecs/EcsServiceHandler.cs b/src/MicroStack/Services/Ecs/EcsServiceHandler.cs index 3a0865f..5c84c9f 100644 --- a/src/MicroStack/Services/Ecs/EcsServiceHandler.cs +++ b/src/MicroStack/Services/Ecs/EcsServiceHandler.cs @@ -1,5 +1,7 @@ using System.Text.Json; using MicroStack.Internal; +using MicroStack.Internal.Admin; +using MicroStack.Admin.Contracts; namespace MicroStack.Services.Ecs; @@ -28,7 +30,7 @@ namespace MicroStack.Services.Ecs; /// SubmitTaskStateChange, SubmitContainerStateChange, SubmitAttachmentStateChanges, /// DiscoverPollEndpoint. /// -internal sealed class EcsServiceHandler : IServiceHandler +internal sealed class EcsServiceHandler : IServiceHandler, IAdminResourceSource { // keyed by cluster name private readonly AccountScopedDictionary> _clusters = new(); @@ -65,6 +67,11 @@ internal sealed class EcsServiceHandler : IServiceHandler public string ServiceName => "ecs"; + public IEnumerable GetKnownAccountIds() => + _clusters.GetAccountIds().Concat(_taskDefs.GetAccountIds()) + .Concat(_services.GetAccountIds()).Concat(_tasks.GetAccountIds()) + .Concat(_capacityProviders.GetAccountIds()); + public Task HandleAsync(ServiceRequest request) { var target = request.GetHeader("x-amz-target") ?? ""; @@ -179,6 +186,140 @@ public void Reset() } } + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + serviceId == ServiceName ? + [ + new("clusters", "Clusters"), + new("services", "Services") { IsRoot = false }, + new("tasks", "Tasks") { IsRoot = false }, + new("task-definitions", "Task definitions"), + ] : []; + + public IEnumerable GetAdminResources(string serviceId) + { + if (serviceId != ServiceName) + return []; + lock (_lock) + { + var taskDefinitions = _taskDefs.Items + .Select(item => TaskDefinitionNode(item.Key, + WithTags(AdminProjection.Snapshot(Sanitize(item.Value)), + item.Value.GetValueOrDefault("taskDefinitionArn")?.ToString()))) + .OrderBy(node => node.Resource.Key.Id, StringComparer.Ordinal).ToArray(); + var nodes = new List(taskDefinitions); + foreach (var (clusterName, cluster) in _clusters.Items.OrderBy(item => item.Key, StringComparer.Ordinal)) + { + var clusterSnapshot = WithTags(AdminProjection.Snapshot(Sanitize(cluster)), + cluster.GetValueOrDefault("clusterArn")?.ToString()); + var children = new List(); + children.AddRange(_services.Items + .Where(item => item.Key.StartsWith(clusterName + "/", StringComparison.Ordinal)) + .Select(item => ServiceNode(clusterName, item.Key, + WithTags(AdminProjection.Snapshot(Sanitize(item.Value)), + item.Value.GetValueOrDefault("serviceArn")?.ToString())))); + var clusterArn = AdminProjection.Scalar(clusterSnapshot.GetValueOrDefault("clusterArn")); + children.AddRange(_tasks.Items.Where(item => + string.Equals(item.Value.GetValueOrDefault("clusterArn")?.ToString(), clusterArn, + StringComparison.Ordinal)) + .Select(item => TaskNode(clusterName, item.Key, + WithTags(AdminProjection.Snapshot(Sanitize(item.Value)), item.Key)))); + var frozenChildren = children.OrderBy(node => node.Resource.Key.Kind, StringComparer.Ordinal) + .ThenBy(node => node.Resource.Key.Id, StringComparer.Ordinal).ToArray(); + nodes.Add(AdminData.Node("clusters", clusterName, clusterName, clusterArn, + AdminProjection.Scalar(clusterSnapshot.GetValueOrDefault("status"))) with + { + ReadFields = () => AdminProjection.Fields(clusterSnapshot, + "status", "registeredContainerInstancesCount", "runningTasksCount", + "pendingTasksCount", "activeServicesCount"), + ReadContent = () => AdminProjection.Content(clusterSnapshot), + ChildKinds = + [ + new("services", "Services") { IsRoot = false }, + new("tasks", "Tasks") { IsRoot = false }, + ], + ReadChildren = () => frozenChildren, + }); + } + return nodes; + } + } + + private IReadOnlyDictionary WithTags( + IReadOnlyDictionary snapshot, string? arn) + { + if (arn is null || !_tags.TryGetValue(arn, out var tags) || tags.Count == 0) + return snapshot; + var result = new Dictionary(snapshot, StringComparer.Ordinal) + { + ["tags"] = AdminProjection.Snapshot( + new Dictionary { ["items"] = tags }), + }; + return result; + } + + private static AdminNode ServiceNode( + string clusterName, string storedKey, IReadOnlyDictionary snapshot) + { + var name = AdminProjection.Scalar(snapshot.GetValueOrDefault("serviceName")) + ?? storedKey[(storedKey.LastIndexOf('/') + 1)..]; + var arn = AdminProjection.Scalar(snapshot.GetValueOrDefault("serviceArn")); + return AdminData.Node("services", name, name, arn, + AdminProjection.Scalar(snapshot.GetValueOrDefault("status"))) with + { + ReadFields = () => AdminProjection.Fields(snapshot, + "status", "desiredCount", "runningCount", "pendingCount", "launchType", "taskDefinition"), + ReadContent = () => AdminProjection.Content(snapshot), + ReadConnections = () => EcsConnections(clusterName, snapshot), + }; + } + + private static AdminNode TaskNode( + string clusterName, string arn, IReadOnlyDictionary snapshot) + { + var id = arn[(arn.LastIndexOf('/') + 1)..]; + return AdminData.Node("tasks", id, id, arn, + AdminProjection.Scalar(snapshot.GetValueOrDefault("lastStatus"))) with + { + ReadFields = () => AdminProjection.Fields(snapshot, + "lastStatus", "desiredStatus", "launchType", "taskDefinitionArn", "createdAt", "startedAt"), + ReadContent = () => AdminProjection.Content(snapshot), + ReadConnections = () => EcsConnections(clusterName, snapshot), + }; + } + + private static AdminNode TaskDefinitionNode( + string storedKey, IReadOnlyDictionary snapshot) + { + var arn = AdminProjection.Scalar(snapshot.GetValueOrDefault("taskDefinitionArn")); + var family = AdminProjection.Scalar(snapshot.GetValueOrDefault("family")) ?? storedKey; + var revision = AdminProjection.Scalar(snapshot.GetValueOrDefault("revision")); + var id = revision is null ? storedKey : $"{family}:{revision}"; + return AdminData.Node("task-definitions", id, id, arn, + AdminProjection.Scalar(snapshot.GetValueOrDefault("status"))) with + { + ReadFields = () => AdminProjection.Fields(snapshot, + "family", "revision", "status", "networkMode", "cpu", "memory", "registeredAt"), + ReadContent = () => AdminProjection.Content(snapshot), + }; + } + + private static IReadOnlyList EcsConnections( + string clusterName, IReadOnlyDictionary snapshot) + { + var links = new List + { + new("Cluster", "runs-in", "ecs", [new("clusters", clusterName)]), + }; + var taskDefinition = AdminProjection.Scalar(snapshot.GetValueOrDefault("taskDefinition")) + ?? AdminProjection.Scalar(snapshot.GetValueOrDefault("taskDefinitionArn")); + if (taskDefinition is not null) + { + var id = taskDefinition.Contains('/') ? taskDefinition[(taskDefinition.LastIndexOf('/') + 1)..] : taskDefinition; + links.Add(new("Task definition", "uses", "ecs", [new("task-definitions", id)])); + } + return links; + } + public JsonElement? GetState() => null; public void RestoreState(JsonElement state) { } diff --git a/src/MicroStack/Services/Efs/EfsServiceHandler.cs b/src/MicroStack/Services/Efs/EfsServiceHandler.cs index 3c2a376..4b875fd 100644 --- a/src/MicroStack/Services/Efs/EfsServiceHandler.cs +++ b/src/MicroStack/Services/Efs/EfsServiceHandler.cs @@ -1,6 +1,8 @@ using System.Text.Json; using System.Text.RegularExpressions; using MicroStack.Internal; +using MicroStack.Internal.Admin; +using MicroStack.Admin.Contracts; namespace MicroStack.Services.Efs; @@ -20,7 +22,7 @@ namespace MicroStack.Services.Efs; /// Account: DescribeAccountPreferences, PutAccountPreferences /// File System Policy: PutFileSystemPolicy, DescribeFileSystemPolicy /// -internal sealed partial class EfsServiceHandler : IServiceHandler +internal sealed partial class EfsServiceHandler : IServiceHandler, IAdminResourceSource { private readonly Lock _lock = new(); @@ -65,6 +67,9 @@ internal sealed partial class EfsServiceHandler : IServiceHandler public string ServiceName => "elasticfilesystem"; + public IEnumerable GetKnownAccountIds() => + _fileSystems.GetAccountIds().Concat(_mountTargets.GetAccountIds()).Concat(_accessPoints.GetAccountIds()); + public Task HandleAsync(ServiceRequest request) { JsonElement body; @@ -125,6 +130,180 @@ public void Reset() public void RestoreState(JsonElement state) { } + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + [ + new("file-systems", "File systems"), + ]; + + public IEnumerable GetAdminResources(string serviceId) + { + lock (_lock) + { + return _fileSystems.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => EfsFileSystemNode(x.Key, x.Value)).ToArray(); + } + } + + private AdminNode EfsFileSystemNode(string id, Dictionary fs) + { + var name = fs.GetValueOrDefault("Name")?.ToString(); + if (string.IsNullOrEmpty(name)) + name = id; + return AdminData.Node("file-systems", id, name, + fs.GetValueOrDefault("FileSystemArn")?.ToString(), + fs.GetValueOrDefault("LifeCycleState")?.ToString()) with + { + ReadFields = () => ReadEfsFields(_fileSystems, id), + ChildKinds = + [ + new("mount-targets", "Mount targets") { IsRoot = false }, + new("access-points", "Access points") { IsRoot = false }, + new("lifecycle-configurations", "Lifecycle configurations") { IsRoot = false }, + new("backup-policies", "Backup policies") { IsRoot = false }, + new("policies", "File system policies") { IsRoot = false }, + new("tags", "Tags") { IsRoot = false }, + ], + ReadChildren = () => ReadEfsChildren(id), + }; + } + + private IReadOnlyList ReadEfsChildren(string fsId) + { + lock (_lock) + { + var children = new List(); + children.AddRange(_mountTargets.Items + .Where(x => Equals(x.Value.GetValueOrDefault("FileSystemId"), fsId)) + .OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => EfsDictionaryNode("mount-targets", x.Key, x.Value, _mountTargets))); + children.AddRange(_accessPoints.Items + .Where(x => Equals(x.Value.GetValueOrDefault("FileSystemId"), fsId)) + .OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => EfsDictionaryNode("access-points", x.Key, x.Value, _accessPoints))); + if (_lifecycleConfigs.ContainsKey(fsId)) + children.Add(AdminData.Node("lifecycle-configurations", fsId, "Lifecycle configuration") with + { + ReadFields = () => + { + lock (_lock) + { + if (!_lifecycleConfigs.TryGetValue(fsId, out var configs)) + return []; + return + [ + AdminData.Field("RuleCount", configs.Count.ToString()), + .. configs.SelectMany((c, i) => EfsScalarFields(c, $"Rule[{i}].")), + ]; + } + }, + }); + if (_backupPolicies.ContainsKey(fsId)) + children.Add(EfsDictionaryNode("backup-policies", fsId, + _backupPolicies[fsId], _backupPolicies)); + if (_fileSystemPolicies.ContainsKey(fsId)) + children.Add(AdminData.Node("policies", fsId, "File system policy") with + { + ReadContent = () => + { + lock (_lock) + { + if (!_fileSystemPolicies.TryGetValue(fsId, out var policy)) + return AdminData.Unavailable("Policy no longer exists.", "application/json"); + try { return AdminData.JsonText(policy); } + catch (JsonException) { return AdminData.Text(policy); } + } + }, + }); + if (_fileSystems.TryGetValue(fsId, out var fs) && + fs.GetValueOrDefault("Tags") is List> tags) + children.AddRange(tags.Where(t => t.GetValueOrDefault("Key") is string) + .OrderBy(t => t["Key"]?.ToString(), StringComparer.Ordinal) + .Select(EfsTagNode)); + return children; + } + } + + private static AdminNode EfsTagNode(Dictionary tag) + { + var key = tag["Key"]?.ToString() ?? ""; + var value = tag.GetValueOrDefault("Value")?.ToString(); + return AdminData.Node("tags", key, key) with + { + ReadFields = () => + [ + AdminData.Field("Key", key), + AdminData.Field("Value", value, EfsSensitiveName(key)), + ], + }; + } + + private AdminNode EfsDictionaryNode( + string kind, string id, Dictionary value, + AccountScopedDictionary> source) => + AdminData.Node(kind, id, value.GetValueOrDefault("Name")?.ToString() ?? id, + value.FirstOrDefault(x => x.Key.EndsWith("Arn", StringComparison.Ordinal)).Value?.ToString(), + value.GetValueOrDefault("LifeCycleState")?.ToString()) with + { + ReadFields = () => ReadEfsFields(source, id), + ChildKinds = [new("tags", "Tags") { IsRoot = false }], + ReadChildren = value.GetValueOrDefault("Tags") is List> + ? () => + { + lock (_lock) + { + if (!source.TryGetValue(id, out var current) || + current.GetValueOrDefault("Tags") is not List> tags) + return []; + return tags.Where(t => t.GetValueOrDefault("Key") is string) + .OrderBy(t => t["Key"]?.ToString(), StringComparer.Ordinal) + .Select(EfsTagNode).ToArray(); + } + } + : null, + }; + + private IReadOnlyList ReadEfsFields( + AccountScopedDictionary> source, string id) + { + lock (_lock) + return source.TryGetValue(id, out var value) ? EfsScalarFields(value) : []; + } + + private static IReadOnlyList EfsScalarFields( + Dictionary value, string prefix = "") + { + var fields = new List(); + AddEfsFields(fields, value, prefix); + return fields; + } + + private static void AddEfsFields( + List fields, Dictionary value, string prefix) + { + foreach (var (key, item) in value.OrderBy(x => x.Key, StringComparer.Ordinal)) + { + var name = prefix + key; + switch (item) + { + case null or string or bool or byte or short or int or long or float or double or decimal: + fields.Add(AdminData.Field(name, item?.ToString(), EfsSensitiveName(name))); + break; + case Dictionary dictionary: + AddEfsFields(fields, dictionary, name + "."); + break; + case List strings: + fields.Add(AdminData.Field(name, string.Join(", ", strings), EfsSensitiveName(name))); + break; + } + } + } + + private static bool EfsSensitiveName(string name) => + name.Contains("password", StringComparison.OrdinalIgnoreCase) || + name.Contains("secret", StringComparison.OrdinalIgnoreCase) || + name.Contains("token", StringComparison.OrdinalIgnoreCase) || + name.Contains("credential", StringComparison.OrdinalIgnoreCase); + // ═══════════════════════════════════════════════════════════════════════════ // Request router // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/MicroStack/Services/ElastiCache/ElastiCacheServiceHandler.cs b/src/MicroStack/Services/ElastiCache/ElastiCacheServiceHandler.cs index 7360b2a..ad9295d 100644 --- a/src/MicroStack/Services/ElastiCache/ElastiCacheServiceHandler.cs +++ b/src/MicroStack/Services/ElastiCache/ElastiCacheServiceHandler.cs @@ -3,6 +3,8 @@ using System.Text.RegularExpressions; using System.Web; using MicroStack.Internal; +using MicroStack.Internal.Admin; +using MicroStack.Admin.Contracts; namespace MicroStack.Services.ElastiCache; @@ -25,10 +27,16 @@ namespace MicroStack.Services.ElastiCache; /// CreateSnapshot, DeleteSnapshot, DescribeSnapshots, /// DescribeEvents. /// -internal sealed class ElastiCacheServiceHandler : IServiceHandler +internal sealed class ElastiCacheServiceHandler : IServiceHandler, IAdminResourceSource { public string ServiceName => "elasticache"; + public IEnumerable GetKnownAccountIds() => + _clusters.GetAccountIds().Concat(_replicationGroups.GetAccountIds()) + .Concat(_subnetGroups.GetAccountIds()).Concat(_paramGroups.GetAccountIds()) + .Concat(_snapshots.GetAccountIds()).Concat(_users.GetAccountIds()) + .Concat(_userGroups.GetAccountIds()); + private const string ElastiCacheNs = "http://elasticache.amazonaws.com/doc/2015-02-02/"; private static string Region => @@ -83,6 +91,140 @@ public void Reset() public void RestoreState(JsonElement state) { } + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + [ + new("cache-clusters", "Cache clusters"), + new("replication-groups", "Replication groups"), + new("cache-subnet-groups", "Cache subnet groups"), + new("cache-parameter-groups", "Cache parameter groups"), + new("snapshots", "Snapshots"), + new("users", "Users"), + new("user-groups", "User groups"), + ]; + + public IEnumerable GetAdminResources(string serviceId) + { + lock (_lock) + { + return + [ + .. ElastiCacheNodes("cache-clusters", _clusters), + .. ElastiCacheNodes("replication-groups", _replicationGroups), + .. ElastiCacheNodes("cache-subnet-groups", _subnetGroups), + .. ElastiCacheNodes("cache-parameter-groups", _paramGroups), + .. ElastiCacheNodes("snapshots", _snapshots), + .. ElastiCacheNodes("users", _users), + .. ElastiCacheNodes("user-groups", _userGroups), + ]; + } + } + + private IEnumerable ElastiCacheNodes( + string kind, AccountScopedDictionary> source) => + source.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => ElastiCacheNode(kind, x.Key, x.Value, source)); + + private AdminNode ElastiCacheNode( + string kind, string id, Dictionary value, + AccountScopedDictionary> source) + { + var arn = value.FirstOrDefault(x => + x.Key.EndsWith("Arn", StringComparison.Ordinal) && x.Value is string).Value?.ToString(); + var status = value.FirstOrDefault(x => + x.Key.EndsWith("Status", StringComparison.Ordinal) && x.Value is string).Value?.ToString(); + return AdminData.Node(kind, id, id, arn, status) with + { + ReadFields = () => + { + lock (_lock) + return source.TryGetValue(id, out var current) ? ElastiCacheFields(current) : []; + }, + ChildKinds = kind == "cache-parameter-groups" + ? [ + new("parameters", "Parameters") { IsRoot = false }, + new("tags", "Tags") { IsRoot = false }, + ] + : [new("tags", "Tags") { IsRoot = false }], + ReadChildren = () => ElastiCacheChildren(kind, id, arn), + }; + } + + private IReadOnlyList ElastiCacheChildren(string kind, string id, string? arn) + { + lock (_lock) + { + var result = new List(); + if (kind == "cache-parameter-groups" && + _paramGroupParams.TryGetValue(id, out var parameters)) + { + result.AddRange(parameters.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => AdminData.Node("parameters", x.Key, x.Key) with + { + ReadFields = () => x.Value.OrderBy(v => v.Key, StringComparer.Ordinal) + .Select(v => AdminData.Field(v.Key, v.Value, + ElastiCacheSensitiveName(v.Key))).ToArray(), + })); + } + if (arn is not null && _tags.TryGetValue(arn, out var tags)) + { + result.AddRange(tags.OrderBy(x => x.GetValueOrDefault("Key"), StringComparer.Ordinal) + .Select(x => + { + var key = x.GetValueOrDefault("Key") ?? ""; + var value = x.GetValueOrDefault("Value"); + return AdminData.Node("tags", key, key) with + { + ReadFields = () => + [ + AdminData.Field("Key", key), + AdminData.Field("Value", value, ElastiCacheSensitiveName(key)), + ], + }; + })); + } + return result; + } + } + + private static IReadOnlyList ElastiCacheFields(Dictionary value) + { + var fields = new List(); + AddElastiCacheFields(fields, value, ""); + return fields; + } + + private static void AddElastiCacheFields( + List fields, Dictionary value, string prefix) + { + foreach (var (key, item) in value.OrderBy(x => x.Key, StringComparer.Ordinal)) + { + var name = prefix + key; + switch (item) + { + case null or string or bool or byte or short or int or long or float or double or decimal: + fields.Add(AdminData.Field(name, item?.ToString(), ElastiCacheSensitiveName(name))); + break; + case Dictionary dictionary: + AddElastiCacheFields(fields, dictionary, name + "."); + break; + case List strings: + fields.Add(AdminData.Field(name, string.Join(", ", strings), ElastiCacheSensitiveName(name))); + break; + case List> dictionaries: + for (var i = 0; i < dictionaries.Count; i++) + AddElastiCacheFields(fields, dictionaries[i], $"{name}[{i}]."); + break; + } + } + } + + private static bool ElastiCacheSensitiveName(string name) => + name.Contains("password", StringComparison.OrdinalIgnoreCase) || + name.Contains("secret", StringComparison.OrdinalIgnoreCase) || + name.Contains("token", StringComparison.OrdinalIgnoreCase) || + name.Contains("credential", StringComparison.OrdinalIgnoreCase) || + name.Contains("auth", StringComparison.OrdinalIgnoreCase); + // ── Action dispatch ─────────────────────────────────────────────────────── private ServiceResponse DispatchAction(string action, Dictionary p) diff --git a/src/MicroStack/Services/Emr/EmrServiceHandler.cs b/src/MicroStack/Services/Emr/EmrServiceHandler.cs index 29e7279..e473d07 100644 --- a/src/MicroStack/Services/Emr/EmrServiceHandler.cs +++ b/src/MicroStack/Services/Emr/EmrServiceHandler.cs @@ -1,5 +1,7 @@ using System.Text.Json; +using MicroStack.Admin.Contracts; using MicroStack.Internal; +using MicroStack.Internal.Admin; namespace MicroStack.Services.Emr; @@ -18,7 +20,7 @@ namespace MicroStack.Services.Emr; /// Tags: AddTags, RemoveTags /// Block Public Access: GetBlockPublicAccessConfiguration, PutBlockPublicAccessConfiguration /// -internal sealed class EmrServiceHandler : IServiceHandler +internal sealed class EmrServiceHandler : IServiceHandler, IAdminResourceSource { private readonly Lock _lock = new(); @@ -37,6 +39,8 @@ internal sealed class EmrServiceHandler : IServiceHandler public string ServiceName => "elasticmapreduce"; + public IEnumerable GetKnownAccountIds() => _clusters.GetAccountIds(); + public Task HandleAsync(ServiceRequest request) { var target = request.GetHeader("x-amz-target") ?? ""; @@ -108,6 +112,128 @@ public void Reset() public void RestoreState(JsonElement state) { } + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + [ + new("cluster", "Clusters"), + ]; + + public IEnumerable GetAdminResources(string serviceId) + { + lock (_lock) + { + return _clusters.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => ClusterNode(x.Key, x.Value)).ToArray(); + } + } + + public string? GetAdminNotice(string serviceId) => + "Cluster and step inspection is read-only; the admin API never runs or cancels work."; + + private AdminNode ClusterNode(string id, Dictionary cluster) + { + var status = AnalyticsAdminData.Dict(cluster, "Status"); + return AdminData.Node("cluster", id, + AnalyticsAdminData.String(cluster, "Name") ?? id, + $"arn:aws:elasticmapreduce:{Region}:{AccountContext.GetAccountId()}:cluster/{id}", + AnalyticsAdminData.String(status, "State")) with + { + ReadFields = () => + { + lock (_lock) + { + var timeline = AnalyticsAdminData.Dict(status, "Timeline"); + return + [ + AdminData.Field("id", id), + AdminData.Field("releaseLabel", AnalyticsAdminData.String(cluster, "ReleaseLabel")), + AdminData.Field("serviceRole", AnalyticsAdminData.String(cluster, "ServiceRole")), + AdminData.Field("jobFlowRole", AnalyticsAdminData.String( + AnalyticsAdminData.Dict(cluster, "Ec2InstanceAttributes"), "IamInstanceProfile")), + AdminData.Field("stepConcurrencyLevel", AnalyticsAdminData.String(cluster, "StepConcurrencyLevel")), + AdminData.Field("createdAt", AnalyticsAdminData.Epoch(timeline.GetValueOrDefault("CreationDateTime")), format: "timestamp"), + AdminData.Field("readyAt", AnalyticsAdminData.Epoch(timeline.GetValueOrDefault("ReadyDateTime")), format: "timestamp"), + ]; + } + }, + ChildKinds = + [ + new("step", "Steps") { IsRoot = false }, + new("instance-fleet", "Instance fleets") { IsRoot = false }, + new("instance-group", "Instance groups") { IsRoot = false }, + new("bootstrap-action", "Bootstrap actions") { IsRoot = false }, + ], + ReadChildren = () => + { + lock (_lock) + { + var children = new List(); + if (_steps.TryGetValue(id, out var steps)) + children.AddRange(steps.OrderBy(x => AnalyticsAdminData.String(x, "Id"), StringComparer.Ordinal) + .Select(x => EmrChild("step", AnalyticsAdminData.String(x, "Id")!, x))); + children.AddRange(Children(cluster, "InstanceFleets", "instance-fleet", "Id")); + children.AddRange(Children(cluster, "InstanceGroups", "instance-group", "Id")); + children.AddRange(Children(cluster, "BootstrapActions", "bootstrap-action", "Name")); + return children; + } + }, + ReadConnections = () => + { + lock (_lock) + { + var link = AnalyticsAdminData.S3Connection("Log location", + AnalyticsAdminData.String(cluster, "LogUri")); + return link is null ? [] : [link]; + } + }, + }; + } + + private IEnumerable Children( + IReadOnlyDictionary parent, string property, string kind, string idProperty) + { + parent.TryGetValue(property, out var value); + return AnalyticsAdminData.Dicts(value) + .Select((child, index) => + { + var id = AnalyticsAdminData.String(child, idProperty) ?? $"{kind}-{index}"; + return EmrChild(kind, id, child); + }).ToArray(); + } + + private AdminNode EmrChild(string kind, string id, IReadOnlyDictionary value) + { + var status = AnalyticsAdminData.Dict(value, "Status"); + return AdminData.Node(kind, id, AnalyticsAdminData.String(value, "Name") ?? id, + status: AnalyticsAdminData.String(status, "State")) with + { + ReadFields = () => + { + lock (_lock) + { + var timeline = AnalyticsAdminData.Dict(status, "Timeline"); + return + [ + AdminData.Field("id", id), + AdminData.Field("createdAt", AnalyticsAdminData.Epoch(timeline.GetValueOrDefault("CreationDateTime")), format: "timestamp"), + AdminData.Field("startedAt", AnalyticsAdminData.Epoch(timeline.GetValueOrDefault("StartDateTime")), format: "timestamp"), + AdminData.Field("endedAt", AnalyticsAdminData.Epoch(timeline.GetValueOrDefault("EndDateTime")), format: "timestamp"), + ]; + } + }, + ReadContent = () => + { + lock (_lock) + { + return AnalyticsAdminData.Json(AnalyticsAdminData.Project( + ("config", value.GetValueOrDefault("Config")), + ("instanceFleetType", value.GetValueOrDefault("InstanceFleetType")), + ("instanceGroupType", value.GetValueOrDefault("InstanceGroupType")), + ("scriptBootstrapAction", value.GetValueOrDefault("ScriptBootstrapAction")))); + } + }, + }; + } + // -- ID generators --------------------------------------------------------- private static string GenerateClusterId() diff --git a/src/MicroStack/Services/EventBridge/EventBridgeServiceHandler.Admin.cs b/src/MicroStack/Services/EventBridge/EventBridgeServiceHandler.Admin.cs new file mode 100644 index 0000000..f6fa600 --- /dev/null +++ b/src/MicroStack/Services/EventBridge/EventBridgeServiceHandler.Admin.cs @@ -0,0 +1,404 @@ +using System.Globalization; +using MicroStack.Admin.Contracts; +using MicroStack.Internal; +using MicroStack.Internal.Admin; + +namespace MicroStack.Services.EventBridge; + +internal sealed partial class EventBridgeServiceHandler : IAdminResourceSource, IAdminRelationshipSource +{ + private static readonly AdminResourceKind[] AdminKinds = + [ + new("event-buses", "Event buses"), + new("rules", "Rules") { IsRoot = false }, + new("targets", "Targets") { IsRoot = false }, + new("archives", "Archives"), + new("replays", "Replays"), + new("connections", "Connections"), + new("api-destinations", "API destinations"), + new("endpoints", "Endpoints"), + new("partner-event-sources", "Partner event sources") + ]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + serviceId == "events" ? AdminKinds : []; + + public IEnumerable GetAdminResources(string serviceId) + { + if (serviceId != "events") + return []; + + var account = AccountContext.GetAccountId(); + Dictionary[] buses; + Dictionary[] rules; + Dictionary[]> targets; + Dictionary> tags; + Dictionary[] archives; + Dictionary[] replays; + Dictionary[] connections; + Dictionary[] destinations; + Dictionary[] endpoints; + Dictionary[] partnerSources; + Dictionary? defaultPolicy; + lock (_lock) + { + buses = _eventBuses.Values + .Where(bus => GetText(bus, "Name") != "default" + && ArnAccount(GetText(bus, "Arn")) == account) + .Select(CloneMap).ToArray(); + foreach (var bus in buses) + if (_eventBusPolicies.TryGetValue(GetText(bus, "Name"), out var busPolicy)) + bus["Policy"] = CloneMap(busPolicy); + rules = _rules.Values.Select(CloneMap).ToArray(); + targets = _targets.Items.ToDictionary( + item => item.Key, + item => item.Value.Select(CloneMap).ToArray(), + StringComparer.Ordinal); + tags = _tags.Items.ToDictionary(item => item.Key, + item => new Dictionary(item.Value, StringComparer.Ordinal), StringComparer.Ordinal); + archives = _archives.Values.Select(CloneMap).ToArray(); + replays = _replays.Values.Select(CloneMap).ToArray(); + connections = _connections.Values.Select(CloneMap).ToArray(); + destinations = _apiDestinations.Values.Select(CloneMap).ToArray(); + endpoints = _endpoints.Values.Select(CloneMap).ToArray(); + partnerSources = _partnerEventSources.Values + .Where(source => source.GetValueOrDefault("Account") == account) + .Select(source => new Dictionary(source, StringComparer.Ordinal)).ToArray(); + defaultPolicy = _eventBusPolicies.TryGetValue("default", out var policy) ? CloneMap(policy) : null; + } + + var defaultBus = new Dictionary(StringComparer.Ordinal) + { + ["Name"] = "default", + ["Arn"] = $"arn:aws:events:{Region}:{account}:event-bus/default", + ["Description"] = "", + ["Policy"] = defaultPolicy + }; + buses = [defaultBus, .. buses]; + + return buses.Select(bus => BusNode(bus, rules, targets, tags)) + .Concat(archives.Select(ArchiveNode)) + .Concat(replays.Select(ReplayNode)) + .Concat(connections.Select(ConnectionNode)) + .Concat(destinations.Select(DestinationNode)) + .Concat(endpoints.Select(EndpointNode)) + .Concat(partnerSources.Select(PartnerSourceNode)) + .ToArray(); + } + + private static AdminNode BusNode( + Dictionary bus, + IReadOnlyList> rules, + IReadOnlyDictionary[]> targets, + IReadOnlyDictionary> tags) + { + var name = GetText(bus, "Name"); + var arn = GetText(bus, "Arn"); + var busRules = rules.Where(rule => GetText(rule, "EventBusName", "default") == name).ToArray(); + return AdminData.Node("event-buses", arn, name, arn, "active", type: "Event bus", + summary: [AdminData.Field("Rules", busRules.Length.ToString(CultureInfo.InvariantCulture))]) with + { + ReadSummary = () => [AdminData.Field("Rules", busRules.Length.ToString(CultureInfo.InvariantCulture))], + ReadFields = () => new[] + { + AdminData.Field("Description", GetText(bus, "Description")), + AdminData.Field("Rules", busRules.Length.ToString(CultureInfo.InvariantCulture)), + AdminData.Field("Created", Epoch(bus, "CreationTime"), format: "datetime", secondary: true), + AdminData.Field("Last modified", Epoch(bus, "LastModifiedTime"), format: "datetime", secondary: true) + }.Concat(TagFields(tags, arn)).ToArray(), + ChildKinds = [AdminKinds[1]], + ReadChildren = () => busRules.Select(rule => RuleNode(rule, targets, tags)).ToArray(), + ReadConnections = () => busRules.SelectMany(rule => + (targets.GetValueOrDefault(RuleKey(GetText(rule, "Name"), name)) ?? []) + .SelectMany(TargetConnections)).ToArray(), + ReadContent = bus.TryGetValue("Policy", out var policy) && policy is not null + ? () => AdminData.Json(policy) + : null + }; + } + + private static AdminNode RuleNode( + Dictionary rule, + IReadOnlyDictionary[]> targets, + IReadOnlyDictionary> tags) + { + var name = GetText(rule, "Name"); + var bus = GetText(rule, "EventBusName", "default"); + var key = RuleKey(name, bus); + var ruleTargets = targets.GetValueOrDefault(key) ?? []; + var pattern = GetText(rule, "EventPattern"); + var schedule = GetText(rule, "ScheduleExpression"); + return AdminData.Node("rules", GetText(rule, "Arn"), name, + GetText(rule, "Arn"), GetText(rule, "State"), + type: string.IsNullOrWhiteSpace(schedule) ? "Event pattern rule" : "Scheduled rule", + summary: + [ + AdminData.Field("Description", GetText(rule, "Description")), + AdminData.Field("Schedule", schedule), + AdminData.Field("Targets", ruleTargets.Length.ToString(CultureInfo.InvariantCulture)) + ]) with + { + ReadSummary = () => [AdminData.Field("Targets", ruleTargets.Length.ToString(CultureInfo.InvariantCulture))], + ReadFields = () => new[] + { + AdminData.Field("Event bus", bus), + AdminData.Field("Description", GetText(rule, "Description")), + AdminData.Field("Schedule", schedule), + AdminData.Field("Role ARN", GetText(rule, "RoleArn"), secondary: true), + AdminData.Field("Targets", ruleTargets.Length.ToString(CultureInfo.InvariantCulture)) + }.Concat(TagFields(tags, GetText(rule, "Arn"))).ToArray(), + ChildKinds = [AdminKinds[2]], + ReadChildren = () => ruleTargets.Select(TargetNode).ToArray(), + ReadContent = !string.IsNullOrWhiteSpace(pattern) ? () => AdminData.JsonText(pattern) + : !string.IsNullOrWhiteSpace(schedule) ? () => AdminData.Text(schedule) : null, + ReadConnections = () => ruleTargets.SelectMany(TargetConnections).ToArray() + }; + } + + private static AdminNode TargetNode(Dictionary target) + { + var id = GetText(target, "Id"); + var arn = GetText(target, "Arn"); + return AdminData.Node("targets", id, id, arn, "configured", type: MapArn(arn)?.Label, + summary: [AdminData.Field("Target ARN", arn)]) with + { + ReadFields = () => + [ + AdminData.Field("Target ARN", arn), + AdminData.Field("Role ARN", GetText(target, "RoleArn"), secondary: true), + AdminData.Field("Input path", GetText(target, "InputPath")) + ], + ReadContent = () => AdminData.Json(target), + ReadConnections = () => TargetConnections(target) + }; + } + + private static IReadOnlyList TargetConnections(Dictionary target) + { + var arn = GetText(target, "Arn"); + if (string.IsNullOrEmpty(arn)) + return []; + var mapped = MapArn(arn); + return mapped is null + ? [new(arn, "targets", State: "configured")] + : [new(mapped.Value.Label, "targets", mapped.Value.Service, + [new(mapped.Value.Kind, arn)], State: "configured")]; + } + + private static AdminNode ArchiveNode(Dictionary archive) + { + var name = GetText(archive, "ArchiveName"); + var arn = GetText(archive, "ArchiveArn"); + var pattern = GetText(archive, "EventPattern"); + return AdminData.Node("archives", arn, name, arn, GetText(archive, "State")) with + { + ReadFields = () => + [ + AdminData.Field("Description", GetText(archive, "Description")), + AdminData.Field("Event source ARN", GetText(archive, "EventSourceArn")), + AdminData.Field("Retention days", GetText(archive, "RetentionDays")), + AdminData.Field("Event count", GetText(archive, "EventCount")), + AdminData.Field("Size bytes", GetText(archive, "SizeBytes")), + AdminData.Field("Created", Epoch(archive, "CreationTime"), format: "datetime") + ], + ReadContent = string.IsNullOrWhiteSpace(pattern) ? null : () => AdminData.JsonText(pattern), + ReadConnections = () => ArnConnections(GetText(archive, "EventSourceArn"), "archives-from") + }; + } + + private static AdminNode ReplayNode(Dictionary replay) + { + var name = GetText(replay, "ReplayName"); + var arn = GetText(replay, "ReplayArn"); + var source = GetText(replay, "EventSourceArn"); + var destination = replay.TryGetValue("Destination", out var value) + && value is Dictionary map ? GetText(map, "Arn") : ""; + return AdminData.Node("replays", arn, name, arn, GetText(replay, "State")) with + { + ReadFields = () => + [ + AdminData.Field("Description", GetText(replay, "Description")), + AdminData.Field("Event source ARN", source), + AdminData.Field("Destination ARN", destination), + AdminData.Field("Started", Epoch(replay, "ReplayStartTime"), format: "datetime") + ], + ReadConnections = () => ArnConnections(source, "replays-from") + .Concat(ArnConnections(destination, "replays-to")).ToArray() + }; + } + + private static AdminNode ConnectionNode(Dictionary connection) + { + var name = GetText(connection, "Name"); + var arn = GetText(connection, "ConnectionArn"); + return AdminData.Node("connections", arn, name, arn, GetText(connection, "ConnectionState")) with + { + ReadFields = () => + [ + AdminData.Field("Authorization type", GetText(connection, "AuthorizationType")), + AdminData.Field("Authentication", "configured", sensitive: true), + AdminData.Field("Description", GetText(connection, "Description")), + AdminData.Field("Created", Epoch(connection, "CreationTime"), format: "datetime"), + AdminData.Field("Last authorized", Epoch(connection, "LastAuthorizedTime"), format: "datetime") + ] + }; + } + + private static AdminNode DestinationNode(Dictionary destination) + { + var name = GetText(destination, "Name"); + var arn = GetText(destination, "ApiDestinationArn"); + var connectionArn = GetText(destination, "ConnectionArn"); + return AdminData.Node("api-destinations", arn, name, arn, + GetText(destination, "ApiDestinationState")) with + { + ReadFields = () => + [ + AdminData.Field("Invocation endpoint", GetText(destination, "InvocationEndpoint"), format: "uri"), + AdminData.Field("HTTP method", GetText(destination, "HttpMethod")), + AdminData.Field("Rate limit", GetText(destination, "InvocationRateLimitPerSecond")), + AdminData.Field("Connection ARN", connectionArn) + ], + ReadConnections = () => string.IsNullOrEmpty(connectionArn) ? [] + : [new("Connection", "authorized-by", "events", + [new("connections", connectionArn)], State: "configured")] + }; + } + + private static AdminNode EndpointNode(Dictionary endpoint) + { + var name = GetText(endpoint, "Name"); + var arn = GetText(endpoint, "Arn"); + return AdminData.Node("endpoints", arn, name, arn, GetText(endpoint, "State")) with + { + ReadFields = () => + [ + AdminData.Field("Description", GetText(endpoint, "Description")), + AdminData.Field("Endpoint URL", GetText(endpoint, "EndpointUrl"), format: "uri"), + AdminData.Field("Role ARN", GetText(endpoint, "RoleArn")), + AdminData.Field("Created", Epoch(endpoint, "CreationTime"), format: "datetime") + ], + ReadContent = endpoint.TryGetValue("RoutingConfig", out var routing) && routing is not null + ? () => AdminData.Json(routing) : null + }; + } + + private static AdminNode PartnerSourceNode(IReadOnlyDictionary source) + { + var name = source.GetValueOrDefault("Name", ""); + var arn = source.GetValueOrDefault("EventSourceArn", ""); + return AdminData.Node("partner-event-sources", arn, name, arn, "ACTIVE") with + { + ReadFields = () => [AdminData.Field("Account", source.GetValueOrDefault("Account"))] + }; + } + + private static IReadOnlyList ArnConnections(string arn, string relation) + { + if (string.IsNullOrEmpty(arn)) + return []; + var mapped = MapArn(arn); + return mapped is null ? [new(arn, relation, State: "configured")] + : [new(mapped.Value.Label, relation, mapped.Value.Service, + [new(mapped.Value.Kind, arn)], State: "configured")]; + } + + private static (string Service, string Kind, string Label)? MapArn(string arn) + { + var parts = arn.Split(':', 6); + if (parts.Length != 6 || parts[0] != "arn") + return null; + return parts[2] switch + { + "sqs" => ("sqs", "queues", "Queue"), + "sns" => ("sns", "topics", "Topic"), + "lambda" => ("lambda", "functions", "Function"), + "states" => ("stepfunctions", "state-machines", "State machine"), + "events" when parts[5].StartsWith("event-bus/", StringComparison.Ordinal) => ("events", "event-buses", "Event bus"), + "events" when parts[5].StartsWith("archive/", StringComparison.Ordinal) => ("events", "archives", "Archive"), + "events" when parts[5].StartsWith("api-destination/", StringComparison.Ordinal) => ("events", "api-destinations", "API destination"), + _ => null + }; + } + + public AdminRelationshipSnapshot GetAdminRelationshipSnapshot() + { + var account = AccountContext.GetAccountId(); + lock (_lock) + { + var resources = new List(); + var relationships = new List(); + var buses = _eventBuses.Values.Where(bus => GetText(bus, "Name") != "default" + && ArnAccount(GetText(bus, "Arn")) == account) + .Select(bus => (Name: GetText(bus, "Name"), Arn: GetText(bus, "Arn"))) + .Prepend(("default", $"arn:aws:events:{Region}:{account}:event-bus/default")); + foreach (var (name, arn) in buses) + { + AdminKey[] busPath = [new("event-buses", arn)]; + resources.Add(new(busPath, name)); + foreach (var rule in _rules.Values.Where(rule => GetText(rule, "EventBusName", "default") == name)) + { + var ruleName = GetText(rule, "Name"); + AdminKey[] rulePath = [.. busPath, new("rules", GetText(rule, "Arn"))]; + resources.Add(new(rulePath, ruleName, GetText(rule, "State"))); + relationships.Add(new("events", rulePath, ruleName, + new("Parent event bus", "belongs-to", "events", busPath))); + if (!_targets.TryGetValue(RuleKey(ruleName, name), out var targets)) + continue; + foreach (var target in targets) + { + AdminKey[] targetPath = [.. rulePath, new("targets", GetText(target, "Id"))]; + resources.Add(new(targetPath, GetText(target, "Id"))); + relationships.AddRange(TargetConnections(target).Select(connection => + new AdminConfiguredRelationship("events", targetPath, + $"{name} / {ruleName} / {GetText(target, "Id")}", connection))); + } + } + } + resources.AddRange(_archives.Values.Select(value => new AdminRelationshipResource( + [new("archives", GetText(value, "ArchiveArn"))], GetText(value, "ArchiveName"), GetText(value, "State")))); + resources.AddRange(_connections.Values.Select(value => new AdminRelationshipResource( + [new("connections", GetText(value, "ConnectionArn"))], GetText(value, "Name"), GetText(value, "ConnectionState")))); + resources.AddRange(_apiDestinations.Values.Select(value => new AdminRelationshipResource( + [new("api-destinations", GetText(value, "ApiDestinationArn"))], GetText(value, "Name"), GetText(value, "ApiDestinationState")))); + return new(resources.ToArray(), relationships.ToArray()); + } + } + + private static string ArnAccount(string arn) + { + var parts = arn.Split(':'); + return parts.Length > 4 ? parts[4] : ""; + } + + private static IEnumerable TagFields( + IReadOnlyDictionary> tags, string arn) => + (tags.GetValueOrDefault(arn) ?? []).OrderBy(item => item.Key, StringComparer.Ordinal) + .Select(item => AdminData.Field($"Tag: {item.Key}", item.Value, secondary: true)); + + private static string GetText(IReadOnlyDictionary value, string key, string fallback = "") => + value.TryGetValue(key, out var item) && item is not null + ? Convert.ToString(item, CultureInfo.InvariantCulture) ?? fallback : fallback; + + private static string? Epoch(IReadOnlyDictionary value, string key) + { + if (!value.TryGetValue(key, out var item) || item is null + || !double.TryParse(Convert.ToString(item, CultureInfo.InvariantCulture), + NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds)) + return null; + return AdminData.IsoUtc(DateTimeOffset.FromUnixTimeMilliseconds((long)(seconds * 1000))); + } + + private static Dictionary CloneMap(Dictionary source) => + source.ToDictionary(item => item.Key, item => CloneValue(item.Value), StringComparer.Ordinal); + + private static object? CloneValue(object? value) => value switch + { + Dictionary map => CloneMap(map), + List> maps => maps.Select(map => (object?)CloneMap(map)).ToArray(), + List list => list.Select(CloneValue).ToArray(), + string or bool or byte or sbyte or short or ushort or int or uint or long or ulong + or float or double or decimal or null => value, + _ => value.ToString() + }; +} diff --git a/src/MicroStack/Services/EventBridge/EventBridgeServiceHandler.cs b/src/MicroStack/Services/EventBridge/EventBridgeServiceHandler.cs index 659926f..88dd277 100644 --- a/src/MicroStack/Services/EventBridge/EventBridgeServiceHandler.cs +++ b/src/MicroStack/Services/EventBridge/EventBridgeServiceHandler.cs @@ -63,6 +63,11 @@ internal sealed partial class EventBridgeServiceHandler : IServiceHandler public string ServiceName => "events"; + public IEnumerable GetKnownAccountIds() => + _rules.GetAccountIds().Concat(_archives.GetAccountIds()) + .Concat(_connections.GetAccountIds()).Concat(_apiDestinations.GetAccountIds()) + .Concat(_replays.GetAccountIds()).Concat(_endpoints.GetAccountIds()); + public Task HandleAsync(ServiceRequest request) { var target = request.GetHeader("x-amz-target") ?? ""; @@ -167,6 +172,7 @@ public void Reset() _apiDestinations.Clear(); _replays.Clear(); _endpoints.Clear(); + _partnerEventSources.Clear(); _eventBuses = CreateDefaultBuses(); } } diff --git a/src/MicroStack/Services/Firehose/FirehoseServiceHandler.cs b/src/MicroStack/Services/Firehose/FirehoseServiceHandler.cs index 714f6f8..fb73803 100644 --- a/src/MicroStack/Services/Firehose/FirehoseServiceHandler.cs +++ b/src/MicroStack/Services/Firehose/FirehoseServiceHandler.cs @@ -1,5 +1,7 @@ using System.Text.Json; +using MicroStack.Admin.Contracts; using MicroStack.Internal; +using MicroStack.Internal.Admin; namespace MicroStack.Services.Firehose; @@ -14,7 +16,7 @@ namespace MicroStack.Services.Firehose; /// TagDeliveryStream, UntagDeliveryStream, ListTagsForDeliveryStream, /// StartDeliveryStreamEncryption, StopDeliveryStreamEncryption. /// -internal sealed class FirehoseServiceHandler : IServiceHandler +internal sealed class FirehoseServiceHandler : IServiceHandler, IAdminResourceSource { private readonly Dictionary _streams = new(StringComparer.Ordinal); private readonly Lock _lock = new(); @@ -91,6 +93,145 @@ public void Reset() public void RestoreState(JsonElement state) { } + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + [ + new("delivery-stream", "Delivery streams"), + ]; + + public IEnumerable GetAdminResources(string serviceId) + { + lock (_lock) + { + return _streams.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => StreamNode(x.Value)).ToArray(); + } + } + + public string? GetAdminNotice(string serviceId) => + "Delivery stream state is shared globally by the emulator and is not account-isolated."; + + private AdminNode StreamNode(FhStream stream) => + AdminData.Node("delivery-stream", stream.Name, stream.Name, stream.Arn, stream.Status, "global") with + { + ReadFields = () => + { + lock (_lock) + { + return + [ + AdminData.Field("type", stream.Type), + AdminData.Field("version", stream.Version.ToString()), + AdminData.Field("createdAt", AnalyticsAdminData.Epoch(stream.CreatedAt), format: "timestamp"), + AdminData.Field("updatedAt", AnalyticsAdminData.Epoch(stream.UpdatedAt), format: "timestamp"), + AdminData.Field("destinationCount", stream.Destinations.Count.ToString()), + AdminData.Field("encryptionStatus", + stream.Encryption is null ? "DISABLED" : AnalyticsAdminData.String(stream.Encryption, "Status")), + ]; + } + }, + ChildKinds = [new("destination", "Destinations") { IsRoot = false }], + ReadChildren = () => + { + lock (_lock) + return stream.Destinations.OrderBy(x => x.Id, StringComparer.Ordinal) + .Select(DestinationNode).ToArray(); + }, + ReadConnections = () => + { + lock (_lock) + { + var connections = new List(); + if (stream.KinesisSource is not null) + { + var arn = AnalyticsAdminData.String(stream.KinesisSource, "KinesisStreamARN"); + var name = arn?.Split('/').LastOrDefault(); + if (!string.IsNullOrEmpty(name)) + connections.Add(new("Kinesis source", "configured-source", "kinesis", + [new AdminKey("stream", name)])); + } + if (stream.Encryption is not null) + { + var keyArn = AnalyticsAdminData.String(stream.Encryption, "KeyARN"); + if (!string.IsNullOrEmpty(keyArn)) + connections.Add(new("Encryption key", "configured-encryption-key", "kms", + [new AdminKey("key", keyArn.Split('/').Last())])); + } + return connections; + } + }, + }; + + private AdminNode DestinationNode(FhDestination destination) => + AdminData.Node("destination", destination.Id, destination.Type, status: "configured", scope: "global") with + { + ReadFields = () => + { + lock (_lock) + { + return + [ + AdminData.Field("id", destination.Id), + AdminData.Field("type", destination.Type), + AdminData.Field("recordCount", destination.Records.Count.ToString()), + ]; + } + }, + ChildKinds = [new("record", "Records") { IsRoot = false }], + ReadChildren = () => + { + lock (_lock) + return destination.Records.OrderBy(x => x.Timestamp).ThenBy(x => x.Id, StringComparer.Ordinal) + .Select(RecordNode).ToArray(); + }, + ReadContent = () => + { + lock (_lock) + { + var config = destination.ConfigAsObject as IReadOnlyDictionary + ?? new Dictionary(); + return AnalyticsAdminData.Json(config); + } + }, + ReadConnections = () => + { + lock (_lock) + { + var config = destination.ConfigAsObject as IReadOnlyDictionary + ?? new Dictionary(); + var bucketArn = AnalyticsAdminData.String(config, "BucketARN"); + if (!string.IsNullOrEmpty(bucketArn)) + { + var bucket = bucketArn.Split(':').LastOrDefault(); + return string.IsNullOrEmpty(bucket) + ? [] + : [new AdminConnection("S3 destination", "configured-destination", "s3", + [new AdminKey("bucket", bucket)])]; + } + var endpoint = AnalyticsAdminData.String(config, "Url") ?? + AnalyticsAdminData.String(config, "Endpoint"); + return string.IsNullOrEmpty(endpoint) + ? [] + : [new AdminConnection("External destination", "configured-destination", + ExternalUri: endpoint)]; + } + }, + }; + + private AdminNode RecordNode(FhRecord record) => + AdminData.Node("record", record.Id, record.Id, scope: "global") with + { + ReadFields = () => + [ + AdminData.Field("recordId", record.Id), + AdminData.Field("timestamp", AnalyticsAdminData.Epoch(record.Timestamp), format: "timestamp"), + ], + ReadContent = () => + { + lock (_lock) + return AnalyticsAdminData.DecodedBase64(record.Data); + }, + }; + // -- Helpers --------------------------------------------------------------- private static string StreamArn(string name) diff --git a/src/MicroStack/Services/Glue/GlueServiceHandler.cs b/src/MicroStack/Services/Glue/GlueServiceHandler.cs index 3476e13..3147467 100644 --- a/src/MicroStack/Services/Glue/GlueServiceHandler.cs +++ b/src/MicroStack/Services/Glue/GlueServiceHandler.cs @@ -1,5 +1,7 @@ using System.Text.Json; +using MicroStack.Admin.Contracts; using MicroStack.Internal; +using MicroStack.Internal.Admin; namespace MicroStack.Services.Glue; @@ -21,7 +23,7 @@ namespace MicroStack.Services.Glue; /// RegisterSchemaVersion, GetSchemaVersion, ListSchemaVersions, /// TagResource, UntagResource, GetTags. /// -internal sealed class GlueServiceHandler : IServiceHandler +internal sealed class GlueServiceHandler : IServiceHandler, IAdminResourceSource { private readonly Lock _lock = new(); @@ -43,6 +45,11 @@ internal sealed class GlueServiceHandler : IServiceHandler public string ServiceName => "glue"; + public IEnumerable GetKnownAccountIds() => + _databases.GetAccountIds().Concat(_tables.GetAccountIds()) + .Concat(_crawlers.GetAccountIds()).Concat(_jobs.GetAccountIds()) + .Concat(_registries.GetAccountIds()).Concat(_schemas.GetAccountIds()); + public Task HandleAsync(ServiceRequest request) { var target = request.GetHeader("x-amz-target") ?? ""; @@ -156,6 +163,199 @@ public void Reset() public void RestoreState(JsonElement state) { } + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + [ + new("database", "Databases"), + new("crawler", "Crawlers"), + new("job", "Jobs"), + new("registry", "Schema registries"), + ]; + + public IEnumerable GetAdminResources(string serviceId) + { + lock (_lock) + { + var nodes = new List(); + nodes.AddRange(_databases.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => DatabaseNode(x.Key, x.Value))); + nodes.AddRange(_crawlers.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => SimpleNode("crawler", x.Key, x.Value, "State", + ["Targets", "Schedule", "SchemaChangePolicy"]))); + nodes.AddRange(_jobs.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => JobNode(x.Key, x.Value))); + nodes.AddRange(_registries.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => RegistryNode(x.Key, x.Value))); + return nodes; + } + } + + public string? GetAdminNotice(string serviceId) => + "Stored job runs and crawler state are inspection-only; inspection never starts work."; + + private AdminNode DatabaseNode(string name, Dictionary database) + { + var arn = Arn("database", name); + return SimpleNode("database", name, database, null, ["Parameters"], arn) with + { + ChildKinds = [new("table", "Tables") { IsRoot = false }], + ReadChildren = () => + { + lock (_lock) + return _tables.Items + .Where(x => x.Key.StartsWith($"{name}/", StringComparison.Ordinal)) + .OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => TableNode(x.Key, x.Value)).ToArray(); + }, + ReadConnections = () => + { + lock (_lock) + { + var location = AnalyticsAdminData.String(database, "LocationUri"); + var link = AnalyticsAdminData.S3Connection("Database location", location); + return link is null ? [] : [link]; + } + }, + }; + } + + private AdminNode TableNode(string key, Dictionary table) + { + var name = AnalyticsAdminData.String(table, "Name") ?? key; + return SimpleNode("table", name, table, "TableType", + ["StorageDescriptor", "PartitionKeys", "Parameters"]) with + { + ChildKinds = [new("partition", "Partitions") { IsRoot = false }], + ReadChildren = () => + { + lock (_lock) + { + if (!_partitions.TryGetValue(key, out var partitions)) + return []; + return partitions.Select((partition, index) => + { + var values = partition.TryGetValue("Values", out var v) && v is List list + ? string.Join("/", list) + : index.ToString(); + return SimpleNode("partition", $"{index}:{values}", partition, null, + ["Values", "StorageDescriptor", "Parameters"]); + }).ToArray(); + } + }, + ReadConnections = () => + { + lock (_lock) + { + var descriptor = AnalyticsAdminData.Dict(table, "StorageDescriptor"); + var link = AnalyticsAdminData.S3Connection("Table location", + AnalyticsAdminData.String(descriptor, "Location")); + return link is null ? [] : [link]; + } + }, + }; + } + + private AdminNode JobNode(string name, Dictionary job) => + SimpleNode("job", name, job, null, ["Command", "DefaultArguments"]) with + { + ChildKinds = [new("job-run", "Job runs") { IsRoot = false }], + ReadChildren = () => + { + lock (_lock) + { + if (!_jobRuns.TryGetValue(name, out var runs)) + return []; + return runs.OrderBy(x => AnalyticsAdminData.String(x, "StartedOn"), StringComparer.Ordinal) + .Select(x => SimpleNode("job-run", AnalyticsAdminData.String(x, "Id") ?? "", + x, "JobRunState", ["Arguments"])).ToArray(); + } + }, + ReadConnections = () => + { + lock (_lock) + { + var command = AnalyticsAdminData.Dict(job, "Command"); + var link = AnalyticsAdminData.S3Connection("Job script", + AnalyticsAdminData.String(command, "ScriptLocation")); + return link is null ? [] : [link]; + } + }, + }; + + private AdminNode RegistryNode(string name, Dictionary registry) => + SimpleNode("registry", name, registry, "Status", [], AnalyticsAdminData.String(registry, "RegistryArn")) with + { + ChildKinds = [new("schema", "Schemas") { IsRoot = false }], + ReadChildren = () => + { + lock (_lock) + return _schemas.Items.Where(x => x.Key.StartsWith($"{name}/", StringComparison.Ordinal)) + .OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => SchemaNode(x.Key, x.Value)).ToArray(); + }, + }; + + private AdminNode SchemaNode(string key, Dictionary schema) => + SimpleNode("schema", AnalyticsAdminData.String(schema, "SchemaName") ?? key, + schema, "SchemaStatus", [], AnalyticsAdminData.String(schema, "SchemaArn")) with + { + ChildKinds = [new("schema-version", "Schema versions") { IsRoot = false }], + ReadChildren = () => + { + lock (_lock) + { + if (!_schemaVersions.TryGetValue(key, out var versions)) + return []; + return versions.OrderBy(x => AnalyticsAdminData.String(x, "VersionNumber"), StringComparer.Ordinal) + .Select(x => SimpleNode("schema-version", + AnalyticsAdminData.String(x, "SchemaVersionId") ?? "", + x, "Status", ["SchemaDefinition"])).ToArray(); + } + }, + }; + + private AdminNode SimpleNode( + string kind, string id, IReadOnlyDictionary value, string? statusKey, + string[] contentKeys, string? arn = null) + { + var name = AnalyticsAdminData.String(value, "Name") ?? + AnalyticsAdminData.String(value, "RegistryName") ?? + AnalyticsAdminData.String(value, "SchemaName") ?? id; + return AdminData.Node(kind, id, name, arn, + statusKey is null ? null : AnalyticsAdminData.String(value, statusKey)) with + { + ReadFields = () => + { + lock (_lock) + { + return value.OrderBy(x => x.Key, StringComparer.Ordinal) + .Where(x => (x.Value is null or string or bool or ValueType) && + !contentKeys.Contains(x.Key)) + .Select(x => + { + var timestamp = x.Key.Contains("Time", StringComparison.OrdinalIgnoreCase) || + x.Key.EndsWith("On", StringComparison.Ordinal); + return AdminData.Field(x.Key, + timestamp ? AnalyticsAdminData.Epoch(x.Value) ?? + AnalyticsAdminData.String(value, x.Key) : + AnalyticsAdminData.String(value, x.Key), + sensitive: x.Key.Contains("password", StringComparison.OrdinalIgnoreCase) || + x.Key.Contains("secret", StringComparison.OrdinalIgnoreCase), + format: timestamp ? "timestamp" : null); + }).ToArray(); + } + }, + ReadContent = contentKeys.Length == 0 ? null : () => + { + lock (_lock) + { + return AnalyticsAdminData.Json(contentKeys + .Where(value.ContainsKey) + .ToDictionary(x => x, x => value[x], StringComparer.Ordinal)); + } + }, + }; + } + // -- JSON helpers ---------------------------------------------------------- private static string? GetString(JsonElement el, string prop) diff --git a/src/MicroStack/Services/Iam/IamServiceHandler.Admin.cs b/src/MicroStack/Services/Iam/IamServiceHandler.Admin.cs new file mode 100644 index 0000000..5478f8f --- /dev/null +++ b/src/MicroStack/Services/Iam/IamServiceHandler.Admin.cs @@ -0,0 +1,365 @@ +using MicroStack.Admin.Contracts; +using MicroStack.Internal.Admin; + +namespace MicroStack.Services.Iam; + +internal sealed partial class IamServiceHandler +{ + private static readonly AdminResourceKind[] AdminKinds = + [ + new("user", "Users"), + new("access-key", "Access keys") { IsRoot = false }, + new("user-inline-policy", "User inline policies") { IsRoot = false }, + new("role", "Roles"), + new("role-inline-policy", "Role inline policies") { IsRoot = false }, + new("policy", "Policies"), + new("policy-version", "Policy versions") { IsRoot = false }, + new("group", "Groups"), + new("instance-profile", "Instance profiles"), + new("oidc-provider", "OpenID Connect providers") + ]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => AdminKinds; + + public IEnumerable GetAdminResources(string serviceId) + { + lock (_lock) + { + var nodes = new List(); + nodes.AddRange(_users.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => UserNode(x.Key, x.Value))); + nodes.AddRange(_roles.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => RoleNode(x.Key, x.Value))); + nodes.AddRange(_policies.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => PolicyNode(x.Key, x.Value))); + nodes.AddRange(_groups.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => GroupNode(x.Key, x.Value))); + nodes.AddRange(_instanceProfiles.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => InstanceProfileNode(x.Key, x.Value))); + nodes.AddRange(_oidcProviders.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => OidcNode(x.Key, x.Value))); + return nodes; + } + } + + private AdminNode UserNode(string name, IamUser snapshot) => + AdminData.Node("user", name, name, snapshot.Arn, "Active", "global") with + { + ReadFields = () => + { + lock (_lock) + { + if (!_users.TryGetValue(name, out var user)) return []; + return + [ + AdminData.Field("User ID", user.UserId), + AdminData.Field("Path", user.Path), + AdminData.Field("Created", user.CreateDate, format: "datetime"), + AdminData.Field("Attached policies", string.Join(", ", user.AttachedPolicies)), + AdminData.Field("Tags", Tags(user.Tags)) + ]; + } + }, + ChildKinds = [AdminKinds[1], AdminKinds[2]], + ReadChildren = () => + { + lock (_lock) + { + if (!_users.ContainsKey(name)) return []; + var children = _accessKeys.Items.Where(x => x.Value.UserName == name) + .OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => AccessKeyNode(x.Key, x.Value)).ToList(); + children.AddRange(_userInlinePolicies.Items + .Where(x => x.Key.StartsWith(name + "\0", StringComparison.Ordinal)) + .OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => InlinePolicyNode("user-inline-policy", name, + x.Key[(name.Length + 1)..]))); + return children; + } + }, + ReadConnections = () => + { + lock (_lock) + { + if (!_users.TryGetValue(name, out var user)) return []; + var links = new List(); + links.AddRange(user.AttachedPolicies.Where(_policies.ContainsKey) + .Select(arn => new AdminConnection(arn, "attached-policy", "iam", + [new AdminKey("policy", arn)]))); + links.AddRange(_groups.Items.Where(x => x.Value.Users.Contains(name)) + .Select(x => new AdminConnection(x.Key, "member-of", "iam", + [new AdminKey("group", x.Key)]))); + return links; + } + } + }; + + private AdminNode AccessKeyNode(string keyId, IamAccessKey snapshot) => + AdminData.Node("access-key", keyId, keyId, status: snapshot.Status, scope: "global") with + { + ReadFields = () => + { + lock (_lock) + { + if (!_accessKeys.TryGetValue(keyId, out var key)) return []; + return + [ + AdminData.Field("User name", key.UserName), + AdminData.Field("Created", key.CreateDate, format: "datetime"), + AdminData.Field("Secret access key", null, sensitive: true) + ]; + } + } + }; + + private AdminNode RoleNode(string name, IamRole snapshot) => + AdminData.Node("role", name, name, snapshot.Arn, "Active", "global") with + { + ReadFields = () => + { + lock (_lock) + { + if (!_roles.TryGetValue(name, out var role)) return []; + return + [ + AdminData.Field("Role ID", role.RoleId), + AdminData.Field("Path", role.Path), + AdminData.Field("Description", role.Description), + AdminData.Field("Created", role.CreateDate, format: "datetime"), + AdminData.Field("Maximum session duration", role.MaxSessionDuration.ToString()), + AdminData.Field("Tags", Tags(role.Tags)) + ]; + } + }, + ChildKinds = [AdminKinds[4]], + ReadChildren = () => + { + lock (_lock) + { + if (!_roles.TryGetValue(name, out var role)) return []; + return role.InlinePolicies.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => InlinePolicyNode("role-inline-policy", name, x.Key)).ToArray(); + } + }, + ReadContent = () => + { + lock (_lock) + { + return _roles.TryGetValue(name, out var role) && + !string.IsNullOrEmpty(role.AssumeRolePolicyDocument) + ? PolicyContent(role.AssumeRolePolicyDocument) + : AdminData.Unavailable("No assume-role policy is retained.", "application/json"); + } + }, + ReadConnections = () => + { + lock (_lock) + { + if (!_roles.TryGetValue(name, out var role)) return []; + var links = new List(); + links.AddRange(role.AttachedPolicies.Where(_policies.ContainsKey) + .Select(arn => new AdminConnection(arn, "attached-policy", "iam", + [new AdminKey("policy", arn)]))); + links.AddRange(_instanceProfiles.Items.Where(x => x.Value.Roles.Contains(name)) + .Select(x => new AdminConnection(x.Key, "instance-profile", "iam", + [new AdminKey("instance-profile", x.Key)]))); + return links; + } + } + }; + + private AdminNode InlinePolicyNode(string kind, string owner, string name) => + AdminData.Node(kind, name, name, status: "Inline", scope: "global") with + { + ReadFields = () => [AdminData.Field("Owner", owner)], + ReadContent = () => + { + lock (_lock) + { + string? document = null; + if (kind == "role-inline-policy" && + _roles.TryGetValue(owner, out var role)) + role.InlinePolicies.TryGetValue(name, out document); + else if (kind == "user-inline-policy") + _userInlinePolicies.TryGetValue($"{owner}\0{name}", out document); + return document is not null + ? PolicyContent(document) + : AdminData.Unavailable("The inline policy no longer exists.", "application/json"); + } + } + }; + + private AdminNode PolicyNode(string arn, IamPolicy snapshot) => + AdminData.Node("policy", arn, snapshot.PolicyName, arn, + snapshot.IsAttachable ? "Attachable" : "Not attachable", "global") with + { + ReadFields = () => + { + lock (_lock) + { + if (!_policies.TryGetValue(arn, out var policy)) return []; + return + [ + AdminData.Field("Policy ID", policy.PolicyId), + AdminData.Field("Path", policy.Path), + AdminData.Field("Default version", policy.DefaultVersionId), + AdminData.Field("Attachment count", policy.AttachmentCount.ToString()), + AdminData.Field("Created", policy.CreateDate, format: "datetime"), + AdminData.Field("Updated", policy.UpdateDate, format: "datetime"), + AdminData.Field("Tags", Tags(policy.Tags)) + ]; + } + }, + ChildKinds = [AdminKinds[6]], + ReadChildren = () => + { + lock (_lock) + { + if (!_policies.TryGetValue(arn, out var policy)) return []; + return policy.Versions.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => PolicyVersionNode(arn, x.Key, x.Value)).ToArray(); + } + }, + ReadConnections = () => PolicyConnections(arn) + }; + + private AdminNode PolicyVersionNode(string policyArn, string versionId, IamPolicyVersion snapshot) => + AdminData.Node("policy-version", versionId, versionId, + status: snapshot.IsDefaultVersion ? "Default" : "Active", scope: "global") with + { + ReadFields = () => + { + lock (_lock) + { + return _policies.TryGetValue(policyArn, out var policy) && + policy.Versions.TryGetValue(versionId, out var version) + ? + [ + AdminData.Field("Created", version.CreateDate, format: "datetime"), + AdminData.Field("Default", version.IsDefaultVersion.ToString()) + ] + : []; + } + }, + ReadContent = () => + { + lock (_lock) + { + return _policies.TryGetValue(policyArn, out var policy) && + policy.Versions.TryGetValue(versionId, out var version) + ? PolicyContent(version.Document) + : AdminData.Unavailable("The policy version no longer exists.", "application/json"); + } + } + }; + + private IReadOnlyList PolicyConnections(string arn) + { + lock (_lock) + { + if (!_policies.ContainsKey(arn)) return []; + var links = new List(); + links.AddRange(_users.Items.Where(x => x.Value.AttachedPolicies.Contains(arn)) + .Select(x => new AdminConnection(x.Key, "attached-user", "iam", + [new AdminKey("user", x.Key)]))); + links.AddRange(_roles.Items.Where(x => x.Value.AttachedPolicies.Contains(arn)) + .Select(x => new AdminConnection(x.Key, "attached-role", "iam", + [new AdminKey("role", x.Key)]))); + links.AddRange(_groups.Items.Where(x => x.Value.AttachedPolicies.Contains(arn)) + .Select(x => new AdminConnection(x.Key, "attached-group", "iam", + [new AdminKey("group", x.Key)]))); + return links; + } + } + + private AdminNode GroupNode(string name, IamGroup snapshot) => + AdminData.Node("group", name, name, snapshot.Arn, "Active", "global") with + { + ReadFields = () => + { + lock (_lock) + { + if (!_groups.TryGetValue(name, out var group)) return []; + return + [ + AdminData.Field("Group ID", group.GroupId), + AdminData.Field("Path", group.Path), + AdminData.Field("Created", group.CreateDate, format: "datetime") + ]; + } + }, + ReadConnections = () => + { + lock (_lock) + { + if (!_groups.TryGetValue(name, out var group)) return []; + var links = group.Users.Where(_users.ContainsKey) + .Select(user => new AdminConnection(user, "member", "iam", + [new AdminKey("user", user)])).ToList(); + links.AddRange(group.AttachedPolicies.Where(_policies.ContainsKey) + .Select(arn => new AdminConnection(arn, "attached-policy", "iam", + [new AdminKey("policy", arn)]))); + return links; + } + } + }; + + private AdminNode InstanceProfileNode(string name, IamInstanceProfile snapshot) => + AdminData.Node("instance-profile", name, name, snapshot.Arn, "Active", "global") with + { + ReadFields = () => + { + lock (_lock) + { + if (!_instanceProfiles.TryGetValue(name, out var profile)) return []; + return + [ + AdminData.Field("Instance profile ID", profile.InstanceProfileId), + AdminData.Field("Path", profile.Path), + AdminData.Field("Created", profile.CreateDate, format: "datetime") + ]; + } + }, + ReadConnections = () => + { + lock (_lock) + { + return _instanceProfiles.TryGetValue(name, out var profile) + ? profile.Roles.Where(_roles.ContainsKey) + .Select(role => new AdminConnection(role, "role", "iam", + [new AdminKey("role", role)])).ToArray() + : []; + } + } + }; + + private AdminNode OidcNode(string arn, IamOidcProvider snapshot) => + AdminData.Node("oidc-provider", arn, snapshot.Url, arn, "Active", "global") with + { + ReadFields = () => + { + lock (_lock) + { + if (!_oidcProviders.TryGetValue(arn, out var provider)) return []; + return + [ + AdminData.Field("URL", provider.Url), + AdminData.Field("Created", provider.CreateDate, format: "datetime"), + AdminData.Field("Client IDs", string.Join(", ", provider.ClientIdList)), + AdminData.Field("Thumbprints", string.Join(", ", provider.ThumbprintList)), + AdminData.Field("Tags", Tags(provider.Tags)) + ]; + } + } + }; + + private static string Tags(IEnumerable tags) => + string.Join(", ", tags.Select(t => $"{t.Key}={t.Value}")); + + private static AdminContent PolicyContent(string document) + { + var decoded = System.Net.WebUtility.UrlDecode(document); + try { return AdminData.JsonText(decoded); } + catch (System.Text.Json.JsonException) { return AdminData.Text(decoded); } + } +} diff --git a/src/MicroStack/Services/Iam/IamServiceHandler.cs b/src/MicroStack/Services/Iam/IamServiceHandler.cs index 02e588d..667cc8e 100644 --- a/src/MicroStack/Services/Iam/IamServiceHandler.cs +++ b/src/MicroStack/Services/Iam/IamServiceHandler.cs @@ -34,7 +34,7 @@ namespace MicroStack.Services.Iam; /// CreateServiceLinkedRole, DeleteServiceLinkedRole, GetServiceLinkedRoleDeletionStatus, /// CreateOpenIDConnectProvider, GetOpenIDConnectProvider, DeleteOpenIDConnectProvider. /// -internal sealed class IamServiceHandler : IServiceHandler +internal sealed partial class IamServiceHandler : IServiceHandler, Internal.Admin.IAdminResourceSource { private readonly AccountScopedDictionary _users = new(); private readonly AccountScopedDictionary _roles = new(); @@ -60,6 +60,11 @@ internal sealed class IamServiceHandler : IServiceHandler public string ServiceName => "iam"; + public IEnumerable GetKnownAccountIds() => + _users.GetAccountIds().Concat(_roles.GetAccountIds()).Concat(_policies.GetAccountIds()) + .Concat(_accessKeys.GetAccountIds()).Concat(_instanceProfiles.GetAccountIds()) + .Concat(_groups.GetAccountIds()).Concat(_oidcProviders.GetAccountIds()); + public Task HandleAsync(ServiceRequest request) { var formParams = new Dictionary(StringComparer.Ordinal); diff --git a/src/MicroStack/Services/Kinesis/KinesisServiceHandler.cs b/src/MicroStack/Services/Kinesis/KinesisServiceHandler.cs index ec93ed2..a5979a0 100644 --- a/src/MicroStack/Services/Kinesis/KinesisServiceHandler.cs +++ b/src/MicroStack/Services/Kinesis/KinesisServiceHandler.cs @@ -1,7 +1,9 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using MicroStack.Admin.Contracts; using MicroStack.Internal; +using MicroStack.Internal.Admin; namespace MicroStack.Services.Kinesis; @@ -19,7 +21,7 @@ namespace MicroStack.Services.Kinesis; /// DescribeStreamConsumer, StartStreamEncryption, StopStreamEncryption, /// EnableEnhancedMonitoring, DisableEnhancedMonitoring. /// -internal sealed class KinesisServiceHandler : IServiceHandler +internal sealed class KinesisServiceHandler : IServiceHandler, IAdminResourceSource { private readonly Lock _lock = new(); @@ -38,6 +40,9 @@ internal sealed class KinesisServiceHandler : IServiceHandler public string ServiceName => "kinesis"; + public IEnumerable GetKnownAccountIds() => + _streams.GetAccountIds().Concat(_consumers.GetAccountIds()); + public Task HandleAsync(ServiceRequest request) { var target = request.GetHeader("x-amz-target") ?? ""; @@ -115,6 +120,125 @@ public void Reset() public void RestoreState(JsonElement state) { } + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + [ + new("stream", "Streams"), + ]; + + public IEnumerable GetAdminResources(string serviceId) + { + lock (_lock) + return _streams.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => StreamNode(x.Value)).ToArray(); + } + + public string? GetAdminNotice(string serviceId) => + "Records are read directly from retained storage without creating or advancing shard iterators."; + + private AdminNode StreamNode(KinStream stream) => + AdminData.Node("stream", stream.StreamName, stream.StreamName, + stream.StreamArn, stream.StreamStatus) with + { + ReadFields = () => + { + lock (_lock) + { + return + [ + AdminData.Field("mode", stream.StreamMode), + AdminData.Field("retentionPeriodHours", stream.RetentionPeriodHours.ToString()), + AdminData.Field("shardCount", stream.Shards.Count.ToString()), + AdminData.Field("createdAt", AnalyticsAdminData.Epoch(stream.CreationTimestamp), format: "timestamp"), + AdminData.Field("encryptionType", stream.EncryptionType), + AdminData.Field("keyId", stream.KeyId), + ]; + } + }, + ChildKinds = + [ + new("shard", "Shards") { IsRoot = false }, + new("consumer", "Consumers") { IsRoot = false }, + ], + ReadChildren = () => + { + lock (_lock) + { + var children = stream.Shards.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => ShardNode(x.Key, x.Value)).ToList(); + children.AddRange(_consumers.Items + .Where(x => string.Equals(x.Value.StreamArn, stream.StreamArn, StringComparison.Ordinal)) + .OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => ConsumerNode(x.Value))); + return children; + } + }, + ReadConnections = () => + { + lock (_lock) + return string.IsNullOrEmpty(stream.KeyId) + ? [] + : [new AdminConnection("Encryption key", "configured-encryption-key", "kms", + [new AdminKey("key", stream.KeyId.Split('/').Last())])]; + }, + }; + + private AdminNode ShardNode(string id, KinShard shard) => + AdminData.Node("shard", id, id) with + { + ReadFields = () => + { + lock (_lock) + { + return + [ + AdminData.Field("startingHashKey", shard.StartingHashKey), + AdminData.Field("endingHashKey", shard.EndingHashKey), + AdminData.Field("startingSequenceNumber", shard.StartingSequenceNumber), + AdminData.Field("parentShardId", shard.ParentShardId), + AdminData.Field("adjacentParentShardId", shard.AdjacentParentShardId), + AdminData.Field("recordCount", shard.Records.Count.ToString()), + ]; + } + }, + ChildKinds = [new("record", "Records") { IsRoot = false }], + ReadChildren = () => + { + lock (_lock) + return shard.Records.OrderBy(x => x.ApproximateArrivalTimestamp) + .ThenBy(x => x.SequenceNumber, StringComparer.Ordinal) + .Select(RecordNode).ToArray(); + }, + }; + + private AdminNode RecordNode(KinRecord record) => + AdminData.Node("record", record.SequenceNumber, record.SequenceNumber) with + { + ReadFields = () => + [ + AdminData.Field("partitionKey", record.PartitionKey), + AdminData.Field("sequenceNumber", record.SequenceNumber), + AdminData.Field("approximateArrivalTimestamp", + AnalyticsAdminData.Epoch(record.ApproximateArrivalTimestamp), format: "timestamp"), + ], + ReadContent = () => + { + lock (_lock) + return AnalyticsAdminData.DecodedBase64(record.Data); + }, + }; + + private AdminNode ConsumerNode(KinConsumer consumer) => + AdminData.Node("consumer", consumer.ConsumerArn, consumer.ConsumerName, + consumer.ConsumerArn, consumer.ConsumerStatus) with + { + ReadFields = () => + [ + AdminData.Field("createdAt", + AnalyticsAdminData.Epoch(consumer.ConsumerCreationTimestamp), format: "timestamp"), + AdminData.Field("streamArn", consumer.StreamArn), + ], + }; + // -- JSON helpers ---------------------------------------------------------- private static string? GetString(JsonElement el, string propertyName) diff --git a/src/MicroStack/Services/Kms/KmsServiceHandler.Admin.cs b/src/MicroStack/Services/Kms/KmsServiceHandler.Admin.cs new file mode 100644 index 0000000..c0501e7 --- /dev/null +++ b/src/MicroStack/Services/Kms/KmsServiceHandler.Admin.cs @@ -0,0 +1,103 @@ +using System.Globalization; +using MicroStack.Admin.Contracts; +using MicroStack.Internal.Admin; + +namespace MicroStack.Services.Kms; + +internal sealed partial class KmsServiceHandler +{ + private static readonly AdminResourceKind[] AdminKinds = + [ + new("key", "Keys"), + new("alias", "Aliases") + ]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => AdminKinds; + + public IEnumerable GetAdminResources(string serviceId) + { + lock (_lock) + { + var keys = _keys.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => KeyNode(x.Key, x.Value)).ToList(); + keys.AddRange(_aliases.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => AliasNode(x.Key, x.Value))); + return keys; + } + } + + private AdminNode KeyNode(string keyId, KmsKeyRecord snapshot) => + AdminData.Node("key", keyId, string.IsNullOrEmpty(snapshot.Description) ? keyId : snapshot.Description, + snapshot.Arn, snapshot.KeyState) with + { + ReadFields = () => + { + lock (_lock) + { + if (!_keys.TryGetValue(keyId, out var key)) return []; + return + [ + AdminData.Field("Key ID", key.KeyId), + AdminData.Field("Description", key.Description), + AdminData.Field("Usage", key.KeyUsage), + AdminData.Field("Spec", key.KeySpec), + AdminData.Field("Origin", key.Origin), + AdminData.Field("Enabled", key.Enabled.ToString()), + AdminData.Field("Created", Epoch(key.CreationDate), format: "datetime"), + AdminData.Field("Rotation enabled", key.KeyRotationEnabled.ToString()), + AdminData.Field("Rotation period (days)", key.RotationPeriodInDays.ToString(CultureInfo.InvariantCulture)), + AdminData.Field("Deletion date", key.DeletionDate is { } d ? Epoch(d) : null, format: "datetime"), + AdminData.Field("Encryption algorithms", string.Join(", ", key.EncryptionAlgorithms)), + AdminData.Field("Signing algorithms", string.Join(", ", key.SigningAlgorithms)), + AdminData.Field("Tags", string.Join(", ", key.Tags.Select(t => $"{t.TagKey}={t.TagValue}"))) + ]; + } + }, + ReadContent = () => + { + lock (_lock) + { + return _keys.TryGetValue(keyId, out var key) && key.Policy is not null + ? AdminData.JsonText(key.Policy) + : AdminData.Unavailable("No key policy is retained.", "application/json"); + } + }, + ReadConnections = () => + { + lock (_lock) + { + return _aliases.Items.Where(x => x.Value == keyId) + .OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => new AdminConnection(x.Key, "aliases", "kms", + [new AdminKey("alias", x.Key)])) + .ToArray(); + } + } + }; + + private AdminNode AliasNode(string name, string targetKeyId) => + AdminData.Node("alias", name, name, status: "Configured") with + { + ReadFields = () => + { + lock (_lock) + { + return _aliases.TryGetValue(name, out var target) + ? [AdminData.Field("Target key ID", target)] + : []; + } + }, + ReadConnections = () => + { + lock (_lock) + { + return _aliases.TryGetValue(name, out var target) && _keys.ContainsKey(target) + ? [new AdminConnection("Target key", "targets", "kms", [new AdminKey("key", target)])] + : []; + } + } + }; + + private static string Epoch(double value) => + AdminData.IsoUtc(DateTimeOffset.FromUnixTimeMilliseconds((long)(value * 1000))); +} diff --git a/src/MicroStack/Services/Kms/KmsServiceHandler.cs b/src/MicroStack/Services/Kms/KmsServiceHandler.cs index 2a1998d..24da09c 100644 --- a/src/MicroStack/Services/Kms/KmsServiceHandler.cs +++ b/src/MicroStack/Services/Kms/KmsServiceHandler.cs @@ -18,7 +18,7 @@ namespace MicroStack.Services.Kms; /// EnableKey, DisableKey, ScheduleKeyDeletion, CancelKeyDeletion, /// TagResource, UntagResource, ListResourceTags. /// -internal sealed class KmsServiceHandler : IServiceHandler +internal sealed partial class KmsServiceHandler : IServiceHandler, Internal.Admin.IAdminResourceSource { private readonly AccountScopedDictionary _keys = new(); // keyed by KeyId private readonly AccountScopedDictionary _aliases = new(); // alias_name -> key_id @@ -30,6 +30,9 @@ internal sealed class KmsServiceHandler : IServiceHandler public string ServiceName => "kms"; + public IEnumerable GetKnownAccountIds() => + _keys.GetAccountIds().Concat(_aliases.GetAccountIds()); + public Task HandleAsync(ServiceRequest request) { var target = request.GetHeader("x-amz-target") ?? ""; diff --git a/src/MicroStack/Services/Lambda/EventSourceMappingPoller.cs b/src/MicroStack/Services/Lambda/EventSourceMappingPoller.cs index 214edcc..b238858 100644 --- a/src/MicroStack/Services/Lambda/EventSourceMappingPoller.cs +++ b/src/MicroStack/Services/Lambda/EventSourceMappingPoller.cs @@ -1,4 +1,5 @@ using MicroStack.Internal; +using MicroStack.Internal.Admin; using MicroStack.Services.DynamoDb; using MicroStack.Services.Sqs; @@ -16,15 +17,18 @@ internal sealed class EventSourceMappingPoller : IDisposable private readonly DynamoDbServiceHandler _ddbHandler; private Timer? _timer; private int _running; // 0 = idle, 1 = running (interlocked) + private readonly AdminChangeHub? _changes; internal EventSourceMappingPoller( LambdaServiceHandler lambdaHandler, SqsServiceHandler sqsHandler, - DynamoDbServiceHandler ddbHandler) + DynamoDbServiceHandler ddbHandler, + AdminChangeHub? changes = null) { _lambdaHandler = lambdaHandler; _sqsHandler = sqsHandler; _ddbHandler = ddbHandler; + _changes = changes; } internal void EnsureStarted() @@ -69,23 +73,31 @@ private void PollOnce() { foreach (var esm in esms) { + var changed = false; try { var sourceArn = esm["EventSourceArn"]?.ToString() ?? ""; if (sourceArn.Contains(":sqs:", StringComparison.Ordinal)) { - PollSqs(esm, accountId); + changed = true; + changed = PollSqs(esm, accountId); } else if (sourceArn.Contains(":dynamodb:", StringComparison.Ordinal) && sourceArn.Contains("/stream/", StringComparison.Ordinal)) { - PollDynamoDbStream(esm, accountId); + changed = true; + changed = PollDynamoDbStream(esm, accountId); } } catch (Exception) { // Log but continue with other ESMs } + finally + { + if (changed) + _changes?.Publish(AdminDirty.Resources | AdminDirty.Accounts, accountId); + } } } finally @@ -95,7 +107,7 @@ private void PollOnce() } } - private void PollSqs(Dictionary esm, string accountId) + private bool PollSqs(Dictionary esm, string accountId) { var sourceArn = esm["EventSourceArn"]?.ToString() ?? ""; var batchSize = esm.TryGetValue("BatchSize", out var bs) && bs is int bsInt ? bsInt : 10; @@ -104,7 +116,7 @@ private void PollSqs(Dictionary esm, string accountId) var parts = sourceArn.Split(':'); if (parts.Length < 6) { - return; + return false; } var region = parts[3]; @@ -113,7 +125,7 @@ private void PollSqs(Dictionary esm, string accountId) var messages = _sqsHandler.ReceiveMessagesForEsm(queueName, batchSize); if (messages.Count == 0) { - return; + return false; } // Build SQS event payload @@ -170,9 +182,10 @@ private void PollSqs(Dictionary esm, string accountId) { esm["LastProcessingResult"] = "FAILED"; } + return true; } - private void PollDynamoDbStream(Dictionary esm, string accountId) + private bool PollDynamoDbStream(Dictionary esm, string accountId) { _ = accountId; // accountId already set via AccountContext before this call @@ -182,7 +195,7 @@ private void PollDynamoDbStream(Dictionary esm, string accountI var tableSegments = sourceArn.Split('/'); if (tableSegments.Length < 2) { - return; + return false; } var tableName = tableSegments[1]; // after "table/" @@ -191,7 +204,7 @@ private void PollDynamoDbStream(Dictionary esm, string accountI var records = _ddbHandler.DrainStreamRecords(tableName, batchSize); if (records.Count == 0) { - return; + return false; } // Build DynamoDB Streams event payload @@ -204,5 +217,6 @@ private void PollDynamoDbStream(Dictionary esm, string accountI var success = _lambdaHandler.InvokeForEsm(funcArn, ddbEvent); esm["LastProcessingResult"] = success ? $"OK - {records.Count} records" : "FAILED"; + return true; } } diff --git a/src/MicroStack/Services/Lambda/LambdaServiceHandler.cs b/src/MicroStack/Services/Lambda/LambdaServiceHandler.cs index 6b2ed4b..0744fc8 100644 --- a/src/MicroStack/Services/Lambda/LambdaServiceHandler.cs +++ b/src/MicroStack/Services/Lambda/LambdaServiceHandler.cs @@ -2,6 +2,8 @@ using System.Text; using System.Text.Json; using MicroStack.Internal; +using MicroStack.Internal.Admin; +using MicroStack.Admin.Contracts; using MicroStack.Services.DynamoDb; using MicroStack.Services.Sqs; @@ -16,7 +18,7 @@ namespace MicroStack.Services.Lambda; /// Concurrency, Function URLs, Event Source Mappings CRUD, Invoke stub, /// Event Invoke Config, Provisioned Concurrency, Code Signing Config stub. /// -internal sealed class LambdaServiceHandler : IServiceHandler +internal sealed class LambdaServiceHandler : IServiceHandler, IAdminResourceSource, IAdminRelationshipSource { private readonly AccountScopedDictionary _functions = new(); private readonly AccountScopedDictionary _layers = new(); @@ -27,21 +29,32 @@ internal sealed class LambdaServiceHandler : IServiceHandler private readonly SqsServiceHandler? _sqsHandler; private readonly DynamoDbServiceHandler? _ddbHandler; private EventSourceMappingPoller? _poller; + private readonly AdminChangeHub? _changes; private static string Region => MicroStackOptions.Instance.Region; internal LambdaServiceHandler() { } - internal LambdaServiceHandler(SqsServiceHandler sqsHandler, DynamoDbServiceHandler ddbHandler) + internal LambdaServiceHandler(SqsServiceHandler sqsHandler, DynamoDbServiceHandler ddbHandler, + AdminChangeHub? changes = null) { _sqsHandler = sqsHandler; _ddbHandler = ddbHandler; + _changes = changes; } // -- IServiceHandler ------------------------------------------------------- public string ServiceName => "lambda"; + public IEnumerable GetKnownAccountIds() + { + lock (_lock) + return _functions.GetAccountIds() + .Concat(_layers.GetAccountIds(layer => layer.Versions.Count > 0)) + .Concat(_esms.GetAccountIds()).ToArray(); + } + public Task HandleAsync(ServiceRequest request) { var path = request.Path; @@ -79,6 +92,265 @@ public void Reset() } } + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + serviceId == ServiceName ? + [ + new("functions", "Functions"), + new("versions", "Versions") { IsRoot = false }, + new("aliases", "Aliases") { IsRoot = false }, + new("layers", "Layers"), + new("layer-versions", "Layer versions") { IsRoot = false }, + new("event-source-mappings", "Event source mappings"), + ] : []; + + public AdminRelationshipSnapshot GetAdminRelationshipSnapshot() + { + lock (_lock) + { + var resources = _functions.Items.OrderBy(item => item.Key, StringComparer.Ordinal) + .Select(item => new AdminRelationshipResource( + [new("functions", item.Key)], item.Key, + item.Value.Config.GetValueOrDefault("State") as string, + item.Value.Config.GetValueOrDefault("FunctionArn") as string)) + .ToArray(); + return new(resources, []); + } + } + + public IEnumerable GetAdminResources(string serviceId) + { + if (serviceId != ServiceName) + return []; + lock (_lock) + { + var nodes = new List(); + foreach (var (name, function) in _functions.Items.OrderBy(item => item.Key, StringComparer.Ordinal)) + { + var functionView = new Dictionary(function.Config, StringComparer.Ordinal) + { + ["Tags"] = new Dictionary(function.Tags, StringComparer.Ordinal), + }; + if (function.Policy is not null) + { + functionView["Policy"] = new Dictionary + { + ["Version"] = function.Policy.Version, + ["Statements"] = function.Policy.Statements.ToArray(), + }; + } + if (function.Concurrency is not null) + functionView["ReservedConcurrentExecutions"] = function.Concurrency; + if (function.ProvisionedConcurrency.Count > 0) + { + functionView["ProvisionedConcurrency"] = function.ProvisionedConcurrency.ToDictionary( + item => item.Key, + item => (object?)new Dictionary + { + ["Requested"] = item.Value.RequestedProvisionedConcurrentExecutions, + ["Available"] = item.Value.AvailableProvisionedConcurrentExecutions, + ["Allocated"] = item.Value.AllocatedProvisionedConcurrentExecutions, + ["Status"] = item.Value.Status, + ["LastModified"] = item.Value.LastModified, + }, StringComparer.Ordinal); + } + if (function.EventInvokeConfig is not null) + { + functionView["EventInvokeConfig"] = new Dictionary + { + ["MaximumRetryAttempts"] = function.EventInvokeConfig.MaximumRetryAttempts, + ["MaximumEventAgeInSeconds"] = function.EventInvokeConfig.MaximumEventAgeInSeconds, + ["FunctionArn"] = function.EventInvokeConfig.FunctionArn, + ["LastModified"] = function.EventInvokeConfig.LastModified, + ["DestinationConfig"] = function.EventInvokeConfig.DestinationConfig, + }; + } + var functionArn = function.Config.GetValueOrDefault("FunctionArn")?.ToString(); + var urlConfigs = _urlConfigs.Where(item => + item.Key.StartsWith(name + ":", StringComparison.Ordinal) + && string.Equals(item.Value.GetValueOrDefault("FunctionArn")?.ToString(), + functionArn, StringComparison.Ordinal)) + .ToDictionary(item => item.Key[(name.Length + 1)..], + item => (object?)item.Value, StringComparer.Ordinal); + if (urlConfigs.Count > 0) + functionView["FunctionUrlConfigs"] = urlConfigs; + var config = AdminProjection.Snapshot(functionView, IsEnvironmentCredential); + var connections = FunctionConfiguredConnections(function.Config); + var versions = function.Versions.OrderBy(item => item.Key, StringComparer.Ordinal) + .Select(item => FunctionVersionNode(name, item.Key, + AdminProjection.Snapshot(item.Value.Config, IsEnvironmentCredential))).ToArray(); + var aliases = function.Aliases.OrderBy(item => item.Key, StringComparer.Ordinal) + .Select(item => AliasNode(name, item.Value)).ToArray(); + nodes.Add(AdminData.Node("functions", name, name, + AdminProjection.Scalar(config.GetValueOrDefault("FunctionArn")), + AdminProjection.Scalar(config.GetValueOrDefault("State"))) with + { + ReadFields = () => AdminProjection.Fields(config, + "Runtime", "Handler", "Role", "PackageType", "MemorySize", "Timeout", "LastModified"), + ReadContent = () => AdminProjection.Content(config), + ChildKinds = + [ + new("versions", "Versions") { IsRoot = false }, + new("aliases", "Aliases") { IsRoot = false }, + ], + ReadChildren = () => versions.Concat(aliases), + ReadConnections = connections.Count == 0 ? null : () => connections, + }); + } + + foreach (var (name, layer) in _layers.Items.OrderBy(item => item.Key, StringComparer.Ordinal)) + { + var versions = layer.Versions.OrderBy(item => item.Key) + .Select(item => LayerVersionNode(name, item.Value)).ToArray(); + nodes.Add(AdminData.Node("layers", name, name, layer.LayerArn) with + { + ReadFields = () => + [ + AdminData.Field("LayerArn", layer.LayerArn), + AdminData.Field("VersionCount", versions.Length.ToString()), + ], + ChildKinds = [new("layer-versions", "Layer versions") { IsRoot = false }], + ReadChildren = () => versions, + }); + } + + foreach (var (id, mapping) in _esms.Items.OrderBy(item => item.Key, StringComparer.Ordinal)) + { + var snapshot = AdminProjection.Snapshot(mapping); + var functionArn = AdminProjection.Scalar(snapshot.GetValueOrDefault("FunctionArn")); + var eventSourceArn = AdminProjection.Scalar(snapshot.GetValueOrDefault("EventSourceArn")); + nodes.Add(AdminData.Node("event-source-mappings", id, id, status: + AdminProjection.Scalar(snapshot.GetValueOrDefault("State"))) with + { + ReadFields = () => AdminProjection.Fields(snapshot, + "UUID", "State", "EventSourceArn", "FunctionArn", "BatchSize", "LastModified"), + ReadContent = () => AdminProjection.Content(snapshot), + ReadConnections = () => + FunctionConnection(functionArn).Concat(EventSourceConnection(eventSourceArn)).ToArray(), + }); + } + return nodes; + } + } + + private static AdminNode FunctionVersionNode( + string functionName, string version, IReadOnlyDictionary config) => + AdminData.Node("versions", version, version, + AdminProjection.Scalar(config.GetValueOrDefault("FunctionArn")), + AdminProjection.Scalar(config.GetValueOrDefault("State"))) with + { + ReadFields = () => AdminProjection.Fields(config, + "Version", "Runtime", "Handler", "MemorySize", "Timeout", "LastModified"), + ReadContent = () => AdminProjection.Content(config), + ReadConnections = () => FunctionConnection(functionName), + }; + + private static AdminNode AliasNode(string functionName, AliasRecord alias) + { + var snapshot = new Dictionary(StringComparer.Ordinal) + { + ["AliasArn"] = alias.AliasArn, + ["Name"] = alias.Name, + ["FunctionVersion"] = alias.FunctionVersion, + ["Description"] = alias.Description, + ["RevisionId"] = alias.RevisionId, + }; + return AdminData.Node("aliases", alias.Name, alias.Name, alias.AliasArn) with + { + ReadFields = () => AdminProjection.Fields(snapshot, "FunctionVersion", "Description", "RevisionId"), + ReadContent = () => AdminProjection.Content(snapshot), + ReadConnections = () => + [ + new("Function", "alias-of", "lambda", [new("functions", functionName)]), + new("Version", "targets", "lambda", + [new("functions", functionName), new("versions", alias.FunctionVersion)]), + ], + }; + } + + private static AdminNode LayerVersionNode(string layerName, LayerVersionRecord version) + { + var snapshot = new Dictionary(StringComparer.Ordinal) + { + ["Version"] = version.VersionNumber, + ["Description"] = version.Description, + ["CodeSha256"] = version.CodeSha256, + ["CodeSize"] = version.CodeSize, + ["CompatibleRuntimes"] = version.CompatibleRuntimes.ToArray(), + ["LayerVersionArn"] = version.LayerVersionArn, + ["CreatedDate"] = version.CreatedDate, + }; + return AdminData.Node("layer-versions", version.VersionNumber.ToString(), + version.VersionNumber.ToString(), version.LayerVersionArn) with + { + ReadFields = () => AdminProjection.Fields(snapshot, + "Version", "Description", "CodeSha256", "CodeSize", "CreatedDate"), + ReadContent = () => AdminProjection.Content(snapshot), + ReadConnections = () => + [new("Layer", "version-of", "lambda", [new("layers", layerName)])], + }; + } + + private static IReadOnlyList FunctionConnection(string? reference) + { + var name = reference is null ? null : ExtractFunctionNameFromArn(reference); + return name is null ? [] : + [new("Function", "invokes", "lambda", [new("functions", name)])]; + } + + private static IReadOnlyList FunctionConfiguredConnections( + IReadOnlyDictionary config) + { + var links = new List(); + if (config.GetValueOrDefault("Role") is string roleArn) + { + var roleName = roleArn[(roleArn.LastIndexOf('/') + 1)..]; + links.Add(new("Execution role", "assumes", "iam", [new("role", roleName)])); + } + if (config.GetValueOrDefault("LoggingConfig") is Dictionary logging + && logging.GetValueOrDefault("LogGroup") is string logGroup) + links.Add(new("Log group", "writes-to", "logs", [new("log-group", logGroup)])); + if (config.GetValueOrDefault("Layers") is List layers) + { + foreach (var layer in layers.OfType>()) + { + if (layer.GetValueOrDefault("Arn") is not string arn) + continue; + var parts = arn.Split(':'); + if (parts.Length >= 7) + links.Add(new("Layer", "uses", "lambda", [new("layers", parts[6])])); + } + } + return links; + } + + private static IReadOnlyList EventSourceConnection(string? arn) + { + if (arn is null) + return []; + if (arn.Contains(":sqs:", StringComparison.Ordinal)) + return [new("Event source", "reads-from", "sqs", [new("queues", arn[(arn.LastIndexOf(':') + 1)..])])]; + if (arn.Contains(":dynamodb:", StringComparison.Ordinal)) + { + const string marker = ":table/"; + var start = arn.IndexOf(marker, StringComparison.Ordinal); + if (start >= 0) + { + start += marker.Length; + var end = arn.IndexOf('/', start); + var table = end < 0 ? arn[start..] : arn[start..end]; + return [new("Event source", "reads-from", "dynamodb", [new("tables", table)])]; + } + } + return []; + } + + private static bool IsEnvironmentCredential(string key) => + key.Contains("SECRET", StringComparison.OrdinalIgnoreCase) + || key.Contains("PASSWORD", StringComparison.OrdinalIgnoreCase) + || key.Contains("TOKEN", StringComparison.OrdinalIgnoreCase) + || key.Equals("AWS_ACCESS_KEY_ID", StringComparison.OrdinalIgnoreCase) + || key.Equals("AWS_SESSION_TOKEN", StringComparison.OrdinalIgnoreCase); + public JsonElement? GetState() => null; public void RestoreState(JsonElement state) { } @@ -2183,7 +2455,7 @@ private ServiceResponse HandleCreateEsm(ServiceRequest request) // Start the ESM background poller if SQS and DynamoDB handlers are available if (_sqsHandler is not null && _ddbHandler is not null) { - _poller ??= new EventSourceMappingPoller(this, _sqsHandler, _ddbHandler); + _poller ??= new EventSourceMappingPoller(this, _sqsHandler, _ddbHandler, _changes); _poller.EnsureStarted(); } diff --git a/src/MicroStack/Services/Rds/RdsServiceHandler.cs b/src/MicroStack/Services/Rds/RdsServiceHandler.cs index a4ebb76..c1ec6d6 100644 --- a/src/MicroStack/Services/Rds/RdsServiceHandler.cs +++ b/src/MicroStack/Services/Rds/RdsServiceHandler.cs @@ -3,13 +3,23 @@ using System.Text.RegularExpressions; using System.Web; using MicroStack.Internal; +using MicroStack.Internal.Admin; +using MicroStack.Admin.Contracts; namespace MicroStack.Services.Rds; -internal sealed class RdsServiceHandler : IServiceHandler +internal sealed class RdsServiceHandler : IServiceHandler, IAdminResourceSource { public string ServiceName => "rds"; + public IEnumerable GetKnownAccountIds() => + _instances.GetAccountIds().Concat(_clusters.GetAccountIds()) + .Concat(_subnetGroups.GetAccountIds()).Concat(_paramGroups.GetAccountIds()) + .Concat(_snapshots.GetAccountIds()).Concat(_clusterParamGroups.GetAccountIds()) + .Concat(_clusterSnapshots.GetAccountIds()).Concat(_eventSubscriptions.GetAccountIds()) + .Concat(_dbProxies.GetAccountIds()).Concat(_optionGroups.GetAccountIds()) + .Concat(_globalClusters.GetAccountIds()); + private const string RdsNs = "http://rds.amazonaws.com/doc/2014-10-31/"; private static string Region => @@ -68,6 +78,130 @@ public void Reset() public void RestoreState(JsonElement state) { } + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + [ + new("db-instances", "DB instances"), + new("db-clusters", "DB clusters"), + new("db-subnet-groups", "DB subnet groups"), + new("db-parameter-groups", "DB parameter groups"), + new("db-cluster-parameter-groups", "DB cluster parameter groups"), + new("db-snapshots", "DB snapshots"), + new("db-cluster-snapshots", "DB cluster snapshots"), + new("event-subscriptions", "Event subscriptions"), + new("db-proxies", "DB proxies"), + new("option-groups", "Option groups"), + new("global-clusters", "Global clusters"), + ]; + + public IEnumerable GetAdminResources(string serviceId) + { + lock (_lock) + { + return + [ + .. RdsNodes("db-instances", _instances, "DBInstanceArn"), + .. RdsNodes("db-clusters", _clusters, "DBClusterArn"), + .. RdsNodes("db-subnet-groups", _subnetGroups, "DBSubnetGroupArn"), + .. RdsNodes("db-parameter-groups", _paramGroups, "DBParameterGroupArn"), + .. RdsNodes("db-cluster-parameter-groups", _clusterParamGroups, "DBClusterParameterGroupArn"), + .. RdsNodes("db-snapshots", _snapshots, "DBSnapshotArn"), + .. RdsNodes("db-cluster-snapshots", _clusterSnapshots, "DBClusterSnapshotArn"), + .. RdsNodes("event-subscriptions", _eventSubscriptions, "EventSubscriptionArn"), + .. RdsNodes("db-proxies", _dbProxies, "DBProxyArn"), + .. RdsNodes("option-groups", _optionGroups, "OptionGroupArn"), + .. RdsNodes("global-clusters", _globalClusters, "GlobalClusterArn"), + ]; + } + } + + private IEnumerable RdsNodes( + string kind, AccountScopedDictionary> source, string arnField) => + source.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => RdsNode(kind, x.Key, x.Value, source, arnField)); + + private AdminNode RdsNode( + string kind, string id, Dictionary value, + AccountScopedDictionary> source, string arnField) + { + var arn = value.GetValueOrDefault(arnField) as string; + var status = value.FirstOrDefault(x => + x.Key.EndsWith("Status", StringComparison.Ordinal) && + x.Value is string).Value?.ToString(); + return AdminData.Node(kind, id, id, arn, status) with + { + ReadFields = () => + { + lock (_lock) + return source.TryGetValue(id, out var current) ? RdsFields(current) : []; + }, + ChildKinds = [new("tags", "Tags") { IsRoot = false }], + ReadChildren = arn is null ? null : () => RdsTagNodes(arn), + }; + } + + private IReadOnlyList RdsTagNodes(string arn) + { + lock (_lock) + { + if (!_tags.TryGetValue(arn, out var tags)) + return []; + return tags.Where(x => x.TryGetValue("Key", out _)) + .OrderBy(x => x.GetValueOrDefault("Key"), StringComparer.Ordinal) + .Select(x => + { + var key = x.GetValueOrDefault("Key") ?? ""; + var value = x.GetValueOrDefault("Value"); + return AdminData.Node("tags", key, key) with + { + ReadFields = () => + [ + AdminData.Field("Key", key), + AdminData.Field("Value", value, RdsSensitiveName(key)), + ], + }; + }).ToArray(); + } + } + + private static IReadOnlyList RdsFields(Dictionary value) + { + var fields = new List(); + AddRdsFields(fields, value, ""); + return fields; + } + + private static void AddRdsFields( + List fields, Dictionary value, string prefix) + { + foreach (var (key, item) in value.OrderBy(x => x.Key, StringComparer.Ordinal)) + { + var name = prefix + key; + switch (item) + { + case null or string or bool or byte or short or int or long or float or double or decimal: + fields.Add(AdminData.Field(name, item?.ToString(), RdsSensitiveName(name))); + break; + case Dictionary dictionary: + AddRdsFields(fields, dictionary, name + "."); + break; + case List strings: + fields.Add(AdminData.Field(name, string.Join(", ", strings), RdsSensitiveName(name))); + break; + case List> dictionaries: + for (var i = 0; i < dictionaries.Count; i++) + AddRdsFields(fields, dictionaries[i], $"{name}[{i}]."); + break; + } + } + } + + private static bool RdsSensitiveName(string name) => + name.Contains("password", StringComparison.OrdinalIgnoreCase) || + name.Contains("secret", StringComparison.OrdinalIgnoreCase) || + name.Contains("token", StringComparison.OrdinalIgnoreCase) || + name.Contains("credential", StringComparison.OrdinalIgnoreCase) || + name.Contains("auth", StringComparison.OrdinalIgnoreCase); + // ── Action dispatch ─────────────────────────────────────────────────────── private ServiceResponse DispatchAction(string action, Dictionary p) diff --git a/src/MicroStack/Services/RdsData/RdsDataServiceHandler.cs b/src/MicroStack/Services/RdsData/RdsDataServiceHandler.cs index 45971c8..64b1fa8 100644 --- a/src/MicroStack/Services/RdsData/RdsDataServiceHandler.cs +++ b/src/MicroStack/Services/RdsData/RdsDataServiceHandler.cs @@ -1,5 +1,7 @@ using System.Text.Json; using MicroStack.Internal; +using MicroStack.Internal.Admin; +using MicroStack.Admin.Contracts; namespace MicroStack.Services.RdsData; @@ -16,7 +18,7 @@ namespace MicroStack.Services.RdsData; /// implemented — the handler returns canned errors when a cluster /// ARN cannot be resolved. /// -internal sealed class RdsDataServiceHandler : IServiceHandler +internal sealed class RdsDataServiceHandler : IServiceHandler, IAdminResourceSource { public string ServiceName => "rds-data"; @@ -75,6 +77,13 @@ public void Reset() public void RestoreState(JsonElement state) { } + public IReadOnlyList GetAdminResourceKinds(string serviceId) => []; + + public IEnumerable GetAdminResources(string serviceId) => []; + + public string? GetAdminNotice(string serviceId) => + "RDS Data API retains no database resources or SQL results. Database infrastructure is available under RDS."; + // ── Execute Statement ───────────────────────────────────────────────────── private ServiceResponse ExecuteStatement(JsonElement data) diff --git a/src/MicroStack/Services/Route53/Route53AdminResources.cs b/src/MicroStack/Services/Route53/Route53AdminResources.cs new file mode 100644 index 0000000..c70aadb --- /dev/null +++ b/src/MicroStack/Services/Route53/Route53AdminResources.cs @@ -0,0 +1,101 @@ +using MicroStack.Admin.Contracts; +using MicroStack.Internal.Admin; +using MicroStack.Services.ApiGateway; + +namespace MicroStack.Services.Route53; + +internal sealed partial class Route53ServiceHandler +{ + private static readonly AdminResourceKind[] AdminKinds = + [ + new("hosted-zones", "Hosted zones"), new("record-sets", "Record sets") { IsRoot = false }, + ]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + serviceId == ServiceName ? AdminKinds : []; + + public IEnumerable GetAdminResources(string serviceId) + { + if (serviceId != ServiceName) + return []; + lock (_lock) + return _zones.Items.ToArray().Select(CreateZoneNode).ToArray(); + } + + private AdminNode CreateZoneNode(KeyValuePair pair) + { + var zone = pair.Value; + return AdminData.Node("hosted-zones", zone.Id, zone.Name, + $"arn:aws:route53:::hostedzone/{zone.Id}") with + { + ReadFields = () => + [ + AdminData.Field("Id", zone.Id), + AdminData.Field("Name", zone.Name), + AdminData.Field("Private", zone.Private ? "true" : "false"), + AdminData.Field("Comment", zone.Comment), + AdminData.Field("CallerReference", zone.CallerReference), + ], + ChildKinds = [AdminKinds[1]], + ReadChildren = () => + { + lock (_lock) + { + if (!_records.TryGetValue(zone.Id, out var records)) + return []; + return records.ToArray().Select(record => CreateRecordNode(zone.Id, record)).ToArray(); + } + }, + }; + } + + private AdminNode CreateRecordNode(string zoneId, RecordSet record) + { + var id = NetworkingAdminData.Composite(record.Name, record.Type, record.SetIdentifier ?? ""); + var aliasZone = record.AliasTarget?.HostedZoneId? + .TrimStart('/').Replace("hostedzone/", "", StringComparison.Ordinal); + var hasLocalAlias = !string.IsNullOrEmpty(aliasZone) && _zones.ContainsKey(aliasZone); + return AdminData.Node("record-sets", id, $"{record.Name} {record.Type}") with + { + ReadFields = () => + [ + AdminData.Field("Name", record.Name), + AdminData.Field("Type", record.Type), + AdminData.Field("TTL", record.Ttl), + AdminData.Field("SetIdentifier", record.SetIdentifier), + AdminData.Field("Weight", record.Weight?.ToString()), + AdminData.Field("Region", record.Region), + AdminData.Field("Failover", record.Failover), + AdminData.Field("MultiValueAnswer", record.MultiValueAnswer?.ToString().ToLowerInvariant()), + AdminData.Field("HealthCheckId", record.HealthCheckId), + AdminData.Field("Values", record.ResourceRecords is null + ? record.AliasTarget?.DnsName + : string.Join(", ", record.ResourceRecords)), + ], + ReadContent = () => NetworkingAdminData.Json(new Dictionary + { + ["Name"] = record.Name, + ["Type"] = record.Type, + ["TTL"] = record.Ttl, + ["SetIdentifier"] = record.SetIdentifier, + ["Weight"] = record.Weight, + ["Region"] = record.Region, + ["Failover"] = record.Failover, + ["MultiValueAnswer"] = record.MultiValueAnswer, + ["ResourceRecords"] = record.ResourceRecords, + ["AliasTarget"] = record.AliasTarget is null ? null : new Dictionary + { + ["HostedZoneId"] = record.AliasTarget.HostedZoneId, + ["DNSName"] = record.AliasTarget.DnsName, + ["EvaluateTargetHealth"] = record.AliasTarget.EvaluateTargetHealth, + }, + ["HealthCheckId"] = record.HealthCheckId, + }), + ReadConnections = !hasLocalAlias ? null : () => + [ + new("Alias hosted zone", "aliases", "route53", + [new("hosted-zones", aliasZone!)]), + ], + }; + } +} diff --git a/src/MicroStack/Services/Route53/Route53ServiceHandler.cs b/src/MicroStack/Services/Route53/Route53ServiceHandler.cs index a7ad215..7b54f2b 100644 --- a/src/MicroStack/Services/Route53/Route53ServiceHandler.cs +++ b/src/MicroStack/Services/Route53/Route53ServiceHandler.cs @@ -3,6 +3,7 @@ using System.Text.RegularExpressions; using System.Xml.Linq; using MicroStack.Internal; +using MicroStack.Internal.Admin; namespace MicroStack.Services.Route53; @@ -11,7 +12,7 @@ namespace MicroStack.Services.Route53; /// /// Port of ministack/services/route53.py. /// -internal sealed partial class Route53ServiceHandler : IServiceHandler +internal sealed partial class Route53ServiceHandler : IServiceHandler, IAdminResourceSource { // ── Constants ──────────────────────────────────────────────────────────────── @@ -63,6 +64,9 @@ internal sealed partial class Route53ServiceHandler : IServiceHandler public string ServiceName => "route53"; + public IEnumerable GetKnownAccountIds() => + _zones.GetAccountIds().Concat(_healthChecks.GetAccountIds()); + public Task HandleAsync(ServiceRequest request) { var (status, headers, body) = HandleRequest(request); diff --git a/src/MicroStack/Services/S3/S3ServiceHandler.cs b/src/MicroStack/Services/S3/S3ServiceHandler.cs index c02a0c9..9db75f8 100644 --- a/src/MicroStack/Services/S3/S3ServiceHandler.cs +++ b/src/MicroStack/Services/S3/S3ServiceHandler.cs @@ -4,6 +4,8 @@ using System.Text.RegularExpressions; using System.Xml.Linq; using MicroStack.Internal; +using MicroStack.Internal.Admin; +using MicroStack.Admin.Contracts; namespace MicroStack.Services.S3; @@ -12,7 +14,7 @@ namespace MicroStack.Services.S3; /// /// Port of ministack/services/s3.py. /// -internal sealed partial class S3ServiceHandler : IServiceHandler +internal sealed partial class S3ServiceHandler : IServiceHandler, IResourceProvider, IAdminResourceSource { // ── Constants ──────────────────────────────────────────────────────────────── @@ -57,17 +59,25 @@ internal sealed partial class S3ServiceHandler : IServiceHandler private readonly AccountScopedDictionary<(string Bucket, string Key), RetentionConfig> _objectRetention = new(); private readonly AccountScopedDictionary<(string Bucket, string Key), string> _objectLegalHold = new(); private readonly AccountScopedDictionary _multipartUploads = new(); + private readonly Lock _adminLock = new(); // ── IServiceHandler ────────────────────────────────────────────────────────── public string ServiceName => "s3"; + public IEnumerable GetKnownAccountIds() => _buckets.GetAccountIds(); + public Task HandleAsync(ServiceRequest request) { - var (bucket, key) = ParseBucketKey(request.Path, request.Headers); - - var (status, respHeaders, respBody) = Dispatch( - request.Method, bucket, key, request.Headers, request.Body, request.QueryParams); + int status; + Dictionary respHeaders; + byte[] respBody; + lock (_adminLock) + { + var (bucket, key) = ParseBucketKey(request.Path, request.Headers); + (status, respHeaders, respBody) = Dispatch( + request.Method, bucket, key, request.Headers, request.Body, request.QueryParams); + } respHeaders.TryAdd("x-amz-request-id", HashHelpers.NewUuid()); respHeaders.TryAdd("x-amz-id-2", Convert.ToBase64String(RandomNumberGenerator.GetBytes(48))); @@ -84,25 +94,28 @@ public Task HandleAsync(ServiceRequest request) public void Reset() { - _buckets.Clear(); - _bucketPolicies.Clear(); - _bucketNotifications.Clear(); - _bucketTags.Clear(); - _bucketVersioning.Clear(); - _bucketEncryption.Clear(); - _bucketLifecycle.Clear(); - _bucketCors.Clear(); - _bucketAcl.Clear(); - _bucketWebsites.Clear(); - _bucketLoggingConfig.Clear(); - _bucketAccelerateConfig.Clear(); - _bucketRequestPaymentConfig.Clear(); - _objectTags.Clear(); - _multipartUploads.Clear(); - _bucketObjectLock.Clear(); - _bucketReplication.Clear(); - _objectRetention.Clear(); - _objectLegalHold.Clear(); + lock (_adminLock) + { + _buckets.Clear(); + _bucketPolicies.Clear(); + _bucketNotifications.Clear(); + _bucketTags.Clear(); + _bucketVersioning.Clear(); + _bucketEncryption.Clear(); + _bucketLifecycle.Clear(); + _bucketCors.Clear(); + _bucketAcl.Clear(); + _bucketWebsites.Clear(); + _bucketLoggingConfig.Clear(); + _bucketAccelerateConfig.Clear(); + _bucketRequestPaymentConfig.Clear(); + _objectTags.Clear(); + _multipartUploads.Clear(); + _bucketObjectLock.Clear(); + _bucketReplication.Clear(); + _objectRetention.Clear(); + _objectLegalHold.Clear(); + } } public JsonElement? GetState() @@ -116,6 +129,330 @@ public void RestoreState(JsonElement state) // Not implementing restore in Phase 1. } + public ResourceSummary GetResources() + { + lock (_adminLock) + { + var accountId = AccountContext.GetAccountId(); + var items = _buckets.Items + .Select(kv => new ResourceItem( + kv.Key, + $"arn:aws:s3:::{kv.Key}", + new Dictionary(StringComparer.Ordinal) + { + ["CreationDate"] = kv.Value.Created, + ["ObjectCount"] = kv.Value.Objects.Count.ToString(), + ["Region"] = kv.Value.Region ?? Region, + ["AccountId"] = accountId, + })) + .OrderBy(item => item.Name, StringComparer.Ordinal) + .ToList(); + + return new ResourceSummary("s3", items.Count, items); + } + } + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + [ + new("buckets", "Buckets"), + ]; + + public IEnumerable GetAdminResources(string serviceId) + { + lock (_adminLock) + { + return _buckets.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => BucketNode(x.Key)).ToArray(); + } + } + + private AdminNode BucketNode(string bucketName) + { + if (!_buckets.TryGetValue(bucketName, out var bucket)) + return AdminData.Node("buckets", bucketName, bucketName, $"arn:aws:s3:::{bucketName}"); + + return AdminData.Node("buckets", bucketName, bucketName, $"arn:aws:s3:::{bucketName}", + type: "Bucket", summary: S3BucketSummary(bucketName, bucket)) + with + { + ChildKinds = S3ContentsKinds(), + ReadSummary = () => + { + lock (_adminLock) + return _buckets.TryGetValue(bucketName, out var current) + ? S3BucketSummary(bucketName, current) : []; + }, + ReadFields = () => + { + lock (_adminLock) + { + if (!_buckets.TryGetValue(bucketName, out var current)) + return []; + return + [ + AdminData.Field("CreationDate", current.Created, format: "datetime"), + AdminData.Field("Region", current.Region ?? Region), + AdminData.Field("ObjectCount", current.Objects.Count.ToString()), + AdminData.Field("Versioning", _bucketVersioning.TryGetValue(bucketName, out var v) ? v : "Disabled"), + AdminData.Field("AccountId", AccountContext.GetAccountId()), + .. S3BucketConfiguration(bucketName, current), + .. (_bucketTags.TryGetValue(bucketName, out var tags) ? tags : []) + .OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => AdminData.Field($"Tags.{x.Key}", x.Value, IsSensitiveName(x.Key))), + ]; + } + }, + ReadChildren = () => ReadS3Children(bucketName, ""), + }; + } + + private static IReadOnlyList S3ContentsKinds() => + [new("prefixes", "Prefixes") { IsRoot = false }, new("objects", "Objects") { IsRoot = false }]; + + private IReadOnlyList S3BucketSummary(string bucketName, S3Bucket bucket) => + new[] + { + AdminData.Field("ObjectCount", bucket.Objects.Count.ToString()), + AdminData.Field("Versioning", _bucketVersioning.TryGetValue(bucketName, out var value) ? value : "Disabled"), + }.Where(field => field.Value is not { Length: > 4096 }).ToArray(); + + private IReadOnlyList S3BucketConfiguration(string bucketName, S3Bucket bucket) + { + var fields = new List + { + AdminData.Field("PolicyConfigured", _bucketPolicies.ContainsKey(bucketName).ToString()), + AdminData.Field("EncryptionConfigured", _bucketEncryption.ContainsKey(bucketName).ToString()), + AdminData.Field("LifecycleConfigured", _bucketLifecycle.ContainsKey(bucketName).ToString()), + AdminData.Field("CorsConfigured", _bucketCors.ContainsKey(bucketName).ToString()), + AdminData.Field("NotificationsConfigured", _bucketNotifications.ContainsKey(bucketName).ToString()), + AdminData.Field("WebsiteConfigured", _bucketWebsites.ContainsKey(bucketName).ToString()), + AdminData.Field("LoggingConfigured", _bucketLoggingConfig.ContainsKey(bucketName).ToString()), + AdminData.Field("AclConfigured", _bucketAcl.ContainsKey(bucketName).ToString()), + AdminData.Field("OwnershipControlsConfigured", (bucket.OwnershipControls is not null).ToString()), + AdminData.Field("PublicAccessBlockConfigured", (bucket.PublicAccessBlock is not null).ToString()), + }; + if (_bucketObjectLock.TryGetValue(bucketName, out var objectLock)) + { + fields.Add(AdminData.Field("ObjectLockEnabled", objectLock.Enabled.ToString())); + fields.Add(AdminData.Field("DefaultRetention.Mode", objectLock.DefaultRetention?.Mode)); + fields.Add(AdminData.Field("DefaultRetention.Days", objectLock.DefaultRetention?.Days?.ToString())); + fields.Add(AdminData.Field("DefaultRetention.Years", objectLock.DefaultRetention?.Years?.ToString())); + } + if (_bucketReplication.TryGetValue(bucketName, out var replication)) + { + fields.Add(AdminData.Field("ReplicationRole", replication.Role)); + fields.Add(AdminData.Field("ReplicationRuleCount", replication.Rules.Count.ToString())); + } + // These settings retain arbitrary XML; inspect only known encryption facts. + if (_bucketEncryption.TryGetValue(bucketName, out var encryption) && + encryption.Length <= AdminData.PreviewMaxBytes) + { + try + { + var document = XDocument.Parse(Encoding.UTF8.GetString(encryption)); + var algorithm = document.Descendants().FirstOrDefault(x => x.Name.LocalName == "SSEAlgorithm"); + var key = document.Descendants().FirstOrDefault(x => x.Name.LocalName == "KMSMasterKeyID"); + if (algorithm is not null) + fields.Add(AdminData.Field("EncryptionAlgorithm", algorithm.Value)); + if (key is not null) + fields.Add(AdminData.Field("KMSMasterKeyID", key.Value)); + } + catch (System.Xml.XmlException) + { + // The AWS emulator retains unvalidated configuration bytes. + } + } + return fields; + } + + private IReadOnlyList ReadS3Children(string bucketName, string prefix) + { + lock (_adminLock) + { + if (!_buckets.TryGetValue(bucketName, out var bucket)) + return []; + + var prefixes = new SortedSet(StringComparer.Ordinal); + var objects = new List(); + foreach (var key in bucket.Objects.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal))) + { + var remainder = key[prefix.Length..]; + var slash = remainder.IndexOf('/'); + if (slash >= 0) + prefixes.Add(prefix + remainder[..(slash + 1)]); + else + objects.Add(key); + } + + return + [ + .. prefixes.Select(p => AdminData.Node("prefixes", p, p[prefix.Length..], type: "Prefix") + with + { + ChildKinds = S3ContentsKinds(), + ReadChildren = () => ReadS3Children(bucketName, p), + }), + .. objects.OrderBy(k => k, StringComparer.Ordinal).Select(k => ObjectNode(bucketName, k)), + ]; + } + } + + private AdminNode ObjectNode(string bucketName, string key) + { + var name = key[(key.LastIndexOf('/') + 1)..]; + var node = AdminData.Node("objects", key, name.Length == 0 ? key : name, type: "Object"); + if (!_buckets.TryGetValue(bucketName, out var bucket) || !bucket.Objects.TryGetValue(key, out var obj)) + return node; + + return node with + { + Resource = node.Resource with { Summary = S3ObjectSummary(obj) }, + ChildKinds = [new("versions", "Versions") { IsRoot = false }], + ReadSummary = () => ReadS3ObjectSummary(bucketName, key), + ReadFields = () => ReadS3ObjectFields(bucketName, key), + ReadContent = () => ReadS3ObjectContent(bucketName, key), + ReadChildren = () => ReadS3Versions(bucketName, key), + }; + } + + private static IReadOnlyList S3ObjectSummary(S3Object obj) => + new[] + { + AdminData.Field("ContentType", obj.ContentType), + AdminData.Field("Length", obj.Size.ToString()), + AdminData.Field("LastModified", obj.LastModified, format: "datetime"), + }.Where(field => field.Value is not { Length: > 4096 }).ToArray(); + + private IReadOnlyList ReadS3ObjectSummary(string bucketName, string key, string? versionId = null) + { + lock (_adminLock) + { + if (!_buckets.TryGetValue(bucketName, out var bucket) || + !bucket.Objects.TryGetValue(key, out var obj) || + (versionId is not null && obj.VersionId != versionId)) + return []; + return S3ObjectSummary(obj); + } + } + + private IReadOnlyList ReadS3ObjectFields(string bucketName, string key) + { + lock (_adminLock) + { + if (!_buckets.TryGetValue(bucketName, out var bucket) || !bucket.Objects.TryGetValue(key, out var obj)) + return []; + return + [ + AdminData.Field("ContentType", obj.ContentType), + AdminData.Field("ContentEncoding", obj.ContentEncoding, secondary: true), + AdminData.Field("Length", obj.Size.ToString()), + AdminData.Field("ETag", obj.ETag, secondary: true), + AdminData.Field("LastModified", obj.LastModified, format: "datetime"), + AdminData.Field("VersionId", obj.VersionId, secondary: true), + .. obj.Metadata.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => AdminData.Field($"Metadata.{x.Key}", x.Value, IsSensitiveName(x.Key), secondary: true)), + .. obj.PreservedHeaders.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => AdminData.Field($"Headers.{x.Key}", x.Value, IsSensitiveName(x.Key), secondary: true)), + .. (_objectTags.TryGetValue((bucketName, key), out var tags) ? tags : []) + .OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => AdminData.Field($"Tags.{x.Key}", x.Value, IsSensitiveName(x.Key), secondary: true)), + ]; + } + } + + private AdminContent ReadS3ObjectContent(string bucketName, string key) + { + lock (_adminLock) + { + if (!_buckets.TryGetValue(bucketName, out var bucket) || !bucket.Objects.TryGetValue(key, out var obj)) + return AdminData.Unavailable("Object no longer exists."); + if (obj.Body.LongLength > AdminData.PreviewMaxBytes) + return AdminData.Oversized(obj.ContentType, obj.Body.LongLength); + if (!IsTextContentType(obj.ContentType)) + return AdminData.Binary(obj.Body.LongLength, obj.ContentType); + try + { + return IsJsonContentType(obj.ContentType) + ? AdminData.JsonText(new UTF8Encoding(false, true).GetString(obj.Body)) + : AdminData.Text(obj.Body, obj.ContentType); + } + catch (DecoderFallbackException) + { + return AdminData.Binary(obj.Body.LongLength, obj.ContentType); + } + catch (JsonException) + { + return AdminData.Unavailable("Content type is JSON but the stored bytes are not valid JSON.", + obj.ContentType, obj.Body.LongLength); + } + } + } + + private IReadOnlyList ReadS3Versions(string bucketName, string key) + { + lock (_adminLock) + { + if (!_buckets.TryGetValue(bucketName, out var bucket) || + !bucket.Objects.TryGetValue(key, out var obj) || obj.VersionId is null) + return []; + var versionId = obj.VersionId; + return + [ + AdminData.Node("versions", versionId, versionId, type: "Version", summary: S3ObjectSummary(obj)) with + { + ReadSummary = () => ReadS3ObjectSummary(bucketName, key, versionId), + ReadFields = () => ReadS3VersionFields(bucketName, key, versionId), + ReadContent = () => ReadS3VersionContent(bucketName, key, versionId), + }, + ]; + } + } + + private IReadOnlyList ReadS3VersionFields( + string bucketName, string key, string versionId) + { + lock (_adminLock) + { + if (!_buckets.TryGetValue(bucketName, out var bucket) || + !bucket.Objects.TryGetValue(key, out var obj) || obj.VersionId != versionId) + return []; + return ReadS3ObjectFields(bucketName, key); + } + } + + private AdminContent ReadS3VersionContent(string bucketName, string key, string versionId) + { + lock (_adminLock) + { + if (!_buckets.TryGetValue(bucketName, out var bucket) || + !bucket.Objects.TryGetValue(key, out var obj) || obj.VersionId != versionId) + return AdminData.Unavailable("Version is no longer retained."); + return ReadS3ObjectContent(bucketName, key); + } + } + + private static bool IsJsonContentType(string contentType) + { + var mediaType = contentType.Split(';', 2)[0].Trim(); + return mediaType.Equals("application/json", StringComparison.OrdinalIgnoreCase) || + mediaType.EndsWith("+json", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsTextContentType(string contentType) + { + var mediaType = contentType.Split(';', 2)[0].Trim(); + return mediaType.StartsWith("text/", StringComparison.OrdinalIgnoreCase) || + IsJsonContentType(contentType) || + mediaType is "application/xml" or "application/javascript" or "application/x-www-form-urlencoded"; + } + + private static bool IsSensitiveName(string name) => + name.Contains("password", StringComparison.OrdinalIgnoreCase) || + name.Contains("secret", StringComparison.OrdinalIgnoreCase) || + name.Contains("token", StringComparison.OrdinalIgnoreCase) || + name.Contains("credential", StringComparison.OrdinalIgnoreCase) || + name.Contains("authorization", StringComparison.OrdinalIgnoreCase); + // ── Dispatch ───────────────────────────────────────────────────────────────── private (int Status, Dictionary Headers, byte[] Body) Dispatch( diff --git a/src/MicroStack/Services/S3Files/S3FilesServiceHandler.cs b/src/MicroStack/Services/S3Files/S3FilesServiceHandler.cs index c2196f7..865ae8a 100644 --- a/src/MicroStack/Services/S3Files/S3FilesServiceHandler.cs +++ b/src/MicroStack/Services/S3Files/S3FilesServiceHandler.cs @@ -1,6 +1,8 @@ using System.Text.Json; using System.Text.RegularExpressions; using MicroStack.Internal; +using MicroStack.Internal.Admin; +using MicroStack.Admin.Contracts; namespace MicroStack.Services.S3Files; @@ -19,7 +21,7 @@ namespace MicroStack.Services.S3Files; /// Sync: GetSynchronizationConfiguration, PutSynchronizationConfiguration /// Tags: TagResource, UntagResource, ListTagsForResource /// -internal sealed partial class S3FilesServiceHandler : IServiceHandler +internal sealed partial class S3FilesServiceHandler : IServiceHandler, IAdminResourceSource { private readonly Lock _lock = new(); @@ -56,6 +58,9 @@ internal sealed partial class S3FilesServiceHandler : IServiceHandler public string ServiceName => "s3files"; + public IEnumerable GetKnownAccountIds() => + _fileSystems.GetAccountIds().Concat(_mountTargets.GetAccountIds()).Concat(_accessPoints.GetAccountIds()); + public Task HandleAsync(ServiceRequest request) { JsonElement body; @@ -112,6 +117,195 @@ public void Reset() public void RestoreState(JsonElement state) { } + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + [ + new("file-systems", "File systems"), + ]; + + public IEnumerable GetAdminResources(string serviceId) + { + lock (_lock) + { + return _fileSystems.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => FileSystemAdminNode(x.Key)).ToArray(); + } + } + + private AdminNode FileSystemAdminNode(string fsId) + { + if (!_fileSystems.TryGetValue(fsId, out var fs)) + return AdminData.Node("file-systems", fsId, fsId); + var name = fs.GetValueOrDefault("FileSystemId")?.ToString() ?? fsId; + var arn = fs.GetValueOrDefault("FileSystemArn")?.ToString(); + return AdminData.Node("file-systems", fsId, name, arn, + fs.GetValueOrDefault("LifeCycleState")?.ToString()) with + { + ReadFields = () => ReadS3FilesFields(_fileSystems, fsId), + ChildKinds = + [ + new("mount-targets", "Mount targets") { IsRoot = false }, + new("access-points", "Access points") { IsRoot = false }, + new("policies", "File system policies") { IsRoot = false }, + new("synchronization-configurations", "Synchronization configurations") { IsRoot = false }, + new("tags", "Tags") { IsRoot = false }, + ], + ReadChildren = () => ReadS3FilesChildren(fsId), + ReadConnections = () => + { + lock (_lock) + { + if (!_fileSystems.TryGetValue(fsId, out var current) || + current.GetValueOrDefault("BucketName") is not string bucket || string.IsNullOrEmpty(bucket)) + return []; + return + [ + new AdminConnection("S3 bucket", "backed-by", "s3", + [new AdminKey("buckets", bucket)]), + ]; + } + }, + }; + } + + private IReadOnlyList ReadS3FilesChildren(string fsId) + { + lock (_lock) + { + var children = new List(); + children.AddRange(_mountTargets.Items + .Where(x => Equals(x.Value.GetValueOrDefault("FileSystemId"), fsId)) + .OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => DictionaryAdminNode("mount-targets", x.Key, x.Value, _mountTargets))); + children.AddRange(_accessPoints.Items + .Where(x => Equals(x.Value.GetValueOrDefault("FileSystemId"), fsId)) + .OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => AccessPointAdminNode(x.Key, x.Value))); + if (_policies.ContainsKey(fsId)) + { + children.Add(AdminData.Node("policies", fsId, "File system policy") with + { + ReadContent = () => + { + lock (_lock) + { + if (!_policies.TryGetValue(fsId, out var policy)) + return AdminData.Unavailable("Policy no longer exists.", "application/json"); + try { return AdminData.JsonText(policy); } + catch (JsonException) { return AdminData.Text(policy); } + } + }, + }); + } + if (_syncConfigs.ContainsKey(fsId)) + { + children.Add(AdminData.Node("synchronization-configurations", fsId, "Synchronization configuration") with + { + ReadFields = () => ReadS3FilesFields(_syncConfigs, fsId), + }); + } + if (_fileSystems.TryGetValue(fsId, out var fs) && + fs.GetValueOrDefault("FileSystemArn") is string arn) + children.AddRange(TagAdminNodes(arn)); + return children; + } + } + + private AdminNode AccessPointAdminNode(string id, Dictionary value) + { + var node = DictionaryAdminNode("access-points", id, value, _accessPoints); + var arn = value.GetValueOrDefault("AccessPointArn")?.ToString(); + return arn is null ? node : node with + { + ChildKinds = [new("tags", "Tags") { IsRoot = false }], + ReadChildren = () => TagAdminNodes(arn), + }; + } + + private IReadOnlyList TagAdminNodes(string arn) + { + lock (_lock) + { + if (!_tags.TryGetValue(arn, out var tags)) + return []; + return tags.Where(t => t.GetValueOrDefault("Key") is string) + .OrderBy(t => t["Key"]?.ToString(), StringComparer.Ordinal) + .Select(t => + { + var key = t["Key"]?.ToString() ?? ""; + var value = t.GetValueOrDefault("Value")?.ToString(); + var sensitive = IsSensitiveName(key); + return AdminData.Node("tags", key, key) with + { + ReadFields = () => + [ + AdminData.Field("Key", key), + AdminData.Field("Value", value, sensitive), + ], + }; + }).ToArray(); + } + } + + private AdminNode DictionaryAdminNode( + string kind, string id, Dictionary value, + AccountScopedDictionary> source) + { + var name = value.GetValueOrDefault("Name")?.ToString() ?? id; + var arn = value.FirstOrDefault(x => x.Key.EndsWith("Arn", StringComparison.Ordinal)).Value?.ToString(); + var status = value.GetValueOrDefault("LifeCycleState")?.ToString(); + return AdminData.Node(kind, id, name, arn, status) with + { + ReadFields = () => ReadS3FilesFields(source, id), + }; + } + + private IReadOnlyList ReadS3FilesFields( + AccountScopedDictionary> source, string id) + { + lock (_lock) + { + return source.TryGetValue(id, out var value) ? ScalarFields(value) : []; + } + } + + private static IReadOnlyList ScalarFields(Dictionary value) + { + var fields = new List(); + AddScalarFields(fields, value, ""); + return fields; + } + + private static void AddScalarFields( + List fields, Dictionary value, string prefix) + { + foreach (var (key, item) in value.OrderBy(x => x.Key, StringComparer.Ordinal)) + { + var name = prefix + key; + switch (item) + { + case null or string or bool or byte or short or int or long or float or double or decimal: + fields.Add(AdminData.Field(name, item?.ToString(), IsSensitiveName(name))); + break; + case Dictionary dictionary: + AddScalarFields(fields, dictionary, name + "."); + break; + case List strings: + fields.Add(AdminData.Field(name, string.Join(", ", strings), IsSensitiveName(name))); + break; + case List> dictionaries: + for (var i = 0; i < dictionaries.Count; i++) + AddScalarFields(fields, dictionaries[i], $"{name}[{i}]."); + break; + } + } + } + + private static bool IsSensitiveName(string name) => + name.Contains("password", StringComparison.OrdinalIgnoreCase) || + name.Contains("secret", StringComparison.OrdinalIgnoreCase) || + name.Contains("token", StringComparison.OrdinalIgnoreCase) || + name.Contains("credential", StringComparison.OrdinalIgnoreCase); + // ═══════════════════════════════════════════════════════════════════════════ // Request router // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/MicroStack/Services/SecretsManager/SecretsManagerServiceHandler.Admin.cs b/src/MicroStack/Services/SecretsManager/SecretsManagerServiceHandler.Admin.cs new file mode 100644 index 0000000..8419e91 --- /dev/null +++ b/src/MicroStack/Services/SecretsManager/SecretsManagerServiceHandler.Admin.cs @@ -0,0 +1,159 @@ +using System.Globalization; +using MicroStack.Admin.Contracts; +using MicroStack.Internal.Admin; + +namespace MicroStack.Services.SecretsManager; + +internal sealed partial class SecretsManagerServiceHandler +{ + private static readonly AdminResourceKind[] AdminKinds = + [ + new("secret", "Secrets"), + new("secret-version", "Secret versions") { IsRoot = false } + ]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => AdminKinds; + + public IEnumerable GetAdminResources(string serviceId) + { + lock (_lock) + { + return _secrets.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => SecretNode(x.Key, x.Value)).ToArray(); + } + } + + private AdminNode SecretNode(string key, SmSecret snapshot) => + AdminData.Node("secret", key, snapshot.Name, snapshot.Arn, + snapshot.DeletedDate is null ? "Active" : "Pending deletion") with + { + ReadFields = () => + { + lock (_lock) + { + if (!_secrets.TryGetValue(key, out var secret)) return []; + return + [ + AdminData.Field("Description", secret.Description), + AdminData.Field("KMS key ID", secret.KmsKeyId), + AdminData.Field("Created", Epoch(secret.CreatedDate), format: "datetime"), + AdminData.Field("Last changed", Epoch(secret.LastChangedDate), format: "datetime"), + AdminData.Field("Last accessed", secret.LastAccessedDate is { } a ? Epoch(a) : null, format: "datetime"), + AdminData.Field("Deleted", secret.DeletedDate is { } d ? Epoch(d) : null, format: "datetime"), + AdminData.Field("Rotation enabled", secret.RotationEnabled.ToString()), + AdminData.Field("Rotation Lambda ARN", secret.RotationLambdaArn), + AdminData.Field("Tags", string.Join(", ", secret.Tags.Select(t => $"{t.Key}={t.Value}"))), + AdminData.Field("Secret value", null, sensitive: true), + AdminData.Field("Resource policy", _resourcePolicies.ContainsKey(secret.Arn) ? "Configured" : "Not configured") + ]; + } + }, + ChildKinds = [AdminKinds[1]], + ReadChildren = () => + { + lock (_lock) + { + if (!_secrets.TryGetValue(key, out var secret)) return []; + return secret.Versions.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => VersionNode(key, x.Key, x.Value)).ToArray(); + } + }, + ReadContent = () => + { + lock (_lock) + { + if (!_secrets.TryGetValue(key, out var secret) || + !_resourcePolicies.TryGetValue(secret.Arn, out var policy)) + return AdminData.Unavailable("No resource policy is retained.", "application/json"); + return AdminData.JsonText(policy); + } + }, + ReadConnections = () => + { + lock (_lock) + { + if (!_secrets.TryGetValue(key, out var secret) || + string.IsNullOrEmpty(secret.KmsKeyId)) + return []; + var alias = secret.KmsKeyId.StartsWith("alias/", StringComparison.Ordinal) || + secret.KmsKeyId.Contains(":alias/", StringComparison.Ordinal); + var keyId = secret.KmsKeyId.Contains(':') + ? (alias ? "alias/" : "") + secret.KmsKeyId[(secret.KmsKeyId.LastIndexOf('/') + 1)..] + : secret.KmsKeyId; + return + [ + new AdminConnection("KMS key", "encrypted-by", "kms", + [new AdminKey(alias ? "alias" : "key", keyId)]) + ]; + } + } + }; + + private AdminNode VersionNode(string secretKey, string versionId, SmSecretVersion snapshot) + { + var revealable = new List(); + if (snapshot.SecretString is not null) revealable.Add("SecretString"); + if (snapshot.SecretBinary is not null) revealable.Add("SecretBinary"); + return AdminData.Node("secret-version", versionId, versionId, + status: string.Join(", ", snapshot.Stages)) with + { + ReadFields = () => + { + lock (_lock) + { + if (!TryVersion(secretKey, versionId, out var version)) return []; + return + [ + AdminData.Field("Created", Epoch(version.CreatedDate), format: "datetime"), + AdminData.Field("Stages", string.Join(", ", version.Stages)), + AdminData.Field("SecretString", version.SecretString, sensitive: true, + canReveal: version.SecretString is not null), + AdminData.Field("SecretBinary", version.SecretBinary, sensitive: true, + canReveal: version.SecretBinary is not null, format: "base64") + ]; + } + }, + RevealableFields = revealable, + RevealField = field => + { + lock (_lock) + { + if (!TryVersion(secretKey, versionId, out var version)) + return AdminData.Unavailable("The secret version no longer exists.", sensitive: true); + return field switch + { + "SecretString" when version.SecretString is not null => + AdminData.Text(version.SecretString, sensitive: true), + "SecretBinary" when version.SecretBinary is not null => + RevealBinary(version.SecretBinary), + _ => AdminData.Unavailable("The requested value is not retained or revealable.", sensitive: true) + }; + } + } + }; + } + + private bool TryVersion(string secretKey, string versionId, + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out SmSecretVersion? version) + { + version = null; + return _secrets.TryGetValue(secretKey, out var secret) && + secret.Versions.TryGetValue(versionId, out version); + } + + private static AdminContent RevealBinary(string base64) + { + try + { + _ = Convert.FromBase64String(base64); + return AdminData.Text(base64, "application/base64", sensitive: true); + } + catch (FormatException) + { + return AdminData.Unavailable("The retained binary value is invalid.", sensitive: true); + } + } + + private static string Epoch(double value) => + AdminData.IsoUtc(DateTimeOffset.FromUnixTimeMilliseconds((long)(value * 1000))); +} diff --git a/src/MicroStack/Services/SecretsManager/SecretsManagerServiceHandler.cs b/src/MicroStack/Services/SecretsManager/SecretsManagerServiceHandler.cs index 035e0bc..0996c03 100644 --- a/src/MicroStack/Services/SecretsManager/SecretsManagerServiceHandler.cs +++ b/src/MicroStack/Services/SecretsManager/SecretsManagerServiceHandler.cs @@ -18,7 +18,7 @@ namespace MicroStack.Services.SecretsManager; /// PutResourcePolicy, GetResourcePolicy, DeleteResourcePolicy, /// ValidateResourcePolicy. /// -internal sealed class SecretsManagerServiceHandler : IServiceHandler +internal sealed partial class SecretsManagerServiceHandler : IServiceHandler, Internal.Admin.IAdminResourceSource { private readonly AccountScopedDictionary _secrets = new(); // keyed by Name private readonly AccountScopedDictionary _resourcePolicies = new(); // keyed by ARN @@ -36,6 +36,8 @@ internal sealed class SecretsManagerServiceHandler : IServiceHandler public string ServiceName => "secretsmanager"; + public IEnumerable GetKnownAccountIds() => _secrets.GetAccountIds(); + public Task HandleAsync(ServiceRequest request) { var target = request.GetHeader("x-amz-target") ?? ""; diff --git a/src/MicroStack/Services/ServiceDiscovery/ServiceDiscoveryAdminResources.cs b/src/MicroStack/Services/ServiceDiscovery/ServiceDiscoveryAdminResources.cs new file mode 100644 index 0000000..b91507c --- /dev/null +++ b/src/MicroStack/Services/ServiceDiscovery/ServiceDiscoveryAdminResources.cs @@ -0,0 +1,109 @@ +using MicroStack.Admin.Contracts; +using MicroStack.Internal.Admin; +using MicroStack.Services.ApiGateway; + +namespace MicroStack.Services.ServiceDiscovery; + +internal sealed partial class ServiceDiscoveryServiceHandler +{ + private static readonly AdminResourceKind[] AdminKinds = + [ + new("namespaces", "Namespaces"), new("services", "Services") { IsRoot = false }, + new("instances", "Instances") { IsRoot = false }, + ]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + serviceId == ServiceName ? AdminKinds : []; + + public IEnumerable GetAdminResources(string serviceId) + { + if (serviceId != ServiceName) + return []; + lock (_lock) + return _namespaces.Items.ToArray().Select(CreateNamespaceNode).ToArray(); + } + + private AdminNode CreateNamespaceNode(KeyValuePair pair) + { + var ns = pair.Value; + return AdminData.Node("namespaces", ns.Id, ns.Name, ns.Arn, ns.Type) with + { + ReadFields = () => NetworkingAdminData.Fields(ns.ToDict()), + ReadContent = () => NetworkingAdminData.Json(ns.ToDict()), + ChildKinds = [AdminKinds[1]], + ReadChildren = () => + { + lock (_lock) + return _services.Items.Where(x => x.Value.NamespaceId == ns.Id) + .Select(CreateServiceNode).ToArray(); + }, + ReadConnections = () => + { + var zoneId = HostedZoneId(ns); + var exists = zoneId is not null + && ((IAdminResourceSource)_route53).GetAdminResources("route53") + .Any(x => x.Resource.Key.Id == zoneId); + return !exists + ? [] + : + [ + new("Route 53 hosted zone", "backed-by", "route53", + [new("hosted-zones", zoneId!)]), + ]; + }, + }; + } + + private AdminNode CreateServiceNode(KeyValuePair pair) + { + var service = pair.Value; + return AdminData.Node("services", service.Id, service.Name, service.Arn) with + { + ReadFields = () => NetworkingAdminData.Fields(service.ToDict()), + ReadContent = () => NetworkingAdminData.Json(service.ToDict()), + ChildKinds = [AdminKinds[2]], + ReadChildren = () => + { + lock (_lock) + { + if (!_instances.TryGetValue(service.Id, out var instances)) + return []; + return instances.ToArray().Select(instance => + CreateInstanceNode(service.Id, instance.Value)).ToArray(); + } + }, + ReadConnections = () => + [ + new("Namespace", "belongs-to", ServiceName, + [new("namespaces", service.NamespaceId)]), + ], + }; + } + + private static AdminNode CreateInstanceNode(string serviceId, SdInstance instance) => + AdminData.Node("instances", instance.Id, instance.Id) with + { + ReadFields = () => + [ + AdminData.Field("Id", instance.Id), + .. instance.Attributes.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => AdminData.Field(x.Key, x.Value)), + ], + ReadContent = () => NetworkingAdminData.Json(instance.ToDict()), + ReadConnections = () => + [ + new("Service", "registered-with", "servicediscovery", + [new("services", serviceId)]), + ], + }; + + private static string? HostedZoneId(SdNamespace ns) + { + if (ns.Properties is null + || !ns.Properties.TryGetValue("DnsProperties", out var dnsValue) + || dnsValue is not Dictionary dns + || !dns.TryGetValue("HostedZoneId", out var zoneValue)) + return null; + return zoneValue?.ToString(); + } +} diff --git a/src/MicroStack/Services/ServiceDiscovery/ServiceDiscoveryServiceHandler.cs b/src/MicroStack/Services/ServiceDiscovery/ServiceDiscoveryServiceHandler.cs index a390df9..79d349a 100644 --- a/src/MicroStack/Services/ServiceDiscovery/ServiceDiscoveryServiceHandler.cs +++ b/src/MicroStack/Services/ServiceDiscovery/ServiceDiscoveryServiceHandler.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Xml.Linq; using MicroStack.Internal; +using MicroStack.Internal.Admin; using MicroStack.Services.Route53; namespace MicroStack.Services.ServiceDiscovery; @@ -21,7 +22,7 @@ namespace MicroStack.Services.ServiceDiscovery; /// UpdatePrivateDnsNamespace, UpdatePublicDnsNamespace, UpdateService, /// UpdateServiceAttributes. /// -internal sealed class ServiceDiscoveryServiceHandler : IServiceHandler +internal sealed partial class ServiceDiscoveryServiceHandler : IServiceHandler, IAdminResourceSource { private static string Region => MicroStackOptions.Instance.Region; @@ -47,6 +48,9 @@ internal ServiceDiscoveryServiceHandler(Route53ServiceHandler route53) public string ServiceName => "servicediscovery"; + public IEnumerable GetKnownAccountIds() => + _namespaces.GetAccountIds().Concat(_services.GetAccountIds()).Concat(_operations.GetAccountIds()); + public async Task HandleAsync(ServiceRequest request) { var target = request.GetHeader("x-amz-target") ?? ""; diff --git a/src/MicroStack/Services/Ses/SesServiceHandler.Admin.cs b/src/MicroStack/Services/Ses/SesServiceHandler.Admin.cs new file mode 100644 index 0000000..ed7394f --- /dev/null +++ b/src/MicroStack/Services/Ses/SesServiceHandler.Admin.cs @@ -0,0 +1,144 @@ +using MicroStack.Admin.Contracts; +using MicroStack.Internal; +using MicroStack.Internal.Admin; + +namespace MicroStack.Services.Ses; + +internal sealed partial class SesServiceHandler : IAdminResourceSource +{ + private static readonly AdminResourceKind[] AdminKinds = + [ + new("identities", "Identities"), + new("templates", "Templates"), + new("configuration-sets", "Configuration sets"), + new("emails", "Sent emails") + ]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + serviceId == "ses" ? AdminKinds : []; + + public IEnumerable GetAdminResources(string serviceId) + { + if (serviceId != "ses") + return []; + + var account = AccountContext.GetAccountId(); + IdentitySnapshot[] identities; + TemplateSnapshot[] templates; + ConfigurationSetSnapshot[] configurationSets; + EmailSnapshot[] emails; + lock (_lock) + { + identities = _identities.Items.Select(item => new IdentitySnapshot( + item.Key, + item.Value.VerificationStatus, + item.Value.IdentityType, + item.Value.DkimEnabled, + item.Value.DkimTokens.ToArray(), + item.Value.DkimVerificationStatus, + new(item.Value.NotificationTopics, StringComparer.Ordinal), + item.Value.FeedbackForwardingEnabled)).ToArray(); + templates = _templates.Values.Select(template => new TemplateSnapshot( + template.TemplateName, template.SubjectPart, template.TextPart, + template.HtmlPart, template.CreatedTimestamp)).ToArray(); + configurationSets = _configurationSets.Values.Select(set => + new ConfigurationSetSnapshot(set.Name, set.CreatedTimestamp)).ToArray(); + emails = _sentEmails.Where(email => email.AccountId == account).Select(email => + new EmailSnapshot(email.MessageId, email.Source, email.To.ToArray(), email.Subject, + email.BodyText, email.BodyHtml, email.Timestamp, email.EmailType, + email.Template, email.TemplateData, email.ConfigurationSetName)).ToArray(); + } + + return identities.Select(IdentityNode) + .Concat(templates.Select(TemplateNode)) + .Concat(configurationSets.Select(ConfigurationSetNode)) + .Concat(emails.Select(EmailNode)) + .ToArray(); + } + + private static AdminNode IdentityNode(IdentitySnapshot identity) => + AdminData.Node("identities", identity.Name, identity.Name, status: identity.VerificationStatus) with + { + ReadFields = () => + [ + AdminData.Field("Type", identity.Type), + AdminData.Field("Verification status", identity.VerificationStatus), + AdminData.Field("DKIM enabled", identity.DkimEnabled.ToString()), + AdminData.Field("DKIM verification status", identity.DkimVerificationStatus), + AdminData.Field("DKIM tokens", string.Join(", ", identity.DkimTokens), sensitive: identity.DkimTokens.Length > 0), + AdminData.Field("Feedback forwarding", identity.FeedbackForwardingEnabled.ToString()) + ], + ReadConnections = () => identity.NotificationTopics + .OrderBy(item => item.Key, StringComparer.Ordinal) + .Select(item => new AdminConnection(item.Key + " notifications", "publishes-to", "sns", + [new("topics", item.Value)], State: "configured")).ToArray() + }; + + private static AdminNode TemplateNode(TemplateSnapshot template) => + AdminData.Node("templates", template.Name, template.Name, status: "active") with + { + ReadFields = () => + [ + AdminData.Field("Created", template.Created, format: "datetime"), + AdminData.Field("Subject", template.Subject) + ], + ReadContent = () => AdminData.Json(new Dictionary + { + ["subject"] = template.Subject, + ["text"] = template.Text, + ["html"] = template.Html + }, sensitive: true) + }; + + private static AdminNode ConfigurationSetNode(ConfigurationSetSnapshot set) => + AdminData.Node("configuration-sets", set.Name, set.Name, status: "active") with + { + ReadFields = () => [AdminData.Field("Created", set.Created, format: "datetime")] + }; + + private static AdminNode EmailNode(EmailSnapshot email) => + AdminData.Node("emails", email.MessageId, email.Subject.Length > 0 ? email.Subject : email.MessageId, + status: "sent") with + { + ReadFields = () => + [ + AdminData.Field("Message ID", email.MessageId), + AdminData.Field("Type", email.Type), + AdminData.Field("From", email.Source), + AdminData.Field("To", string.Join(", ", email.To)), + AdminData.Field("Sent", AdminData.IsoUtc(DateTimeOffset.FromUnixTimeSeconds(email.Timestamp)), format: "datetime"), + AdminData.Field("Template", email.Template), + AdminData.Field("Configuration set", email.ConfigurationSet) + ], + ReadContent = () => AdminData.Json(new Dictionary + { + ["subject"] = email.Subject, + ["text"] = email.BodyText, + ["html"] = email.BodyHtml, + ["templateData"] = email.TemplateData + }, sensitive: true), + ReadConnections = () => + { + var connections = new List(); + if (!string.IsNullOrEmpty(email.Template)) + connections.Add(new("Template", "rendered-with", "ses", + [new("templates", email.Template)], State: "configured")); + if (!string.IsNullOrEmpty(email.ConfigurationSet)) + connections.Add(new("Configuration set", "uses", "ses", + [new("configuration-sets", email.ConfigurationSet)], State: "configured")); + return connections; + } + }; + + private sealed record IdentitySnapshot( + string Name, string VerificationStatus, string Type, bool DkimEnabled, + string[] DkimTokens, string? DkimVerificationStatus, + Dictionary NotificationTopics, bool FeedbackForwardingEnabled); + private sealed record TemplateSnapshot( + string Name, string Subject, string Text, string Html, string Created); + private sealed record ConfigurationSetSnapshot(string Name, string Created); + private sealed record EmailSnapshot( + string MessageId, string Source, string[] To, string Subject, + string BodyText, string BodyHtml, long Timestamp, string Type, + string? Template, string? TemplateData, string? ConfigurationSet); +} diff --git a/src/MicroStack/Services/Ses/SesServiceHandler.cs b/src/MicroStack/Services/Ses/SesServiceHandler.cs index 2b4e890..8cb2976 100644 --- a/src/MicroStack/Services/Ses/SesServiceHandler.cs +++ b/src/MicroStack/Services/Ses/SesServiceHandler.cs @@ -50,6 +50,9 @@ internal sealed partial class SesServiceHandler : IServiceHandler public string ServiceName => "ses"; + public IEnumerable GetKnownAccountIds() => + _identities.GetAccountIds().Concat(_templates.GetAccountIds()).Concat(_configurationSets.GetAccountIds()); + public Task HandleAsync(ServiceRequest request) { // v2 REST/JSON API detected via path prefix /v2/email/ @@ -150,6 +153,7 @@ private ServiceResponse ActSendEmail(Dictionary p) { _sentEmails.Add(new SesEmail { + AccountId = AccountContext.GetAccountId(), MessageId = msgId, Source = source, To = toAddrs, @@ -174,6 +178,7 @@ private ServiceResponse ActSendRawEmail(Dictionary p) { _sentEmails.Add(new SesEmail { + AccountId = AccountContext.GetAccountId(), MessageId = msgId, Source = P(p, "Source"), Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), @@ -205,6 +210,7 @@ private ServiceResponse ActSendTemplatedEmail(Dictionary p) { _sentEmails.Add(new SesEmail { + AccountId = AccountContext.GetAccountId(), MessageId = msgId, Source = source, To = toAddrs, @@ -245,6 +251,7 @@ private ServiceResponse ActSendBulkTemplatedEmail(Dictionary p) { _sentEmails.Add(new SesEmail { + AccountId = AccountContext.GetAccountId(), MessageId = msgId, Source = source, To = toAddrs, @@ -724,6 +731,7 @@ private ServiceResponse V2SendEmail(JsonElement data) { _sentEmails.Add(new SesEmail { + AccountId = AccountContext.GetAccountId(), MessageId = msgId, Source = fromAddr, Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), @@ -1108,6 +1116,7 @@ private sealed class SesIdentity private sealed class SesEmail { + internal string AccountId { get; set; } = ""; internal string MessageId { get; set; } = ""; internal string Source { get; set; } = ""; internal List To { get; set; } = []; diff --git a/src/MicroStack/Services/Sns/SnsServiceHandler.Admin.cs b/src/MicroStack/Services/Sns/SnsServiceHandler.Admin.cs new file mode 100644 index 0000000..16f1360 --- /dev/null +++ b/src/MicroStack/Services/Sns/SnsServiceHandler.Admin.cs @@ -0,0 +1,195 @@ +using MicroStack.Admin.Contracts; +using MicroStack.Internal.Admin; + +namespace MicroStack.Services.Sns; + +internal sealed partial class SnsServiceHandler : IAdminResourceSource, IAdminRelationshipSource +{ + private static readonly AdminResourceKind[] AdminKinds = + [ + new("topics", "Topics"), + new("subscriptions", "Subscriptions") { IsRoot = false }, + new("platform-applications", "Platform applications"), + new("platform-endpoints", "Platform endpoints") { IsRoot = false } + ]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + serviceId == "sns" ? AdminKinds : []; + + public IEnumerable GetAdminResources(string serviceId) + { + if (serviceId != "sns") + return []; + + TopicSnapshot[] topics; + AppSnapshot[] applications; + lock (_lock) + { + topics = _topics.Values.Select(topic => new TopicSnapshot( + topic.Name, + topic.Arn, + new(topic.Attributes, StringComparer.Ordinal), + new(topic.Tags, StringComparer.Ordinal), + topic.Subscriptions.Select(subscription => new SubscriptionSnapshot( + subscription.Arn, + subscription.Protocol, + subscription.Endpoint, + subscription.Confirmed, + subscription.TopicArn, + subscription.Owner, + new(subscription.Attributes, StringComparer.Ordinal))).ToArray())).ToArray(); + + var endpoints = _platformEndpoints.Values.Select(endpoint => new EndpointSnapshot( + endpoint.Arn, endpoint.ApplicationArn, + new(endpoint.Attributes, StringComparer.Ordinal))).ToLookup(endpoint => endpoint.ApplicationArn); + applications = _platformApps.Values.Select(application => new AppSnapshot( + application.Arn, + application.Name, + application.Platform, + new(application.Attributes, StringComparer.Ordinal), + endpoints[application.Arn].ToArray())).ToArray(); + } + + return topics.Select(TopicNode).Concat(applications.Select(ApplicationNode)).ToArray(); + } + + private static AdminNode TopicNode(TopicSnapshot topic) => + AdminData.Node("topics", topic.Arn, topic.Name, topic.Arn, "active", + type: topic.Attributes.GetValueOrDefault("FifoTopic") == "true" ? "FIFO" : "Standard", + summary: [AdminData.Field("Subscriptions", topic.Subscriptions.Length.ToString())]) with + { + ReadSummary = () => + [ + AdminData.Field("Confirmed subscriptions", topic.Subscriptions.Count(item => item.Confirmed).ToString()), + AdminData.Field("Pending subscriptions", topic.Subscriptions.Count(item => !item.Confirmed).ToString()) + ], + ReadFields = () => topic.Attributes.OrderBy(item => item.Key, StringComparer.Ordinal) + .Select(item => AdminData.Field(item.Key, item.Value, + format: item.Key is "Policy" or "DeliveryPolicy" or "EffectiveDeliveryPolicy" ? "json" : null, + secondary: item.Key is not ("DisplayName" or "Owner"))) + .Concat(topic.Tags.OrderBy(item => item.Key, StringComparer.Ordinal) + .Select(item => AdminData.Field($"Tag: {item.Key}", item.Value, secondary: true))).ToArray(), + ChildKinds = [AdminKinds[1]], + ReadChildren = () => topic.Subscriptions.Select(SubscriptionNode).ToArray(), + ReadConnections = () => topic.Subscriptions.SelectMany(SubscriptionConnections).ToArray() + }; + + private static AdminNode SubscriptionNode(SubscriptionSnapshot subscription) => + AdminData.Node("subscriptions", subscription.Arn, subscription.Endpoint, + subscription.Arn, subscription.Confirmed ? "confirmed" : "pending", + type: subscription.Protocol, + summary: + [ + AdminData.Field("Protocol", subscription.Protocol), + AdminData.Field("Endpoint", subscription.Endpoint), + AdminData.Field("Confirmation", subscription.Confirmed ? "confirmed" : "pending"), + AdminData.Field("Filter policy", subscription.Attributes.GetValueOrDefault("FilterPolicy"), format: "json") + ]) with + { + ReadFields = () => new[] + { + AdminData.Field("Protocol", subscription.Protocol), + AdminData.Field("Endpoint", subscription.Endpoint), + AdminData.Field("Confirmation", subscription.Confirmed ? "confirmed" : "pending"), + AdminData.Field("Topic ARN", subscription.TopicArn), + AdminData.Field("Owner", subscription.Owner, secondary: true), + AdminData.Field("Raw message delivery", subscription.Attributes.GetValueOrDefault("RawMessageDelivery")), + AdminData.Field("Filter policy", subscription.Attributes.GetValueOrDefault("FilterPolicy"), format: "json") + }.Concat(subscription.Attributes.OrderBy(item => item.Key, StringComparer.Ordinal) + .Where(item => item.Key is not ("RawMessageDelivery" or "FilterPolicy" or "Protocol" or "Endpoint" or "Owner")) + .Select(item => AdminData.Field(item.Key, item.Value, secondary: true))).ToArray(), + ReadConnections = () => SubscriptionConnections(subscription) + }; + + private static IReadOnlyList SubscriptionConnections(SubscriptionSnapshot subscription) + { + if (subscription.Protocol == "sqs" && subscription.Endpoint.StartsWith("arn:", StringComparison.Ordinal)) + return [new("Subscribed queue", "delivers-to", "sqs", + [new("queues", subscription.Endpoint)], State: "configured")]; + if (subscription.Protocol == "lambda" && subscription.Endpoint.StartsWith("arn:", StringComparison.Ordinal)) + return [new("Subscribed function", "invokes", "lambda", + [new("functions", subscription.Endpoint)], State: "configured")]; + if (subscription.Protocol is "http" or "https" + && Uri.TryCreate(subscription.Endpoint, UriKind.Absolute, out _)) + return [new("Subscribed endpoint", "delivers-to", ExternalUri: subscription.Endpoint, State: "configured")]; + return [new(subscription.Endpoint, "delivers-to", State: "external")]; + } + + private static AdminNode ApplicationNode(AppSnapshot application) => + AdminData.Node("platform-applications", application.Arn, application.Name, + application.Arn, "active") with + { + ReadFields = () => SafeAttributes(application.Attributes) + .Prepend(AdminData.Field("Platform", application.Platform)).ToArray(), + ChildKinds = [AdminKinds[3]], + ReadChildren = () => application.Endpoints.Select(EndpointNode).ToArray() + }; + + private static AdminNode EndpointNode(EndpointSnapshot endpoint) => + AdminData.Node("platform-endpoints", endpoint.Arn, endpoint.Arn, endpoint.Arn, + endpoint.Attributes.GetValueOrDefault("Enabled", "true") == "true" ? "enabled" : "disabled") with + { + ReadFields = () => SafeAttributes(endpoint.Attributes) + }; + + private static AdminField[] SafeAttributes(IReadOnlyDictionary attributes) => + attributes.OrderBy(item => item.Key, StringComparer.Ordinal) + .Select(item => IsCredential(item.Key) + ? AdminData.Field(item.Key, item.Value, sensitive: true) + : AdminData.Field(item.Key, item.Value)) + .ToArray(); + + private static bool IsCredential(string name) => + name.Contains("credential", StringComparison.OrdinalIgnoreCase) + || name.Contains("secret", StringComparison.OrdinalIgnoreCase) + || name.Contains("token", StringComparison.OrdinalIgnoreCase) + || name.Contains("key", StringComparison.OrdinalIgnoreCase); + + public AdminRelationshipSnapshot GetAdminRelationshipSnapshot() + { + lock (_lock) + { + var resources = new List(); + var relationships = new List(); + foreach (var topic in _topics.Values) + { + AdminKey[] topicPath = [new("topics", topic.Arn)]; + resources.Add(new(topicPath, topic.Name)); + foreach (var subscription in topic.Subscriptions) + { + AdminKey[] path = [.. topicPath, new("subscriptions", subscription.Arn)]; + resources.Add(new(path, subscription.Endpoint)); + var snapshot = new SubscriptionSnapshot(subscription.Arn, subscription.Protocol, + subscription.Endpoint, subscription.Confirmed, subscription.TopicArn, + subscription.Owner, new(subscription.Attributes, StringComparer.Ordinal)); + relationships.AddRange(SubscriptionConnections(snapshot).Select(connection => + new AdminConfiguredRelationship("sns", path, $"{topic.Name} / {subscription.Arn}", connection))); + relationships.Add(new("sns", path, topic.Name, + new("Parent topic", "belongs-to", "sns", [new("topics", subscription.TopicArn)]))); + } + } + foreach (var application in _platformApps.Values) + { + AdminKey[] path = [new("platform-applications", application.Arn)]; + resources.Add(new(path, application.Name)); + resources.AddRange(_platformEndpoints.Values.Where(endpoint => endpoint.ApplicationArn == application.Arn) + .Select(endpoint => new AdminRelationshipResource( + [.. path, new("platform-endpoints", endpoint.Arn)], endpoint.Arn, + endpoint.Attributes.GetValueOrDefault("Enabled", "true") == "true" ? "enabled" : "disabled"))); + } + return new(resources.ToArray(), relationships.ToArray()); + } + } + + private sealed record TopicSnapshot( + string Name, string Arn, Dictionary Attributes, + Dictionary Tags, SubscriptionSnapshot[] Subscriptions); + private sealed record SubscriptionSnapshot( + string Arn, string Protocol, string Endpoint, bool Confirmed, + string TopicArn, string Owner, Dictionary Attributes); + private sealed record AppSnapshot( + string Arn, string Name, string Platform, + Dictionary Attributes, EndpointSnapshot[] Endpoints); + private sealed record EndpointSnapshot( + string Arn, string ApplicationArn, Dictionary Attributes); +} diff --git a/src/MicroStack/Services/Sns/SnsServiceHandler.cs b/src/MicroStack/Services/Sns/SnsServiceHandler.cs index 0bcbb64..4bb7a16 100644 --- a/src/MicroStack/Services/Sns/SnsServiceHandler.cs +++ b/src/MicroStack/Services/Sns/SnsServiceHandler.cs @@ -20,7 +20,7 @@ namespace MicroStack.Services.Sns; /// CreatePlatformApplication, CreatePlatformEndpoint. /// SNS -> SQS fanout delivers synchronously into the SQS handler's queues. /// -internal sealed class SnsServiceHandler : IServiceHandler +internal sealed partial class SnsServiceHandler : IServiceHandler { private readonly SqsServiceHandler _sqs; @@ -43,6 +43,9 @@ internal SnsServiceHandler(SqsServiceHandler sqsHandler) public string ServiceName => "sns"; + public IEnumerable GetKnownAccountIds() => + _topics.GetAccountIds().Concat(_platformApps.GetAccountIds()).Concat(_platformEndpoints.GetAccountIds()); + public Task HandleAsync(ServiceRequest request) { // SNS uses Query (form-encoded) protocol — parse form params from body and query string. diff --git a/src/MicroStack/Services/Sqs/SqsServiceHandler.Admin.cs b/src/MicroStack/Services/Sqs/SqsServiceHandler.Admin.cs new file mode 100644 index 0000000..8f9663f --- /dev/null +++ b/src/MicroStack/Services/Sqs/SqsServiceHandler.Admin.cs @@ -0,0 +1,251 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using MicroStack.Admin.Contracts; +using MicroStack.Internal; +using MicroStack.Internal.Admin; + +namespace MicroStack.Services.Sqs; + +internal sealed partial class SqsServiceHandler : IAdminResourceSource, IAdminRelationshipSource +{ + private static readonly AdminResourceKind[] AdminKinds = + [ + new("queues", "Queues"), + new("messages", "Messages") { IsRoot = false } + ]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + serviceId == "sqs" ? AdminKinds : []; + + public IEnumerable GetAdminResources(string serviceId) + { + if (serviceId != "sqs") + return []; + + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + QueueSnapshot[] queues; + lock (_lock) + { + queues = _queues.Values.Select(queue => + { + var visible = 0; + var delayed = 0; + foreach (var message in queue.Messages) + { + if (message.VisibleAtMs <= now) + visible++; + else if (message.ReceiveCount == 0 && message.FirstReceiveAtMs is null) + delayed++; + } + return new QueueSnapshot(queue.Name, QueueUrl(QueueEndpoint(), queue.Name), queue.IsFifo, + new(queue.Attributes, StringComparer.Ordinal), + new(queue.Tags, StringComparer.Ordinal), + visible, delayed, queue.Messages.Count - visible - delayed, + () => ReadMessages(queue)); + }).ToArray(); + } + + return queues.Select(queue => QueueNode(queue, now)).ToArray(); + } + + private MessageSnapshot[] ReadMessages(SqsQueue queue) + { + lock (_lock) + { + return queue.Messages.Select(message => new MessageSnapshot( + message.Id, + message.Body, + message.Md5Body, + message.Md5Attrs, + message.SentAtMs, + message.VisibleAtMs, + message.ReceiveCount, + message.FirstReceiveAtMs, + message.MessageAttributes.DeepClone(), + new(message.SystemAttributes, StringComparer.Ordinal), + message.GroupId, + message.DedupId, + message.SequenceNumber)).ToArray(); + } + } + + private static AdminNode QueueNode(QueueSnapshot queue, long capturedAt) + { + var arn = queue.Attributes.GetValueOrDefault("QueueArn") + ?? $"arn:aws:sqs:{_region}:{AccountContext.GetAccountId()}:{queue.Name}"; + var visible = queue.Visible; + var delayed = queue.Delayed; + var inFlight = queue.InFlight; + AdminField[] summary = + [ + AdminData.Field("Visible messages", visible.ToString(CultureInfo.InvariantCulture)), + AdminData.Field("Delayed messages", delayed.ToString(CultureInfo.InvariantCulture)), + AdminData.Field("In-flight messages", inFlight.ToString(CultureInfo.InvariantCulture)) + ]; + return AdminData.Node("queues", arn, queue.Name, arn, "available", + type: queue.IsFifo ? "FIFO" : "Standard", summary: summary) with + { + ReadSummary = () => summary, + ChildKinds = [AdminKinds[1]], + ReadFields = () => new[] + { + AdminData.Field("Queue URL", queue.Url, format: "uri"), + AdminData.Field("Type", queue.IsFifo ? "FIFO" : "Standard") + }.Concat(queue.Attributes.OrderBy(item => item.Key, StringComparer.Ordinal) + .Select(item => AdminData.Field(item.Key, item.Key switch + { + "ApproximateNumberOfMessages" => visible.ToString(CultureInfo.InvariantCulture), + "ApproximateNumberOfMessagesDelayed" => delayed.ToString(CultureInfo.InvariantCulture), + "ApproximateNumberOfMessagesNotVisible" => inFlight.ToString(CultureInfo.InvariantCulture), + _ => item.Value + }, + format: item.Key is "Policy" or "RedrivePolicy" or "RedriveAllowPolicy" ? "json" : null, + secondary: true))) + .Concat(queue.Tags.OrderBy(item => item.Key, StringComparer.Ordinal) + .Select(item => AdminData.Field($"Tag: {item.Key}", item.Value, secondary: true))).ToArray(), + ReadChildren = () => queue.ReadMessages() + .Select(message => MessageNode(message, capturedAt, queue.IsFifo)).ToArray(), + ReadConnections = () => QueueConnections(queue.Attributes) + }; + } + + private static AdminNode MessageNode(MessageSnapshot message, long capturedAt, bool isFifo) + { + var state = MessageState(message, capturedAt); + var sent = AdminData.IsoUtc(DateTimeOffset.FromUnixTimeMilliseconds(message.SentAtMs)); + return AdminData.Node("messages", message.Id, message.Id, status: state, + type: isFifo ? "FIFO message" : "Message", + summary: [AdminData.Field("Sent", sent, format: "datetime")]) with + { + ReadFields = () => + [ + AdminData.Field("State", state), + AdminData.Field("Sent", sent, format: "datetime"), + AdminData.Field("Visible at", AdminData.IsoUtc(DateTimeOffset.FromUnixTimeMilliseconds(message.VisibleAtMs)), format: "datetime"), + AdminData.Field("Receive count", message.ReceiveCount.ToString()), + AdminData.Field("First received", message.FirstReceiveAtMs is long first + ? AdminData.IsoUtc(DateTimeOffset.FromUnixTimeMilliseconds(first)) : null, format: "datetime"), + AdminData.Field("Body MD5", message.Md5Body, secondary: true), + AdminData.Field("Attributes MD5", message.Md5Attrs, secondary: true), + AdminData.Field("Message attributes", message.MessageAttributes.ToJsonString(), format: "json", secondary: true), + AdminData.Field("System attributes", string.Join(", ", + message.SystemAttributes.OrderBy(item => item.Key, StringComparer.Ordinal) + .Select(item => $"{item.Key}={item.Value}")), secondary: true), + AdminData.Field("Message group ID", message.GroupId), + AdminData.Field("Deduplication ID", message.DedupId), + AdminData.Field("Sequence number", message.SequenceNumber) + ], + ReadContent = () => MessageContent(message.Body) + }; + } + + private static AdminContent MessageContent(string body) + { + var text = AdminData.Text(body); + if (text.Kind == "oversized") + return text; + JsonDocument document; + try + { + document = JsonDocument.Parse(body); + } + catch (JsonException) + { + return text; + } + using (document) + { + try + { + using var stream = new MessagePreviewStream(); + using (var writer = new Utf8JsonWriter(stream, new() { Indented = true })) + document.RootElement.WriteTo(writer); + return AdminData.Text(stream.GetBuffer().AsSpan(0, checked((int)stream.Length)), + "application/json") with { Kind = "json" }; + } + catch (MessagePreviewLimitException) + { + return AdminData.Oversized("application/json"); + } + } + } + + private sealed class MessagePreviewLimitException : Exception; + + private sealed class MessagePreviewStream : MemoryStream + { + public override void Write(byte[] buffer, int offset, int count) + { + if (count > AdminData.PreviewMaxBytes - Length) + throw new MessagePreviewLimitException(); + base.Write(buffer, offset, count); + } + + public override void Write(ReadOnlySpan buffer) + { + if (buffer.Length > AdminData.PreviewMaxBytes - Length) + throw new MessagePreviewLimitException(); + base.Write(buffer); + } + } + + private static string MessageState(MessageSnapshot message, long now) => + message.VisibleAtMs <= now ? "visible" + : message.ReceiveCount > 0 || message.FirstReceiveAtMs is not null ? "in-flight" + : "delayed"; + + private static IReadOnlyList QueueConnections(IReadOnlyDictionary attributes) + { + if (!attributes.TryGetValue("RedrivePolicy", out var policy) || string.IsNullOrWhiteSpace(policy)) + return []; + JsonDocument document; + try + { + document = JsonDocument.Parse(policy); + } + catch (JsonException) + { + return [new("Invalid redrive policy JSON", "redrive", State: "unavailable")]; + } + using (document) + { + if (document.RootElement.ValueKind != JsonValueKind.Object + || !document.RootElement.TryGetProperty("deadLetterTargetArn", out var target) + || target.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(target.GetString())) + return [new("Redrive policy has no valid target ARN", "redrive", State: "unavailable")]; + return [new("Dead-letter queue", "redrive", "sqs", [new("queues", target.GetString()!)], State: "configured")]; + } + } + + public AdminRelationshipSnapshot GetAdminRelationshipSnapshot() + { + lock (_lock) + { + var resources = _queues.Values.Select(queue => new AdminRelationshipResource( + [new("queues", queue.Attributes.GetValueOrDefault("QueueArn") + ?? $"arn:aws:sqs:{_region}:{AccountContext.GetAccountId()}:{queue.Name}")], + queue.Name)).ToArray(); + var edges = _queues.Values.SelectMany(queue => + { + var arn = queue.Attributes.GetValueOrDefault("QueueArn") + ?? $"arn:aws:sqs:{_region}:{AccountContext.GetAccountId()}:{queue.Name}"; + return QueueConnections(queue.Attributes).Select(connection => + new AdminConfiguredRelationship("sqs", [new("queues", arn)], queue.Name, connection)); + }).ToArray(); + return new(resources, edges); + } + } + + private sealed record QueueSnapshot( + string Name, string Url, bool IsFifo, + Dictionary Attributes, + Dictionary Tags, + int Visible, int Delayed, int InFlight, Func ReadMessages); + + private sealed record MessageSnapshot( + string Id, string Body, string Md5Body, string? Md5Attrs, + long SentAtMs, long VisibleAtMs, int ReceiveCount, long? FirstReceiveAtMs, + JsonNode MessageAttributes, Dictionary SystemAttributes, + string? GroupId, string? DedupId, string? SequenceNumber); +} diff --git a/src/MicroStack/Services/Sqs/SqsServiceHandler.cs b/src/MicroStack/Services/Sqs/SqsServiceHandler.cs index 8ce79c6..f274334 100644 --- a/src/MicroStack/Services/Sqs/SqsServiceHandler.cs +++ b/src/MicroStack/Services/Sqs/SqsServiceHandler.cs @@ -13,7 +13,7 @@ namespace MicroStack.Services.Sqs; /// /// Port of ministack/services/sqs.py. /// -internal sealed class SqsServiceHandler : IServiceHandler +internal sealed partial class SqsServiceHandler : IServiceHandler, IResourceProvider { // ── Module-level state ────────────────────────────────────────────────────── @@ -60,6 +60,8 @@ internal sealed class SqsServiceHandler : IServiceHandler public string ServiceName => "sqs"; + public IEnumerable GetKnownAccountIds() => _queues.GetAccountIds(); + public async Task HandleAsync(ServiceRequest request) { var target = request.GetHeader("x-amz-target") ?? ""; @@ -144,6 +146,37 @@ public void RestoreState(JsonElement state) } } + public ResourceSummary GetResources() + { + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + lock (_lock) + { + var items = _queues.Items + .Select(kv => + { + var queue = kv.Value; + var visible = queue.Messages.Count(m => m.VisibleAtMs <= now); + var inFlight = queue.Messages.Count - visible; + var arn = queue.Attributes.GetValueOrDefault("QueueArn") + ?? $"arn:aws:sqs:{_region}:{AccountContext.GetAccountId()}:{queue.Name}"; + + return new ResourceItem( + queue.Name, + arn, + new Dictionary(StringComparer.Ordinal) + { + ["QueueUrl"] = QueueUrl(QueueEndpoint(), queue.Name), + ["VisibleMessages"] = visible.ToString(), + ["InFlightMessages"] = inFlight.ToString(), + }); + }) + .OrderBy(item => item.Name, StringComparer.Ordinal) + .ToList(); + + return new ResourceSummary("sqs", items.Count, items); + } + } + // ── JSON protocol ─────────────────────────────────────────────────────────── private async Task HandleJsonAsync( @@ -989,10 +1022,10 @@ private static JsonObject FilterMsgAttrs(JsonObject attrs, List names) // ── Helpers ───────────────────────────────────────────────────────────────── - private static string QueueEndpoint(ServiceRequest request) + private static string QueueEndpoint(ServiceRequest? request = null) { if (MicroStackOptions.Instance.SqsEndpointStrategy == SqsEndpointStrategy.Request - && !string.IsNullOrWhiteSpace(request.Origin)) + && !string.IsNullOrWhiteSpace(request?.Origin)) { return request.Origin.TrimEnd('/'); } diff --git a/src/MicroStack/Services/Ssm/SsmServiceHandler.Admin.cs b/src/MicroStack/Services/Ssm/SsmServiceHandler.Admin.cs new file mode 100644 index 0000000..cf86a66 --- /dev/null +++ b/src/MicroStack/Services/Ssm/SsmServiceHandler.Admin.cs @@ -0,0 +1,136 @@ +using System.Globalization; +using MicroStack.Admin.Contracts; +using MicroStack.Internal.Admin; + +namespace MicroStack.Services.Ssm; + +internal sealed partial class SsmServiceHandler +{ + private static readonly AdminResourceKind[] AdminKinds = [new("parameter", "Parameters")]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + serviceId == "ssm" ? AdminKinds : []; + + public IEnumerable GetAdminResources(string serviceId) + { + if (serviceId != "ssm") return []; + lock (_lock) + { + return _parameters.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => ParameterNode(x.Key, x.Value.Arn, x.Value.Type, x.Value.Version)).ToArray(); + } + } + + private AdminNode ParameterNode(string name, string arn, string type, int version) + { + var secure = type == "SecureString"; + return AdminData.Node("parameter", name, name, arn, $"{type} v{version}") with + { + ReadFields = () => ReadParameterFields(name), + ChildKinds = + [ + new("parameter-version", "Parameter versions") { IsRoot = false }, + new("tag", "Tags") { IsRoot = false }, + ], + ReadChildren = () => ReadParameterChildren(name), + ReadContent = () => ReadParameterContent(name), + RevealableFields = secure ? ["Value"] : [], + RevealField = secure ? field => RevealParameter(name, field) : null, + }; + } + + private IReadOnlyList ReadParameterFields(string name) + { + lock (_lock) + { + if (!_parameters.TryGetValue(name, out var parameter)) return []; + var secure = parameter.Type == "SecureString"; + return + [ + AdminData.Field("Value", secure ? null : parameter.OriginalValue, secure, secure), + AdminData.Field("Type", parameter.Type), + AdminData.Field("Version", parameter.Version.ToString(CultureInfo.InvariantCulture)), + AdminData.Field("Description", parameter.Description), + AdminData.Field("Tier", parameter.Tier), + AdminData.Field("Data type", parameter.DataType), + AdminData.Field("Key ID", parameter.KeyId), + AdminData.Field("Allowed pattern", parameter.AllowedPattern), + AdminData.Field("Last modified", Seconds(parameter.LastModifiedDate), format: "datetime"), + ]; + } + } + + private AdminContent ReadParameterContent(string name) + { + lock (_lock) + { + if (!_parameters.TryGetValue(name, out var parameter)) + return AdminData.Unavailable("Parameter no longer exists.", "text/plain"); + return parameter.Type == "SecureString" + ? AdminData.Unavailable("SecureString values require an explicit reveal.", "text/plain", sensitive: true) + : AdminData.Text(parameter.OriginalValue); + } + } + + private AdminContent RevealParameter(string name, string field) + { + lock (_lock) + { + if (field != "Value" || !_parameters.TryGetValue(name, out var parameter) || + parameter.Type != "SecureString") + return AdminData.Unavailable("The requested revealable field does not exist.", "text/plain", sensitive: true); + return AdminData.Text(parameter.OriginalValue, sensitive: true); + } + } + + private IEnumerable ReadParameterChildren(string name) + { + lock (_lock) + { + var nodes = new List(); + if (_parameterHistory.TryGetValue(name, out var history)) + { + nodes.AddRange(history.OrderByDescending(x => x.Version).Select(entry => + { + var secure = entry.Type == "SecureString"; + var version = entry.Version; + var value = entry.OriginalValue; + var modified = Seconds(entry.LastModifiedDate); + var modifiedBy = entry.LastModifiedUser; + var description = entry.Description; + var labels = string.Join(", ", entry.Labels); + return AdminData.Node("parameter-version", version.ToString(CultureInfo.InvariantCulture), + $"Version {version}", status: entry.Type) with + { + ReadFields = () => + [ + AdminData.Field("Value", secure ? null : value, secure, secure), + AdminData.Field("Modified", modified, format: "datetime"), + AdminData.Field("Modified by", modifiedBy), + AdminData.Field("Description", description), + AdminData.Field("Labels", labels), + ], + ReadContent = () => secure + ? AdminData.Unavailable("SecureString values require an explicit reveal.", "text/plain", sensitive: true) + : AdminData.Text(value), + RevealableFields = secure ? ["Value"] : [], + RevealField = secure ? field => field == "Value" + ? AdminData.Text(value, sensitive: true) + : AdminData.Unavailable("The requested revealable field does not exist.", "text/plain", sensitive: true) : null, + }; + })); + } + + if (_parameters.TryGetValue(name, out var parameter) && _tags.TryGetValue(parameter.Arn, out var tags)) + nodes.AddRange(tags.OrderBy(x => x.Key, StringComparer.Ordinal).Select(x => + AdminData.Node("tag", x.Key, x.Key) with + { + ReadFields = () => [AdminData.Field("Value", x.Value)], + })); + return nodes; + } + } + + private static string Seconds(double value) => + AdminData.IsoUtc(DateTimeOffset.FromUnixTimeMilliseconds((long)(value * 1000))); +} diff --git a/src/MicroStack/Services/Ssm/SsmServiceHandler.cs b/src/MicroStack/Services/Ssm/SsmServiceHandler.cs index bde7ba1..46c0116 100644 --- a/src/MicroStack/Services/Ssm/SsmServiceHandler.cs +++ b/src/MicroStack/Services/Ssm/SsmServiceHandler.cs @@ -1,6 +1,7 @@ using System.Text; using System.Text.Json; using MicroStack.Internal; +using MicroStack.Internal.Admin; namespace MicroStack.Services.Ssm; @@ -14,7 +15,7 @@ namespace MicroStack.Services.Ssm; /// GetParameterHistory, LabelParameterVersion, /// AddTagsToResource, RemoveTagsFromResource, ListTagsForResource. /// -internal sealed class SsmServiceHandler : IServiceHandler +internal sealed partial class SsmServiceHandler : IServiceHandler, IAdminResourceSource { private readonly AccountScopedDictionary _parameters = new(); private readonly AccountScopedDictionary> _parameterHistory = new(); @@ -29,6 +30,8 @@ internal sealed class SsmServiceHandler : IServiceHandler public string ServiceName => "ssm"; + public IEnumerable GetKnownAccountIds() => _parameters.GetAccountIds(); + public Task HandleAsync(ServiceRequest request) { var target = request.GetHeader("x-amz-target") ?? ""; diff --git a/src/MicroStack/Services/StepFunctions/StepFunctionsServiceHandler.Admin.cs b/src/MicroStack/Services/StepFunctions/StepFunctionsServiceHandler.Admin.cs new file mode 100644 index 0000000..d878d77 --- /dev/null +++ b/src/MicroStack/Services/StepFunctions/StepFunctionsServiceHandler.Admin.cs @@ -0,0 +1,266 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using MicroStack.Admin.Contracts; +using MicroStack.Internal; +using MicroStack.Internal.Admin; + +namespace MicroStack.Services.StepFunctions; + +internal sealed partial class StepFunctionsServiceHandler : IAdminResourceSource, IAdminRelationshipSource +{ + private static readonly AdminResourceKind[] AdminKinds = + [ + new("state-machines", "State machines"), + new("executions", "Executions") { IsRoot = false }, + new("history-events", "History events") { IsRoot = false }, + new("activities", "Activities"), + new("activity-tasks", "Activity tasks") { IsRoot = false } + ]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => + serviceId == "stepfunctions" ? AdminKinds : []; + + public AdminRelationshipSnapshot GetAdminRelationshipSnapshot() + { + var resources = new List(); + AddResources(_stateMachines, "state-machines", "stateMachineArn"); + AddResources(_activities, "activities", "activityArn"); + return new(resources, []); + + void AddResources( + AccountScopedDictionary> source, string kind, string arnKey) + { + foreach (var item in source.Items.OrderBy(item => item.Key, StringComparer.Ordinal).ToArray()) + { + lock (item.Value) + { + var arn = GetString(item.Value, arnKey) ?? item.Key; + resources.Add(new([new(kind, arn)], GetString(item.Value, "name") ?? arn, + GetString(item.Value, "status"), arn)); + } + } + } + } + + public IEnumerable GetAdminResources(string serviceId) + { + if (serviceId != "stepfunctions") + return []; + + var machines = Snapshot(_stateMachines); + var executions = Snapshot(_executions); + var activities = Snapshot(_activities); + var taskSnapshots = new Dictionary[]>(StringComparer.Ordinal); + foreach (var item in _activityTasks.Items) + { + lock (item.Value) + { + taskSnapshots[item.Key] = item.Value.Select(DeepCopyDict).ToArray(); + } + } + + return machines.Select(machine => StateMachineNode(machine, executions)) + .Concat(activities.Select(activity => ActivityNode(activity, taskSnapshots))) + .ToArray(); + } + + private static Dictionary[] Snapshot( + AccountScopedDictionary> source) + { + var values = source.Values.ToArray(); + var result = new Dictionary[values.Length]; + for (var index = 0; index < values.Length; index++) + { + lock (values[index]) + result[index] = DeepCopyDict(values[index]); + } + return result; + } + + private static AdminNode StateMachineNode( + Dictionary machine, + IReadOnlyList> executions) + { + var arn = GetString(machine, "stateMachineArn") ?? ""; + var name = GetString(machine, "name") ?? arn; + var definition = GetString(machine, "definition") ?? "{}"; + var children = executions + .Where(execution => GetString(execution, "stateMachineArn") == arn).ToArray(); + return AdminData.Node("state-machines", arn, name, arn, + GetString(machine, "status")) with + { + ReadFields = () => + [ + AdminData.Field("Type", GetString(machine, "type")), + AdminData.Field("Role ARN", GetString(machine, "roleArn")), + AdminData.Field("Created", Iso(machine, "creationDate"), format: "datetime"), + AdminData.Field("Executions", children.Length.ToString(CultureInfo.InvariantCulture)) + ], + ChildKinds = [AdminKinds[1]], + ReadChildren = () => children.Select(ExecutionNode).ToArray(), + ReadContent = () => SafeJsonText(definition), + ReadConnections = () => DefinitionConnections(definition) + }; + } + + private static AdminNode ExecutionNode(Dictionary execution) + { + var arn = GetString(execution, "executionArn") ?? ""; + var name = GetString(execution, "name") ?? arn; + var events = GetList(execution, "events") + .OfType>().Select(DeepCopyDict).ToArray(); + return AdminData.Node("executions", arn, name, arn, + GetString(execution, "status")) with + { + ReadFields = () => + [ + AdminData.Field("State machine ARN", GetString(execution, "stateMachineArn")), + AdminData.Field("Started", Iso(execution, "startDate"), format: "datetime"), + AdminData.Field("Stopped", Iso(execution, "stopDate"), format: "datetime"), + AdminData.Field("History events", events.Length.ToString(CultureInfo.InvariantCulture)) + ], + ChildKinds = [AdminKinds[2]], + ReadChildren = () => events.Select(HistoryEventNode).ToArray(), + ReadContent = () => AdminData.Json(new Dictionary + { + ["input"] = GetString(execution, "input"), + ["output"] = AdminValue(execution.GetValueOrDefault("output")) + }, sensitive: true) + }; + } + + private static AdminNode HistoryEventNode(Dictionary historyEvent) + { + var id = GetString(historyEvent, "id") ?? ""; + var type = GetString(historyEvent, "type") ?? "Event"; + return AdminData.Node("history-events", id, $"{id} · {type}", status: type) with + { + ReadFields = () => + [ + AdminData.Field("Type", type), + AdminData.Field("Timestamp", Iso(historyEvent, "timestamp"), format: "datetime") + ], + ReadContent = () => AdminData.Json(AdminValue(historyEvent), sensitive: true) + }; + } + + private static AdminNode ActivityNode( + Dictionary activity, + IReadOnlyDictionary[]> taskSnapshots) + { + var arn = GetString(activity, "activityArn") ?? ""; + var name = GetString(activity, "name") ?? arn; + var tasks = taskSnapshots.GetValueOrDefault(arn) ?? []; + return AdminData.Node("activities", arn, name, arn, "active") with + { + ReadFields = () => + [ + AdminData.Field("Created", Iso(activity, "creationDate"), format: "datetime"), + AdminData.Field("Pending tasks", tasks.Length.ToString(CultureInfo.InvariantCulture)) + ], + ChildKinds = [AdminKinds[4]], + ReadChildren = () => tasks.Select(ActivityTaskNode).ToArray() + }; + } + + private static AdminNode ActivityTaskNode(Dictionary task) + { + var token = GetString(task, "taskToken") ?? ""; + var key = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(token))); + return AdminData.Node("activity-tasks", key, $"Task {key[..12]}", status: "pending") with + { + ReadFields = () => + [ + AdminData.Field("Task token", token, sensitive: true) + ], + ReadContent = () => AdminData.Text(GetString(task, "input") ?? "{}", "application/json", sensitive: true) + }; + } + + private static IReadOnlyList DefinitionConnections(string definition) + { + try + { + using var document = JsonDocument.Parse(definition); + var arns = new HashSet(StringComparer.Ordinal); + FindResources(document.RootElement, arns); + return arns.Select(ResourceConnection).ToArray(); + } + catch (JsonException) + { + return []; + } + } + + private static AdminContent SafeJsonText(string value) + { + try + { + return AdminData.JsonText(value); + } + catch (JsonException) + { + return AdminData.Unavailable("The retained state machine definition is not valid JSON.", + "application/json", Encoding.UTF8.GetByteCount(value)); + } + } + + private static void FindResources(JsonElement element, HashSet resources) + { + if (element.ValueKind == JsonValueKind.Object) + { + foreach (var property in element.EnumerateObject()) + { + if (property.Value.ValueKind == JsonValueKind.String + && (property.NameEquals("Resource") + || property.Name.EndsWith("Arn", StringComparison.Ordinal))) + { + var value = property.Value.GetString(); + if (!string.IsNullOrEmpty(value)) + resources.Add(value); + } + FindResources(property.Value, resources); + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + FindResources(item, resources); + } + } + + private static AdminConnection ResourceConnection(string resource) + { + if (resource.StartsWith("arn:aws:states:::", StringComparison.Ordinal)) + return new(resource, "uses-integration", State: "configured"); + if (resource.StartsWith("arn:aws:sqs:", StringComparison.Ordinal)) + return new("Queue", "invokes", "sqs", [new("queues", resource)], State: "configured"); + if (resource.StartsWith("arn:aws:sns:", StringComparison.Ordinal)) + return new("Topic", "invokes", "sns", [new("topics", resource)], State: "configured"); + if (resource.StartsWith("arn:aws:lambda:", StringComparison.Ordinal)) + return new("Function", "invokes", "lambda", [new("functions", resource)], State: "configured"); + if (resource.Contains(":stateMachine:", StringComparison.Ordinal)) + return new("State machine", "invokes", "stepfunctions", + [new("state-machines", resource)], State: "configured"); + return new(resource, "invokes", State: "configured"); + } + + private static string? Iso(IReadOnlyDictionary value, string key) + { + var text = value.TryGetValue(key, out var item) ? item?.ToString() : null; + return DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, out var timestamp) + ? AdminData.IsoUtc(timestamp) : null; + } + + private static object? AdminValue(object? value) => value switch + { + Dictionary map => map.ToDictionary( + item => item.Key, item => AdminValue(item.Value), StringComparer.Ordinal), + List list => list.Select(AdminValue).ToArray(), + List> maps => maps.Select(map => AdminValue(map)).ToArray(), + _ => value + }; +} diff --git a/src/MicroStack/Services/StepFunctions/StepFunctionsServiceHandler.cs b/src/MicroStack/Services/StepFunctions/StepFunctionsServiceHandler.cs index 5e77665..ce131b9 100644 --- a/src/MicroStack/Services/StepFunctions/StepFunctionsServiceHandler.cs +++ b/src/MicroStack/Services/StepFunctions/StepFunctionsServiceHandler.cs @@ -56,6 +56,9 @@ internal StepFunctionsServiceHandler(LambdaServiceHandler lambda, ServiceRegistr public string ServiceName => "states"; + public IEnumerable GetKnownAccountIds() => + _stateMachines.GetAccountIds().Concat(_executions.GetAccountIds()).Concat(_activities.GetAccountIds()); + public async Task HandleAsync(ServiceRequest request) { var target = request.GetHeader("x-amz-target") ?? ""; @@ -213,22 +216,25 @@ private static string SerializeOutput(Dictionary data) private static void AddEvent(Dictionary execution, string eventType, Dictionary? details = null) { - var events = GetList(execution, "events"); - var evt = new Dictionary - { - ["id"] = events.Count + 1, - ["type"] = eventType, - ["timestamp"] = NowIso(), - }; - if (details is not null) + lock (execution) { - foreach (var kv in details) + var events = GetList(execution, "events"); + var evt = new Dictionary { - evt[kv.Key] = kv.Value; + ["id"] = events.Count + 1, + ["type"] = eventType, + ["timestamp"] = NowIso(), + }; + if (details is not null) + { + foreach (var kv in details) + { + evt[kv.Key] = kv.Value; + } } - } - events.Add(evt); + events.Add(evt); + } } private static string? NextOrEnd(Dictionary stateDef) @@ -531,19 +537,22 @@ private ServiceResponse ActUpdateStateMachine(Dictionary data) return ErrorResponse("StateMachineDoesNotExist", $"State machine {arn} not found"); } - if (data.ContainsKey("definition")) + lock (sm) { - sm["definition"] = GetString(data, "definition"); - } + if (data.ContainsKey("definition")) + { + sm["definition"] = GetString(data, "definition"); + } - if (data.ContainsKey("roleArn")) - { - sm["roleArn"] = GetString(data, "roleArn"); - } + if (data.ContainsKey("roleArn")) + { + sm["roleArn"] = GetString(data, "roleArn"); + } - if (data.TryGetValue("loggingConfiguration", out var lc2)) - { - sm["loggingConfiguration"] = lc2; + if (data.TryGetValue("loggingConfiguration", out var lc2)) + { + sm["loggingConfiguration"] = lc2; + } } return JsonResp(new Dictionary { ["updateDate"] = NowIso() }); @@ -658,8 +667,11 @@ private ServiceResponse ActStopExecution(Dictionary data) } var stopDate = NowIso(); - execution["status"] = "ABORTED"; - execution["stopDate"] = stopDate; + lock (execution) + { + execution["status"] = "ABORTED"; + execution["stopDate"] = stopDate; + } AddEvent(execution, "ExecutionAborted", new Dictionary { ["executionAbortedEventDetails"] = new Dictionary @@ -1273,9 +1285,12 @@ private void RunExecution(string execArn) if (string.Equals(GetString(execution, "status"), "RUNNING", StringComparison.Ordinal)) { var outputJson = SerializeOutput(currentInput); - execution["status"] = "SUCCEEDED"; - execution["output"] = outputJson; - execution["stopDate"] = NowIso(); + lock (execution) + { + execution["status"] = "SUCCEEDED"; + execution["output"] = outputJson; + execution["stopDate"] = NowIso(); + } AddEvent(execution, "ExecutionSucceeded", new Dictionary { ["executionSucceededEventDetails"] = new Dictionary { ["output"] = outputJson }, @@ -1294,9 +1309,12 @@ private void RunExecution(string execArn) private static void FailExecution(Dictionary execution, string error, string cause) { - execution["status"] = "FAILED"; - execution["output"] = DictionaryObjectJsonConverter.SerializeValue(new Dictionary { ["Error"] = error, ["Cause"] = cause }); - execution["stopDate"] = NowIso(); + lock (execution) + { + execution["status"] = "FAILED"; + execution["output"] = DictionaryObjectJsonConverter.SerializeValue(new Dictionary { ["Error"] = error, ["Cause"] = cause }); + execution["stopDate"] = NowIso(); + } AddEvent(execution, "ExecutionFailed", new Dictionary { ["executionFailedEventDetails"] = new Dictionary { ["error"] = error, ["cause"] = cause }, diff --git a/src/MicroStack/Services/Sts/StsServiceHandler.Admin.cs b/src/MicroStack/Services/Sts/StsServiceHandler.Admin.cs new file mode 100644 index 0000000..007062f --- /dev/null +++ b/src/MicroStack/Services/Sts/StsServiceHandler.Admin.cs @@ -0,0 +1,16 @@ +using MicroStack.Admin.Contracts; +using MicroStack.Internal.Admin; + +namespace MicroStack.Services.Sts; + +internal sealed partial class StsServiceHandler +{ + public IReadOnlyList GetAdminResourceKinds(string serviceId) => []; + + public IEnumerable GetAdminResources(string serviceId) => []; + + public string? GetAdminNotice(string serviceId) => + serviceId == "sts" + ? "STS is stateless. Issued temporary credentials are not retained and cannot be inspected." + : null; +} diff --git a/src/MicroStack/Services/Sts/StsServiceHandler.cs b/src/MicroStack/Services/Sts/StsServiceHandler.cs index cf1aedd..48ac8fc 100644 --- a/src/MicroStack/Services/Sts/StsServiceHandler.cs +++ b/src/MicroStack/Services/Sts/StsServiceHandler.cs @@ -15,7 +15,7 @@ namespace MicroStack.Services.Sts; /// Supports: GetCallerIdentity, AssumeRole, AssumeRoleWithWebIdentity, /// GetSessionToken, GetAccessKeyInfo. /// -internal sealed class StsServiceHandler : IServiceHandler +internal sealed partial class StsServiceHandler : IServiceHandler, Internal.Admin.IAdminResourceSource { private readonly IamServiceHandler _iam; private readonly Lock _lock = new(); diff --git a/src/MicroStack/Services/Waf/WafServiceHandler.Admin.cs b/src/MicroStack/Services/Waf/WafServiceHandler.Admin.cs new file mode 100644 index 0000000..5a070c0 --- /dev/null +++ b/src/MicroStack/Services/Waf/WafServiceHandler.Admin.cs @@ -0,0 +1,130 @@ +using System.Globalization; +using System.Text.Json; +using MicroStack.Admin.Contracts; +using MicroStack.Internal.Admin; + +namespace MicroStack.Services.Waf; + +internal sealed partial class WafServiceHandler +{ + private static readonly AdminResourceKind[] AdminKinds = + [ + new("web-acl", "Web ACLs"), + new("ip-set", "IP sets"), + new("rule-group", "Rule groups") + ]; + + public IReadOnlyList GetAdminResourceKinds(string serviceId) => AdminKinds; + + public IEnumerable GetAdminResources(string serviceId) + { + lock (_lock) + { + var nodes = _webAcls.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => ResourceNode("web-acl", x.Key, x.Value)).ToList(); + nodes.AddRange(_ipSets.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => ResourceNode("ip-set", x.Key, x.Value))); + nodes.AddRange(_ruleGroups.Items.OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => ResourceNode("rule-group", x.Key, x.Value))); + return nodes; + } + } + + private AdminNode ResourceNode(string kind, string id, Dictionary snapshot) => + AdminData.Node(kind, id, Text(snapshot, "Name") ?? id, Text(snapshot, "ARN"), + "Active") with + { + ReadFields = () => + { + lock (_lock) + { + if (!TryResource(kind, id, out var resource)) return []; + var fields = new List + { + AdminData.Field("Description", Text(resource, "Description")), + AdminData.Field("Scope", Text(resource, "Scope")), + AdminData.Field("Capacity", Scalar(resource, "Capacity")), + AdminData.Field("Tags", Tags(Text(resource, "ARN"))) + }; + if (kind == "ip-set") + { + fields.Add(AdminData.Field("IP address version", Text(resource, "IPAddressVersion"))); + fields.Add(AdminData.Field("Addresses", SafeAddresses(resource.GetValueOrDefault("Addresses")))); + } + else + { + fields.Add(AdminData.Field("Rule count", Count(resource.GetValueOrDefault("Rules")))); + } + return fields; + } + }, + ReadConnections = kind == "web-acl" + ? () => + { + lock (_lock) + { + if (!TryResource(kind, id, out var resource) || + Text(resource, "ARN") is not { } arn) + return []; + return _associations.Items.Where(x => x.Value == arn) + .OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => new AdminConnection(x.Key, "associated-resource")) + .ToArray(); + } + } + : null + }; + + private bool TryResource(string kind, string id, + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Dictionary? resource) + { + return kind switch + { + "web-acl" => _webAcls.TryGetValue(id, out resource), + "ip-set" => _ipSets.TryGetValue(id, out resource), + "rule-group" => _ruleGroups.TryGetValue(id, out resource), + _ => Missing(out resource) + }; + } + + private static bool Missing(out Dictionary? resource) + { + resource = null; + return false; + } + + private string Tags(string? arn) + { + if (arn is null || !_wafTags.TryGetValue(arn, out var tags)) return ""; + return string.Join(", ", tags.Select(t => + $"{t.GetValueOrDefault("Key")}={t.GetValueOrDefault("Value")}")); + } + + private static string? Text(IReadOnlyDictionary value, string key) => + value.TryGetValue(key, out var item) ? item as string : null; + + private static string? Scalar(IReadOnlyDictionary value, string key) => + value.TryGetValue(key, out var item) && item is not null + ? Convert.ToString(item, CultureInfo.InvariantCulture) + : null; + + private static string Count(object? value) => value switch + { + ICollection collection => collection.Count.ToString(CultureInfo.InvariantCulture), + JsonElement { ValueKind: JsonValueKind.Array } element => + element.GetArrayLength().ToString(CultureInfo.InvariantCulture), + _ => "0" + }; + + private static string SafeAddresses(object? value) + { + return value switch + { + IEnumerable values => string.Join(", ", values.OfType()), + JsonElement { ValueKind: JsonValueKind.Array } element => + string.Join(", ", element.EnumerateArray() + .Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString())), + _ => "" + }; + } +} diff --git a/src/MicroStack/Services/Waf/WafServiceHandler.cs b/src/MicroStack/Services/Waf/WafServiceHandler.cs index 0ad432e..78796f3 100644 --- a/src/MicroStack/Services/Waf/WafServiceHandler.cs +++ b/src/MicroStack/Services/Waf/WafServiceHandler.cs @@ -15,7 +15,7 @@ namespace MicroStack.Services.Waf; /// TagResource, UntagResource, ListTagsForResource, /// CheckCapacity, DescribeManagedRuleGroup. /// -internal sealed class WafServiceHandler : IServiceHandler +internal sealed partial class WafServiceHandler : IServiceHandler, Internal.Admin.IAdminResourceSource { private readonly Lock _lock = new(); @@ -32,6 +32,9 @@ internal sealed class WafServiceHandler : IServiceHandler public string ServiceName => "wafv2"; + public IEnumerable GetKnownAccountIds() => + _webAcls.GetAccountIds().Concat(_ipSets.GetAccountIds()).Concat(_ruleGroups.GetAccountIds()); + public Task HandleAsync(ServiceRequest request) { var target = request.GetHeader("x-amz-target") ?? ""; diff --git a/tests/MicroStack.Tests/AcmTests.cs b/tests/MicroStack.Tests/AcmTests.cs index 0fe7736..d01fc95 100644 --- a/tests/MicroStack.Tests/AcmTests.cs +++ b/tests/MicroStack.Tests/AcmTests.cs @@ -24,7 +24,7 @@ public AcmTests(MicroStackFixture fixture) private static AmazonCertificateManagerClient CreateAcmClient(MicroStackFixture fixture) { - var innerHandler = fixture.Factory.Server.CreateHandler(); + var innerHandler = fixture.CreateHandler(); var httpClient = new HttpClient(new CanonicalizeUriHandler(innerHandler)) { BaseAddress = new Uri("http://localhost/"), diff --git a/tests/MicroStack.Tests/AdminAccountsTests.cs b/tests/MicroStack.Tests/AdminAccountsTests.cs new file mode 100644 index 0000000..0ca02b9 --- /dev/null +++ b/tests/MicroStack.Tests/AdminAccountsTests.cs @@ -0,0 +1,365 @@ +using System.Diagnostics; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using MicroStack.Admin.Contracts; +using MicroStack.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MicroStack.Tests; + +public sealed class AdminAccountsTests +{ + private const string Default = "000000000000"; + private const string First = "111111111111"; + private const string Second = "222222222222"; + private const string Empty = "333333333333"; + private const string AccountsUrl = "/_microstack/admin/v1/accounts"; + + [Fact] + public async Task Accounts_FollowResources_NotRequests_AndDisappearOnDeletionAndReset() + { + using var fixture = new MicroStackFixture(); + (await Accounts(fixture)).ShouldBe([Default]); + + using var queue = await Aws(fixture, Second, "sqs", "AmazonSQS.CreateQueue", """{"QueueName":"known-queue"}"""); + var queueUrl = queue.RootElement.GetProperty("QueueUrl").GetString()!; + using var table = await Aws(fixture, First, "dynamodb", "DynamoDB_20120810.CreateTable", """ + {"TableName":"known-table","KeySchema":[{"AttributeName":"id","KeyType":"HASH"}], + "AttributeDefinitions":[{"AttributeName":"id","AttributeType":"S"}],"BillingMode":"PAY_PER_REQUEST"} + """); + using var otherQueue = await Aws(fixture, First, "sqs", "AmazonSQS.CreateQueue", """{"QueueName":"known-queue"}"""); + var otherQueueUrl = otherQueue.RootElement.GetProperty("QueueUrl").GetString()!; + (await Accounts(fixture)).ShouldBe([Default, First, Second]); + + using var list = await Aws(fixture, Empty, "sqs", "AmazonSQS.ListQueues", "{}"); + using var ec2Read = new HttpRequestMessage(HttpMethod.Post, "/"); + ec2Read.Headers.TryAddWithoutValidation("Authorization", + $"AWS4-HMAC-SHA256 Credential={Empty}/20260913/us-east-1/ec2/aws4_request, SignedHeaders=host, Signature=test"); + ec2Read.Content = new StringContent("Action=DescribeVpcs", Encoding.UTF8, "application/x-www-form-urlencoded"); + using var ec2Response = await fixture.HttpClient.SendAsync(ec2Read); + ec2Response.EnsureSuccessStatusCode(); + using var browse = await fixture.HttpClient.GetAsync( + $"/_microstack/admin/v1/services/sqs/resources?accountId={Empty}"); + browse.EnsureSuccessStatusCode(); + using var scopedAccounts = await fixture.HttpClient.GetAsync(AccountsUrl + "?accountId=" + Empty); + scopedAccounts.EnsureSuccessStatusCode(); + (await Accounts(fixture)).ShouldBe([Default, First, Second]); + + using var deletedQueue = await Aws(fixture, Second, "sqs", "AmazonSQS.DeleteQueue", + $$"""{"QueueUrl":"{{queueUrl}}"}"""); + (await Accounts(fixture)).ShouldBe([Default, First]); + using var deletedTable = await Aws(fixture, First, "dynamodb", "DynamoDB_20120810.DeleteTable", + """{"TableName":"known-table"}"""); + (await Accounts(fixture)).ShouldBe([Default, First]); + using var deletedOtherQueue = await Aws(fixture, First, "sqs", "AmazonSQS.DeleteQueue", + $$"""{"QueueUrl":"{{otherQueueUrl}}"}"""); + (await Accounts(fixture)).ShouldBe([Default]); + + using var recreated = await Aws(fixture, Second, "sqs", "AmazonSQS.CreateQueue", """{"QueueName":"known-again"}"""); + using var reset = await fixture.HttpClient.PostAsync("/_microstack/reset", null); + reset.EnsureSuccessStatusCode(); + (await Accounts(fixture)).ShouldBe([Default]); + } + + [Fact] + public async Task ConfiguredDefault_IsAlwaysIncluded_AndDiscoveryDoesNotChangeScope() + { + using var fixture = new MicroStackFixture(); + var options = fixture.Factory.Services.GetRequiredService(); + options.DefaultAccountId = "999999999999"; + using (AccountContext.BeginScope(First)) + { + (await Accounts(fixture)).ShouldBe(["999999999999"]); + AccountContext.GetAccountId().ShouldBe(First); + } + } + + [Fact] + public async Task InstanceGlobalResources_DoNotInventOwners() + { + using var fixture = new MicroStackFixture(); + using var stream = await Aws(fixture, First, "firehose", "Firehose_20150804.CreateDeliveryStream", + """{"DeliveryStreamName":"global-stream"}"""); + using var workgroup = await Aws(fixture, First, "athena", "AmazonAthena.CreateWorkGroup", + """{"Name":"global-workgroup"}"""); + using var bus = await Aws(fixture, Second, "events", "AWSEvents.CreateEventBus", + """{"Name":"global-bus"}"""); + (await Accounts(fixture)).ShouldBe([Default]); + + using var query = await Aws(fixture, Second, "athena", "AmazonAthena.CreateNamedQuery", + """{"Name":"retained-query","Database":"db","QueryString":"select 1"}"""); + (await Accounts(fixture)).ShouldBe([Default, Second]); + } + + [Fact] + public async Task EmptyLayerContainers_AndSecondaryTags_DoNotKeepDeletedOwners() + { + using var fixture = new MicroStackFixture(); + using var layer = await Aws(fixture, First, "lambda", null, "{}", + "/2018-10-31/layers/known-layer/versions"); + (await Accounts(fixture)).ShouldBe([Default, First]); + using var deleted = await Aws(fixture, First, "lambda", null, null, + "/2018-10-31/layers/known-layer/versions/1", HttpMethod.Delete); + (await Accounts(fixture)).ShouldBe([Default]); + + using var parameter = await Aws(fixture, Second, "ssm", "AmazonSSM.PutParameter", + """{"Name":"/known/parameter","Value":"value","Type":"String","Tags":[{"Key":"purpose","Value":"test"}]}"""); + (await Accounts(fixture)).ShouldBe([Default, Second]); + using var removed = await Aws(fixture, Second, "ssm", "AmazonSSM.DeleteParameter", + """{"Name":"/known/parameter"}"""); + (await Accounts(fixture)).ShouldBe([Default]); + } + + [Fact] + public async Task Ec2_SyntheticDefaultsDoNotKeepAccountAfterLastCustomResourceIsDeleted() + { + using var fixture = new MicroStackFixture(); + using var createdRequest = Ec2("Action=CreateKeyPair&KeyName=known-key"); + using var created = await fixture.HttpClient.SendAsync(createdRequest); + created.EnsureSuccessStatusCode(); + (await Accounts(fixture)).ShouldBe([Default, First]); + + using var deletedRequest = Ec2("Action=DeleteKeyPair&KeyName=known-key"); + using var deleted = await fixture.HttpClient.SendAsync(deletedRequest); + deleted.EnsureSuccessStatusCode(); + (await Accounts(fixture)).ShouldBe([Default]); + + static HttpRequestMessage Ec2(string body) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/"); + request.Headers.TryAddWithoutValidation("Authorization", + $"AWS4-HMAC-SHA256 Credential={First}/20260913/us-east-1/ec2/aws4_request, SignedHeaders=host, Signature=test"); + request.Content = new StringContent(body, Encoding.UTF8, "application/x-www-form-urlencoded"); + return request; + } + } + + [Theory] + [InlineData("/restapis", """{"name":"rest-api"}""")] + [InlineData("/v2/apis", """{"name":"http-api","protocolType":"HTTP"}""")] + [InlineData("/apikeys", """{"name":"standalone-key","enabled":true}""")] + public async Task CompositeApiGateway_DiscoversBothVersionsAndStandaloneResources(string path, string body) + { + using var fixture = new MicroStackFixture(); + using var created = await Aws(fixture, First, "apigateway", null, body, path); + (await Accounts(fixture)).ShouldBe([Default, First]); + } + + [Fact] + public async Task PersistedDictionaryRestore_DiscoversAccountsInFreshHost_WithoutLeakingOldOwners() + { + var directory = Path.Combine(Directory.GetCurrentDirectory(), "TestResults", "known-accounts-" + Guid.NewGuid()); + var options = new MicroStackOptions { PersistState = true, StateDir = directory }; + try + { + using (var original = new MicroStackFixture()) + { + using var queue = await Aws(original, First, "sqs", "AmazonSQS.CreateQueue", """{"QueueName":"persisted"}"""); + using var second = await Aws(original, Second, "sqs", "AmazonSQS.CreateQueue", """{"QueueName":"persisted"}"""); + new StatePersistence(NullLogger.Instance, Registry(original), options).SaveAll(); + } + + using var restored = new MicroStackFixture(); + (await Accounts(restored)).ShouldBe([Default]); + var persistence = new StatePersistence(NullLogger.Instance, Registry(restored), options); + persistence.RestoreAll(); + (await Accounts(restored)).ShouldBe([Default, First, Second]); + using var queues = await Aws(restored, First, "sqs", "AmazonSQS.ListQueues", "{}"); + queues.RootElement.GetProperty("QueueUrls").GetArrayLength().ShouldBe(1); + Registry(restored).ResetAll(); + persistence.DeleteAll(); + (await Accounts(restored)).ShouldBe([Default]); + persistence.RestoreAll(); + (await Accounts(restored)).ShouldBe([Default]); + } + finally + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task InvalidRestoredIds_AreExcluded_WithoutReadingPayloadsOrPersistence() + { + using var fixture = new MicroStackFixture(); + var source = new RetainedAccountSource(); + source.State.FromRaw(new[] { First, Second, "short", "123456789012", "11111111111x", "11111111111\n", null! } + .Select(account => new KeyValuePair<(string, string), string>((account, "resource"), "not inspected"))); + Registry(fixture).Register(source); + (await Accounts(fixture)).ShouldBe([Default, First, Second]); + source.State.Clear(); + (await Accounts(fixture)).ShouldBe([Default]); + } + + [Fact] + public void DictionaryEnumeration_FollowsAllMutationPaths_WithoutAddingReadAccounts() + { + var state = new AccountScopedDictionary>(); + using (AccountContext.BeginScope(Empty)) + { + state.ContainsKey("missing").ShouldBeFalse(); + state.TryGetValue("missing", out _).ShouldBeFalse(); + state.Values.ShouldBeEmpty(); + state.GetAccountIds().ShouldBeEmpty(); + } + using (AccountContext.BeginScope(First)) + { + state.TryAdd("a", [1]).ShouldBeTrue(); + state.GetOrAdd("b", _ => []).ShouldBeEmpty(); + state.AddOrUpdate("c", [3], (_, value) => value); + state["d"] = [4]; + } + state.GetAccountIds().ShouldBe([First]); + state.GetAccountIds(value => value.Count > 0).ShouldBe([First]); + using (AccountContext.BeginScope(First)) + { + state.TryRemove("a", out _).ShouldBeTrue(); + state.TryRemove("c", out _).ShouldBeTrue(); + state.TryRemove("d", out _).ShouldBeTrue(); + } + state.GetAccountIds(value => value.Count > 0).ShouldBeEmpty(); + state.FromRaw([new KeyValuePair<(string, string), List>((Second, "restored"), [2])]); + state.GetAccountIds().ShouldBe([Second]); + state.FromRaw([]); + state.GetAccountIds().ShouldBeEmpty(); + } + + [Theory] + [InlineData("")] + [InlineData("123")] + [InlineData("123456789012")] + [InlineData("12345678901x")] + public void InvalidDefaultConfiguration_IsRejected(string value) => + Should.Throw(() => new MicroStackOptions { DefaultAccountId = value }); + + [Fact] + public void DisabledSources_DoNotContributeRetainedAccounts() + { + var registry = new ServiceRegistry(new MicroStackOptions { Services = "sqs" }); + var source = new RetainedAccountSource(); + using (AccountContext.BeginScope(First)) + source.State["resource"] = "retained"; + registry.Register(source); + registry.GetKnownAccountIds(Default).ShouldBe([Default]); + } + + [Fact] + public async Task ProgramStartup_RestoresKnownAccounts_BeforeServingRequests() + { + var directory = Path.Combine(Directory.GetCurrentDirectory(), "TestResults", "accounts-startup-" + Guid.NewGuid()); + try + { + using (var fixture = new MicroStackFixture()) + { + using var queue = await Aws(fixture, First, "sqs", "AmazonSQS.CreateQueue", """{"QueueName":"startup"}"""); + var options = new MicroStackOptions { PersistState = true, StateDir = directory }; + new StatePersistence(NullLogger.Instance, Registry(fixture), options).SaveAll(); + } + + var start = new ProcessStartInfo("dotnet") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = AppContext.BaseDirectory, + }; + start.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, "MicroStack.dll")); + start.Environment["DOTNET_ENVIRONMENT"] = "Testing"; + start.Environment["ASPNETCORE_ENVIRONMENT"] = "Testing"; + start.Environment["ASPNETCORE_URLS"] = "http://127.0.0.1:0"; + start.Environment["MICROSTACK_ACCOUNT_ID"] = Default; + start.Environment["PERSIST_STATE"] = "1"; + start.Environment["STATE_DIR"] = directory; + start.Environment["SERVICES"] = "sqs"; + using var process = Process.Start(start)!; + var errors = process.StandardError.ReadToEndAsync(); + try + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + string? address = null; + while (await process.StandardOutput.ReadLineAsync(timeout.Token) is { } line) + { + const string marker = "Now listening on: "; + var index = line.IndexOf(marker, StringComparison.Ordinal); + if (index < 0) + continue; + address = line[(index + marker.Length)..].Trim(); + break; + } + address.ShouldNotBeNull(); + var output = process.StandardOutput.ReadToEndAsync(); + using var client = new HttpClient(new SocketsHttpHandler { UseProxy = false }) + { + BaseAddress = new Uri(address), + }; + (await client.GetFromJsonAsync(AccountsUrl, AdminJsonContext.Default.StringArray)) + .ShouldBe([Default, First]); + using var reset = await client.PostAsync("/_microstack/reset", null); + reset.EnsureSuccessStatusCode(); + (await client.GetFromJsonAsync(AccountsUrl, AdminJsonContext.Default.StringArray)) + .ShouldBe([Default]); + File.Exists(Path.Combine(directory, "sqs.json")).ShouldBeFalse(); + process.Kill(); + await process.WaitForExitAsync(); + await output; + (await errors).ShouldBeEmpty(); + } + finally + { + if (!process.HasExited) + { + process.Kill(); + await process.WaitForExitAsync(); + } + } + } + finally + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + } + + private static ServiceRegistry Registry(MicroStackFixture fixture) => + fixture.Factory.Services.GetRequiredService(); + + private static async Task Accounts(MicroStackFixture fixture) + { + using var response = await fixture.HttpClient.GetAsync(AccountsUrl); + response.EnsureSuccessStatusCode(); + response.Content.Headers.ContentType!.MediaType.ShouldBe("application/json"); + response.Headers.CacheControl!.NoStore.ShouldBeTrue(); + return (await response.Content.ReadFromJsonAsync(AdminJsonContext.Default.StringArray))!; + } + + private static async Task Aws( + MicroStackFixture fixture, string account, string service, string? target, string? body, + string path = "/", HttpMethod? method = null) + { + using var request = new HttpRequestMessage(method ?? HttpMethod.Post, path); + request.Headers.TryAddWithoutValidation("Authorization", + $"AWS4-HMAC-SHA256 Credential={account}/20260913/us-east-1/{service}/aws4_request, SignedHeaders=host, Signature=test"); + if (target is not null) + request.Headers.TryAddWithoutValidation("X-Amz-Target", target); + if (body is not null) + request.Content = new StringContent(body, Encoding.UTF8, "application/x-amz-json-1.1"); + using var response = await fixture.HttpClient.SendAsync(request); + var json = await response.Content.ReadAsStringAsync(); + response.IsSuccessStatusCode.ShouldBeTrue(json); + return JsonDocument.Parse(string.IsNullOrWhiteSpace(json) ? "{}" : json); + } + + private sealed class RetainedAccountSource : IServiceHandler, IKnownAccountSource + { + internal AccountScopedDictionary State { get; } = new(); + public string ServiceName => "retained-account-test"; + public IEnumerable GetKnownAccountIds() => State.GetAccountIds(); + public Task HandleAsync(ServiceRequest request) => throw new NotSupportedException(); + public void Reset() => State.Clear(); + public JsonElement? GetState() => throw new InvalidOperationException("Discovery must not serialize state."); + public void RestoreState(JsonElement state) => throw new NotSupportedException(); + } +} diff --git a/tests/MicroStack.Tests/AdminAnalyticsTests.cs b/tests/MicroStack.Tests/AdminAnalyticsTests.cs new file mode 100644 index 0000000..36cdc30 --- /dev/null +++ b/tests/MicroStack.Tests/AdminAnalyticsTests.cs @@ -0,0 +1,96 @@ +using System.Text; +using System.Text.Json; +using MicroStack.Internal; +using MicroStack.Internal.Admin; +using MicroStack.Services.Athena; +using MicroStack.Services.Emr; +using MicroStack.Services.Firehose; +using MicroStack.Services.Glue; +using MicroStack.Services.Kinesis; + +namespace MicroStack.Tests; + +public sealed class AdminAnalyticsTests +{ + [Fact] + public void AnalyticsHandlersDeclareEveryRootKind() + { + Kinds(new AthenaServiceHandler()).ShouldBe( + ["data-catalog", "named-query", "prepared-statement", "query-execution", "workgroup"], + ignoreOrder: true); + Kinds(new EmrServiceHandler()).ShouldBe(["cluster"]); + Kinds(new FirehoseServiceHandler()).ShouldBe(["delivery-stream"]); + Kinds(new GlueServiceHandler()).ShouldBe(["crawler", "database", "job", "registry"], + ignoreOrder: true); + Kinds(new KinesisServiceHandler()).ShouldBe(["stream"]); + } + + [Fact] + public async Task AccountScopedAnalyticsResourcesDoNotLeak() + { + var athena = new AthenaServiceHandler(); + using (AccountContext.BeginScope("111111111111")) + { + await Send(athena, "AmazonAthena.CreateNamedQuery", + """{"Name":"mine","Database":"default","QueryString":"SELECT 1"}"""); + athena.GetAdminResources("athena").ShouldContain(x => x.Resource.Key.Kind == "named-query"); + } + + using (AccountContext.BeginScope("222222222222")) + { + athena.GetAdminResources("athena").ShouldNotContain(x => x.Resource.Key.Kind == "named-query"); + athena.GetAdminResources("athena") + .Where(x => x.Resource.Key.Kind is "workgroup" or "data-catalog") + .ShouldAllBe(x => x.Resource.Scope == "global"); + } + } + + [Fact] + public async Task KinesisInspectionDoesNotConsumeOrAdvanceRecords() + { + var kinesis = new KinesisServiceHandler(); + await Send(kinesis, "Kinesis_20131202.CreateStream", + """{"StreamName":"events","ShardCount":1}"""); + await Send(kinesis, "Kinesis_20131202.PutRecord", + $$"""{"StreamName":"events","PartitionKey":"p","Data":"{{Convert.ToBase64String("payload"u8.ToArray())}}"}"""); + var iteratorResponse = await Send(kinesis, "Kinesis_20131202.GetShardIterator", + """{"StreamName":"events","ShardId":"shardId-000000000000","ShardIteratorType":"TRIM_HORIZON"}"""); + using var iteratorJson = JsonDocument.Parse(iteratorResponse.Body); + var iterator = iteratorJson.RootElement.GetProperty("ShardIterator").GetString(); + + var stream = kinesis.GetAdminResources("kinesis").Single(); + var shard = stream.ReadChildren!().Single(x => x.Resource.Key.Kind == "shard"); + shard.ReadChildren!().Single().ReadContent!().Text.ShouldBe("payload"); + + var records = await Send(kinesis, "Kinesis_20131202.GetRecords", + $$"""{"ShardIterator":"{{iterator}}"}"""); + using var recordsJson = JsonDocument.Parse(records.Body); + recordsJson.RootElement.GetProperty("Records").GetArrayLength().ShouldBe(1); + } + + [Fact] + public void PreviewAndCredentialProjectionAreBoundedAndMasked() + { + AdminData.Text(new string('x', AdminData.PreviewMaxBytes + 1)).Kind.ShouldBe("oversized"); + var projected = AnalyticsAdminData.Json(new Dictionary + { + ["Username"] = "visible", + ["Password"] = "do-not-show", + ["AccessKey"] = "do-not-show", + }); + projected.Text.ShouldNotBeNull(); + using var json = JsonDocument.Parse(projected.Text); + json.RootElement.GetProperty("Username").GetString().ShouldBe("visible"); + json.RootElement.GetProperty("Password").GetString().ShouldBe(AdminData.MaskedValue); + json.RootElement.GetProperty("AccessKey").GetString().ShouldBe(AdminData.MaskedValue); + Encoding.UTF8.GetByteCount(projected.Text).ShouldBeLessThanOrEqualTo(AdminData.PreviewMaxBytes); + } + + private static string[] Kinds(IAdminResourceSource source) => + source.GetAdminResourceKinds("ignored").Select(x => x.Id).ToArray(); + + private static Task Send(IServiceHandler handler, string target, string json) => + handler.HandleAsync(new ServiceRequest("POST", "/", + new Dictionary { ["x-amz-target"] = target }, + Encoding.UTF8.GetBytes(json), new Dictionary())); +} diff --git a/tests/MicroStack.Tests/AdminApiTests.cs b/tests/MicroStack.Tests/AdminApiTests.cs new file mode 100644 index 0000000..afb3d7c --- /dev/null +++ b/tests/MicroStack.Tests/AdminApiTests.cs @@ -0,0 +1,121 @@ +using System.Net.Http.Json; +using MicroStack.Admin.Contracts; +using MicroStack.Internal; +using MicroStack.Internal.Admin; +using Microsoft.Extensions.DependencyInjection; + +namespace MicroStack.Tests; + +public sealed class AdminApiTests(MicroStackFixture fixture) : IClassFixture +{ + [Fact] + public async Task ContextAndCatalogUseVersionedCamelCaseContract() + { + using var contextResponse = await fixture.HttpClient.GetAsync("/_microstack/admin/v1/context"); + contextResponse.EnsureSuccessStatusCode(); + var json = await contextResponse.Content.ReadAsStringAsync(); + json.ShouldContain("\"defaultAccount\""); + json.Contains("\"DefaultAccount\":", StringComparison.Ordinal).ShouldBeFalse(); + + var context = await contextResponse.Content.ReadFromJsonAsync(AdminJsonContext.Default.AdminContext); + context.ShouldNotBeNull(); + context.DefaultAccount.Length.ShouldBe(12); + context.PreviewMaxBytes.ShouldBe(1_048_576); + + var services = await fixture.HttpClient.GetFromJsonAsync( + "/_microstack/admin/v1/services", AdminJsonContext.Default.AdminServiceArray); + services.ShouldNotBeNull(); + services.Length.ShouldBe(40); + services.Select(service => service.Id).Distinct(StringComparer.Ordinal).Count().ShouldBe(40); + services.Single(service => service.Id == "apigateway").CanonicalHandler.ShouldBe("apigateway"); + services.Single(service => service.Id == "apigatewayv2").CanonicalHandler.ShouldBe("apigateway"); + } + + [Fact] + public void CatalogIdentityAndScopeMatchRuntimeHandlers() + { + var expected = new Dictionary + { + ["sqs"]=("sqs","Messaging & workflows","account"), ["sns"]=("sns","Messaging & workflows","account"), + ["events"]=("events","Messaging & workflows","account"), ["ses"]=("ses","Messaging & workflows","account"), + ["stepfunctions"]=("states","Messaging & workflows","account"), + ["s3"]=("s3","Storage & databases","account"), ["s3files"]=("s3files","Storage & databases","account"), + ["efs"]=("elasticfilesystem","Storage & databases","account"), ["dynamodb"]=("dynamodb","Storage & databases","account"), + ["rds"]=("rds","Storage & databases","account"), ["rdsdata"]=("rds-data","Storage & databases","account"), + ["elasticache"]=("elasticache","Storage & databases","account"), + ["lambda"]=("lambda","Compute & containers","account"), ["ec2"]=("ec2","Compute & containers","account"), + ["ecs"]=("ecs","Compute & containers","account"), ["ecr"]=("ecr","Compute & containers","account"), + ["apigateway"]=("apigateway","Networking & delivery","account"), + ["apigatewayv2"]=("apigateway","Networking & delivery","account"), + ["alb"]=("elasticloadbalancing","Networking & delivery","account"), ["appsync"]=("appsync","Networking & delivery","account"), + ["cloudfront"]=("cloudfront","Networking & delivery","account"), ["route53"]=("route53","Networking & delivery","account"), + ["servicediscovery"]=("servicediscovery","Networking & delivery","account"), + ["acm"]=("acm","Security & identity","account"), ["cognitoidp"]=("cognito-idp","Security & identity","account"), + ["cognitoidentity"]=("cognito-identity","Security & identity","account"), ["iam"]=("iam","Security & identity","account"), + ["kms"]=("kms","Security & identity","account"), ["secretsmanager"]=("secretsmanager","Security & identity","account"), + ["sts"]=("sts","Security & identity","account"), ["waf"]=("wafv2","Security & identity","account"), + ["athena"]=("athena","Analytics & streaming","account"), ["emr"]=("elasticmapreduce","Analytics & streaming","account"), + ["firehose"]=("firehose","Analytics & streaming","global"), ["glue"]=("glue","Analytics & streaming","account"), + ["kinesis"]=("kinesis","Analytics & streaming","account"), + ["cloudformation"]=("cloudformation","Management & observability","account"), + ["cloudwatch"]=("monitoring","Management & observability","account"), ["logs"]=("logs","Management & observability","account"), + ["ssm"]=("ssm","Management & observability","account") + }; + + AdminCatalog.Entries.Count.ShouldBe(40); + AdminCatalog.Entries.Select(entry => entry.Id).ShouldBeUnique(); + AdminCatalog.Entries.Select(entry => entry.Icon).ShouldBeUnique(); + foreach (var entry in AdminCatalog.Entries) + { + expected.ShouldContainKey(entry.Id); + entry.Icon.ShouldBe(entry.Id); + (entry.CanonicalHandler, entry.Category, entry.Scope).ShouldBe(expected[entry.Id]); + } + + var registry = fixture.Factory.Services.GetRequiredService(); + var apiGateway = (IAdminResourceSource)registry.Resolve("apigateway")!; + apiGateway.GetAdminResourceKinds("__wrong_variant__").ShouldBeEmpty(); + apiGateway.GetAdminResourceKinds("apigateway").Select(kind => kind.Id).ShouldContain("rest-apis"); + apiGateway.GetAdminResourceKinds("apigateway").Select(kind => kind.Id).ShouldNotContain("apis"); + apiGateway.GetAdminResourceKinds("apigatewayv2").Select(kind => kind.Id).ShouldContain("apis"); + apiGateway.GetAdminResourceKinds("apigatewayv2").Select(kind => kind.Id).ShouldNotContain("rest-apis"); + } + + [Theory] + [InlineData("")] + [InlineData("123")] + [InlineData("12345678901x")] + [InlineData("1234567890123")] + public async Task InvalidAccountsReturnStructuredBadRequest(string account) + { + using var response = await fixture.HttpClient.GetAsync( + $"/_microstack/admin/v1/services?accountId={account}"); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var error = await response.Content.ReadFromJsonAsync(AdminJsonContext.Default.AdminError); + error.ShouldNotBeNull(); + error.Code.ShouldBe("invalid_account"); + } + + [Fact] + public async Task UnknownServiceAndMalformedPagingAreStructured() + { + using var missing = await fixture.HttpClient.GetAsync( + "/_microstack/admin/v1/services/not-a-service/resources"); + missing.StatusCode.ShouldBe(HttpStatusCode.NotFound); + (await missing.Content.ReadFromJsonAsync(AdminJsonContext.Default.AdminError))!.Code + .ShouldBe("service_not_found"); + + using var invalidLimit = await fixture.HttpClient.GetAsync( + "/_microstack/admin/v1/services/sqs/resources?pageSize=201"); + invalidLimit.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + (await invalidLimit.Content.ReadFromJsonAsync(AdminJsonContext.Default.AdminError))!.Code + .ShouldBe("invalid_limit"); + + using var invalidCursor = await fixture.HttpClient.GetAsync( + "/_microstack/admin/v1/services/sqs/resources?cursor=not-a-cursor"); + invalidCursor.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + (await invalidCursor.Content.ReadFromJsonAsync(AdminJsonContext.Default.AdminError))!.Code + .ShouldBe("invalid_cursor"); + } +} diff --git a/tests/MicroStack.Tests/AdminComputeTests.cs b/tests/MicroStack.Tests/AdminComputeTests.cs new file mode 100644 index 0000000..de212f1 --- /dev/null +++ b/tests/MicroStack.Tests/AdminComputeTests.cs @@ -0,0 +1,101 @@ +using System.Text; +using MicroStack.Internal; +using MicroStack.Internal.Admin; +using MicroStack.Services.Ec2; +using MicroStack.Services.Ecr; +using MicroStack.Services.Ecs; +using MicroStack.Services.Lambda; +using Shouldly; + +namespace MicroStack.Tests; + +public sealed class AdminComputeTests +{ + [Fact] + public void Compute_sources_publish_all_supported_kinds() + { + Kinds(new LambdaServiceHandler(), "lambda").Order().ShouldBe( + new[] { "aliases", "event-source-mappings", "functions", "layer-versions", "layers", "versions" }); + Kinds(new EcsServiceHandler(), "ecs").Order().ShouldBe( + new[] { "clusters", "services", "task-definitions", "tasks" }); + Kinds(new EcrServiceHandler(), "ecr").Order().ShouldBe( + new[] { "images", "lifecycle-policies", "repositories", "repository-policies" }); + Kinds(new Ec2ServiceHandler(), "ec2").Order().ShouldBe(new[] + { + "customer-gateways", "dhcp-options", "egress-internet-gateways", "elastic-ips", + "flow-logs", "instances", "internet-gateways", "key-pairs", "launch-template-versions", + "launch-templates", "nat-gateways", "network-acls", "network-interfaces", "prefix-lists", + "route-tables", "security-groups", "snapshots", "subnets", "volumes", "vpc-endpoints", + "vpc-peerings", "vpcs", "vpn-gateways", + }); + Kinds(new LambdaServiceHandler(), "not-lambda").ShouldBeEmpty(); + } + + [Fact] + public async Task Lambda_admin_projection_redacts_environment_without_mutating_service_state() + { + var handler = new LambdaServiceHandler(); + var body = """ + {"FunctionName":"redacted","Runtime":"dotnet8","Role":"arn:aws:iam::000000000000:role/test", + "Handler":"Example::Handler","Code":{"ZipFile":""}, + "Environment":{"Variables":{"AWS_SECRET_ACCESS_KEY":"keep-me","NORMAL":"visible"}}} + """; + (await handler.HandleAsync(Rest("POST", "/2015-03-31/functions", body))).StatusCode.ShouldBe(201); + + var source = (IAdminResourceSource)handler; + var function = source.GetAdminResources("lambda").Single(); + var projected = function.ReadContent!().Text!; + using (var document = JsonDocument.Parse(projected)) + { + document.RootElement.GetProperty("Environment").GetProperty("Variables") + .GetProperty("AWS_SECRET_ACCESS_KEY").GetString().ShouldBe(AdminData.MaskedValue); + document.RootElement.GetProperty("Environment").GetProperty("Variables") + .GetProperty("NORMAL").GetString().ShouldBe("visible"); + } + projected.ShouldNotContain("keep-me"); + + var response = await handler.HandleAsync(Rest("GET", "/2015-03-31/functions/redacted/configuration")); + Encoding.UTF8.GetString(response.Body).ShouldContain("keep-me"); + } + + [Fact] + public async Task Ecr_admin_resources_are_account_scoped() + { + var handler = new EcrServiceHandler(); + using (AccountContext.BeginScope("111111111111")) + { + var request = JsonTarget("AmazonEC2ContainerRegistry_V20150921.CreateRepository", + """{"repositoryName":"one"}"""); + (await handler.HandleAsync(request)).StatusCode.ShouldBe(200); + ((IAdminResourceSource)handler).GetAdminResources("ecr").Single().Resource.Key.Id.ShouldBe("one"); + } + using (AccountContext.BeginScope("222222222222")) + ((IAdminResourceSource)handler).GetAdminResources("ecr").ShouldBeEmpty(); + } + + [Fact] + public async Task Ec2_key_pair_projection_keeps_metadata_but_never_private_material() + { + var handler = new Ec2ServiceHandler(); + var request = Rest("POST", "/", "Action=CreateKeyPair&KeyName=admin-test"); + (await handler.HandleAsync(request)).StatusCode.ShouldBe(200); + + var keyPair = ((IAdminResourceSource)handler).GetAdminResources("ec2") + .Single(node => node.Resource.Key.Kind == "key-pairs"); + var content = keyPair.ReadContent!().Text!; + content.ShouldContain("KeyFingerprint"); + content.ShouldNotContain("KeyMaterial"); + content.ShouldNotContain("PRIVATE KEY"); + } + + private static string[] Kinds(IAdminResourceSource source, string service) => + source.GetAdminResourceKinds(service).Select(kind => kind.Id).ToArray(); + + private static ServiceRequest Rest(string method, string path, string body = "") => + new(method, path, new Dictionary(), + Encoding.UTF8.GetBytes(body), new Dictionary()); + + private static ServiceRequest JsonTarget(string target, string body) => + new("POST", "/", new Dictionary { ["x-amz-target"] = target }, + Encoding.UTF8.GetBytes(body), new Dictionary()); +} diff --git a/tests/MicroStack.Tests/AdminCorsTests.cs b/tests/MicroStack.Tests/AdminCorsTests.cs new file mode 100644 index 0000000..92fc979 --- /dev/null +++ b/tests/MicroStack.Tests/AdminCorsTests.cs @@ -0,0 +1,83 @@ +namespace MicroStack.Tests; + +public sealed class AdminCorsTests(MicroStackFixture fixture) : IClassFixture +{ + [Theory] + [InlineData("GET", "/_microstack/health")] + [InlineData("GET", "/health")] + [InlineData("GET", "/_localstack/health")] + [InlineData("GET", "/_microstack/resources")] + [InlineData("GET", "/_microstack/requests")] + [InlineData("DELETE", "/_microstack/requests")] + [InlineData("POST", "/_microstack/reset")] + [InlineData("POST", "/_microstack/config")] + [InlineData("GET", "/_microstack/admin/v1/context")] + [InlineData("GET", "/_microstack/admin/v1/services")] + public async Task AdminEndpointsRemainAvailableWithoutCrossOriginHeaders(string method, string path) + { + using var request = new HttpRequestMessage(new HttpMethod(method), path); + + using var response = await fixture.HttpClient.SendAsync(request); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + response.Headers.Contains("Access-Control-Allow-Origin").ShouldBeFalse(); + } + + [Theory] + [InlineData("GET", "/_microstack/health")] + [InlineData("GET", "/_microstack/resources")] + [InlineData("GET", "/_microstack/requests")] + [InlineData("DELETE", "/_microstack/requests")] + [InlineData("POST", "/_microstack/reset")] + [InlineData("POST", "/_microstack/config")] + [InlineData("GET", "/_microstack/admin/v1/context")] + [InlineData("GET", "/_microstack/admin/v1/services")] + public async Task AdminPreflightDoesNotGrantCrossOriginAccess(string method, string path) + { + using var request = new HttpRequestMessage(HttpMethod.Options, path); + request.Headers.Add("Origin", "http://localhost:4567"); + request.Headers.Add("Access-Control-Request-Method", method); + request.Headers.Add("Access-Control-Request-Headers", "content-type"); + + using var response = await fixture.HttpClient.SendAsync(request); + + response.StatusCode.ShouldBe(HttpStatusCode.NoContent); + response.Headers.Contains("Access-Control-Allow-Origin").ShouldBeFalse(); + } + + [Theory] + [InlineData("http://untrusted.example")] + [InlineData("http://localhost:9999")] + [InlineData("null")] + public async Task AdminRequestsDoNotAllowUnconfiguredOrigins(string origin) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/_microstack/health"); + request.Headers.Add("Origin", origin); + using var response = await fixture.HttpClient.SendAsync(request); + response.Headers.Contains("Access-Control-Allow-Origin").ShouldBeFalse(); + + using var preflight = new HttpRequestMessage(HttpMethod.Options, "/_microstack/requests"); + preflight.Headers.Add("Origin", origin); + preflight.Headers.Add("Access-Control-Request-Method", "DELETE"); + using var preflightResponse = await fixture.HttpClient.SendAsync(preflight); + preflightResponse.StatusCode.ShouldBe(HttpStatusCode.NoContent); + preflightResponse.Headers.Contains("Access-Control-Allow-Origin").ShouldBeFalse(); + } + + [Fact] + public async Task AwsRequestsKeepExistingCorsBehavior() + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Add("Origin", "http://sdk-client.example"); + using var response = await fixture.HttpClient.SendAsync(request); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + response.Headers.GetValues("Access-Control-Allow-Origin").ShouldBe(["*"]); + + using var preflight = new HttpRequestMessage(HttpMethod.Options, "/"); + preflight.Headers.Add("Origin", "http://sdk-client.example"); + preflight.Headers.Add("Access-Control-Request-Method", "PUT"); + using var preflightResponse = await fixture.HttpClient.SendAsync(preflight); + preflightResponse.StatusCode.ShouldBe(HttpStatusCode.NoContent); + preflightResponse.Headers.GetValues("Access-Control-Allow-Origin").ShouldBe(["*"]); + } +} diff --git a/tests/MicroStack.Tests/AdminEventsTests.cs b/tests/MicroStack.Tests/AdminEventsTests.cs new file mode 100644 index 0000000..020b669 --- /dev/null +++ b/tests/MicroStack.Tests/AdminEventsTests.cs @@ -0,0 +1,369 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using MicroStack.Admin.Contracts; +using MicroStack.Internal; +using MicroStack.Internal.Admin; +using Microsoft.Extensions.DependencyInjection; + +namespace MicroStack.Tests; + +public sealed class AdminEventsTests +{ + private const string First = "111111111111"; + private const string Second = "222222222222"; + private const string Events = "/_microstack/admin/v1/events"; + + [Fact] + public void Hub_BoundsSubscribersAndMergesIndependentDirtyScopes() + { + using var hub = new AdminChangeHub(); + using var first = hub.Subscribe(First)!; + using var second = hub.Subscribe(Second)!; + using var global = hub.Subscribe(null)!; + first.Take(true).Resync.ShouldBeTrue(); + for (var i = 0; i < 10_000; i++) + hub.DispatchCompleted("sqs", First); + hub.Publish(AdminDirty.Activity); + first.Wakeup.Reader.Count.ShouldBe(1); + var change = first.Take(); + change.Resources.ShouldBeTrue(); + change.Accounts.ShouldBeTrue(); + change.Activity.ShouldBeTrue(); + change.Instance.ShouldBeFalse(); + change.Sequence.ShouldBe(10_001); + second.Take().Resources.ShouldBeFalse(); + global.Take().Resources.ShouldBeTrue(); + first.Wakeup.Reader.Count.ShouldBe(0); + hub.Publish(AdminDirty.Resources | AdminDirty.Instance); + second.Take().Resources.ShouldBeTrue(); + global.Take().Instance.ShouldBeTrue(); + var rest = Enumerable.Range(0, 61).Select(_ => hub.Subscribe(null)!).ToArray(); + hub.Subscribe(null).ShouldBeNull(); + rest[0].Dispose(); + using var replacement = hub.Subscribe(null); + replacement.ShouldNotBeNull(); + foreach (var item in rest) + item.Dispose(); + } + + [Theory] + [InlineData("athena")] + [InlineData("events")] + [InlineData("firehose")] + [InlineData("unknown-service")] + public void SharedOrUnknownDispatch_BroadensResourceScope(string service) + { + using var hub = new AdminChangeHub(); + using var other = hub.Subscribe(Second)!; + hub.DispatchCompleted(service, First); + other.Take().Resources.ShouldBeTrue(); + } + + [Fact] + public async Task Stream_ResyncsFiltersCoalescesAndNeverLogsItsOwnReads() + { + using var fixture = new MicroStackFixture(); + using var first = await Open(fixture, First); + using var second = await Open(fixture, Second); + var initial = await first.Read(); + initial.ShouldBe(initial with { Version = 1, Resync = true, Resources = true, + Accounts = true, Instance = true, Activity = true }); + (await second.Read()).Epoch.ShouldBe(initial.Epoch); + fixture.Factory.Services.GetRequiredService().GetEntries().ShouldBeEmpty(); + + using var observer = fixture.Factory.Services.GetRequiredService().Subscribe(First)!; + using var resources = await fixture.HttpClient.GetAsync( + "/_microstack/admin/v1/services/sqs/resources?accountId=" + First); + resources.EnsureSuccessStatusCode(); + observer.Wakeup.Reader.Count.ShouldBe(0); + using var created = await Aws(fixture, First, "sqs", "AmazonSQS.CreateQueue", + """{"QueueName":"live-queue"}"""); + created.EnsureSuccessStatusCode(); + var changed = await first.Read(); + changed.Resync.ShouldBeFalse(); + changed.Resources.ShouldBeTrue(); + changed.Accounts.ShouldBeTrue(); + changed.Activity.ShouldBeTrue(); + changed.Instance.ShouldBeFalse(); + changed.Sequence.ShouldBeGreaterThan(initial.Sequence); + var other = await second.Read(); + other.Resources.ShouldBeFalse(); + other.Accounts.ShouldBeTrue(); + other.Activity.ShouldBeTrue(); + fixture.Factory.Services.GetRequiredService().GetEntries().Count.ShouldBe(1); + + using var cleared = await fixture.HttpClient.DeleteAsync("/_microstack/requests"); + cleared.EnsureSuccessStatusCode(); + var clear = await first.Read(); + clear.Activity.ShouldBeTrue(); + clear.Resources.ShouldBeFalse(); + clear.Accounts.ShouldBeFalse(); + fixture.Factory.Services.GetRequiredService().GetEntries().ShouldBeEmpty(); + + using var reconnected = await Open(fixture, First, initial.Epoch + ":0"); + var resync = await reconnected.Read(); + resync.Resync.ShouldBeTrue(); + resync.Epoch.ShouldBe(initial.Epoch); + resync.Sequence.ShouldBeGreaterThan(changed.Sequence); + } + + [Fact] + public async Task UnscopedStreamReceivesResourceOnlyHintsFromEveryAccount() + { + using var fixture = new MicroStackFixture(); + using var global = await Open(fixture, null); + using var first = await Open(fixture, First); + await global.Read(); + await first.Read(); + var hub = fixture.Factory.Services.GetRequiredService(); + using var observer = hub.Subscribe(First)!; + hub.Publish(AdminDirty.Resources, Second); + var otherAccount = await global.Read(); + otherAccount.Resources.ShouldBeTrue(); + otherAccount.Accounts.ShouldBeFalse(); + otherAccount.Activity.ShouldBeFalse(); + observer.Wakeup.Reader.Count.ShouldBe(0); + hub.Publish(AdminDirty.Resources, First); + (await first.Read()).Resources.ShouldBeTrue(); + (await global.Read()).Resources.ShouldBeTrue(); + } + + [Fact] + public async Task StreamCoalescesBurstDuringHalfSecondWindow() + { + using var fixture = new MicroStackFixture(); + using var stream = await Open(fixture, First); + var initial = await stream.Read(); + var hub = fixture.Factory.Services.GetRequiredService(); + var started = System.Diagnostics.Stopwatch.StartNew(); + for (var i = 0; i < 1_000; i++) + hub.Publish(AdminDirty.Resources, First); + hub.Publish(AdminDirty.Activity); + hub.Publish(AdminDirty.Instance); + var change = await stream.Read(); + started.Elapsed.ShouldBeGreaterThan(TimeSpan.FromMilliseconds(400)); + change.Resources.ShouldBeTrue(); + change.Activity.ShouldBeTrue(); + change.Instance.ShouldBeTrue(); + change.Accounts.ShouldBeFalse(); + change.Sequence.ShouldBe(initial.Sequence + 1_002); + } + + [Fact] + public async Task AdministrativeMutations_SignalConfigResetAndPartialReset() + { + using var fixture = new MicroStackFixture(); + using var stream = await Open(fixture, First); + await stream.Read(); + using var config = await fixture.HttpClient.PostAsync("/_microstack/config", + new StringContent("""{"stepfunctions":{"_sfn_mock_config":{}}}""", Encoding.UTF8, "application/json")); + config.EnsureSuccessStatusCode(); + var changed = await stream.Read(); + changed.Instance.ShouldBeTrue(); + changed.Resources.ShouldBeTrue(); + changed.Activity.ShouldBeFalse(); + using var reset = await fixture.HttpClient.PostAsync("/_microstack/reset", null); + reset.EnsureSuccessStatusCode(); + (await stream.Read()).ShouldBe(changed with + { + Sequence = changed.Sequence + 1, Accounts = true, Activity = true + }); + fixture.Factory.Services.GetRequiredService().Register(new FailingHandler("reset-test")); + using var failedReset = await fixture.HttpClient.PostAsync("/_microstack/reset", null); + failedReset.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + (await stream.Read()).Instance.ShouldBeTrue(); + } + + [Fact] + public async Task CompletedFailedDispatch_UsesCapturedAccountAndCanonicalSpecialRoute() + { + using var fixture = new MicroStackFixture(); + fixture.Factory.Services.GetRequiredService().Register(new FailingHandler("s3")); + using var first = await Open(fixture, First); + using var second = await Open(fixture, Second); + await first.Read(); + await second.Read(); + using var request = new HttpRequestMessage(HttpMethod.Get, "/v20180820/example"); + request.Headers.TryAddWithoutValidation("Authorization", Auth(First, "s3")); + using var response = await fixture.HttpClient.SendAsync(request); + response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + (await first.Read()).Resources.ShouldBeTrue(); + (await second.Read()).Resources.ShouldBeFalse(); + } + + [Fact] + public async Task Stream_ValidatesAccountAndCapacityAndReleasesAbortedSubscription() + { + using var fixture = new MicroStackFixture(); + foreach (var query in new[] { "?accountId=", "?accountId=123", "?accountId=111111111111&accountId=222222222222" }) + { + using var invalid = await fixture.HttpClient.GetAsync(Events + query); + invalid.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + var hub = fixture.Factory.Services.GetRequiredService(); + var subscribers = Enumerable.Range(0, 63).Select(_ => hub.Subscribe(null)!).ToArray(); + using (var stream = await Open(fixture, null)) + { + await stream.Read(); + using var full = await fixture.HttpClient.GetAsync(Events); + full.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable); + full.Headers.RetryAfter!.Delta.ShouldBe(TimeSpan.FromSeconds(5)); + } + AdminChangeHub.Subscription? replacement = null; + for (var attempt = 0; attempt < 100 && replacement is null; attempt++) + { + await Task.Delay(20); + replacement = hub.Subscribe(null); + } + replacement.ShouldNotBeNull(); + replacement.Dispose(); + foreach (var subscription in subscribers) + subscription.Dispose(); + } + + [Fact] + public async Task BackgroundEsmProcessingSignalsCapturedAccountWithoutRequestLogActivity() + { + using var fixture = new MicroStackFixture(); + var registry = fixture.Factory.Services.GetRequiredService(); + var sqs = registry.Resolve("sqs")!; + var lambda = registry.Resolve("lambda")!; + using var first = await Open(fixture, First); + using var second = await Open(fixture, Second); + await first.Read(); + await second.Read(); + try + { + using (AccountContext.BeginScope(First)) + { + var created = await Direct(sqs, "/", "AmazonSQS.CreateQueue", + """{"QueueName":"live-background"}"""); + created.StatusCode.ShouldBe(200); + using var json = JsonDocument.Parse(created.Body); + var url = json.RootElement.GetProperty("QueueUrl").GetString()!; + (await Direct(sqs, "/", "AmazonSQS.SendMessage", + $$"""{"QueueUrl":"{{url}}","MessageBody":"background"}""")).StatusCode.ShouldBe(200); + (await Direct(lambda, "/2015-03-31/event-source-mappings/", null, + $$"""{"FunctionName":"missing-function","EventSourceArn":"arn:aws:sqs:us-east-1:{{First}}:live-background"}""")) + .StatusCode.ShouldBe(202); + } + var change = await first.Read(); + change.Resources.ShouldBeTrue(); + change.Accounts.ShouldBeTrue(); + change.Activity.ShouldBeFalse(); + (await second.Read()).Resources.ShouldBeFalse(); + fixture.Factory.Services.GetRequiredService().GetEntries().ShouldBeEmpty(); + } + finally + { + lambda.Reset(); + } + + static Task Direct(IServiceHandler handler, string path, string? target, string body) + { + var headers = new Dictionary { ["content-type"] = "application/x-amz-json-1.0" }; + if (target is not null) + headers["x-amz-target"] = target; + return handler.HandleAsync(new("POST", path, headers, Encoding.UTF8.GetBytes(body), new Dictionary())); + } + } + + [Fact] + public async Task StreamRetainsAdminCorsPolicy() + { + using var fixture = new MicroStackFixture(); + using var request = new HttpRequestMessage(HttpMethod.Get, Events); + request.Headers.Add("Origin", "http://untrusted.example"); + using var response = await fixture.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + response.Headers.Contains("Access-Control-Allow-Origin").ShouldBeFalse(); + using var preflight = new HttpRequestMessage(HttpMethod.Options, Events); + preflight.Headers.Add("Origin", "http://untrusted.example"); + preflight.Headers.Add("Access-Control-Request-Method", "GET"); + using var preflightResponse = await fixture.HttpClient.SendAsync(preflight); + preflightResponse.StatusCode.ShouldBe(HttpStatusCode.NoContent); + preflightResponse.Headers.Contains("Access-Control-Allow-Origin").ShouldBeFalse(); + } + + [Fact] + public async Task Stream_HeartbeatIsCommentAndShutdownCompletesSubscriptions() + { + using var fixture = new MicroStackFixture(); + using var stream = await Open(fixture, null); + await stream.Read(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + (await stream.Reader.ReadLineAsync(timeout.Token)).ShouldBe(": heartbeat"); + using var hub = new AdminChangeHub(); + using var subscription = hub.Subscribe(null)!; + hub.Dispose(); + (await subscription.Wakeup.Reader.WaitToReadAsync()).ShouldBeFalse(); + hub.Subscribe(null).ShouldBeNull(); + } + + private static string Auth(string account, string service) => + $"AWS4-HMAC-SHA256 Credential={account}/20260913/us-east-1/{service}/aws4_request, SignedHeaders=host, Signature=test"; + + private static async Task Aws(MicroStackFixture fixture, string account, + string service, string target, string body) + { + using var request = new HttpRequestMessage(HttpMethod.Post, "/"); + request.Headers.TryAddWithoutValidation("Authorization", Auth(account, service)); + request.Headers.TryAddWithoutValidation("X-Amz-Target", target); + request.Content = new StringContent(body, Encoding.UTF8, "application/x-amz-json-1.0"); + return await fixture.HttpClient.SendAsync(request); + } + + private static async Task Open(MicroStackFixture fixture, string? account, string? lastId = null) + { + var client = fixture.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, + Events + (account is null ? "" : "?accountId=" + account)); + if (lastId is not null) + request.Headers.TryAddWithoutValidation("Last-Event-ID", lastId); + var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + response.Content.Headers.ContentType!.MediaType.ShouldBe("text/event-stream"); + response.Headers.CacheControl!.NoStore.ShouldBeTrue(); + return new(client, response, new StreamReader(await response.Content.ReadAsStreamAsync())); + } + + private sealed class EventStream(HttpClient client, HttpResponseMessage response, StreamReader reader) : IDisposable + { + internal StreamReader Reader => reader; + internal async Task Read() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + string? json = null; + while (true) + { + var line = await reader.ReadLineAsync(timeout.Token); + if (line is null) + throw new EndOfStreamException(); + if (line.StartsWith("data: ", StringComparison.Ordinal)) + json = line[6..]; + if (line.Length == 0 && json is not null) + return JsonSerializer.Deserialize(json, AdminJsonContext.Default.AdminChangeEvent)!; + } + } + public void Dispose() + { + reader.Dispose(); + response.Dispose(); + client.Dispose(); + } + } + + private sealed class FailingHandler(string service) : IServiceHandler + { + public string ServiceName => service; + public Task HandleAsync(ServiceRequest request) + { + AccountContext.SetFromAccessKey(Second); + throw new InvalidOperationException("Partial dispatch failure."); + } + public void Reset() => throw new InvalidOperationException("Partial reset failure."); + public JsonElement? GetState() => null; + public void RestoreState(JsonElement state) { } + } +} diff --git a/tests/MicroStack.Tests/AdminIdentityTests.cs b/tests/MicroStack.Tests/AdminIdentityTests.cs new file mode 100644 index 0000000..d54137f --- /dev/null +++ b/tests/MicroStack.Tests/AdminIdentityTests.cs @@ -0,0 +1,142 @@ +using System.Text; +using System.Text.Json; +using MicroStack.Internal; +using MicroStack.Internal.Admin; +using MicroStack.Services.Acm; +using MicroStack.Services.Cognito; +using MicroStack.Services.Iam; +using MicroStack.Services.Kms; +using MicroStack.Services.SecretsManager; +using MicroStack.Services.Sts; +using MicroStack.Services.Waf; + +namespace MicroStack.Tests; + +public sealed class AdminIdentityTests : IDisposable +{ + public void Dispose() => AccountContext.Reset(); + + [Fact] + public void IdentityHandlersDeclareOnlyRetainedRootKinds() + { + var idp = new CognitoIdpServiceHandler(); + + Kinds(new AcmServiceHandler()).ShouldBe(["certificate"]); + Kinds(idp).ShouldBe(["group", "user", "user-pool", "user-pool-client"], ignoreOrder: true); + Kinds(new CognitoIdentityServiceHandler(idp)).ShouldBe(["identity", "identity-pool"], ignoreOrder: true); + Kinds(new IamServiceHandler()).ShouldBe( + ["access-key", "group", "instance-profile", "oidc-provider", "policy", "policy-version", + "role", "role-inline-policy", "user", "user-inline-policy"], ignoreOrder: true); + Kinds(new KmsServiceHandler()).ShouldBe(["alias", "key"], ignoreOrder: true); + Kinds(new SecretsManagerServiceHandler()).ShouldBe(["secret", "secret-version"]); + Kinds(new WafServiceHandler()).ShouldBe(["ip-set", "rule-group", "web-acl"], ignoreOrder: true); + + var sts = (IAdminResourceSource)new StsServiceHandler(new IamServiceHandler()); + sts.GetAdminResourceKinds("ignored").ShouldBeEmpty(); + sts.GetAdminResources("ignored").ShouldBeEmpty(); + sts.GetAdminNotice("sts")!.ShouldContain("not retained"); + } + + [Fact] + public async Task SecretsAreMaskedAndOnlyExplicitlyRevealExistingValues() + { + const string secretValue = "do-not-leak"; + var handler = new SecretsManagerServiceHandler(); + await Json(handler, "secretsmanager", "AWSSecretsManager.CreateSecret", + $$"""{"Name":"admin-secret","SecretString":"{{secretValue}}"}"""); + + var secret = ((IAdminResourceSource)handler).GetAdminResources("ignored").Single(); + var version = secret.ReadChildren!().Single(); + var fields = version.ReadFields!(); + fields.Single(x => x.Name == "SecretString").Value.ShouldBe(AdminData.MaskedValue); + JsonSerializer.Serialize(fields).ShouldNotContain(secretValue); + version.RevealableFields.ShouldBe(["SecretString"]); + version.RevealField!("SecretString").Text.ShouldBe(secretValue); + version.RevealField!("SecretBinary").Text.ShouldBeNull(); + version.RevealField!("missing").Text.ShouldBeNull(); + } + + [Fact] + public async Task CognitoUsesSharedStateAndNeverRevealsPasswords() + { + var idp = new CognitoIdpServiceHandler(); + var identity = new CognitoIdentityServiceHandler(idp); + var poolResponse = await Json(idp, "cognito-idp", + "AWSCognitoIdentityProviderService.CreateUserPool", """{"PoolName":"shared"}"""); + using var poolJson = JsonDocument.Parse(poolResponse.Body); + var poolId = poolJson.RootElement.GetProperty("UserPool").GetProperty("Id").GetString()!; + + var clientResponse = await Json(idp, "cognito-idp", + "AWSCognitoIdentityProviderService.CreateUserPoolClient", + $$"""{"UserPoolId":"{{poolId}}","ClientName":"app","GenerateSecret":true}"""); + using var clientJson = JsonDocument.Parse(clientResponse.Body); + var expectedSecret = clientJson.RootElement.GetProperty("UserPoolClient") + .GetProperty("ClientSecret").GetString()!; + await Json(idp, "cognito-idp", "AWSCognitoIdentityProviderService.AdminCreateUser", + $$"""{"UserPoolId":"{{poolId}}","Username":"alice","TemporaryPassword":"hidden-password"}"""); + await Json(identity, "cognito-identity", "AWSCognitoIdentityService.CreateIdentityPool", + "{\"IdentityPoolName\":\"federated\",\"AllowUnauthenticatedIdentities\":false," + + "\"CognitoIdentityProviders\":[{\"ProviderName\":\"cognito-idp.us-east-1.amazonaws.com/" + + poolId + "\"}]}"); + + var pool = ((IAdminResourceSource)idp).GetAdminResources("ignored").Single(); + var children = pool.ReadChildren!().ToArray(); + var client = children.Single(x => x.Resource.Key.Kind == "user-pool-client"); + client.ReadFields!().Single(x => x.Name == "Client secret").Value.ShouldBe(AdminData.MaskedValue); + client.RevealField!("ClientSecret").Text.ShouldBe(expectedSecret); + var user = children.Single(x => x.Resource.Key.Kind == "user"); + JsonSerializer.Serialize(user.ReadFields!()).ShouldNotContain("hidden-password"); + user.RevealField.ShouldBeNull(); + + ((IAdminResourceSource)identity).GetAdminResources("ignored").Single() + .ReadConnections!().Single().TargetPath![0].Id.ShouldBe(poolId); + } + + [Fact] + public async Task KmsProjectionNeverExposesOrGeneratesKeyMaterial() + { + var handler = new KmsServiceHandler(); + await Json(handler, "kms", "TrentService.CreateKey", + """{"Description":"admin key","KeySpec":"SYMMETRIC_DEFAULT"}"""); + + var key = ((IAdminResourceSource)handler).GetAdminResources("ignored") + .Single(x => x.Resource.Key.Kind == "key"); + key.RevealableFields.ShouldBeEmpty(); + key.RevealField.ShouldBeNull(); + var projection = JsonSerializer.Serialize(key.ReadFields!()); + projection.ShouldNotContain("SymmetricKey"); + projection.ShouldNotContain("Private"); + } + + [Fact] + public async Task ResourcesAreAccountScopedAndInspectionDoesNotMutate() + { + var handler = new AcmServiceHandler(); + using (AccountContext.BeginScope("111111111111")) + { + await Json(handler, "acm", "CertificateManager.RequestCertificate", + """{"DomainName":"example.test"}"""); + var node = ((IAdminResourceSource)handler).GetAdminResources("ignored").Single(); + var before = JsonSerializer.Serialize(node.ReadFields!()); + _ = node.ReadFields!(); + JsonSerializer.Serialize(node.ReadFields!()).ShouldBe(before); + } + + using (AccountContext.BeginScope("222222222222")) + ((IAdminResourceSource)handler).GetAdminResources("ignored").ShouldBeEmpty(); + } + + private static string[] Kinds(IAdminResourceSource source) => + source.GetAdminResourceKinds("ignored").Select(x => x.Id).ToArray(); + + private static Task Json( + IServiceHandler handler, string service, string target, string body) => + handler.HandleAsync(new ServiceRequest("POST", "/", + new Dictionary + { + ["x-amz-target"] = target, + ["content-type"] = "application/x-amz-json-1.1", + ["host"] = $"{service}.us-east-1.amazonaws.com" + }, + Encoding.UTF8.GetBytes(body), new Dictionary())); +} diff --git a/tests/MicroStack.Tests/AdminMessagingTests.cs b/tests/MicroStack.Tests/AdminMessagingTests.cs new file mode 100644 index 0000000..8dc0726 --- /dev/null +++ b/tests/MicroStack.Tests/AdminMessagingTests.cs @@ -0,0 +1,432 @@ +using System.Text; +using System.Text.Json; +using System.Net.Http.Json; +using MicroStack.Admin.Contracts; +using MicroStack.Internal; +using MicroStack.Internal.Admin; +using Microsoft.Extensions.DependencyInjection; + +namespace MicroStack.Tests; + +public sealed class AdminMessagingTests(MicroStackFixture fixture) : IClassFixture, IAsyncLifetime +{ + private ServiceRegistry Registry => fixture.Factory.Services.GetRequiredService(); + + public ValueTask InitializeAsync() + { + Registry.ResetAll(); + AccountContext.Reset(); + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() + { + AccountContext.Reset(); + return ValueTask.CompletedTask; + } + + [Fact] + public async Task QueueRootAndDetailReadsDoNotMaterializeLargeMessageAttributes() + { + var handler = Registry.Resolve("sqs")!; + var source = (IAdminResourceSource)handler; + await Json(handler, "AmazonSQS.CreateQueue", new { QueueName = "live-read-cost" }); + var queue = source.GetAdminResources("sqs").Single(); + var url = queue.ReadFields!().Single(field => field.Name == "Queue URL").Value!; + var payload = new + { + QueueUrl = url, + MessageBody = new string('b', 32_000), + MessageAttributes = new { large = new { DataType = "String", StringValue = new string('a', 32_000) } } + }; + for (var i = 0; i < 100; i++) + (await Json(handler, "AmazonSQS.SendMessage", payload)).StatusCode.ShouldBe(200); + + source.GetAdminResources("sqs").Single().ReadFields!(); + var start = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 10; i++) + { + var snapshot = source.GetAdminResources("sqs").Single(); + snapshot.ReadFields!(); + snapshot.ReadSummary!(); + snapshot.ReadConnections!(); + } + var allocated = GC.GetAllocatedBytesForCurrentThread() - start; + allocated.ShouldBeLessThan(256_000, + "Queue metadata reads must not copy/serialize the 6.4 MB of retained message payloads."); + var messages = source.GetAdminResources("sqs").Single().ReadChildren!().ToArray(); + messages.Length.ShouldBe(100); + messages[0].ReadFields!().Single(field => field.Name == "Message attributes").Value! + .ShouldContain(new string('a', 32_000)); + messages[0].ReadContent!().Text.ShouldBe(new string('b', 32_000)); + source.GetAdminResources("sqs").Single().ReadSummary!() + .Single(field => field.Name == "Visible messages").Value.ShouldBe("100"); + } + + [Fact] + public async Task SqsSnapshotIsNonConsumingAndDistinguishesMessageStates() + { + var handler = Registry.Resolve("sqs")!; + var source = (IAdminResourceSource)handler; + await Query(handler, ("Action", "CreateQueue"), ("QueueName", "admin-queue")); + var queue = source.GetAdminResources("sqs").Single(); + var url = queue.ReadFields!().Single(field => field.Name == "Queue URL").Value!; + + await Query(handler, ("Action", "SendMessage"), ("QueueUrl", url), ("MessageBody", "visible")); + await Query(handler, ("Action", "SendMessage"), ("QueueUrl", url), ("MessageBody", "delayed"), + ("DelaySeconds", "60")); + await Json(handler, "AmazonSQS.ReceiveMessage", + new { QueueUrl = url, MaxNumberOfMessages = 1, VisibilityTimeout = 60 }); + + var first = source.GetAdminResources("sqs").Single(); + var before = first.ReadChildren!().Select(node => (node.Resource.Key.Id, node.Resource.Status)).ToArray(); + var second = source.GetAdminResources("sqs").Single(); + var after = second.ReadChildren!().Select(node => (node.Resource.Key.Id, node.Resource.Status)).ToArray(); + + before.ShouldBe(after); + before.Select(item => item.Status).ShouldContain("in-flight"); + before.Select(item => item.Status).ShouldContain("delayed"); + first.ReadChildren!().Single(node => node.Resource.Status == "delayed") + .ReadContent!().Text.ShouldBe("delayed"); + } + + [Fact] + public async Task QueueSummaryCountsWholeSnapshotAndExposesRetainedAttributesAndTags() + { + var handler = Registry.Resolve("sqs")!; + var source = (IAdminResourceSource)handler; + await Json(handler, "AmazonSQS.CreateQueue", new + { + QueueName = "snapshot-counts", + Attributes = new { VisibilityTimeout = "75", MessageRetentionPeriod = "86400" }, + Tags = new { owner = "team-messaging", empty = "" } + }); + var queue = source.GetAdminResources("sqs").Single(); + var url = queue.ReadFields!().Single(field => field.Name == "Queue URL").Value!; + await Json(handler, "AmazonSQS.SendMessage", new { QueueUrl = url, MessageBody = """{"order":42}""" }); + await Json(handler, "AmazonSQS.SetQueueAttributes", new + { + QueueUrl = url, Attributes = new { DelaySeconds = "600" } + }); + await Json(handler, "AmazonSQS.SendMessage", new { QueueUrl = url, MessageBody = "delayed" }); + await Json(handler, "AmazonSQS.SetQueueAttributes", new + { + QueueUrl = url, Attributes = new { DelaySeconds = "0" } + }); + await Json(handler, "AmazonSQS.ReceiveMessage", new { QueueUrl = url, MaxNumberOfMessages = 1, VisibilityTimeout = 600 }); + await Json(handler, "AmazonSQS.SendMessage", new { QueueUrl = url, MessageBody = "visible" }); + var before = handler.GetState()!.Value.GetRawText(); + + var detail = await fixture.HttpClient.GetFromJsonAsync( + AdminUrl("sqs", "resource", [queue.Resource.Key]), AdminJsonContext.Default.AdminResourceDetail); + var children = await fixture.HttpClient.GetFromJsonAsync( + AdminUrl("sqs", "children", [queue.Resource.Key]) + "&pageSize=1", + AdminJsonContext.Default.AdminPageAdminResourceSummary); + + detail.ShouldNotBeNull(); + children.ShouldNotBeNull(); + detail.Resource.Type.ShouldBe("Standard"); + detail.ChildKinds.Single().Id.ShouldBe("messages"); + detail.ChildKinds.Single().IsRoot.ShouldBeFalse(); + detail.Summary.Single(field => field.Name == "Visible messages").Value.ShouldBe("1"); + detail.Summary.Single(field => field.Name == "Delayed messages").Value.ShouldBe("1"); + detail.Summary.Single(field => field.Name == "In-flight messages").Value.ShouldBe("1"); + detail.Fields.Single(field => field.Name == "VisibilityTimeout").Value.ShouldBe("75"); + detail.Fields.Single(field => field.Name == "MessageRetentionPeriod").Secondary.ShouldBeTrue(); + detail.Fields.Single(field => field.Name == "Tag: owner").Value.ShouldBe("team-messaging"); + detail.Fields.Single(field => field.Name == "Tag: empty").Value.ShouldBe(""); + children.Items.Count.ShouldBe(1); + children.NextCursor.ShouldNotBeNull(); + children.Items[0].Type.ShouldBe("Message"); + children.Items[0].Summary.Single(field => field.Name == "Sent").Format.ShouldBe("datetime"); + JsonSerializer.Serialize(children, AdminJsonContext.Default.AdminPageAdminResourceSummary) + .ShouldNotContain("order"); + + await fixture.HttpClient.GetFromJsonAsync(AdminUrl("sqs", "connections", [queue.Resource.Key]), + AdminJsonContext.Default.AdminPageAdminConnection); + handler.GetState()!.Value.GetRawText().ShouldBe(before); + } + + [Fact] + public async Task EmptyFifoQueueDeclaresMessagesWithoutInventingMetadata() + { + var handler = Registry.Resolve("sqs")!; + await Json(handler, "AmazonSQS.CreateQueue", new + { + QueueName = "empty.fifo", Attributes = new { FifoQueue = "true", ContentBasedDeduplication = "true" } + }); + var node = ((IAdminResourceSource)handler).GetAdminResources("sqs").Single(); + var detail = await fixture.HttpClient.GetFromJsonAsync(AdminUrl("sqs", "resource", [node.Resource.Key]), + AdminJsonContext.Default.AdminResourceDetail); + + detail.ShouldNotBeNull(); + detail.Resource.Type.ShouldBe("FIFO"); + detail.ChildKinds.Single().Label.ShouldBe("Messages"); + detail.Summary.Select(field => field.Value).ShouldAllBe(value => value == "0"); + detail.Fields.ShouldNotContain(field => field.Name.StartsWith("Tag:", StringComparison.Ordinal)); + detail.Fields.Single(field => field.Name == "ContentBasedDeduplication").Value.ShouldBe("true"); + ((IAdminResourceSource)handler).GetAdminResourceKinds("sqs") + .Where(kind => kind.IsRoot).Select(kind => kind.Id).ShouldBe(["queues"]); + } + + [Theory] + [InlineData("""{"order":{"id":42}}""", "json", "application/json")] + [InlineData("{invalid json", "text", "text/plain")] + [InlineData("plain message", "text", "text/plain")] + public async Task MessageContentUsesValidatedPrettyJsonOrPlainText(string body, string kind, string contentType) + { + var handler = Registry.Resolve("sqs")!; + await Json(handler, "AmazonSQS.CreateQueue", new { QueueName = "content" }); + var source = (IAdminResourceSource)handler; + var queue = source.GetAdminResources("sqs").Single(); + var url = queue.ReadFields!().Single(field => field.Name == "Queue URL").Value!; + await Json(handler, "AmazonSQS.SendMessage", new { QueueUrl = url, MessageBody = body }); + var message = source.GetAdminResources("sqs").Single().ReadChildren!().Single(); + var before = handler.GetState()!.Value.GetRawText(); + var content = await fixture.HttpClient.GetFromJsonAsync( + AdminUrl("sqs", "content", [queue.Resource.Key, message.Resource.Key]), AdminJsonContext.Default.AdminContent); + + content.ShouldNotBeNull(); + content.Kind.ShouldBe(kind); + content.ContentType.ShouldBe(contentType); + if (kind == "json") + { + content.Text.ShouldNotBeNull(); + content.Text.ShouldContain("\n"); + } + else + content.Text.ShouldBe(body); + handler.GetState()!.Value.GetRawText().ShouldBe(before); + } + + [Theory] + [InlineData(0, "text")] + [InlineData(1, "oversized")] + public async Task MessageContentPreservesExactUtf8PreviewLimit(int extraBytes, string expectedKind) + { + var handler = Registry.Resolve("sqs")!; + await Json(handler, "AmazonSQS.CreateQueue", new { QueueName = "limit" }); + var source = (IAdminResourceSource)handler; + var queue = source.GetAdminResources("sqs").Single(); + var url = queue.ReadFields!().Single(field => field.Name == "Queue URL").Value!; + var body = new string('é', AdminData.PreviewMaxBytes / 2) + new string('a', extraBytes); + await Json(handler, "AmazonSQS.SendMessage", new { QueueUrl = url, MessageBody = body }); + var message = source.GetAdminResources("sqs").Single().ReadChildren!().Single(); + var content = await fixture.HttpClient.GetFromJsonAsync( + AdminUrl("sqs", "content", [queue.Resource.Key, message.Resource.Key]), AdminJsonContext.Default.AdminContent); + content.ShouldNotBeNull(); + content.Kind.ShouldBe(expectedKind); + content.Length.ShouldBe(AdminData.PreviewMaxBytes + extraBytes); + if (extraBytes > 0) + content.Text.ShouldBeNull(); + } + + [Fact] + public async Task JsonFormattingCannotBypassInputOrOutputPreviewLimits() + { + var handler = Registry.Resolve("sqs")!; + await Json(handler, "AmazonSQS.CreateQueue", new { QueueName = "json-limits" }); + var source = (IAdminResourceSource)handler; + var queue = source.GetAdminResources("sqs").Single(); + var url = queue.ReadFields!().Single(field => field.Name == "Queue URL").Value!; + foreach (var body in new[] + { + new string(' ', AdminData.PreviewMaxBytes) + "{}", + "[" + string.Join(",", Enumerable.Repeat("0", 300_000)) + "]" + }) + { + await Json(handler, "AmazonSQS.SendMessage", new { QueueUrl = url, MessageBody = body }); + } + foreach (var message in source.GetAdminResources("sqs").Single().ReadChildren!()) + { + var content = await fixture.HttpClient.GetFromJsonAsync( + AdminUrl("sqs", "content", [queue.Resource.Key, message.Resource.Key]), AdminJsonContext.Default.AdminContent); + content.ShouldNotBeNull(); + content.Kind.ShouldBe("oversized"); + content.Text.ShouldBeNull(); + } + } + + [Fact] + public async Task SnsSubscriptionRowsExposeRetainedFilterAndConfirmationWithParentTopic() + { + var handler = Registry.Resolve("sns")!; + var source = (IAdminResourceSource)handler; + await Query(handler, ("Action", "CreateTopic"), ("Name", "subscription-metadata")); + var topic = source.GetAdminResources("sns").Single(); + await Query(handler, ("Action", "Subscribe"), ("TopicArn", topic.Resource.Arn!), + ("Protocol", "https"), ("Endpoint", "https://example.test/events"), + ("Attributes.entry.1.key", "FilterPolicy"), ("Attributes.entry.1.value", """{"event":["created"]}""")); + var child = source.GetAdminResources("sns").Single().ReadChildren!().Single(); + var rows = await fixture.HttpClient.GetFromJsonAsync(AdminUrl("sns", "children", [topic.Resource.Key]), + AdminJsonContext.Default.AdminPageAdminResourceSummary); + var connections = await fixture.HttpClient.GetFromJsonAsync( + AdminUrl("sns", "connections", [topic.Resource.Key, child.Resource.Key]), + AdminJsonContext.Default.AdminPageAdminConnection); + + rows.ShouldNotBeNull(); + var row = rows.Items.Single(); + row.Type.ShouldBe("https"); + row.Status.ShouldBe("pending"); + row.Summary.Single(field => field.Name == "Endpoint").Value.ShouldBe("https://example.test/events"); + row.Summary.Single(field => field.Name == "Filter policy").Value.ShouldBe("""{"event":["created"]}"""); + connections.ShouldNotBeNull(); + connections.Items.Single(link => link.Relation == "belongs-to").TargetPath.ShouldBe([topic.Resource.Key]); + connections.Items.Single(link => link.Relation == "delivers-to").State.ShouldBe("external"); + source.GetAdminResourceKinds("sns").Where(kind => kind.IsRoot).Select(kind => kind.Id) + .ShouldBe(["topics", "platform-applications"]); + } + + [Fact] + public async Task ScheduledRuleHasScheduleContentAndStableEmptyTargetCollection() + { + var handler = Registry.Resolve("events")!; + await Json(handler, "AWSEvents.PutRule", new + { + Name = "scheduled", ScheduleExpression = "rate(5 minutes)", Description = "Retained schedule", State = "DISABLED" + }); + var source = (IAdminResourceSource)handler; + var bus = source.GetAdminResources("events").Single(); + var rule = bus.ReadChildren!().Single(); + var detail = await fixture.HttpClient.GetFromJsonAsync( + AdminUrl("events", "resource", [bus.Resource.Key, rule.Resource.Key]), + AdminJsonContext.Default.AdminResourceDetail); + var content = await fixture.HttpClient.GetFromJsonAsync( + AdminUrl("events", "content", [bus.Resource.Key, rule.Resource.Key]), AdminJsonContext.Default.AdminContent); + + detail.ShouldNotBeNull(); + detail.Resource.Type.ShouldBe("Scheduled rule"); + detail.Resource.Status.ShouldBe("DISABLED"); + detail.ChildKinds.Single().Label.ShouldBe("Targets"); + detail.ChildKinds.Single().IsRoot.ShouldBeFalse(); + detail.Fields.Single(field => field.Name == "Description").Value.ShouldBe("Retained schedule"); + content.ShouldNotBeNull(); + content.Kind.ShouldBe("text"); + content.Text.ShouldBe("rate(5 minutes)"); + source.GetAdminResourceKinds("events").Where(kind => kind.IsRoot) + .ShouldNotContain(kind => kind.Id == "rules" || kind.Id == "targets"); + } + + [Fact] + public async Task SnsShowsConfiguredSubscriptionsButNoMessageInbox() + { + var handler = Registry.Resolve("sns")!; + var source = (IAdminResourceSource)handler; + await Query(handler, ("Action", "CreateTopic"), ("Name", "admin-topic")); + var topic = source.GetAdminResources("sns").Single(node => node.Resource.Key.Kind == "topics"); + await Query(handler, ("Action", "Subscribe"), ("TopicArn", topic.Resource.Arn!), + ("Protocol", "sqs"), ("Endpoint", "arn:aws:sqs:us-east-1:000000000000:orders")); + await Query(handler, ("Action", "Publish"), ("TopicArn", topic.Resource.Arn!), ("Message", "not-an-inbox")); + + topic = source.GetAdminResources("sns").Single(node => node.Resource.Key.Kind == "topics"); + var children = topic.ReadChildren!().ToArray(); + + children.ShouldHaveSingleItem(); + children[0].Resource.Key.Kind.ShouldBe("subscriptions"); + children[0].ReadConnections!().Single().State.ShouldBe("configured"); + source.GetAdminResourceKinds("sns").ShouldNotContain(kind => kind.Id == "messages"); + } + + [Fact] + public async Task EventBridgeExposesOnlyConfiguredTargetLinksAndMasksAuthentication() + { + var handler = Registry.Resolve("events")!; + var source = (IAdminResourceSource)handler; + await Json(handler, "AWSEvents.PutRule", new + { + Name = "admin-rule", + EventPattern = """{"source":["orders"]}""" + }); + await Json(handler, "AWSEvents.PutTargets", new + { + Rule = "admin-rule", + Targets = new[] { new { Id = "queue", Arn = "arn:aws:sqs:us-east-1:000000000000:orders" } } + }); + await Json(handler, "AWSEvents.CreateConnection", new + { + Name = "admin-connection", + AuthorizationType = "API_KEY", + AuthParameters = new { ApiKeyAuthParameters = new { ApiKeyName = "x-api-key", ApiKeyValue = "super-secret" } } + }); + + var roots = source.GetAdminResources("events").ToArray(); + var rule = roots.Single(node => node.Resource.Key.Kind == "event-buses" + && node.Resource.Name == "default") + .ReadChildren!().Single(); + var link = rule.ReadConnections!().Single(); + var connection = roots.Single(node => node.Resource.Key.Kind == "connections"); + + link.State.ShouldBe("configured"); + link.TargetServiceId.ShouldBe("sqs"); + connection.ReadFields!().Single(field => field.Name == "Authentication").Value + .ShouldBe(AdminData.MaskedValue); + connection.ReadFields!().Any(field => + field.Value is not null && field.Value.Contains("super-secret", StringComparison.Ordinal)).ShouldBeFalse(); + } + + [Fact] + public async Task SesSnapshotsAreAccountScopedAndUseExactPreviewLimit() + { + var handler = Registry.Resolve("ses")!; + var source = (IAdminResourceSource)handler; + AdminData.Text(new string('a', AdminData.PreviewMaxBytes)).Kind.ShouldBe("text"); + AdminData.Text(new string('a', AdminData.PreviewMaxBytes + 1)).Kind.ShouldBe("oversized"); + using (AccountContext.BeginScope("111111111111")) + { + await Query(handler, ("Action", "VerifyEmailIdentity"), ("EmailAddress", "one@example.test")); + await Query(handler, ("Action", "CreateTemplate"), ("Template.TemplateName", "large"), + ("Template.TextPart", new string('a', AdminData.PreviewMaxBytes))); + source.GetAdminResources("ses").Single(node => node.Resource.Key.Kind == "templates") + .ReadContent!().Kind.ShouldBe("oversized"); + } + using (AccountContext.BeginScope("222222222222")) + { + await Query(handler, ("Action", "VerifyEmailIdentity"), ("EmailAddress", "two@example.test")); + source.GetAdminResources("ses").Single(node => node.Resource.Key.Kind == "identities") + .Resource.Name.ShouldBe("two@example.test"); + } + using (AccountContext.BeginScope("111111111111")) + { + source.GetAdminResources("ses").Single(node => node.Resource.Key.Kind == "identities") + .Resource.Name.ShouldBe("one@example.test"); + } + } + + [Fact] + public async Task StepFunctionsInspectionNeverStartsAnExecution() + { + var handler = Registry.Resolve("states")!; + var source = (IAdminResourceSource)handler; + const string definition = """{"StartAt":"Done","States":{"Done":{"Type":"Succeed"}}}"""; + await Json(handler, "AWSStepFunctions.CreateStateMachine", new + { + name = "inspect-only", + roleArn = "arn:aws:iam::000000000000:role/test", + definition + }); + + var first = source.GetAdminResources("stepfunctions").Single(); + first.ReadContent!().Text.ShouldBe(definition); + first.ReadChildren!().ShouldBeEmpty(); + source.GetAdminResources("stepfunctions").Single().ReadChildren!().ShouldBeEmpty(); + } + + private static Task Query(IServiceHandler handler, params (string Key, string Value)[] values) + { + var body = string.Join("&", values.Select(value => + $"{Uri.EscapeDataString(value.Key)}={Uri.EscapeDataString(value.Value)}")); + return handler.HandleAsync(new("POST", "/", new Dictionary(), + Encoding.UTF8.GetBytes(body), new Dictionary())); + } + + private static string AdminUrl(string service, string operation, AdminKey[] path) => + $"/_microstack/admin/v1/services/{service}/{operation}?path=" + + Uri.EscapeDataString(JsonSerializer.Serialize(path, AdminJsonContext.Default.AdminKeyArray)); + + private static Task Json(IServiceHandler handler, string target, object value) => + handler.HandleAsync(new("POST", "/", new Dictionary + { + ["x-amz-target"] = target, + ["content-type"] = "application/x-amz-json-1.0" + }, JsonSerializer.SerializeToUtf8Bytes(value), new Dictionary())); +} diff --git a/tests/MicroStack.Tests/AdminNetworkingTests.cs b/tests/MicroStack.Tests/AdminNetworkingTests.cs new file mode 100644 index 0000000..19eccd3 --- /dev/null +++ b/tests/MicroStack.Tests/AdminNetworkingTests.cs @@ -0,0 +1,207 @@ +using System.Text; +using System.Text.Json; +using System.Xml.Linq; +using MicroStack.Internal; +using MicroStack.Internal.Admin; +using MicroStack.Services.Alb; +using MicroStack.Services.ApiGateway; +using MicroStack.Services.AppSync; +using MicroStack.Services.CloudFront; +using MicroStack.Services.Lambda; +using MicroStack.Services.Route53; +using MicroStack.Services.ServiceDiscovery; + +namespace MicroStack.Tests; + +public sealed class AdminNetworkingTests +{ + [Fact] + public async Task ApiGatewayProviderExposesV1AndV2FromSameLiveHandler() + { + var handler = new ApiGatewayV2ServiceHandler(new LambdaServiceHandler()); + var source = (IAdminResourceSource)handler; + + var v1 = await handler.HandleAsync(Json("POST", "/restapis", """{"name":"rest"}""")); + var v1Id = JsonString(v1, "id"); + await handler.HandleAsync(Json("POST", $"/restapis/{v1Id}/resources", + """{"pathPart":"items"}""")); + + var v2 = await handler.HandleAsync(Json("POST", "/v2/apis", + """{"name":"http","protocolType":"HTTP"}""")); + var v2Id = JsonString(v2, "apiId"); + await handler.HandleAsync(Json("POST", $"/v2/apis/{v2Id}/routes", + """{"routeKey":"GET /items"}""")); + await handler.HandleAsync(Json("POST", $"/v2/apis/{v2Id}/integrations", + """{"integrationType":"MOCK"}""")); + await handler.HandleAsync(Json("POST", $"/v2/apis/{v2Id}/stages", + """{"stageName":"dev"}""")); + + source.GetAdminResourceKinds("apigateway").Select(x => x.Id) + .ShouldContain("rest-apis"); + source.GetAdminResourceKinds("apigatewayv2").Select(x => x.Id) + .ShouldContain("routes"); + + var rest = source.GetAdminResources("apigateway").Single(x => x.Resource.Key.Id == v1Id); + rest.ReadChildren!().ShouldContain(x => x.Resource.Key.Kind == "resources"); + var http = source.GetAdminResources("apigatewayv2").Single(x => x.Resource.Key.Id == v2Id); + var children = http.ReadChildren!().ToArray(); + children.ShouldContain(x => x.Resource.Key.Kind == "routes"); + children.ShouldContain(x => x.Resource.Key.Kind == "integrations"); + children.ShouldContain(x => x.Resource.Key.Kind == "stages"); + } + + [Fact] + public async Task AlbProviderBuildsLinkedLoadBalancerGraph() + { + var handler = new AlbServiceHandler(); + var lb = await handler.HandleAsync(Query("CreateLoadBalancer", ("Name", "admin-lb"))); + var lbArn = XmlValue(lb, "LoadBalancerArn"); + var tg = await handler.HandleAsync(Query("CreateTargetGroup", + ("Name", "admin-tg"), ("Port", "80"), ("Protocol", "HTTP"))); + var tgArn = XmlValue(tg, "TargetGroupArn"); + await handler.HandleAsync(Query("RegisterTargets", + ("TargetGroupArn", tgArn), ("Targets.member.1.Id", "i-123"))); + await handler.HandleAsync(Query("CreateListener", + ("LoadBalancerArn", lbArn), ("Port", "80"), ("Protocol", "HTTP"), + ("DefaultActions.member.1.Type", "forward"), + ("DefaultActions.member.1.TargetGroupArn", tgArn))); + + var roots = ((IAdminResourceSource)handler) + .GetAdminResources("elasticloadbalancing").ToArray(); + var loadBalancer = roots.Single(x => x.Resource.Key.Id == lbArn); + var listener = loadBalancer.ReadChildren!().Single(); + listener.ReadConnections!().ShouldContain(x => + x.TargetPath != null && x.TargetPath.Single().Id == tgArn && x.Relation == "forwards-to"); + var targetGroup = roots.Single(x => x.Resource.Key.Id == tgArn); + targetGroup.ReadChildren!().Single().Resource.Name.ShouldBe("i-123"); + targetGroup.ReadConnections!().Single().TargetPath!.Single().Id.ShouldBe(lbArn); + } + + [Fact] + public async Task AppSyncProviderExposesConfigurationSchemaAndMasksKeys() + { + var handler = new AppSyncServiceHandler(); + var created = await handler.HandleAsync(Json("POST", "/v1/apis", + """{"name":"admin-api","authenticationType":"API_KEY"}""")); + var apiId = JsonNestedString(created, "graphqlApi", "apiId"); + var key = await handler.HandleAsync(Json("POST", $"/v1/apis/{apiId}/apikeys", "{}")); + var secret = JsonNestedString(key, "apiKey", "id"); + await handler.HandleAsync(Json("POST", $"/v1/apis/{apiId}/types", + """{"definition":"type Query { ping: String }","format":"SDL"}""")); + await handler.HandleAsync(Json("POST", $"/v1/apis/{apiId}/datasources", + """{"name":"none","type":"NONE"}""")); + await handler.HandleAsync(Json("POST", $"/v1/apis/{apiId}/types/Query/resolvers", + """{"fieldName":"ping","dataSourceName":"none"}""")); + + var api = ((IAdminResourceSource)handler).GetAdminResources("appsync").Single(); + var children = api.ReadChildren!().ToArray(); + var apiKey = children.Single(x => x.Resource.Key.Kind == "api-keys"); + apiKey.Resource.Key.Id.ShouldNotContain(secret); + apiKey.ReadFields!().Single(x => x.Name == "id").Value.ShouldBe(AdminData.MaskedValue); + children.Single(x => x.Resource.Key.Kind == "types") + .ReadContent!().Text!.ShouldContain("type Query"); + children.Single(x => x.Resource.Key.Kind == "resolvers") + .ReadConnections!().Single().TargetPath!.Single().Id.ShouldBe("none"); + } + + [Fact] + public async Task CloudFrontProviderExposesConfigAndInvalidations() + { + var handler = new CloudFrontServiceHandler(); + var create = await handler.HandleAsync(Xml("POST", "/2020-05-31/distribution", + "one" + + "true")); + var id = XmlValue(create, "Id"); + await handler.HandleAsync(Xml("POST", $"/2020-05-31/distribution/{id}/invalidation", + "1/*" + + "inv-one")); + + var distribution = ((IAdminResourceSource)handler) + .GetAdminResources("cloudfront").Single(); + distribution.ReadContent!().ContentType.ShouldBe("application/xml"); + distribution.ReadContent().Text!.ShouldContain("DistributionConfig"); + distribution.ReadChildren!().Single().Resource.Key.Kind.ShouldBe("invalidations"); + } + + [Fact] + public async Task Route53AndCloudMapProvidersExposeRecordsAndActualLinks() + { + var route53 = new Route53ServiceHandler(); + await route53.HandleAsync(Xml("POST", "/2013-04-01/hostedzone", + "example.test" + + "route-one")); + var zone = ((IAdminResourceSource)route53).GetAdminResources("route53").Single(); + zone.ReadChildren!().Select(x => x.Resource.Name) + .ShouldContain(x => x.EndsWith(" SOA", StringComparison.Ordinal)); + + var cloudMap = new ServiceDiscoveryServiceHandler(route53); + await cloudMap.HandleAsync(Target("CreatePrivateDnsNamespace", + """{"Name":"internal.test","Vpc":"vpc-1"}""")); + var ns = ((IAdminResourceSource)cloudMap).GetAdminResources("servicediscovery").Single(); + var zoneLink = ns.ReadConnections!().Single(); + ((IAdminResourceSource)route53).GetAdminResources("route53") + .ShouldContain(x => x.Resource.Key.Id == zoneLink.TargetPath!.Single().Id); + + await cloudMap.HandleAsync(Target("CreateService", + $"{{\"Name\":\"orders\",\"NamespaceId\":\"{ns.Resource.Key.Id}\"}}")); + var service = ns.ReadChildren!().Single(); + await cloudMap.HandleAsync(Target("RegisterInstance", + $"{{\"ServiceId\":\"{service.Resource.Key.Id}\",\"InstanceId\":\"node-1\"," + + "\"Attributes\":{\"AWS_INSTANCE_IPV4\":\"10.0.0.1\"}}")); + var instance = service.ReadChildren!().Single(); + service.ReadConnections!().Single().TargetPath!.Single().Id.ShouldBe(ns.Resource.Key.Id); + instance.ReadConnections!().Single().TargetPath!.Single().Id.ShouldBe(service.Resource.Key.Id); + } + + [Fact] + public async Task ProvidersRemainAccountScoped() + { + var handler = new Route53ServiceHandler(); + using (AccountContext.BeginScope("111111111111")) + await handler.HandleAsync(Xml("POST", "/2013-04-01/hostedzone", + "one.test" + + "one")); + + using (AccountContext.BeginScope("222222222222")) + ((IAdminResourceSource)handler).GetAdminResources("route53").ShouldBeEmpty(); + } + + private static ServiceRequest Json(string method, string path, string body) => + new(method, path, new Dictionary(), + Encoding.UTF8.GetBytes(body), new Dictionary()); + + private static ServiceRequest Xml(string method, string path, string body) => + new(method, path, new Dictionary(), + Encoding.UTF8.GetBytes(body), new Dictionary()); + + private static ServiceRequest Target(string action, string body) => + new("POST", "/", new Dictionary + { + ["x-amz-target"] = $"Route53AutoNaming_v20170314.{action}", + }, Encoding.UTF8.GetBytes(body), new Dictionary()); + + private static ServiceRequest Query(string action, params (string Key, string Value)[] values) + { + var parameters = new[] { ("Action", action) }.Concat(values); + var body = string.Join("&", parameters.Select(x => + $"{Uri.EscapeDataString(x.Item1)}={Uri.EscapeDataString(x.Item2)}")); + return new("POST", "/", new Dictionary(), + Encoding.UTF8.GetBytes(body), new Dictionary()); + } + + private static string JsonString(ServiceResponse response, string property) + { + using var doc = JsonDocument.Parse(response.Body); + return doc.RootElement.GetProperty(property).GetString()!; + } + + private static string JsonNestedString(ServiceResponse response, string parent, string property) + { + using var doc = JsonDocument.Parse(response.Body); + return doc.RootElement.GetProperty(parent).GetProperty(property).GetString()!; + } + + private static string XmlValue(ServiceResponse response, string localName) => + XDocument.Parse(Encoding.UTF8.GetString(response.Body)) + .Descendants().First(x => x.Name.LocalName == localName).Value; +} diff --git a/tests/MicroStack.Tests/AdminObservabilityTests.cs b/tests/MicroStack.Tests/AdminObservabilityTests.cs new file mode 100644 index 0000000..ac198b2 --- /dev/null +++ b/tests/MicroStack.Tests/AdminObservabilityTests.cs @@ -0,0 +1,267 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Amazon; +using Amazon.CloudFormation; +using Amazon.CloudFormation.Model; +using Amazon.CloudWatch; +using Amazon.CloudWatch.Model; +using Amazon.CloudWatchLogs; +using Amazon.CloudWatchLogs.Model; +using Amazon.Runtime; +using Amazon.SimpleSystemsManagement; +using Amazon.SimpleSystemsManagement.Model; + +namespace MicroStack.Tests; + +public sealed class AdminObservabilityTests(MicroStackFixture fixture) : IClassFixture, IAsyncLifetime +{ + private readonly AmazonCloudFormationClient _cloudFormation = Client( + fixture, config => new(new BasicAWSCredentials("test", "test"), config)); + private readonly AmazonCloudWatchClient _cloudWatch = Client( + fixture, config => new(new BasicAWSCredentials("test", "test"), config)); + private readonly AmazonCloudWatchLogsClient _logs = Client( + fixture, config => new(new BasicAWSCredentials("test", "test"), config)); + private readonly AmazonSimpleSystemsManagementClient _ssm = + Client( + fixture, config => new(new BasicAWSCredentials("test", "test"), config)); + + public async ValueTask InitializeAsync() => await fixture.HttpClient.PostAsync("/_microstack/reset", null); + + public ValueTask DisposeAsync() + { + _cloudFormation.Dispose(); + _cloudWatch.Dispose(); + _logs.Dispose(); + _ssm.Dispose(); + return ValueTask.CompletedTask; + } + + [Fact] + public async Task ExposesEveryObservabilityRootAndCloudFormationChildKind() + { + const string template = """ + {"Resources":{"Queue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"admin-cfn-queue"}}}, + "Outputs":{"QueueName":{"Value":"admin-cfn-queue","Description":"created queue"}}} + """; + await _cloudFormation.CreateStackAsync(new CreateStackRequest { StackName = "admin-stack", TemplateBody = template }); + await _cloudWatch.PutMetricDataAsync(new PutMetricDataRequest + { + Namespace = "Admin/Test", + MetricData = + [ + new MetricDatum + { + MetricName = "Latency", Value = 2.5, Unit = Amazon.CloudWatch.StandardUnit.Milliseconds, + }, + ], + }); + await _cloudWatch.PutMetricAlarmAsync(new PutMetricAlarmRequest + { + AlarmName = "admin-alarm", Namespace = "Admin/Test", MetricName = "Latency", + ComparisonOperator = ComparisonOperator.GreaterThanThreshold, EvaluationPeriods = 1, + Period = 60, Statistic = Statistic.Average, Threshold = 1, + }); + await _cloudWatch.PutDashboardAsync(new PutDashboardRequest + { + DashboardName = "admin-dashboard", DashboardBody = """{"widgets":[]}""", + }); + + var cfn = await Resources("cloudformation"); + Kinds(cfn).ShouldBe(["stack"]); + var stackChildren = await Children("cloudformation", new PathKey("stack", "admin-stack")); + Kinds(stackChildren).ShouldContain("resource"); + Kinds(stackChildren).ShouldContain("event"); + Kinds(stackChildren).ShouldContain("output"); + Kinds(stackChildren).ShouldContain("template"); + + var cloudWatch = await Resources("cloudwatch"); + Kinds(cloudWatch).ShouldContain("metric"); + Kinds(cloudWatch).ShouldContain("alarm"); + Kinds(cloudWatch).ShouldContain("dashboard"); + var metric = cloudWatch.GetProperty("items").EnumerateArray().Single(x => Kind(x) == "metric"); + var points = await Children("cloudwatch", Key(metric)); + Kinds(points).ShouldBe(["datapoint"]); + var point = points.GetProperty("items")[0]; + (await Content("cloudwatch", Key(metric), Key(point))).GetProperty("kind").GetString().ShouldBe("json"); + } + + [Fact] + public async Task LogHierarchyPaginatesWithoutConsumingEventsAndIncludesFilters() + { + await _logs.CreateLogGroupAsync(new CreateLogGroupRequest + { + LogGroupName = "/admin/logs", Tags = new Dictionary { ["environment"] = "test" }, + }); + await _logs.CreateLogStreamAsync(new CreateLogStreamRequest + { + LogGroupName = "/admin/logs", LogStreamName = "stream", + }); + await _logs.PutLogEventsAsync(new PutLogEventsRequest + { + LogGroupName = "/admin/logs", LogStreamName = "stream", + LogEvents = + [ + new() { Timestamp = DateTime.UtcNow, Message = "first" }, + new() { Timestamp = DateTime.UtcNow.AddMilliseconds(1), Message = "second" }, + ], + }); + await _logs.PutSubscriptionFilterAsync(new PutSubscriptionFilterRequest + { + LogGroupName = "/admin/logs", FilterName = "subscription", FilterPattern = "ERROR", + DestinationArn = "arn:aws:lambda:us-east-1:000000000000:function:sink", + }); + await _logs.PutMetricFilterAsync(new PutMetricFilterRequest + { + LogGroupName = "/admin/logs", FilterName = "metric", FilterPattern = "ERROR", + MetricTransformations = + [ + new() { MetricName = "Errors", MetricNamespace = "Admin/Test", MetricValue = "1" }, + ], + }); + + var groups = await Resources("logs"); + Kinds(groups).ShouldBe(["log-group"]); + var groupChildren = await Children("logs", new PathKey("log-group", "/admin/logs")); + Kinds(groupChildren).ShouldContain("log-stream"); + Kinds(groupChildren).ShouldContain("subscription-filter"); + Kinds(groupChildren).ShouldContain("metric-filter"); + Kinds(groupChildren).ShouldContain("tag"); + + var firstPage = await Children("logs", new("log-group", "/admin/logs"), + new("log-stream", "stream"), pageSize: 1); + firstPage.GetProperty("items").GetArrayLength().ShouldBe(1); + var cursor = firstPage.GetProperty("nextCursor").GetString(); + cursor.ShouldNotBeNull(); + var secondPage = await Children("logs", [new("log-group", "/admin/logs"), new("log-stream", "stream")], + pageSize: 1, cursor: cursor); + secondPage.GetProperty("items").GetArrayLength().ShouldBe(1); + + var eventsAfterInspection = await _logs.GetLogEventsAsync(new GetLogEventsRequest + { + LogGroupName = "/admin/logs", LogStreamName = "stream", + }); + eventsAfterInspection.Events.Count.ShouldBe(2); + } + + [Fact] + public async Task SecureParametersStayRedactedUntilExplicitRevealAndInspectionDoesNotMutateOrCrossAccounts() + { + await _ssm.PutParameterAsync(new PutParameterRequest + { + Name = "/admin/plain", Type = ParameterType.String, Value = "ordinary", + }); + await _ssm.PutParameterAsync(new PutParameterRequest + { + Name = "/admin/secret", Type = ParameterType.SecureString, Value = "swordfish", + }); + await _ssm.PutParameterAsync(new PutParameterRequest + { + Name = "/admin/secret", Type = ParameterType.SecureString, Value = "new-swordfish", Overwrite = true, + }); + await _ssm.AddTagsToResourceAsync(new AddTagsToResourceRequest + { + ResourceId = "/admin/secret", ResourceType = ResourceTypeForTagging.Parameter, + Tags = [new() { Key = "owner", Value = "admin-test" }], + }); + + var parameters = await Resources("ssm"); + Kinds(parameters).ShouldBe(["parameter", "parameter"]); + (await Content("ssm", new PathKey("parameter", "/admin/plain"))).GetProperty("text").GetString() + .ShouldBe("ordinary"); + var secretPath = new PathKey("parameter", "/admin/secret"); + var detail = await Detail("ssm", secretPath); + detail.GetRawText().ShouldNotContain("swordfish"); + detail.GetProperty("fields").EnumerateArray().Single(x => x.GetProperty("name").GetString() == "Value") + .GetProperty("value").GetString().ShouldBe("••••••••"); + (await Content("ssm", secretPath)).GetRawText().ShouldNotContain("swordfish"); + + var children = await Children("ssm", secretPath); + Kinds(children).Count(x => x == "parameter-version").ShouldBe(2); + Kinds(children).ShouldContain("tag"); + children.GetRawText().ShouldNotContain("swordfish"); + + using var revealResponse = await fixture.HttpClient.PostAsync( + AdminUrl("ssm", "reveal", [secretPath]) + "&field=Value", null); + revealResponse.Headers.CacheControl?.NoStore.ShouldBeTrue(); + var reveal = await ReadJson(revealResponse); + reveal.GetProperty("text").GetString().ShouldBe("new-swordfish"); + reveal.GetProperty("sensitive").GetBoolean().ShouldBeTrue(); + + var after = await _ssm.GetParameterAsync(new GetParameterRequest { Name = "/admin/secret", WithDecryption = true }); + after.Parameter.Value.ShouldBe("new-swordfish"); + (await Resources("ssm", "111111111111")).GetProperty("items").GetArrayLength().ShouldBe(0); + using var ordinaryReveal = await fixture.HttpClient.PostAsync( + AdminUrl("ssm", "reveal", [new PathKey("parameter", "/admin/plain")]) + "&field=Value", null); + ordinaryReveal.StatusCode.ShouldBe(HttpStatusCode.Conflict); + } + + private async Task Resources(string service, string? account = null) + { + var url = $"/_microstack/admin/v1/services/{service}/resources"; + if (account is not null) url += $"?accountId={account}"; + return await ReadJson(await fixture.HttpClient.GetAsync(url)); + } + + private async Task Detail(string service, params PathKey[] path) => + await ReadJson(await fixture.HttpClient.GetAsync(AdminUrl(service, "resource", path))); + + private async Task Content(string service, params PathKey[] path) => + await ReadJson(await fixture.HttpClient.GetAsync(AdminUrl(service, "content", path))); + + private Task Children(string service, PathKey key, int pageSize = 50) => + Children(service, [key], pageSize); + + private Task Children(string service, PathKey first, PathKey second, int pageSize = 50) => + Children(service, [first, second], pageSize); + + private async Task Children( + string service, PathKey[] path, int pageSize = 50, string? cursor = null) + { + var url = AdminUrl(service, "children", path) + $"&pageSize={pageSize}"; + if (cursor is not null) url += $"&cursor={Uri.EscapeDataString(cursor)}"; + return await ReadJson(await fixture.HttpClient.GetAsync(url)); + } + + private static string AdminUrl(string service, string operation, PathKey[] path) => + $"/_microstack/admin/v1/services/{service}/{operation}?path=" + + Uri.EscapeDataString(JsonSerializer.Serialize(path)); + + private static async Task ReadJson(HttpResponseMessage response) + { + response.EnsureSuccessStatusCode(); + return JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.Clone(); + } + + private static PathKey Key(JsonElement resource) + { + var key = resource.GetProperty("key"); + return new(key.GetProperty("kind").GetString()!, key.GetProperty("id").GetString()!); + } + + private static string Kind(JsonElement resource) => resource.GetProperty("key").GetProperty("kind").GetString()!; + + private static string[] Kinds(JsonElement page) => + page.GetProperty("items").EnumerateArray().Select(Kind).ToArray(); + + private static TClient Client( + MicroStackFixture fixture, Func create) + where TClient : AmazonServiceClient + where TConfig : ClientConfig, new() + { + var httpClient = new HttpClient(new CanonicalizeUriHandler(fixture.CreateHandler())) + { + BaseAddress = new Uri("http://localhost/"), + }; + var config = new TConfig + { + RegionEndpoint = RegionEndpoint.USEast1, + ServiceURL = "http://localhost/", + HttpClientFactory = new FixedHttpClientFactory(httpClient), + }; + return create(config); + } + + private sealed record PathKey( + [property: JsonPropertyName("kind")] string Kind, + [property: JsonPropertyName("id")] string Id); +} diff --git a/tests/MicroStack.Tests/AdminPresentationContractTests.cs b/tests/MicroStack.Tests/AdminPresentationContractTests.cs new file mode 100644 index 0000000..a137503 --- /dev/null +++ b/tests/MicroStack.Tests/AdminPresentationContractTests.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using MicroStack.Admin.Contracts; +using MicroStack.Internal.Admin; + +namespace MicroStack.Tests; + +public sealed class AdminPresentationContractTests +{ + [Fact] + public void Older_contracts_keep_conservative_presentation_defaults() + { + var kind = JsonSerializer.Deserialize( + """{"id":"queues","label":"Queues"}""", AdminJsonContext.Default.AdminResourceKind)!; + kind.IsRoot.ShouldBeTrue(); + + var detail = JsonSerializer.Deserialize( + """{"resource":{"key":{"kind":"queues","id":"orders"},"name":"orders"},"hasChildren":true}""", + AdminJsonContext.Default.AdminResourceDetail)!; + detail.Resource.Type.ShouldBeNull(); + detail.Resource.Summary.ShouldBeEmpty(); + detail.ChildKinds.ShouldBeEmpty(); + detail.Summary.ShouldBeEmpty(); + detail.ConnectionCount.ShouldBeNull(); + detail.Fields.ShouldBeEmpty(); + detail.RevealableFields.ShouldBeEmpty(); + + var service = JsonSerializer.Deserialize( + """{"id":"sqs","name":"SQS","label":"SQS","category":"Messaging","icon":"sqs","canonicalHandler":"sqs","availability":"available","scope":"account"}""", + AdminJsonContext.Default.AdminService)!; + service.Kinds.ShouldBeEmpty(); + } + + [Fact] + public void Presentation_metadata_roundtrips_through_the_AOT_contract() + { + var detail = new AdminResourceDetail(new(new("queues", "orders"), "orders") + { + Type = "FIFO queue", + Summary = [new("Created", "2026-09-13T10:00:00Z", Format: "datetime")] + }) + { + HasChildren = true, + ChildKinds = [new("messages", "Messages") { IsRoot = false }], + Summary = [new("Visible", "0")], + Fields = [new("Body MD5", "demo") { Secondary = true }], + ConnectionCount = 0 + }; + + var json = JsonSerializer.Serialize(detail, AdminJsonContext.Default.AdminResourceDetail); + json.ShouldContain("\"isRoot\":false"); + json.ShouldContain("\"secondary\":true"); + json.ShouldContain("\"connectionCount\":0"); + var restored = JsonSerializer.Deserialize(json, AdminJsonContext.Default.AdminResourceDetail)!; + restored.Resource.Type.ShouldBe("FIFO queue"); + restored.Resource.Summary.Single().Format.ShouldBe("datetime"); + restored.ChildKinds.Single().IsRoot.ShouldBeFalse(); + restored.Summary.Single().Value.ShouldBe("0"); + restored.Fields.Single().Secondary.ShouldBeTrue(); + restored.ConnectionCount.ShouldBe(0); + } + + [Fact] + public void Secondary_sensitive_fields_are_still_masked() + { + var field = AdminData.Field("Token", "never-render-this", sensitive: true, secondary: true); + field.Value.ShouldBe(AdminData.MaskedValue); + field.Sensitive.ShouldBeTrue(); + field.Secondary.ShouldBeTrue(); + } + + [Fact] + public void Configured_connection_origin_preserves_opaque_source_paths() + { + var source = new AdminKey[] + { + new("event-buses", "arn:aws:events:us-east-1:000000000000:event-bus/custom"), + new("rules", "arn:aws:events:us-east-1:000000000000:rule/custom/order-routing") + }; + var connection = new AdminConnection("Orders", "targets", "sqs", + [new("queues", "arn:aws:sqs:us-east-1:000000000000:orders")]) + { + SourceServiceId = "events", + SourcePath = source + }; + var json = JsonSerializer.Serialize(connection, AdminJsonContext.Default.AdminConnection); + var restored = JsonSerializer.Deserialize(json, AdminJsonContext.Default.AdminConnection)!; + restored.SourceServiceId.ShouldBe("events"); + restored.SourcePath.ShouldBe(source); + restored.TargetPath.ShouldBe(connection.TargetPath); + } +} diff --git a/tests/MicroStack.Tests/AdminRelationshipTests.cs b/tests/MicroStack.Tests/AdminRelationshipTests.cs new file mode 100644 index 0000000..d2c7f3b --- /dev/null +++ b/tests/MicroStack.Tests/AdminRelationshipTests.cs @@ -0,0 +1,339 @@ +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using MicroStack.Admin.Contracts; +using MicroStack.Internal; +using MicroStack.Internal.Admin; +using Microsoft.Extensions.DependencyInjection; + +namespace MicroStack.Tests; + +public sealed class AdminRelationshipTests(MicroStackFixture fixture) + : IClassFixture, IAsyncLifetime +{ + private const string Account = "111111111111"; + private ServiceRegistry Registry => fixture.Factory.Services.GetRequiredService(); + + public ValueTask InitializeAsync() + { + Registry.ResetAll(); + AccountContext.Reset(); + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() + { + AccountContext.Reset(); + return ValueTask.CompletedTask; + } + + [Fact] + public async Task ReverseQueueLinksDeduplicateProjectionViewsAndRetainDistinctConfigurationsAndPaging() + { + using var scope = AccountContext.BeginScope(Account); + var queue = await Queue("destination"); + var sourceQueue = await Queue("source", queue.Resource.Arn); + await Topic("producer-a", queue.Resource.Arn!); + await Topic("producer-b", queue.Resource.Arn!); + await Rule("rule-a", queue.Resource.Arn!); + await Rule("rule-b", queue.Resource.Arn!); + var state = Registry.Resolve("sqs")!.GetState()!.Value.GetRawText(); + + var detail = await Detail("sqs", [queue.Resource.Key]); + var links = await Connections("sqs", [queue.Resource.Key]); + detail.HasConnections.ShouldBeTrue(); + detail.ConnectionCount.ShouldBe(5); + links.KnownTotal.ShouldBe(detail.ConnectionCount); + links.Items.Count.ShouldBe(5); + links.Items.Count(link => link.TargetServiceId == "sns").ShouldBe(2); + links.Items.Count(link => link.TargetServiceId == "events").ShouldBe(2); + links.Items.Single(link => link.Relation == "redrive-source").TargetPath.ShouldBe([sourceQueue.Resource.Key]); + links.Items.ShouldAllBe(link => link.State == "configured"); + links.Items.Where(link => link.TargetServiceId == "events").ShouldAllBe(link => link.TargetPath!.Length == 3); + links.Items.Where(link => link.TargetServiceId == "sns").ShouldAllBe(link => link.TargetPath!.Length == 2); + + var paged = new List(); + string? cursor = null; + do + { + var page = await Connections("sqs", [queue.Resource.Key], "&pageSize=2" + + (cursor is null ? "" : "&cursor=" + Uri.EscapeDataString(cursor))); + page.KnownTotal.ShouldBe(5); + paged.AddRange(page.Items); + cursor = page.NextCursor; + } while (cursor is not null); + JsonSerializer.Serialize(paged.ToArray(), AdminJsonContext.Default.AdminConnectionArray) + .ShouldBe(JsonSerializer.Serialize(links.Items.ToArray(), AdminJsonContext.Default.AdminConnectionArray)); + + foreach (var link in links.Items) + { + var destination = await Detail(link.TargetServiceId!, link.TargetPath!); + destination.Resource.Key.ShouldBe(link.TargetPath![^1]); + } + var outgoing = await Connections("sqs", [sourceQueue.Resource.Key]); + outgoing.Items.Single(link => link.Relation == "redrive").TargetPath.ShouldBe([queue.Resource.Key]); + Registry.Resolve("sqs")!.GetState()!.Value.GetRawText().ShouldBe(state); + } + + [Fact] + public async Task TopicAndBusParentContextRetainsOriginalSubscriptionAndTargetPaths() + { + using var scope = AccountContext.BeginScope(Account); + var queue = await Queue("parent-context"); + var topic = await Topic("parent-topic", queue.Resource.Arn!); + await Rule("parent-rule", queue.Resource.Arn!); + var bus = Source("events").GetAdminResources("events").Single(); + var rule = bus.ReadChildren!().Single(); + var target = rule.ReadChildren!().Single(); + var subscription = topic.ReadChildren!().Single(); + + var topicLinks = await Connections("sns", [topic.Resource.Key]); + var busLinks = await Connections("events", [bus.Resource.Key]); + var ruleLinks = await Connections("events", [bus.Resource.Key, rule.Resource.Key]); + topicLinks.Items.ShouldHaveSingleItem(); + topicLinks.Items[0].SourceServiceId.ShouldBe("sns"); + topicLinks.Items[0].SourcePath.ShouldBe([topic.Resource.Key, subscription.Resource.Key]); + topicLinks.Items[0].TargetPath.ShouldBe([queue.Resource.Key]); + busLinks.Items.ShouldHaveSingleItem(); + busLinks.Items[0].SourceServiceId.ShouldBe("events"); + busLinks.Items[0].SourcePath.ShouldBe([bus.Resource.Key, rule.Resource.Key, target.Resource.Key]); + ruleLinks.Items.Single(link => link.Relation == "belongs-to").TargetPath.ShouldBe([bus.Resource.Key]); + ruleLinks.Items.Single(link => link.Relation == "targets").SourcePath + .ShouldBe([bus.Resource.Key, rule.Resource.Key, target.Resource.Key]); + (await Detail("sns", [topic.Resource.Key])).ConnectionCount.ShouldBe(topicLinks.KnownTotal); + (await Detail("events", [bus.Resource.Key])).ConnectionCount.ShouldBe(busLinks.KnownTotal); + } + + [Fact] + public async Task ReverseLinksRequireExactFullArnAndSameAccount() + { + AdminNode queue; + using (AccountContext.BeginScope(Account)) + { + queue = await Queue("same-name"); + await Topic("matching", queue.Resource.Arn!); + await Topic("different-region", queue.Resource.Arn!.Replace(":us-east-1:", ":eu-west-1:", StringComparison.Ordinal)); + await Topic("similar-name", queue.Resource.Arn + "-suffix"); + await Topic("other-account", queue.Resource.Arn.Replace(Account, "222222222222", StringComparison.Ordinal)); + } + using (AccountContext.BeginScope("222222222222")) + { + var otherQueue = await Queue("same-name"); + await Topic("foreign-producer", queue.Resource.Arn!); + await Topic("own-producer", otherQueue.Resource.Arn!); + await Rule("foreign-rule", queue.Resource.Arn!); + } + + var own = await Connections("sqs", [queue.Resource.Key]); + own.Items.ShouldHaveSingleItem(); + own.Items[0].Label.ShouldContain("matching"); + using var foreignPath = await fixture.HttpClient.GetAsync( + Url("sqs", "resource", [queue.Resource.Key], account: "222222222222")); + foreignPath.StatusCode.ShouldBe(HttpStatusCode.NotFound); + } + + [Fact] + public async Task MissingExternalAndDisabledDestinationsAreTruthful() + { + using var scope = AccountContext.BeginScope(Account); + var queue = await Queue("deleted"); + var topic = await Topic("links", queue.Resource.Arn!); + await Query(Registry.Resolve("sns")!, ("Action", "Subscribe"), ("TopicArn", topic.Resource.Arn!), + ("Protocol", "sqs"), ("Endpoint", queue.Resource.Arn!.Replace(Account, "222222222222", StringComparison.Ordinal))); + await Query(Registry.Resolve("sns")!, ("Action", "Subscribe"), ("TopicArn", topic.Resource.Arn!), + ("Protocol", "https"), ("Endpoint", "https://example.test/webhook")); + await Json(Registry.Resolve("sqs")!, "AmazonSQS.DeleteQueue", new + { + QueueUrl = queue.ReadFields!().Single(field => field.Name == "Queue URL").Value! + }); + + var links = await Connections("sns", [topic.Resource.Key]); + links.Items.Count(link => link.State == "missing").ShouldBe(1); + links.Items.Count(link => link.State == "external").ShouldBe(2); + links.Items.Single(link => link.State == "missing").TargetPath.ShouldBe([queue.Resource.Key]); + + var disabledRegistry = new ServiceRegistry(new MicroStackOptions { Services = "sns" }); + disabledRegistry.Register(Registry.Resolve("sns")!); + var current = Source("sns").GetAdminResources("sns").Single(); + var disabled = new AdminRelationshipResolver(disabledRegistry, Account) + .Read(current, "sns", [current.Resource.Key]); + disabled.Single(link => link.TargetPath is { } path && path[0] == queue.Resource.Key).State.ShouldBe("disabled"); + disabled.Count(link => link.State == "external").ShouldBe(2); + } + + [Fact] + public async Task StepFunctionsTargetUsesCatalogIdAndExactIdentity() + { + using var scope = AccountContext.BeginScope(Account); + await Json(Registry.Resolve("states")!, "AWSStepFunctions.CreateStateMachine", new + { + name = "configured-workflow", + roleArn = $"arn:aws:iam::{Account}:role/test", + definition = """{"StartAt":"Done","States":{"Done":{"Type":"Succeed"}}}""" + }); + var machine = Source("states").GetAdminResources("stepfunctions").Single(); + await Rule("workflow-rule", machine.Resource.Arn!); + var bus = Source("events").GetAdminResources("events").Single(); + var links = await Connections("events", [bus.Resource.Key]); + + var link = links.Items.Single(); + link.TargetServiceId.ShouldBe("stepfunctions"); + link.TargetPath.ShouldBe([machine.Resource.Key]); + link.State.ShouldBe("configured"); + (await Detail(link.TargetServiceId!, link.TargetPath!)).Resource.Key.ShouldBe(machine.Resource.Key); + Source("states").GetAdminResources("stepfunctions").Single().ReadChildren!().ShouldBeEmpty(); + } + + [Fact] + public async Task DisabledRuleSourceRetainsFullReturnPath() + { + using var scope = AccountContext.BeginScope(Account); + var queue = await Queue("disabled-rule-target"); + await Rule("disabled-source", queue.Resource.Arn!); + await Json(Registry.Resolve("events")!, "AWSEvents.DisableRule", new { Name = "disabled-source" }); + var links = await Connections("sqs", [queue.Resource.Key]); + var link = links.Items.Single(); + link.State.ShouldBe("disabled"); + link.TargetPath!.Length.ShouldBe(3); + link.TargetPath[1].Kind.ShouldBe("rules"); + } + + [Fact] + public async Task MalformedRetainedRedrivePolicyRemainsVisibleAsUnavailableConfiguration() + { + using var scope = AccountContext.BeginScope(Account); + var queue = await Queue("malformed-redrive"); + await Json(Registry.Resolve("sqs")!, "AmazonSQS.SetQueueAttributes", new + { + QueueUrl = queue.ReadFields!().Single(field => field.Name == "Queue URL").Value!, + Attributes = new { RedrivePolicy = "{invalid" } + }); + var detail = await Detail("sqs", [queue.Resource.Key]); + var links = await Connections("sqs", [queue.Resource.Key]); + detail.Fields.Single(field => field.Name == "RedrivePolicy").Value.ShouldBe("{invalid"); + detail.ConnectionCount.ShouldBe(1); + links.Items.Single().State.ShouldBe("unavailable"); + links.Items.Single().Label.ShouldContain("Invalid redrive policy"); + } + + [Fact] + public void OtherProvidersKeepUnverifiedConfiguredLinkSemantics() + { + AdminKey[] sourcePath = [new("instances", "database")]; + AdminKey[] destinationPath = [new("vpcs", "vpc-retained-configuration")]; + var configured = new AdminConnection("VPC", "runs-in", "ec2", destinationPath); + var node = AdminData.Node("instances", "database", "database") with + { + ReadConnections = () => [configured] + }; + var registry = new ServiceRegistry(new MicroStackOptions()); + + var links = new AdminRelationshipResolver(registry, Account).Read(node, "rds", sourcePath); + + links.ShouldHaveSingleItem(); + links[0].ShouldBe(configured); + links[0].State.ShouldBe("configured"); + links[0].TargetPath.ShouldBe(destinationPath); + } + + [Fact] + public void RelationshipSnapshotsNeverReadProviderHierarchiesPayloadsOrCallAwsOperations() + { + using var scope = AccountContext.BeginScope(Account); + AdminKey[] queuePath = [new("queues", $"arn:aws:sqs:us-east-1:{Account}:metadata-only")]; + var topic = new AdminKey("topics", $"arn:aws:sns:us-east-1:{Account}:metadata-only"); + AdminKey[] subscriptionA = [topic, new("subscriptions", topic.Id + ":a")]; + AdminKey[] subscriptionB = [topic, new("subscriptions", topic.Id + ":b")]; + var first = new AdminConfiguredRelationship("sns", subscriptionA, "same label", + new("Queue", "delivers-to", "sqs", queuePath)); + var second = first with { SourcePath = subscriptionB }; + var registry = new ServiceRegistry(new MicroStackOptions()); + registry.Register(new MetadataOnlyHandler("sqs", new([new(queuePath, "queue")], []))); + registry.Register(new MetadataOnlyHandler("sns", new( + [new(subscriptionA, "subscription"), new(subscriptionB, "subscription")], + [first, first, second]))); + var links = new AdminRelationshipResolver(registry, Account) + .Read(AdminData.Node("queues", queuePath[0].Id, "queue"), "sqs", queuePath); + + links.Count.ShouldBe(2); + links.Select(link => link.TargetPath![^1].Id).ShouldBe([subscriptionA[^1].Id, subscriptionB[^1].Id]); + links.ShouldAllBe(link => link.State == "configured"); + } + + private IAdminResourceSource Source(string service) => (IAdminResourceSource)Registry.Resolve(service)!; + + private async Task Queue(string name, string? deadLetterArn = null) + { + var attributes = new Dictionary(); + if (deadLetterArn is not null) + attributes["RedrivePolicy"] = JsonSerializer.Serialize(new { deadLetterTargetArn = deadLetterArn, maxReceiveCount = "3" }); + await Json(Registry.Resolve("sqs")!, "AmazonSQS.CreateQueue", new { QueueName = name, Attributes = attributes }); + return Source("sqs").GetAdminResources("sqs").Single(node => node.Resource.Name == name); + } + + private async Task Topic(string name, string targetArn) + { + var handler = Registry.Resolve("sns")!; + await Query(handler, ("Action", "CreateTopic"), ("Name", name)); + var topic = Source("sns").GetAdminResources("sns").Single(node => node.Resource.Name == name); + await Query(handler, ("Action", "Subscribe"), ("TopicArn", topic.Resource.Arn!), + ("Protocol", "sqs"), ("Endpoint", targetArn)); + return Source("sns").GetAdminResources("sns").Single(node => node.Resource.Name == name); + } + + private async Task Rule(string name, string targetArn) + { + var handler = Registry.Resolve("events")!; + await Json(handler, "AWSEvents.PutRule", new { Name = name, EventPattern = """{"source":["orders"]}""" }); + await Json(handler, "AWSEvents.PutTargets", new + { + Rule = name, Targets = new[] { new { Id = "configured-target", Arn = targetArn, Input = """{"not":"relationship metadata"}""" } } + }); + } + + private async Task Detail(string service, AdminKey[] path) => + (await fixture.HttpClient.GetFromJsonAsync(Url(service, "resource", path), + AdminJsonContext.Default.AdminResourceDetail))!; + + private async Task> Connections(string service, AdminKey[] path, string query = "") => + (await fixture.HttpClient.GetFromJsonAsync(Url(service, "connections", path) + query, + AdminJsonContext.Default.AdminPageAdminConnection))!; + + private static string Url(string service, string operation, AdminKey[] path, string account = Account) => + $"/_microstack/admin/v1/services/{service}/{operation}?accountId={account}&path=" + + Uri.EscapeDataString(JsonSerializer.Serialize(path, AdminJsonContext.Default.AdminKeyArray)); + + private static async Task Query(IServiceHandler handler, params (string Key, string Value)[] values) + { + var body = string.Join("&", values.Select(value => + $"{Uri.EscapeDataString(value.Key)}={Uri.EscapeDataString(value.Value)}")); + var response = await handler.HandleAsync(new("POST", "/", new Dictionary(), + Encoding.UTF8.GetBytes(body), new Dictionary())); + response.StatusCode.ShouldBe(200); + } + + private static async Task Json(IServiceHandler handler, string target, object value) + { + var response = await handler.HandleAsync(new("POST", "/", new Dictionary + { + ["x-amz-target"] = target, + ["content-type"] = "application/x-amz-json-1.0" + }, JsonSerializer.SerializeToUtf8Bytes(value), new Dictionary())); + response.StatusCode.ShouldBe(200); + } + + private sealed class MetadataOnlyHandler(string service, AdminRelationshipSnapshot snapshot) + : IServiceHandler, IAdminResourceSource, IAdminRelationshipSource + { + public string ServiceName => service; + public AdminRelationshipSnapshot GetAdminRelationshipSnapshot() => snapshot; + public IEnumerable GetAdminResources(string serviceId) => + throw new InvalidOperationException("Relationships must not inspect payload-bearing resource hierarchies."); + public IReadOnlyList GetAdminResourceKinds(string serviceId) => []; + public Task HandleAsync(ServiceRequest request) => + throw new InvalidOperationException("Inspection must not perform AWS operations."); + public JsonElement? GetState() => throw new InvalidOperationException("Inspection must not read persisted payloads."); + public void RestoreState(JsonElement state) => throw new InvalidOperationException("Inspection must not mutate state."); + public void Reset() { } + } +} diff --git a/tests/MicroStack.Tests/AdminRootKindMetadataTests.cs b/tests/MicroStack.Tests/AdminRootKindMetadataTests.cs new file mode 100644 index 0000000..f038c19 --- /dev/null +++ b/tests/MicroStack.Tests/AdminRootKindMetadataTests.cs @@ -0,0 +1,329 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Amazon; +using Amazon.APIGateway; +using Amazon.APIGateway.Model; +using Amazon.ElasticLoadBalancingV2; +using Amazon.ElasticLoadBalancingV2.Model; +using Amazon.RDS; +using Amazon.RDS.Model; +using Amazon.Runtime; +using MicroStack.Admin.Contracts; +using MicroStack.Internal; +using MicroStack.Internal.Admin; +using Microsoft.Extensions.DependencyInjection; + +namespace MicroStack.Tests; + +public sealed class AdminRootKindMetadataTests(MicroStackFixture fixture) + : IClassFixture, IAsyncLifetime +{ + private static readonly Dictionary ExpectedRoots = new(StringComparer.Ordinal) + { + ["ses"] = ["identities", "templates", "configuration-sets", "emails"], + ["stepfunctions"] = ["state-machines", "activities"], + ["s3files"] = ["file-systems"], + ["efs"] = ["file-systems"], + ["rds"] = + [ + "db-instances", "db-clusters", "db-subnet-groups", "db-parameter-groups", + "db-cluster-parameter-groups", "db-snapshots", "db-cluster-snapshots", + "event-subscriptions", "db-proxies", "option-groups", "global-clusters", + ], + ["rdsdata"] = [], + ["elasticache"] = + [ + "cache-clusters", "replication-groups", "cache-subnet-groups", "cache-parameter-groups", + "snapshots", "users", "user-groups", + ], + ["lambda"] = ["functions", "layers", "event-source-mappings"], + ["ec2"] = + [ + "instances", "security-groups", "key-pairs", "vpcs", "subnets", "internet-gateways", + "elastic-ips", "route-tables", "network-interfaces", "vpc-endpoints", "volumes", + "snapshots", "nat-gateways", "network-acls", "flow-logs", "vpc-peerings", + "dhcp-options", "egress-internet-gateways", "prefix-lists", "vpn-gateways", + "customer-gateways", "launch-templates", + ], + ["ecs"] = ["clusters", "task-definitions"], + ["ecr"] = ["repositories"], + ["apigateway"] = ["rest-apis", "api-keys", "usage-plans", "domain-names"], + ["apigatewayv2"] = ["apis"], + ["alb"] = ["load-balancers", "target-groups"], + ["appsync"] = ["graphql-apis"], + ["cloudfront"] = ["distributions"], + ["route53"] = ["hosted-zones"], + ["servicediscovery"] = ["namespaces"], + ["acm"] = ["certificate"], + ["cognitoidp"] = ["user-pool"], + ["cognitoidentity"] = ["identity-pool"], + ["iam"] = ["user", "role", "policy", "group", "instance-profile", "oidc-provider"], + ["kms"] = ["key", "alias"], + ["secretsmanager"] = ["secret"], + ["sts"] = [], + ["waf"] = ["web-acl", "ip-set", "rule-group"], + ["athena"] = ["query-execution", "workgroup", "named-query", "data-catalog", "prepared-statement"], + ["emr"] = ["cluster"], + ["firehose"] = ["delivery-stream"], + ["glue"] = ["database", "crawler", "job", "registry"], + ["kinesis"] = ["stream"], + ["cloudformation"] = ["stack"], + ["cloudwatch"] = ["metric", "alarm", "dashboard"], + ["logs"] = ["log-group", "destination"], + ["ssm"] = ["parameter"], + }; + + public async ValueTask InitializeAsync() + { + using var response = await fixture.HttpClient.PostAsync("/_microstack/reset", null); + response.EnsureSuccessStatusCode(); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + [Fact] + public async Task EveryOtherCatalogServiceDeclaresItsActualRootKindsWithoutInferringFromData() + { + var services = await fixture.HttpClient.GetFromJsonAsync( + "/_microstack/admin/v1/services", AdminJsonContext.Default.AdminServiceArray); + services.ShouldNotBeNull(); + var covered = services.Where(service => + service.Id is not ("sqs" or "sns" or "events" or "s3" or "dynamodb")).ToArray(); + covered.Select(service => service.Id).Order().ShouldBe(ExpectedRoots.Keys.Order()); + + foreach (var service in covered) + { + service.Kinds.Select(kind => kind.Id).ShouldBeUnique(); + service.Kinds.ShouldAllBe(kind => !string.IsNullOrWhiteSpace(kind.Label)); + service.Kinds.Where(kind => kind.IsRoot).Select(kind => kind.Id).Order() + .ShouldBe(ExpectedRoots[service.Id].Order(), service.Id); + + var roots = await Resources(service.Id); + roots.Items.ShouldAllBe(resource => ExpectedRoots[service.Id].Contains(resource.Key.Kind)); + foreach (var root in roots.Items) + { + var detail = await Detail(service.Id, root.Key); + if (detail.HasChildren) + detail.ChildKinds.ShouldNotBeEmpty(service.Id + ":" + root.Key.Kind); + } + } + } + + [Fact] + public async Task AlbCatalogIdRetainsBothRootsAndStableEmptyListenerRuleAndTargetCollections() + { + using var client = Client( + config => new(new BasicAWSCredentials("test", "test"), config)); + var lb = await client.CreateLoadBalancerAsync(new CreateLoadBalancerRequest { Name = "metadata-lb" }); + var tg = await client.CreateTargetGroupAsync(new CreateTargetGroupRequest + { + Name = "metadata-tg", Port = 80, Protocol = ProtocolEnum.HTTP, + }); + var lbArn = lb.LoadBalancers.Single().LoadBalancerArn; + var tgArn = tg.TargetGroups.Single().TargetGroupArn; + var lbKey = new AdminKey("load-balancers", lbArn); + var tgKey = new AdminKey("target-groups", tgArn); + + (await Resources("alb")).Items.Select(item => item.Key.Kind).Order() + .ShouldBe(new[] { "load-balancers", "target-groups" }); + AssertChildKinds(await Detail("alb", lbKey), "listeners"); + (await Children("alb", lbKey)).Items.ShouldBeEmpty(); + AssertChildKinds(await Detail("alb", tgKey), "targets"); + (await Children("alb", tgKey)).Items.ShouldBeEmpty(); + + await client.CreateListenerAsync(new CreateListenerRequest + { + LoadBalancerArn = lbArn, Port = 80, Protocol = ProtocolEnum.HTTP, + DefaultActions = [new() { Type = ActionTypeEnum.Forward, TargetGroupArn = tgArn }], + }); + var listener = (await Children("alb", lbKey)).Items.Single(); + AssertChildKinds(await Detail("alb", lbKey, listener.Key), "rules"); + await client.RegisterTargetsAsync(new RegisterTargetsRequest + { + TargetGroupArn = tgArn, Targets = [new() { Id = "i-metadata" }], + }); + (await Children("alb", tgKey)).Items.Single().Key.Kind.ShouldBe("targets"); + AssertChildKinds(await Detail("alb", tgKey), "targets"); + } + + [Fact] + public async Task RestApiKeepsAdditionalRootsAndEmptyNestedMethodCapabilities() + { + using var client = Client( + config => new(new BasicAWSCredentials("test", "test"), config)); + var api = await client.CreateRestApiAsync(new CreateRestApiRequest { Name = "metadata-api" }); + await client.CreateApiKeyAsync(new CreateApiKeyRequest { Name = "metadata-key" }); + var plan = await client.CreateUsagePlanAsync(new CreateUsagePlanRequest { Name = "metadata-plan" }); + var apiKey = new AdminKey("rest-apis", api.Id); + var planKey = new AdminKey("usage-plans", plan.Id); + (await Resources("apigateway")).Items.Select(item => item.Key.Kind).Order() + .ShouldBe(new[] { "api-keys", "rest-apis", "usage-plans" }); + AssertChildKinds(await Detail("apigateway", apiKey), + "resources", "stages", "deployments", "authorizers", "models"); + AssertChildKinds(await Detail("apigateway", planKey), "usage-plan-keys"); + (await Children("apigateway", planKey)).Items.ShouldBeEmpty(); + + var resources = await client.GetResourcesAsync(new GetResourcesRequest { RestApiId = api.Id }); + var rootId = resources.Items.Single(item => item.Path == "/").Id; + var resource = await client.CreateResourceAsync(new CreateResourceRequest + { + RestApiId = api.Id, ParentId = rootId, PathPart = "items", + }); + var resourceKey = new AdminKey("resources", resource.Id); + AssertChildKinds(await Detail("apigateway", apiKey, resourceKey), "methods"); + (await Children("apigateway", apiKey, resourceKey)).Items.ShouldBeEmpty(); + + await client.PutMethodAsync(new PutMethodRequest + { + RestApiId = api.Id, ResourceId = resource.Id, HttpMethod = "GET", AuthorizationType = "NONE", + }); + var methodKey = new AdminKey("methods", "GET"); + AssertChildKinds(await Detail("apigateway", apiKey, resourceKey, methodKey), + "method-responses", "integrations"); + (await Children("apigateway", apiKey, resourceKey, methodKey)).Items.ShouldBeEmpty(); + await client.PutIntegrationAsync(new PutIntegrationRequest + { + RestApiId = api.Id, ResourceId = resource.Id, HttpMethod = "GET", Type = IntegrationType.MOCK, + }); + var integration = (await Children("apigateway", apiKey, resourceKey, methodKey)).Items.Single(); + AssertChildKinds(await Detail("apigateway", apiKey, resourceKey, methodKey, integration.Key), + "integration-responses"); + } + + [Fact] + public async Task RdsRetainsIndependentInfrastructureRootsAndStableTagMetadata() + { + using var client = Client( + config => new(new BasicAWSCredentials("test", "test"), config)); + var instance = await client.CreateDBInstanceAsync(new CreateDBInstanceRequest + { + DBInstanceIdentifier = "metadata-instance", DBInstanceClass = "db.t3.micro", + Engine = "postgres", MasterUsername = "admin", MasterUserPassword = "test-password", + AllocatedStorage = 20, + MonitoringRoleArn = "arn:aws:iam::000000000000:role/metadata-monitoring", + }); + await client.CreateDBClusterAsync(new CreateDBClusterRequest + { + DBClusterIdentifier = "metadata-cluster", Engine = "aurora-postgresql", + MasterUsername = "admin", MasterUserPassword = "test-password", + }); + await client.CreateDBSubnetGroupAsync(new CreateDBSubnetGroupRequest + { + DBSubnetGroupName = "metadata-subnets", DBSubnetGroupDescription = "Metadata test", + SubnetIds = ["subnet-a", "subnet-b"], + }); + + (await Resources("rds")).Items.Select(item => item.Key.Kind).Order() + .ShouldBe(new[] { "db-clusters", "db-instances", "db-subnet-groups" }); + var key = new AdminKey("db-instances", "metadata-instance"); + var detail = await Detail("rds", key); + detail.Resource.Arn.ShouldBe(instance.DBInstance.DBInstanceArn); + AssertChildKinds(detail, "tags"); + (await Children("rds", key)).Items.ShouldBeEmpty(); + await client.AddTagsToResourceAsync(new AddTagsToResourceRequest + { + ResourceName = instance.DBInstance.DBInstanceArn, + Tags = [new Amazon.RDS.Model.Tag { Key = "owner", Value = "metadata" }], + }); + var retainedTags = await client.ListTagsForResourceAsync(new ListTagsForResourceRequest + { + ResourceName = instance.DBInstance.DBInstanceArn, + }); + retainedTags.TagList.ShouldContain(tag => tag.Key == "owner" && tag.Value == "metadata"); + AssertChildKinds(await Detail("rds", key), "tags"); + (await Children("rds", key)).Items.Single().Key.ShouldBe(new AdminKey("tags", "owner")); + } + + [Fact] + public async Task WorkflowAndFunctionRelationshipSnapshotsRetainOnlyAccountScopedDestinationIdentity() + { + using var lambda = Client( + config => new(new BasicAWSCredentials("test", "test"), config)); + using var states = Client( + config => new(new BasicAWSCredentials("test", "test"), config)); + using var code = new MemoryStream(); + var function = await lambda.CreateFunctionAsync(new Amazon.Lambda.Model.CreateFunctionRequest + { + FunctionName = "metadata-function", Runtime = Amazon.Lambda.Runtime.Dotnet8, + Role = "arn:aws:iam::000000000000:role/metadata", Handler = "Example::Handler", + Code = new() { ZipFile = code }, + }); + var machine = await states.CreateStateMachineAsync(new Amazon.StepFunctions.Model.CreateStateMachineRequest + { + Name = "metadata-machine", RoleArn = "arn:aws:iam::000000000000:role/metadata", + Definition = """{"StartAt":"Done","States":{"Done":{"Type":"Succeed"}}}""", + }); + var activity = await states.CreateActivityAsync(new Amazon.StepFunctions.Model.CreateActivityRequest + { + Name = "metadata-activity", + }); + var context = await fixture.HttpClient.GetFromJsonAsync( + "/_microstack/admin/v1/context", AdminJsonContext.Default.AdminContext); + context.ShouldNotBeNull(); + var registry = fixture.Factory.Services.GetRequiredService(); + var lambdaSource = (IAdminRelationshipSource)registry.Resolve("lambda")!; + var statesSource = (IAdminRelationshipSource)registry.Resolve("states")!; + using (AccountContext.BeginScope(context.DefaultAccount)) + { + var functionSnapshot = lambdaSource.GetAdminRelationshipSnapshot(); + functionSnapshot.Relationships.ShouldBeEmpty(); + var functionResource = functionSnapshot.Resources.Single(); + functionResource.Path.ShouldBe([new AdminKey("functions", "metadata-function")]); + functionResource.Name.ShouldBe("metadata-function"); + functionResource.Arn.ShouldBe(function.FunctionArn); + + var workflowSnapshot = statesSource.GetAdminRelationshipSnapshot(); + workflowSnapshot.Relationships.ShouldBeEmpty(); + workflowSnapshot.Resources.Count.ShouldBe(2); + workflowSnapshot.Resources.Single(item => item.Name == "metadata-machine").Path + .ShouldBe([new AdminKey("state-machines", machine.StateMachineArn)]); + workflowSnapshot.Resources.Single(item => item.Name == "metadata-activity").Path + .ShouldBe([new AdminKey("activities", activity.ActivityArn)]); + } + var otherAccount = context.DefaultAccount == "111111111111" ? "222222222222" : "111111111111"; + using (AccountContext.BeginScope(otherAccount)) + { + lambdaSource.GetAdminRelationshipSnapshot().Resources.ShouldBeEmpty(); + statesSource.GetAdminRelationshipSnapshot().Resources.ShouldBeEmpty(); + } + } + + private static void AssertChildKinds(AdminResourceDetail detail, params string[] expected) + { + detail.HasChildren.ShouldBeTrue(); + detail.ChildKinds.Select(kind => kind.Id).ShouldBe(expected); + detail.ChildKinds.ShouldAllBe(kind => !kind.IsRoot && !string.IsNullOrWhiteSpace(kind.Label)); + } + + private async Task> Resources(string service) => + (await fixture.HttpClient.GetFromJsonAsync( + $"/_microstack/admin/v1/services/{service}/resources?pageSize=200", + AdminJsonContext.Default.AdminPageAdminResourceSummary))!; + + private async Task Detail(string service, params AdminKey[] path) => + (await fixture.HttpClient.GetFromJsonAsync(Url(service, "resource", path), + AdminJsonContext.Default.AdminResourceDetail))!; + + private async Task> Children(string service, params AdminKey[] path) => + (await fixture.HttpClient.GetFromJsonAsync(Url(service, "children", path), + AdminJsonContext.Default.AdminPageAdminResourceSummary))!; + + private static string Url(string service, string operation, AdminKey[] path) => + $"/_microstack/admin/v1/services/{service}/{operation}?path=" + + Uri.EscapeDataString(JsonSerializer.Serialize(path, AdminJsonContext.Default.AdminKeyArray)); + + private TClient Client(Func create) + where TConfig : ClientConfig, new() + { + var http = new HttpClient(new CanonicalizeUriHandler(fixture.CreateHandler())) + { + BaseAddress = new Uri("http://localhost/"), + }; + return create(new TConfig + { + RegionEndpoint = RegionEndpoint.USEast1, + ServiceURL = "http://localhost/", + HttpClientFactory = new FixedHttpClientFactory(http), + }); + } +} diff --git a/tests/MicroStack.Tests/AdminStorageParityApiTests.cs b/tests/MicroStack.Tests/AdminStorageParityApiTests.cs new file mode 100644 index 0000000..146e2a3 --- /dev/null +++ b/tests/MicroStack.Tests/AdminStorageParityApiTests.cs @@ -0,0 +1,171 @@ +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using MicroStack.Admin.Contracts; +using MicroStack.Internal; +using MicroStack.Services.DynamoDb; +using MicroStack.Services.S3; +using Microsoft.Extensions.DependencyInjection; + +namespace MicroStack.Tests; + +public sealed class AdminStorageParityApiTests(MicroStackFixture fixture) : IClassFixture +{ + private const string Account = "838383838383"; + + [Fact] + public async Task S3_SummaryPagingFiltersFullKeysAndKeepsPrefixVersionPaths() + { + var handler = (S3ServiceHandler)fixture.Factory.Services.GetRequiredService().Resolve("s3")!; + using (AccountContext.BeginScope(Account)) + { + await Send(handler, "PUT", "/parity-api-bucket"); + await Send(handler, "PUT", "/parity-api-bucket", + "Enabled", + query: new Dictionary { ["versioning"] = [] }); + await Send(handler, "PUT", "/parity-api-bucket/folder/a.json", "body-must-not-appear", + new Dictionary { ["content-type"] = "application/json" }); + await Send(handler, "PUT", "/parity-api-bucket/folder/b.txt", "second-body-must-not-appear", + new Dictionary { ["content-type"] = "text/plain" }); + } + + AdminKey[] bucket = [new("buckets", "parity-api-bucket")]; + AdminKey[] prefix = [.. bucket, new("prefixes", "folder/")]; + var bucketDetail = await fixture.HttpClient.GetFromJsonAsync( + Url("s3", "resource", bucket), AdminJsonContext.Default.AdminResourceDetail); + bucketDetail!.Summary.Single(x => x.Name == "ObjectCount").Value.ShouldBe("2"); + bucketDetail.ChildKinds.Select(x => x.Id).ShouldBe(["prefixes", "objects"]); + var childrenUrl = Url("s3", "children", prefix); + using var first = await fixture.HttpClient.GetAsync(childrenUrl + "&pageSize=1"); + first.EnsureSuccessStatusCode(); + var json = await first.Content.ReadAsStringAsync(); + json.ShouldNotContain("body-must-not-appear"); + using var page = JsonDocument.Parse(json); + page.RootElement.GetProperty("knownTotal").ValueKind.ShouldBe(JsonValueKind.Null); + page.RootElement.GetProperty("items").GetArrayLength().ShouldBe(1); + var item = page.RootElement.GetProperty("items")[0]; + item.GetProperty("name").GetString().ShouldBe("a.json"); + item.GetProperty("key").GetProperty("id").GetString().ShouldBe("folder/a.json"); + item.GetProperty("summary").GetArrayLength().ShouldBe(3); + item.TryGetProperty("text", out _).ShouldBeFalse(); + var cursor = page.RootElement.GetProperty("nextCursor").GetString(); + cursor.ShouldNotBeNullOrEmpty(); + using var second = await fixture.HttpClient.GetAsync(childrenUrl + "&pageSize=1&cursor=" + Uri.EscapeDataString(cursor)); + second.EnsureSuccessStatusCode(); + using var secondPage = JsonDocument.Parse(await second.Content.ReadAsStringAsync()); + secondPage.RootElement.GetProperty("items").GetArrayLength().ShouldBe(1); + secondPage.RootElement.GetProperty("items")[0].GetProperty("name").GetString().ShouldBe("b.txt"); + secondPage.RootElement.GetProperty("nextCursor").ValueKind.ShouldBe(JsonValueKind.Null); + + using var filtered = await fixture.HttpClient.GetAsync(childrenUrl + "&filter=folder%2Fa.json"); + filtered.EnsureSuccessStatusCode(); + using var filteredPage = JsonDocument.Parse(await filtered.Content.ReadAsStringAsync()); + filteredPage.RootElement.GetProperty("knownTotal").ValueKind.ShouldBe(JsonValueKind.Null); + filteredPage.RootElement.GetProperty("items").GetArrayLength().ShouldBe(1); + filteredPage.RootElement.GetProperty("items")[0].GetProperty("key").GetProperty("id").GetString() + .ShouldBe("folder/a.json"); + filteredPage.RootElement.GetProperty("nextCursor").ValueKind.ShouldBe(JsonValueKind.Null); + AdminKey[] objectPath = [.. prefix, new("objects", "folder/a.json")]; + using var versions = await fixture.HttpClient.GetAsync(Url("s3", "children", objectPath)); + versions.EnsureSuccessStatusCode(); + using var versionPage = JsonDocument.Parse(await versions.Content.ReadAsStringAsync()); + var versionId = versionPage.RootElement.GetProperty("items")[0].GetProperty("key").GetProperty("id").GetString()!; + AdminKey[] versionPath = [.. objectPath, new("versions", versionId)]; + var version = await fixture.HttpClient.GetFromJsonAsync( + Url("s3", "resource", versionPath), AdminJsonContext.Default.AdminResourceDetail); + version!.Resource.Key.Id.ShouldBe(versionId); + version.Resource.Type.ShouldBe("Version"); + version.HasContent.ShouldBeTrue(); + version.Summary.Single(x => x.Name == "Length").Value.ShouldBe("20"); + using (AccountContext.BeginScope(Account)) + { + var retained = await Send(handler, "GET", "/parity-api-bucket/folder/a.json"); + Encoding.UTF8.GetString(retained.Body).ShouldBe("body-must-not-appear"); + retained.Headers["x-amz-version-id"].ShouldBe(versionId); + } + } + + [Fact] + public async Task DynamoDb_SummaryPageHasKeysNotPayloadAndCountIsIndependentOfPage() + { + var handler = (DynamoDbServiceHandler)fixture.Factory.Services.GetRequiredService().Resolve("dynamodb")!; + using (AccountContext.BeginScope(Account)) + { + await Ddb(handler, "CreateTable", """ + {"TableName":"parity-api-table","KeySchema":[{"AttributeName":"PK","KeyType":"HASH"},{"AttributeName":"SK","KeyType":"RANGE"}], + "AttributeDefinitions":[{"AttributeName":"PK","AttributeType":"S"},{"AttributeName":"SK","AttributeType":"N"}]} + """); + await Ddb(handler, "PutItem", """ + {"TableName":"parity-api-table","Item":{"PK":{"S":"a"},"SK":{"N":"01"},"payload":{"S":"body-must-not-appear"}}} + """); + await Ddb(handler, "PutItem", """ + {"TableName":"parity-api-table","Item":{"PK":{"S":"b"},"SK":{"N":"02"},"payload":{"S":"body-must-not-appear"}}} + """); + } + AdminKey[] tablePath = [new("tables", "parity-api-table")]; + var table = await fixture.HttpClient.GetFromJsonAsync( + Url("dynamodb", "resource", tablePath), AdminJsonContext.Default.AdminResourceDetail); + table!.Summary.Single(x => x.Name == "PartitionKey").Value.ShouldBe("PK (S)"); + table.Summary.Single(x => x.Name == "SortKey").Value.ShouldBe("SK (N)"); + table.Summary.Single(x => x.Name == "ItemCount").Value.ShouldBe("2"); + using var children = await fixture.HttpClient.GetAsync(Url("dynamodb", "children", tablePath) + "&pageSize=1"); + children.EnsureSuccessStatusCode(); + var json = await children.Content.ReadAsStringAsync(); + json.ShouldNotContain("body-must-not-appear"); + json.ShouldNotContain("payload"); + using var page = JsonDocument.Parse(json); + page.RootElement.GetProperty("knownTotal").ValueKind.ShouldBe(JsonValueKind.Null); + page.RootElement.GetProperty("items").GetArrayLength().ShouldBe(1); + var item = page.RootElement.GetProperty("items")[0]; + item.GetProperty("summary").GetArrayLength().ShouldBe(2); + var id = item.GetProperty("key").GetProperty("id").GetString()!; + var cursor = page.RootElement.GetProperty("nextCursor").GetString(); + cursor.ShouldNotBeNullOrEmpty(); + using var second = await fixture.HttpClient.GetAsync( + Url("dynamodb", "children", tablePath) + "&pageSize=1&cursor=" + Uri.EscapeDataString(cursor)); + second.EnsureSuccessStatusCode(); + using var secondPage = JsonDocument.Parse(await second.Content.ReadAsStringAsync()); + secondPage.RootElement.GetProperty("items").GetArrayLength().ShouldBe(1); + secondPage.RootElement.GetProperty("items")[0].GetProperty("key").GetProperty("id").GetString().ShouldNotBe(id); + secondPage.RootElement.GetProperty("nextCursor").ValueKind.ShouldBe(JsonValueKind.Null); + using var filtered = await fixture.HttpClient.GetAsync( + Url("dynamodb", "children", tablePath) + "&filter=" + Uri.EscapeDataString(id)); + filtered.EnsureSuccessStatusCode(); + using var filteredPage = JsonDocument.Parse(await filtered.Content.ReadAsStringAsync()); + filteredPage.RootElement.GetProperty("knownTotal").ValueKind.ShouldBe(JsonValueKind.Null); + filteredPage.RootElement.GetProperty("items").GetArrayLength().ShouldBe(1); + filteredPage.RootElement.GetProperty("items")[0].GetProperty("key").GetProperty("id").GetString().ShouldBe(id); + filteredPage.RootElement.GetProperty("nextCursor").ValueKind.ShouldBe(JsonValueKind.Null); + var itemDetail = await fixture.HttpClient.GetFromJsonAsync( + Url("dynamodb", "resource", [.. tablePath, new("items", id)]), AdminJsonContext.Default.AdminResourceDetail); + itemDetail!.Resource.Key.Id.ShouldBe(id); + itemDetail.HasContent.ShouldBeTrue(); + itemDetail.Fields.Single(x => x.Name == "AttributeCount").Secondary.ShouldBeTrue(); + using (AccountContext.BeginScope(Account)) + { + var retained = await Ddb(handler, "GetItem", """ + {"TableName":"parity-api-table","Key":{"PK":{"S":"a"},"SK":{"N":"01"}}} + """); + using var retainedItem = JsonDocument.Parse(retained.Body); + retainedItem.RootElement.GetProperty("Item").GetProperty("payload").GetProperty("S").GetString() + .ShouldBe("body-must-not-appear"); + } + } + + private static string Url(string service, string operation, AdminKey[] path) => + $"/_microstack/admin/v1/services/{service}/{operation}?accountId={Account}&path=" + + Uri.EscapeDataString(JsonSerializer.Serialize(path, AdminJsonContext.Default.AdminKeyArray)); + + private static Task Ddb(DynamoDbServiceHandler handler, string action, string body) => + Send(handler, "POST", "/", body, new Dictionary + { + ["x-amz-target"] = $"DynamoDB_20120810.{action}", + ["content-type"] = "application/x-amz-json-1.0" + }); + + private static Task Send( + IServiceHandler handler, string method, string path, string? body = null, + IReadOnlyDictionary? headers = null, IReadOnlyDictionary? query = null) => + handler.HandleAsync(new ServiceRequest(method, path, headers ?? new Dictionary(), + Encoding.UTF8.GetBytes(body ?? ""), query ?? new Dictionary())); +} diff --git a/tests/MicroStack.Tests/AdminStorageTests.cs b/tests/MicroStack.Tests/AdminStorageTests.cs new file mode 100644 index 0000000..84ba49c --- /dev/null +++ b/tests/MicroStack.Tests/AdminStorageTests.cs @@ -0,0 +1,337 @@ +using System.Text; +using System.Text.Json; +using MicroStack.Admin.Contracts; +using MicroStack.Internal; +using MicroStack.Internal.Admin; +using MicroStack.Services.DynamoDb; +using MicroStack.Services.Efs; +using MicroStack.Services.ElastiCache; +using MicroStack.Services.Rds; +using MicroStack.Services.RdsData; +using MicroStack.Services.S3; +using MicroStack.Services.S3Files; + +namespace MicroStack.Tests; + +public sealed class AdminStorageTests : IDisposable +{ + public void Dispose() => AccountContext.Reset(); + + [Fact] + public async Task S3_ProjectsPrefixesObjectsVersionsAndLazyContent() + { + var handler = new S3ServiceHandler(); + await Send(handler, "PUT", "/admin-storage"); + await Send(handler, "PUT", "/admin-storage/a/value.json", + """{"value":"old"}"""u8.ToArray(), ("content-type", "application/json")); + await Send(handler, "PUT", "/admin-storage", "Enabled"u8.ToArray(), + query: new Dictionary { ["versioning"] = [] }); + await Send(handler, "PUT", "/admin-storage/a/versioned.txt", "first"u8.ToArray(), ("content-type", "text/plain")); + + var source = (IAdminResourceSource)handler; + source.GetAdminResourceKinds("s3").Select(x => x.Id) + .ShouldBe(["buckets"]); + var bucket = source.GetAdminResources("s3").Single(); + var prefix = bucket.ReadChildren!().Single(x => x.Resource.Key.Kind == "prefixes"); + var json = prefix.ReadChildren!().Single(x => x.Resource.Key.Id == "a/value.json"); + json.ReadContent!().Kind.ShouldBe("json"); + + await Send(handler, "PUT", "/admin-storage/a/value.json", + """{"value":"new"}"""u8.ToArray(), ("content-type", "application/json")); + json.ReadContent!().Text!.ShouldContain("\"new\""); + + var versioned = prefix.ReadChildren!().Single(x => x.Resource.Key.Id == "a/versioned.txt"); + versioned.ReadChildren!().Single().Resource.Key.Kind.ShouldBe("versions"); + } + + [Fact] + public async Task S3_ContentLimitIsBytesAndBinaryIsMetadataOnly() + { + var handler = new S3ServiceHandler(); + await Send(handler, "PUT", "/limits-bucket"); + await Send(handler, "PUT", "/limits-bucket/exact.txt", + Enumerable.Repeat((byte)'a', AdminData.PreviewMaxBytes).ToArray(), ("content-type", "text/plain")); + await Send(handler, "PUT", "/limits-bucket/unicode.txt", + Encoding.UTF8.GetBytes(new string('\u00e9', AdminData.PreviewMaxBytes / 2 + 1)), ("content-type", "text/plain")); + await Send(handler, "PUT", "/limits-bucket/blob.bin", [0, 1, 2], ("content-type", "application/octet-stream")); + + var children = ((IAdminResourceSource)handler).GetAdminResources("s3").Single().ReadChildren!().ToArray(); + children.Single(x => x.Resource.Key.Id == "exact.txt").ReadContent!().Kind.ShouldBe("text"); + children.Single(x => x.Resource.Key.Id == "unicode.txt").ReadContent!().Kind.ShouldBe("oversized"); + var binary = children.Single(x => x.Resource.Key.Id == "blob.bin").ReadContent!(); + binary.Kind.ShouldBe("binary"); + binary.Text.ShouldBeNull(); + binary.Length.ShouldBe(3); + } + + [Fact] + public async Task S3_SummariesKeepFullIdentityFriendlyNamesAndLiveVersionMetadata() + { + var handler = new S3ServiceHandler(); + await Send(handler, "PUT", "/parity-bucket"); + var source = (IAdminResourceSource)handler; + var bucket = source.GetAdminResources("s3").Single(); + bucket.Resource.Type.ShouldBe("Bucket"); + bucket.Resource.Status.ShouldBeNull(); + bucket.ChildKinds.Select(x => x.Id).ShouldBe(["prefixes", "objects"]); + bucket.ChildKinds.ShouldAllBe(x => !x.IsRoot); + bucket.ReadSummary!().Single(x => x.Name == "ObjectCount").Value.ShouldBe("0"); + bucket.ReadChildren!().ShouldBeEmpty(); + + await Send(handler, "PUT", "/parity-bucket", + "Enabled"u8.ToArray(), + query: new Dictionary { ["versioning"] = [] }); + await Send(handler, "PUT", "/parity-bucket/2026/日本語/value.json", + "not valid json"u8.ToArray(), ("content-type", "application/json")); + + bucket.ReadSummary!().Single(x => x.Name == "ObjectCount").Value.ShouldBe("1"); + bucket.ReadSummary!().Single(x => x.Name == "Versioning").Value.ShouldBe("Enabled"); + var year = bucket.ReadChildren!().Single(); + var prefix = year.ReadChildren!().Single(); + prefix.Resource.Key.Id.ShouldBe("2026/日本語/"); + prefix.Resource.Name.ShouldBe("日本語/"); + prefix.Resource.Type.ShouldBe("Prefix"); + prefix.ChildKinds.Select(x => x.Id).ShouldBe(["prefixes", "objects"]); + var obj = prefix.ReadChildren!().Single(); + obj.Resource.Key.Id.ShouldBe("2026/日本語/value.json"); + obj.Resource.Name.ShouldBe("value.json"); + obj.Resource.Type.ShouldBe("Object"); + obj.Resource.Summary.Select(x => x.Name).ShouldBe(["ContentType", "Length", "LastModified"]); + obj.Resource.Summary.Single(x => x.Name == "Length").Value.ShouldBe("14"); + AssertIsoUtc(obj.Resource.Summary.Single(x => x.Name == "LastModified")); + AssertIsoUtc(bucket.ReadFields!().Single(x => x.Name == "CreationDate")); + obj.ReadFields!().Single(x => x.Name == "ETag").Secondary.ShouldBeTrue(); + obj.ReadFields!().Single(x => x.Name == "ContentType").Secondary.ShouldBeFalse(); + obj.ReadContent!().Kind.ShouldBe("unavailable"); + + var version = obj.ReadChildren!().Single(); + version.Resource.Type.ShouldBe("Version"); + version.Resource.Key.Kind.ShouldBe("versions"); + version.ReadSummary!().ShouldBe(obj.Resource.Summary); + await Send(handler, "PUT", "/parity-bucket/2026/日本語/value.json", + """{"ok":true}"""u8.ToArray(), ("content-type", "application/json")); + obj.ReadSummary!().Single(x => x.Name == "Length").Value.ShouldBe("11"); + version.ReadSummary!().ShouldBeEmpty(); + version.ReadFields!().ShouldBeEmpty(); + version.ReadContent!().Kind.ShouldBe("unavailable"); + obj.ReadChildren!().Single().Resource.Key.Id.ShouldNotBe(version.Resource.Key.Id); + } + + [Fact] + public async Task S3_ConfigurationUsesRetainedValuesAndMasksSensitiveTags() + { + var handler = new S3ServiceHandler(); + await Send(handler, "PUT", "/settings-bucket"); + await Send(handler, "PUT", "/settings-bucket", + "AES256"u8.ToArray(), + query: new Dictionary { ["encryption"] = [] }); + await Send(handler, "PUT", "/settings-bucket", + "environmentlocalsecretdo-not-display"u8.ToArray(), + query: new Dictionary { ["tagging"] = [] }); + await Send(handler, "PUT", "/settings-bucket/file.txt", "hello"u8.ToArray(), + ("content-type", "text/plain"), ("x-amz-meta-token", "do-not-display"), + ("cache-control", "max-age=60")); + + var bucket = ((IAdminResourceSource)handler).GetAdminResources("s3").Single(); + var fields = bucket.ReadFields!(); + fields.Single(x => x.Name == "Versioning").Value.ShouldBe("Disabled"); + fields.Single(x => x.Name == "EncryptionConfigured").Value.ShouldBe("True"); + fields.Single(x => x.Name == "EncryptionAlgorithm").Value.ShouldBe("AES256"); + fields.Single(x => x.Name == "LifecycleConfigured").Value.ShouldBe("False"); + fields.Single(x => x.Name == "Tags.environment").Value.ShouldBe("local"); + fields.Single(x => x.Name == "Tags.secret").Value.ShouldBe(AdminData.MaskedValue); + var obj = bucket.ReadChildren!().Single(); + obj.ChildKinds.Single().Id.ShouldBe("versions"); + obj.ReadChildren!().ShouldBeEmpty(); + var token = obj.ReadFields!().Single(x => x.Name == "Metadata.x-amz-meta-token"); + token.Sensitive.ShouldBeTrue(); + token.Value.ShouldBe(AdminData.MaskedValue); + obj.ReadFields!().Single(x => x.Name == "Headers.cache-control").Value.ShouldBe("max-age=60"); + obj.Resource.Summary.ShouldNotContain(x => x.Name.StartsWith("Metadata.", StringComparison.Ordinal)); + + await Send(handler, "DELETE", "/settings-bucket/file.txt"); + obj.ReadSummary!().ShouldBeEmpty(); + obj.ReadFields!().ShouldBeEmpty(); + obj.ReadContent!().Kind.ShouldBe("unavailable"); + bucket.ReadSummary!().Single(x => x.Name == "ObjectCount").Value.ShouldBe("0"); + } + + [Fact] + public async Task DynamoDb_PreservesTypedCompositeKeysAndDoesNotMutateTtl() + { + var handler = new DynamoDbServiceHandler(); + await Ddb(handler, "CreateTable", """ + {"TableName":"typed","KeySchema":[{"AttributeName":"pk","KeyType":"HASH"},{"AttributeName":"sk","KeyType":"RANGE"}], + "AttributeDefinitions":[{"AttributeName":"pk","AttributeType":"N"},{"AttributeName":"sk","AttributeType":"B"}], + "BillingMode":"PAY_PER_REQUEST"} + """); + await Ddb(handler, "PutItem", """ + {"TableName":"typed","Item":{"pk":{"N":"01"},"sk":{"B":"AQI="},"text":{"S":"hello"},"flag":{"BOOL":true}}} + """); + await Ddb(handler, "UpdateTimeToLive", """ + {"TableName":"typed","TimeToLiveSpecification":{"Enabled":true,"AttributeName":"expires"}} + """); + + var table = ((IAdminResourceSource)handler).GetAdminResources("dynamodb").Single(); + var before = await Ddb(handler, "DescribeTimeToLive", """{"TableName":"typed"}"""); + var item = table.ReadChildren!().Single(); + item.Resource.Name.ShouldContain("""pk={"N":"01"}"""); + item.Resource.Name.ShouldContain("""sk={"B":"AQI="}"""); + item.ReadContent!().Text!.ShouldContain("\"flag\":{\"BOOL\":true}"); + _ = table.ReadFields!(); + var after = await Ddb(handler, "DescribeTimeToLive", """{"TableName":"typed"}"""); + Encoding.UTF8.GetString(after.Body).ShouldBe(Encoding.UTF8.GetString(before.Body)); + } + + [Fact] + public async Task DynamoDb_SummariesExposeSchemaActualKeysAndRetainedCountWithoutItemBodies() + { + var handler = new DynamoDbServiceHandler(); + await Ddb(handler, "CreateTable", """ + {"TableName":"parity","KeySchema":[{"AttributeName":"PK","KeyType":"HASH"},{"AttributeName":"SK","KeyType":"RANGE"}], + "AttributeDefinitions":[{"AttributeName":"PK","AttributeType":"S"},{"AttributeName":"SK","AttributeType":"N"}], + "BillingMode":"PAY_PER_REQUEST","StreamSpecification":{"StreamEnabled":true,"StreamViewType":"NEW_IMAGE"}, + "SSESpecification":{"Enabled":true,"SSEType":"KMS","KMSMasterKeyId":"alias/local"}} + """); + var table = ((IAdminResourceSource)handler).GetAdminResources("dynamodb").Single(); + table.Resource.Type.ShouldBe("Table"); + table.ChildKinds.Single().Id.ShouldBe("items"); + table.ChildKinds.Single().IsRoot.ShouldBeFalse(); + table.ReadChildren!().ShouldBeEmpty(); + table.ReadSummary!().Single(x => x.Name == "ItemCount").Value.ShouldBe("0"); + table.ReadSummary!().Single(x => x.Name == "PartitionKey").Value.ShouldBe("PK (S)"); + table.ReadSummary!().Single(x => x.Name == "SortKey").Value.ShouldBe("SK (N)"); + await Ddb(handler, "PutItem", """ + {"TableName":"parity","Item":{"PK":{"S":"注文/42"},"SK":{"N":"01"},"payload":{"M":{"list":{"L":[{"BOOL":true},{"NULL":true}]}}}, + "strings":{"SS":["a","b"]},"numbers":{"NS":["1","2"]},"binary":{"B":"AQI="},"binaries":{"BS":["AQI="]}}} + """); + await Ddb(handler, "UpdateTimeToLive", """ + {"TableName":"parity","TimeToLiveSpecification":{"Enabled":true,"AttributeName":"expires"}} + """); + await Ddb(handler, "TagResource", $$""" + {"ResourceArn":"{{table.Resource.Arn}}","Tags":[{"Key":"environment","Value":"local"},{"Key":"auth-token","Value":"hidden"}]} + """); + var beforeTtl = await Ddb(handler, "DescribeTimeToLive", """{"TableName":"parity"}"""); + table.ReadSummary!().Single(x => x.Name == "ItemCount").Value.ShouldBe("1"); + var item = table.ReadChildren!().Single(); + item.Resource.Type.ShouldBe("Item"); + item.Resource.Summary.Count.ShouldBe(2); + item.Resource.Summary.Single(x => x.Name == "PK").Format.ShouldBe("dynamodb-attribute"); + item.Resource.Summary.Single(x => x.Name == "SK").Value.ShouldBe("""{"N":"01"}"""); + item.Resource.Summary.ShouldAllBe(x => !x.Secondary); + item.ReadFields!().Single(x => x.Name == "AttributeCount").Secondary.ShouldBeTrue(); + var fields = table.ReadFields!(); + fields.Single(x => x.Name == "PartitionKeyType").Value.ShouldBe("S"); + var keySchema = fields.Single(x => x.Name == "KeySchema").Value; + keySchema.ShouldNotBeNull(); + keySchema.ShouldContain("\"KeyType\":\"HASH\""); + var streamSpecification = fields.Single(x => x.Name == "StreamSpecification").Value; + streamSpecification.ShouldNotBeNull(); + streamSpecification.ShouldContain("\"StreamEnabled\":true"); + var sseDescription = fields.Single(x => x.Name == "SSEDescription").Value; + sseDescription.ShouldNotBeNull(); + sseDescription.ShouldContain("alias/local"); + fields.Single(x => x.Name == "Tags.environment").Value.ShouldBe("local"); + fields.Single(x => x.Name == "Tags.auth-token").Value.ShouldBe(AdminData.MaskedValue); + AssertIsoUtc(fields.Single(x => x.Name == "CreationDateTime")); + using var content = JsonDocument.Parse(item.ReadContent!().Text!); + content.RootElement.GetProperty("SK").GetProperty("N").GetString().ShouldBe("01"); + content.RootElement.GetProperty("payload").GetProperty("M").GetProperty("list").GetProperty("L")[0] + .GetProperty("BOOL").GetBoolean().ShouldBeTrue(); + content.RootElement.GetProperty("strings").GetProperty("SS").GetArrayLength().ShouldBe(2); + content.RootElement.GetProperty("numbers").GetProperty("NS").GetArrayLength().ShouldBe(2); + content.RootElement.GetProperty("binary").GetProperty("B").GetString().ShouldBe("AQI="); + content.RootElement.GetProperty("binaries").GetProperty("BS").GetArrayLength().ShouldBe(1); + var afterTtl = await Ddb(handler, "DescribeTimeToLive", """{"TableName":"parity"}"""); + afterTtl.Body.ShouldBe(beforeTtl.Body); + + await Ddb(handler, "DeleteItem", """{"TableName":"parity","Key":{"PK":{"S":"注文/42"},"SK":{"N":"01"}}}"""); + table.ReadSummary!().Single(x => x.Name == "ItemCount").Value.ShouldBe("0"); + item.ReadSummary!().ShouldBeEmpty(); + item.ReadFields!().ShouldBeEmpty(); + item.ReadContent!().Kind.ShouldBe("unavailable"); + } + + [Fact] + public async Task DynamoDb_HashOnlySchemaOmitsInventedSortKeySummary() + { + var handler = new DynamoDbServiceHandler(); + await Ddb(handler, "CreateTable", """ + {"TableName":"hash-only","KeySchema":[{"AttributeName":"id","KeyType":"HASH"}], + "AttributeDefinitions":[{"AttributeName":"id","AttributeType":"B"}]} + """); + await Ddb(handler, "PutItem", """{"TableName":"hash-only","Item":{"id":{"B":"AQI="},"body":{"S":"payload-only"}}}"""); + var table = ((IAdminResourceSource)handler).GetAdminResources("dynamodb").Single(); + table.Resource.Summary.Select(x => x.Name).ShouldBe(["PartitionKey", "ItemCount"]); + var item = table.ReadChildren!().Single(); + item.Resource.Summary.Single().Value.ShouldBe("""{"B":"AQI="}"""); + item.ReadSummary!().ShouldBe(item.Resource.Summary); + } + + [Fact] + public async Task ProvidersAreAccountScoped() + { + var handler = new S3ServiceHandler(); + AccountContext.SetFromAccessKey("111111111111"); + await Send(handler, "PUT", "/account-bucket"); + ((IAdminResourceSource)handler).GetAdminResources("s3").Count().ShouldBe(1); + + AccountContext.SetFromAccessKey("222222222222"); + ((IAdminResourceSource)handler).GetAdminResources("s3").ShouldBeEmpty(); + + AccountContext.SetFromAccessKey("111111111111"); + ((IAdminResourceSource)handler).GetAdminResources("s3").Single() + .ReadFields!().Single(x => x.Name == "AccountId").Value.ShouldBe("111111111111"); + } + + [Fact] + public void StorageProvidersExposeOnlyTheirRetainedKinds() + { + ((IAdminResourceSource)new EfsServiceHandler()).GetAdminResourceKinds("elasticfilesystem").ShouldNotBeEmpty(); + ((IAdminResourceSource)new S3FilesServiceHandler()).GetAdminResourceKinds("s3files").ShouldNotBeEmpty(); + ((IAdminResourceSource)new RdsServiceHandler()).GetAdminResourceKinds("rds").ShouldNotBeEmpty(); + ((IAdminResourceSource)new ElastiCacheServiceHandler()).GetAdminResourceKinds("elasticache").ShouldNotBeEmpty(); + + var data = (IAdminResourceSource)new RdsDataServiceHandler(); + data.GetAdminResourceKinds("rds-data").ShouldBeEmpty(); + data.GetAdminResources("rds-data").ShouldBeEmpty(); + data.GetAdminNotice("rds-data")!.ShouldContain("no database resources"); + data.GetAdminNotice("rds-data")!.Contains("SELECT", StringComparison.OrdinalIgnoreCase).ShouldBeFalse(); + } + + private static Task Ddb(DynamoDbServiceHandler handler, string action, string body) => + Send(handler, "POST", "/", Encoding.UTF8.GetBytes(body), + ("x-amz-target", $"DynamoDB_20120810.{action}"), + ("content-type", "application/x-amz-json-1.0")); + + private static void AssertIsoUtc(AdminField field) + { + field.Format.ShouldBe("datetime"); + field.Value.ShouldNotBeNull(); + field.Value.ShouldEndWith("Z"); + DateTimeOffset.TryParse(field.Value, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.RoundtripKind, out var parsed).ShouldBeTrue(); + parsed.Offset.ShouldBe(TimeSpan.Zero); + } + + private static Task Send( + IServiceHandler handler, string method, string path, byte[]? body = null, + params (string Key, string Value)[] headers) => + Send(handler, method, path, body, headers, new Dictionary()); + + private static Task Send( + IServiceHandler handler, string method, string path, byte[]? body, + (string Key, string Value) header = default, + IReadOnlyDictionary? query = null) => + Send(handler, method, path, body, + string.IsNullOrEmpty(header.Key) ? [] : [header], query ?? new Dictionary()); + + private static Task Send( + IServiceHandler handler, string method, string path, byte[]? body, + IEnumerable<(string Key, string Value)> headers, + IReadOnlyDictionary query) + { + var headerMap = headers.ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase); + return handler.HandleAsync(new ServiceRequest(method, path, headerMap, body ?? [], query)); + } +} diff --git a/tests/MicroStack.Tests/AdminUiHostingTests.cs b/tests/MicroStack.Tests/AdminUiHostingTests.cs new file mode 100644 index 0000000..fa7c896 --- /dev/null +++ b/tests/MicroStack.Tests/AdminUiHostingTests.cs @@ -0,0 +1,115 @@ +namespace MicroStack.Tests; + +public sealed class AdminUiHostingTests(MicroStackFixture fixture) : IClassFixture +{ + [Fact] + public async Task Gateway_serves_ui_and_deep_links_under_reserved_prefix() + { + using var client = fixture.CreateClient(allowAutoRedirect: false); + + using var slash = await client.GetAsync("/ui"); + slash.StatusCode.ShouldBe(HttpStatusCode.PermanentRedirect); + slash.Headers.Location.ShouldBe(new Uri("/ui/", UriKind.Relative)); + + using var slashWithQuery = await client.GetAsync("/ui?account=123456789012"); + slashWithQuery.StatusCode.ShouldBe(HttpStatusCode.PermanentRedirect); + slashWithQuery.Headers.Location.ShouldBe(new Uri("/ui/?account=123456789012", UriKind.Relative)); + + using var index = await client.GetAsync("/ui/"); + index.StatusCode.ShouldBe(HttpStatusCode.OK); + index.Content.Headers.ContentType?.MediaType.ShouldBe("text/html"); + (await index.Content.ReadAsStringAsync()).ShouldContain(""""""); + + using var deepLink = await client.GetAsync("/ui/services/s3?account=000000000000"); + deepLink.StatusCode.ShouldBe(HttpStatusCode.OK); + deepLink.Content.Headers.ContentType?.MediaType.ShouldBe("text/html"); + + using var css = await client.GetAsync("/ui/css/app.css"); + css.StatusCode.ShouldBe(HttpStatusCode.OK); + css.Content.Headers.ContentType?.MediaType.ShouldBe("text/css"); + + using var missingAsset = await client.GetAsync("/ui/missing.js"); + missingAsset.StatusCode.ShouldBe(HttpStatusCode.NotFound); + + using var wrongMethod = await client.PostAsync("/ui/services/s3", null); + wrongMethod.StatusCode.ShouldBe(HttpStatusCode.MethodNotAllowed); + wrongMethod.Content.Headers.Allow.ShouldBe(["GET", "HEAD"], ignoreOrder: true); + + using var nearPrefix = await client.GetAsync("/uix"); + nearPrefix.Content.Headers.ContentType?.MediaType.ShouldNotBe("text/html"); + } + + [Fact] + public async Task Root_redirects_only_conservative_browser_navigation() + { + using var client = fixture.CreateClient(allowAutoRedirect: false); + using var browser = new HttpRequestMessage(HttpMethod.Get, "/"); + browser.Headers.Accept.ParseAdd("text/html,application/xhtml+xml"); + browser.Headers.Add("Sec-Fetch-Mode", "navigate"); + browser.Headers.Add("Sec-Fetch-Dest", "document"); + + using var redirected = await client.SendAsync(browser); + redirected.StatusCode.ShouldBe(HttpStatusCode.Found); + redirected.Headers.Location.ShouldBe(new Uri("/ui/", UriKind.Relative)); + redirected.Headers.CacheControl?.NoStore.ShouldBeTrue(); + + foreach (var request in RootRequestsThatMustRemainAws()) + { + using (request) + { + using var response = await client.SendAsync(request); + ((int)response.StatusCode).ShouldNotBeInRange(300, 399); + response.Content.Headers.ContentType?.MediaType.ShouldNotBe("text/html"); + } + } + } + + [Fact] + public async Task Service_hosts_do_not_expose_or_redirect_ui_routes() + { + using var client = fixture.CreateClient(allowAutoRedirect: false); + using var root = new HttpRequestMessage(HttpMethod.Get, "/"); + root.Headers.Host = "bucket.s3.localhost"; + root.Headers.Accept.ParseAdd("text/html"); + using var rootResponse = await client.SendAsync(root); + rootResponse.StatusCode.ShouldNotBe(HttpStatusCode.Redirect); + + using var ui = new HttpRequestMessage(HttpMethod.Get, "/ui/"); + ui.Headers.Host = "bucket.s3.localhost"; + using var uiResponse = await client.SendAsync(ui); + uiResponse.Content.Headers.ContentType?.MediaType.ShouldNotBe("text/html"); + + using var asset = new HttpRequestMessage(HttpMethod.Get, "/ui/css/app.css"); + asset.Headers.Host = "bucket.s3.localhost"; + using var assetResponse = await client.SendAsync(asset); + assetResponse.Content.Headers.ContentType?.MediaType.ShouldNotBe("text/css"); + } + + private static IEnumerable RootRequestsThatMustRemainAws() + { + yield return new(HttpMethod.Get, "/"); + yield return new(HttpMethod.Get, "/") { Headers = { Accept = { new("text/html", 0) } } }; + yield return new(HttpMethod.Get, "/") { Headers = { Accept = { new("*/*") } } }; + yield return new(HttpMethod.Get, "/?X-Amz-Signature=signature") { Headers = { Accept = { new("text/html") } } }; + yield return new(HttpMethod.Get, "/") { Headers = { Accept = { new("text/html") }, Authorization = new("AWS4-HMAC-SHA256", "Credential=test") } }; + var target = new HttpRequestMessage(HttpMethod.Get, "/"); + target.Headers.Accept.ParseAdd("text/html"); + target.Headers.Add("X-Amz-Target", "AmazonSQS.ListQueues"); + yield return target; + var image = new HttpRequestMessage(HttpMethod.Get, "/"); + image.Headers.Accept.ParseAdd("text/html"); + image.Headers.Add("Sec-Fetch-Dest", "image"); + yield return image; + var body = new HttpRequestMessage(HttpMethod.Get, "/") { Content = new StringContent("request body") }; + body.Headers.Accept.ParseAdd("text/html"); + yield return body; + var chunked = new HttpRequestMessage(HttpMethod.Get, "/") + { + Content = new ByteArrayContent([]) + }; + chunked.Headers.Accept.ParseAdd("text/html"); + chunked.Headers.TransferEncodingChunked = true; + yield return chunked; + yield return new(HttpMethod.Post, "/") { Headers = { Accept = { new("text/html") } } }; + } +} diff --git a/tests/MicroStack.Tests/AlbTests.cs b/tests/MicroStack.Tests/AlbTests.cs index f73c3eb..587d953 100644 --- a/tests/MicroStack.Tests/AlbTests.cs +++ b/tests/MicroStack.Tests/AlbTests.cs @@ -11,7 +11,7 @@ public sealed class AlbTests(MicroStackFixture fixture) : IClassFixture(MicroStackFixture fixture) where TClient : AmazonServiceClient where TConfig : ClientConfig, new() { - var innerHandler = fixture.Factory.Server.CreateHandler(); + var innerHandler = fixture.CreateHandler(); var httpClient = new HttpClient(new CanonicalizeUriHandler(innerHandler)) { BaseAddress = new Uri("http://localhost/"), diff --git a/tests/MicroStack.Tests/CloudFrontTests.cs b/tests/MicroStack.Tests/CloudFrontTests.cs index 1bc2236..f363f5a 100644 --- a/tests/MicroStack.Tests/CloudFrontTests.cs +++ b/tests/MicroStack.Tests/CloudFrontTests.cs @@ -17,7 +17,7 @@ public sealed class CloudFrontTests(MicroStackFixture fixture) : IClassFixture -/// Shared test fixture that starts the MicroStack server in-process using -/// WebApplicationFactory and provides an HttpClient pointed at it. +/// Shared test fixture that starts MicroStack on an ephemeral loopback port. /// public sealed class MicroStackFixture : IDisposable { public WebApplicationFactory Factory { get; } public HttpClient HttpClient { get; } + public Uri ServerAddress { get; } public MicroStackFixture() { - Factory = new WebApplicationFactory(); + Factory = new WebApplicationFactory().WithWebHostBuilder(builder => + builder.UseEnvironment("Testing")); + Factory.UseKestrel(options => options.Listen(IPAddress.Loopback, 0)); HttpClient = Factory.CreateClient(); + ServerAddress = HttpClient.BaseAddress + ?? throw new InvalidOperationException("The test server returned no address."); } + public HttpClient CreateClient(bool allowAutoRedirect = true) => + new(new SocketsHttpHandler + { + AllowAutoRedirect = allowAutoRedirect, + UseProxy = false + }) + { + BaseAddress = ServerAddress + }; + + public HttpMessageHandler CreateHandler() => new LoopbackHttpHandler(ServerAddress); + public void Dispose() { HttpClient.Dispose(); Factory.Dispose(); } + + private sealed class LoopbackHttpHandler(Uri serverAddress) + : DelegatingHandler(new SocketsHttpHandler { UseProxy = false }) + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var original = request.RequestUri + ?? throw new InvalidOperationException("The test request has no URI."); + request.Headers.Host ??= original.Authority; + request.RequestUri = new UriBuilder(serverAddress) + { + Path = original.AbsolutePath, + Query = original.Query.TrimStart('?') + }.Uri; + return base.SendAsync(request, cancellationToken); + } + } } diff --git a/tests/MicroStack.Tests/MultiTenancyTests.cs b/tests/MicroStack.Tests/MultiTenancyTests.cs index a56f4cf..ab04314 100644 --- a/tests/MicroStack.Tests/MultiTenancyTests.cs +++ b/tests/MicroStack.Tests/MultiTenancyTests.cs @@ -32,7 +32,7 @@ public async ValueTask InitializeAsync() private AmazonSecurityTokenServiceClient CreateStsClient(string accessKey) { - var innerHandler = fixture.Factory.Server.CreateHandler(); + var innerHandler = fixture.CreateHandler(); var httpClient = new HttpClient(new CanonicalizeUriHandler(innerHandler)) { BaseAddress = new Uri("http://localhost/"), @@ -51,7 +51,7 @@ private AmazonSecurityTokenServiceClient CreateStsClient(string accessKey) private AmazonSQSClient CreateSqsClient(string accessKey) { - var innerHandler = fixture.Factory.Server.CreateHandler(); + var innerHandler = fixture.CreateHandler(); var httpClient = new HttpClient(new CanonicalizeUriHandler(innerHandler)) { BaseAddress = new Uri("http://localhost/"), @@ -69,7 +69,7 @@ private AmazonSQSClient CreateSqsClient(string accessKey) private AmazonS3Client CreateS3Client(string accessKey) { - var innerHandler = fixture.Factory.Server.CreateHandler(); + var innerHandler = fixture.CreateHandler(); var httpClient = new HttpClient(new CanonicalizeUriHandler(innerHandler)) { BaseAddress = new Uri("http://localhost/"), @@ -88,7 +88,7 @@ private AmazonS3Client CreateS3Client(string accessKey) private AmazonDynamoDBClient CreateDdbClient(string accessKey) { - var innerHandler = fixture.Factory.Server.CreateHandler(); + var innerHandler = fixture.CreateHandler(); var httpClient = new HttpClient(new CanonicalizeUriHandler(innerHandler)) { BaseAddress = new Uri("http://localhost/"), diff --git a/tests/MicroStack.Tests/PersistenceTests.cs b/tests/MicroStack.Tests/PersistenceTests.cs index db89b5a..75022a7 100644 --- a/tests/MicroStack.Tests/PersistenceTests.cs +++ b/tests/MicroStack.Tests/PersistenceTests.cs @@ -45,7 +45,7 @@ public ValueTask DisposeAsync() private static AmazonSQSClient CreateSqsClient(MicroStackFixture fixture) { - var innerHandler = fixture.Factory.Server.CreateHandler(); + var innerHandler = fixture.CreateHandler(); var httpClient = new HttpClient(new CanonicalizeUriHandler(innerHandler)) { BaseAddress = new Uri("http://localhost/"), @@ -63,7 +63,7 @@ private static AmazonSQSClient CreateSqsClient(MicroStackFixture fixture) private static AmazonSimpleNotificationServiceClient CreateSnsClient(MicroStackFixture fixture) { - var innerHandler = fixture.Factory.Server.CreateHandler(); + var innerHandler = fixture.CreateHandler(); var httpClient = new HttpClient(new CanonicalizeUriHandler(innerHandler)) { BaseAddress = new Uri("http://localhost/"), @@ -82,7 +82,7 @@ private static AmazonSimpleNotificationServiceClient CreateSnsClient(MicroStackF private static AmazonSimpleSystemsManagementClient CreateSsmClient(MicroStackFixture fixture) { - var innerHandler = fixture.Factory.Server.CreateHandler(); + var innerHandler = fixture.CreateHandler(); var httpClient = new HttpClient(new CanonicalizeUriHandler(innerHandler)) { BaseAddress = new Uri("http://localhost/"), @@ -101,7 +101,7 @@ private static AmazonSimpleSystemsManagementClient CreateSsmClient(MicroStackFix private static AmazonSecretsManagerClient CreateSmClient(MicroStackFixture fixture) { - var innerHandler = fixture.Factory.Server.CreateHandler(); + var innerHandler = fixture.CreateHandler(); var httpClient = new HttpClient(new CanonicalizeUriHandler(innerHandler)) { BaseAddress = new Uri("http://localhost/"), diff --git a/tests/MicroStack.Tests/RdsTests.cs b/tests/MicroStack.Tests/RdsTests.cs index 1eff924..c863eac 100644 --- a/tests/MicroStack.Tests/RdsTests.cs +++ b/tests/MicroStack.Tests/RdsTests.cs @@ -11,7 +11,7 @@ public sealed class RdsTests(MicroStackFixture fixture) : IClassFixture + { + ["x-amz-target"] = "AmazonSQS.CreateQueue", + ["content-type"] = "application/x-amz-json-1.0" + }, Encoding.UTF8.GetBytes("""{"QueueName":"inspect-endpoint"}"""), new Dictionary()) + { + Origin = "http://127.0.0.1:54321" + }); + response.StatusCode.ShouldBe(200); + var options = MicroStackOptions.Instance; + var expected = $"http://{options.Host}:{options.GatewayPort}/{account}/inspect-endpoint"; + handler.GetResources().Items.Single().Attributes!["QueueUrl"].ShouldBe(expected); + handler.GetAdminResources("sqs").Single().ReadFields!() + .Single(field => field.Name == "Queue URL").Value.ShouldBe(expected); + } + finally + { + AccountContext.Reset(); + } + } +} diff --git a/tests/MicroStack.Tests/SqsTests.cs b/tests/MicroStack.Tests/SqsTests.cs index 39c8747..787695a 100644 --- a/tests/MicroStack.Tests/SqsTests.cs +++ b/tests/MicroStack.Tests/SqsTests.cs @@ -22,12 +22,8 @@ private static AmazonSQSClient CreateSqsClient( MicroStackFixture fixture, string serviceUrl) { - // AWS SDK v4 builds request URIs with DangerousDisablePathAndQueryCanonicalization, - // which is incompatible with TestServer's ClientHandler (it calls GetComponents() on - // the URI which throws for such URIs). We work around this by injecting a delegating - // handler that rewrites the URI to a plain canonical form before forwarding to the - // test server handler — stripping the dangerous flag. - var innerHandler = fixture.Factory.Server.CreateHandler(); + // Strip the AWS SDK's noncanonical URI flag before forwarding over loopback. + var innerHandler = fixture.CreateHandler(); var httpClient = new HttpClient(new CanonicalizeUriHandler(innerHandler)) { BaseAddress = new Uri(serviceUrl), @@ -472,7 +468,7 @@ await _sqs.ChangeMessageVisibilityAsync( /// /// Rewrites the request URI to a canonical form, stripping the /// DangerousDisablePathAndQueryCanonicalization flag that AWS SDK v4 sets. -/// This is required for compatibility with ASP.NET Core's . +/// This keeps AWS SDK v4 requests compatible with the standard HTTP transport. /// internal sealed class CanonicalizeUriHandler : DelegatingHandler { @@ -491,7 +487,7 @@ protected override Task SendAsync( /// /// Provides a fixed to the AWS SDK so it uses the -/// in-process test server instead of making real network calls. +/// ephemeral loopback server. /// internal sealed class FixedHttpClientFactory : Amazon.Runtime.HttpClientFactory { diff --git a/tests/MicroStack.Tests/SsmTests.cs b/tests/MicroStack.Tests/SsmTests.cs index 7e360c3..7ebfc93 100644 --- a/tests/MicroStack.Tests/SsmTests.cs +++ b/tests/MicroStack.Tests/SsmTests.cs @@ -17,7 +17,7 @@ public sealed class SsmTests(MicroStackFixture fixture) : IClassFixture(); + var view = context.Render(parameters => parameters + .Add(component => component.Account, "000000000000") + .Add(component => component.DefaultAccount, "000000000000") + .Add(component => component.Accounts, ["000000000000", "111111111111"]) + .Add(component => component.AccountChanged, value => selections.Add(value))); + + Assert.Equal(2, view.FindAll("option").Count); + Assert.Equal("000000000000 (default)", view.Find("option").TextContent); + Assert.Empty(view.FindAll("input,button")); + await view.Find("select").ChangeAsync(new() { Value = "999999999999" }); + Assert.Empty(selections); + Assert.Contains("no longer available", view.Find("[role=alert]").TextContent); + await view.Find("select").ChangeAsync(new() { Value = "111111111111" }); + Assert.Equal(["111111111111"], selections); + Assert.Empty(view.FindAll("[role=alert]")); + } + + [Fact] + public void One_known_account_is_read_only_but_unknown_current_account_can_recover() + { + using var context = new BunitContext(); + var view = context.Render(parameters => parameters + .Add(component => component.Account, "000000000000") + .Add(component => component.DefaultAccount, "000000000000") + .Add(component => component.Accounts, ["000000000000"])); + + Assert.Equal("000000000000", view.Find("code").TextContent); + Assert.Empty(view.FindAll("select,input,button")); + view.Render(parameters => parameters.Add(component => component.Account, "999999999999")); + Assert.Single(view.FindAll("select")); + Assert.Equal("Select a known account", view.Find("option[disabled]").TextContent); + Assert.Single(view.FindAll("option:not([disabled])")); + Assert.DoesNotContain("999999999999", view.Markup); + } +} diff --git a/tests/MicroStack.UI.Tests/AdminClientTests.cs b/tests/MicroStack.UI.Tests/AdminClientTests.cs new file mode 100644 index 0000000..74c05b9 --- /dev/null +++ b/tests/MicroStack.UI.Tests/AdminClientTests.cs @@ -0,0 +1,246 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json.Serialization.Metadata; +using Bunit; +using MicroStack.Admin.Contracts; +using MicroStack.UI.Client.Components; +using MicroStack.UI.Client.Pages; +using MicroStack.UI.Client.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace MicroStack.UI.Tests; + +public sealed class AdminClientTests +{ + [Fact] + public async Task Api_encodes_opaque_nested_keys_and_account_without_path_segments() + { + Uri? uri = null; + using var handler = new Handler((request, _) => + { + uri = request.RequestUri; + return Task.FromResult(Json(new AdminResourceDetail(new(new("objects", "a/b"), "object")), AdminJsonContext.Default.AdminResourceDetail)); + }); + var api = new AdminApiClient(new HttpClient(handler) { BaseAddress = new("http://localhost:4566") }); + AdminKey[] path = [new("buckets", "a"), new("objects", "a/b?#文")]; + await api.DetailAsync("s3", "123456789012", path, CancellationToken.None); + Assert.NotNull(uri); + Assert.Equal("/_microstack/admin/v1/services/s3/resource", uri.AbsolutePath); + var query = uri.Query.TrimStart('?').Split('&').Select(part => part.Split('=', 2)) + .ToDictionary(part => part[0], part => Uri.UnescapeDataString(part[1])); + Assert.Equal("123456789012", query["accountId"]); + Assert.Equal(path, ExplorerLocation.DecodePath(query["path"])); + } + + [Fact] + public async Task Failed_api_read_is_not_an_empty_success() + { + using var handler = new Handler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { Content = JsonContent.Create(new AdminError("unavailable", "Service unavailable."), AdminJsonContext.Default.AdminError) })); + var api = new AdminApiClient(new HttpClient(handler) { BaseAddress = new("http://localhost:4566") }); + var exception = await Assert.ThrowsAsync(() => api.ResourcesAsync("s3", "000000000000", "buckets", null, CancellationToken.None)); + Assert.Equal("unavailable", exception.Code); + } + + [Fact] + public void Directory_renders_every_live_catalog_entry_without_frontend_inventory() + { + using var context = new BunitContext(); + LiveTestServices.AddPaused(context); + context.JSInterop.Mode = JSRuntimeMode.Loose; + context.Services.AddScoped(); + var services = Enumerable.Range(1, 40).Select(index => + new AdminService($"service-{index}", $"Live service {index}", $"Service {index}", "Live category", "s3", $"handler-{index}", "enabled", "account") + { Kinds = [new("resources", "Resources")] }).ToArray(); + using var handler = new Handler((request, _) => Task.FromResult(request.RequestUri!.AbsolutePath.Split('/').Last() switch + { + "context" => Json(new AdminContext("000000000000", "eu-west-1"), AdminJsonContext.Default.AdminContext), + "accounts" => Json(new[] { "000000000000" }, AdminJsonContext.Default.StringArray), + _ => Json(services, AdminJsonContext.Default.AdminServiceArray) + })); + context.Services.AddSingleton(new AdminApiClient(new HttpClient(handler) { BaseAddress = new("http://localhost:4566") })); + var view = context.Render(); + view.WaitForAssertion(() => Assert.Equal(40, view.FindAll(".directory-service").Count)); + Assert.DoesNotContain("eu-west-1", view.Markup); + Assert.Contains("Live category", view.Markup); + Assert.DoesNotContain("SQS", view.Markup); + Assert.Equal("http://localhost/accounts/000000000000/services", + context.Services.GetRequiredService().Uri); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Overview_reads_instance_region_and_reports_context_failure(bool failContext) + { + using var context = new BunitContext(); + LiveTestServices.AddPaused(context); + using var handler = new Handler((request, _) => Task.FromResult(request.RequestUri!.AbsolutePath switch + { + "/_microstack/health" => new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("""{"services":{},"edition":"test","version":"1"}""") }, + "/_microstack/resources" => new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("[]") }, + "/_microstack/admin/v1/context" when failContext => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable), + "/_microstack/admin/v1/context" => Json(new AdminContext("000000000000", "eu-west-1"), AdminJsonContext.Default.AdminContext), + _ => throw new InvalidOperationException(request.RequestUri.AbsolutePath) + })); + using var http = new HttpClient(handler) { BaseAddress = new("http://localhost:4566") }; + context.Services.AddSingleton(new MicroStackApiService(http)); + context.Services.AddSingleton(new AdminApiClient(http)); + var view = context.Render(); + if (failContext) + { + view.WaitForAssertion(() => Assert.Contains("Instance status could not be loaded", view.Find("[role=alert]").TextContent)); + Assert.Empty(view.FindAll(".overview-facts")); + } + else + { + view.WaitForAssertion(() => Assert.Contains("eu-west-1", view.Find(".overview-facts").TextContent)); + Assert.Contains(view.FindAll(".overview-facts dt"), label => label.TextContent == "Region"); + Assert.Empty(view.FindAll("#account-id")); + } + } + + [Fact] + public async Task Reveal_requires_a_click_and_hides_when_account_changes() + { + using var context = new BunitContext(); + var calls = 0; + using var handler = new Handler((request, _) => + { + calls++; + Assert.Equal(HttpMethod.Post, request.Method); + return Task.FromResult(Json(new AdminContent("text", "text/plain", Text: "revealed-value", Sensitive: true), AdminJsonContext.Default.AdminContent)); + }); + context.Services.AddSingleton(new AdminApiClient(new HttpClient(handler) { BaseAddress = new("http://localhost:4566") })); + var view = context.Render(parameters => parameters + .Add(component => component.Fields, [new AdminField("Password", null, true, true)]) + .Add(component => component.Service, "rds") + .Add(component => component.Account, "000000000000") + .Add(component => component.Path, [new("databases", "db")])); + Assert.Equal(0, calls); + await view.Find("button").ClickAsync(new MouseEventArgs()); + Assert.Equal(1, calls); + Assert.Contains("revealed-value", view.Markup); + view.Render(parameters => parameters.Add(component => component.Account, "111111111111")); + Assert.DoesNotContain("revealed-value", view.Markup); + Assert.Equal(1, calls); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Live_revision_clears_reveal_and_cancels_late_result_without_fetching_again(bool pending) + { + using var context = new BunitContext(); + context.JSInterop.Mode = JSRuntimeMode.Loose; + var response = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + using var handler = new Handler((_, _) => + { + calls++; + return response.Task; + }); + context.Services.AddSingleton(new AdminApiClient(new HttpClient(handler) { BaseAddress = new("http://localhost:4566") })); + var view = context.Render(parameters => parameters + .Add(component => component.Fields, [new AdminField("Password", null, true, true)]) + .Add(component => component.Service, "rds") + .Add(component => component.Account, "000000000000") + .Add(component => component.Path, [new("databases", "db")])); + var click = view.Find("button").ClickAsync(new MouseEventArgs()); + if (!pending) + { + response.SetResult(Json(new AdminContent("text", "text/plain", Text: "private-value", Sensitive: true), AdminJsonContext.Default.AdminContent)); + await click; + Assert.Contains("private-value", view.Markup); + } + view.Render(parameters => parameters.Add(component => component.Revision, 1)); + if (pending) + { + response.SetResult(Json(new AdminContent("text", "text/plain", Text: "private-value", Sensitive: true), AdminJsonContext.Default.AdminContent)); + await click; + } + Assert.DoesNotContain("private-value", view.Markup); + Assert.Equal(1, calls); + Assert.Equal("Reveal Password", view.Find("button").TextContent); + } + + [Fact] + public void Deep_link_loads_selected_nested_content_in_requested_account() + { + using var context = new BunitContext(); + LiveTestServices.AddPaused(context); + context.JSInterop.Mode = JSRuntimeMode.Loose; + context.Services.AddScoped(); + var account = "111111111111"; + var bucket = new AdminResourceSummary(new("buckets", "a"), "Bucket A"); + var child = new AdminResourceSummary(new("objects", "folder/a.json"), "folder/a.json"); + var observedAccounts = new List(); + var service = new AdminService("s3", "Simple Storage Service", "S3", "Storage", "s3", "s3", "enabled", "account") + { Kinds = [new("buckets", "Buckets")] }; + using var handler = new Handler((request, _) => + { + var uri = request.RequestUri!; + var query = uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries).Select(part => part.Split('=', 2)) + .ToDictionary(part => part[0], part => Uri.UnescapeDataString(part[1])); + if (query.TryGetValue("accountId", out var requestedAccount)) observedAccounts.Add(requestedAccount); + var endpoint = uri.AbsolutePath.Split('/').Last(); + return Task.FromResult(endpoint switch + { + "context" => Json(new AdminContext("000000000000", "eu-west-1"), AdminJsonContext.Default.AdminContext), + "accounts" => Json(new[] { "000000000000", account }, AdminJsonContext.Default.StringArray), + "services" => Json(new[] { service }, AdminJsonContext.Default.AdminServiceArray), + "resources" => Json(new AdminPage { Items = [bucket] }, AdminJsonContext.Default.AdminPageAdminResourceSummary), + "children" => Json(new AdminPage { Items = [child] }, AdminJsonContext.Default.AdminPageAdminResourceSummary), + "resource" => Json(ExplorerLocation.DecodePath(query["path"]).Length == 1 + ? new AdminResourceDetail(bucket) { HasChildren = true } + : new AdminResourceDetail(child) { HasContent = true }, AdminJsonContext.Default.AdminResourceDetail), + "content" => Json(new AdminContent("json", "application/json", Text: """{"live":true}"""), AdminJsonContext.Default.AdminContent), + _ => throw new InvalidOperationException(endpoint) + }); + }); + context.Services.AddSingleton(new AdminApiClient(new HttpClient(handler) { BaseAddress = new("http://localhost:4566") })); + var navigation = context.Services.GetRequiredService(); + navigation.NavigateTo($"/services/s3?account={account}&path={Uri.EscapeDataString(ExplorerLocation.EncodePath([bucket.Key]))}&item={Uri.EscapeDataString(ExplorerLocation.EncodePath([child.Key]))}"); + var view = context.Render(); + view.WaitForAssertion(() => Assert.Contains("\"live\": true", view.Find("pre").TextContent)); + Assert.NotEmpty(observedAccounts); + Assert.All(observedAccounts, actual => Assert.Equal(account, actual)); + Assert.Contains("folder/a.json", view.Markup); + Assert.StartsWith("http://localhost/accounts/111111111111/services/s3?", navigation.Uri, StringComparison.Ordinal); + Assert.DoesNotContain("account=", navigation.Uri, StringComparison.Ordinal); + Assert.Contains("item=", navigation.Uri, StringComparison.Ordinal); + } + + [Fact] + public async Task Late_reveal_cannot_cross_account_scope() + { + using var context = new BunitContext(); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + CancellationToken readToken = default; + using var handler = new Handler((_, token) => { readToken = token; return completion.Task; }); + context.Services.AddSingleton(new AdminApiClient(new HttpClient(handler) { BaseAddress = new("http://localhost:4566") })); + var view = context.Render(parameters => parameters + .Add(component => component.Fields, [new AdminField("Password", null, true, true)]) + .Add(component => component.Service, "rds") + .Add(component => component.Account, "000000000000") + .Add(component => component.Path, [new("databases", "db")])); + var click = view.Find("button").ClickAsync(new MouseEventArgs()); + view.WaitForAssertion(() => Assert.True(readToken.CanBeCanceled)); + view.Render(parameters => parameters.Add(component => component.Account, "111111111111")); + Assert.True(readToken.IsCancellationRequested); + completion.SetResult(Json(new AdminContent("text", "text/plain", Text: "previous-account-secret", Sensitive: true), AdminJsonContext.Default.AdminContent)); + await click; + Assert.DoesNotContain("previous-account-secret", view.Markup); + } + + private static HttpResponseMessage Json(T value, JsonTypeInfo type) => new(HttpStatusCode.OK) { Content = JsonContent.Create(value, type) }; + private sealed class Handler(Func> send) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => send(request, cancellationToken); + } +} diff --git a/tests/MicroStack.UI.Tests/AdminCollectionTests.cs b/tests/MicroStack.UI.Tests/AdminCollectionTests.cs new file mode 100644 index 0000000..656e4f1 --- /dev/null +++ b/tests/MicroStack.UI.Tests/AdminCollectionTests.cs @@ -0,0 +1,150 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using MicroStack.Admin.Contracts; +using MicroStack.UI.Client.Services; +using Xunit; + +namespace MicroStack.UI.Tests; + +public sealed class AdminCollectionTests +{ + private const string Account = "123456789012"; + private static readonly AdminKey[] Path = [new("buckets", "folder/bucket")]; + + [Theory] + [InlineData("resources")] + [InlineData("children")] + [InlineData("connections")] + [InlineData("activity")] + public async Task Collections_load_beyond_api_page_size_with_scope_preserved(string endpoint) + { + var requests = new List>(); + using var handler = new Handler((request, _) => + { + Assert.EndsWith("/" + endpoint, request.RequestUri!.AbsolutePath, StringComparison.Ordinal); + var query = Query(request.RequestUri); + requests.Add(query); + var second = query.ContainsKey("cursor"); + var start = second ? 200 : 0; + var count = second ? 5 : 200; + var next = second ? null : "opaque/page+token?"; + HttpContent content = endpoint switch + { + "resources" or "children" => JsonContent.Create(new AdminPage + { + Items = Enumerable.Range(start, count).Select(i => new AdminResourceSummary(new("objects", i.ToString()), $"Object {i}")).ToArray(), + NextCursor = next + }, AdminJsonContext.Default.AdminPageAdminResourceSummary), + "connections" => JsonContent.Create(new AdminPage + { + Items = Enumerable.Range(start, count).Select(i => new AdminConnection($"Connection {i}", "configured")).ToArray(), + NextCursor = next + }, AdminJsonContext.Default.AdminPageAdminConnection), + _ => JsonContent.Create(new AdminPage + { + Items = Enumerable.Range(start, count).Select(_ => new AdminActivity("sqs", "SameAction", Account, DateTimeOffset.UnixEpoch, 200, 1)).ToArray(), + NextCursor = next + }, AdminJsonContext.Default.AdminPageAdminActivity) + }; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + }); + var api = new AdminApiClient(new HttpClient(handler) { BaseAddress = new("http://localhost:4566") }); + var count = endpoint switch + { + "resources" => (await api.ResourcesAsync("s3", Account, "objects", "sample", CancellationToken.None)).Items.Count, + "children" => (await api.ChildrenAsync("s3", Account, Path, "objects", "sample", "folder/", CancellationToken.None)).Items.Count, + "connections" => (await api.ConnectionsAsync("s3", Account, Path, CancellationToken.None)).Items.Count, + _ => (await api.ActivityAsync("s3", Account, CancellationToken.None)).Items.Count + }; + Assert.Equal(205, count); + Assert.Equal(2, requests.Count); + Assert.False(requests[0].ContainsKey("cursor")); + Assert.Equal("opaque/page+token?", requests[1]["cursor"]); + Assert.All(requests, query => + { + Assert.Equal(Account, query["accountId"]); + Assert.Equal("200", query["pageSize"]); + if (endpoint is "resources" or "children") + { + Assert.Equal("objects", query["kind"]); + Assert.Equal("sample", query["filter"]); + } + if (endpoint is "children" or "connections") Assert.Equal(Path, ExplorerLocation.DecodePath(query["path"])); + if (endpoint == "children") Assert.Equal("folder/", query["prefix"]); + }); + } + + [Fact] + public async Task Moving_page_boundary_does_not_duplicate_resource_keys() + { + var calls = 0; + using var handler = new Handler((_, _) => Task.FromResult(Page( + ++calls == 1 ? ["a", "b"] : ["b", "c"], calls == 1 ? "next" : null))); + var api = new AdminApiClient(new HttpClient(handler) { BaseAddress = new("http://localhost:4566") }); + var result = await api.ResourcesAsync("s3", Account, "objects", null, CancellationToken.None); + Assert.Equal(["a", "b", "c"], result.Items.Select(item => item.Key.Id)); + Assert.Equal(3, result.KnownTotal); + Assert.Null(result.NextCursor); + } + + [Fact] + public async Task Later_page_failure_is_not_a_partial_success() + { + var calls = 0; + using var handler = new Handler((_, _) => Task.FromResult( + ++calls == 1 ? Page(["a"], "next") : new HttpResponseMessage(HttpStatusCode.ServiceUnavailable))); + var api = new AdminApiClient(new HttpClient(handler) { BaseAddress = new("http://localhost:4566") }); + await Assert.ThrowsAsync(() => api.ResourcesAsync("s3", Account, "objects", null, CancellationToken.None)); + Assert.Equal(2, calls); + } + + [Fact] + public async Task Repeated_cursor_is_reported_instead_of_looping() + { + var calls = 0; + using var handler = new Handler((_, _) => + { + calls++; + return Task.FromResult(Page(["a"], "same")); + }); + var api = new AdminApiClient(new HttpClient(handler) { BaseAddress = new("http://localhost:4566") }); + await Assert.ThrowsAsync(() => api.ResourcesAsync("s3", Account, "objects", null, CancellationToken.None)); + Assert.Equal(2, calls); + } + + [Fact] + public async Task Cancellation_stops_loading_remaining_pages() + { + using var cancellation = new CancellationTokenSource(); + var calls = 0; + using var handler = new Handler((_, _) => + { + calls++; + cancellation.Cancel(); + return Task.FromResult(Page(["a"], "next")); + }); + var api = new AdminApiClient(new HttpClient(handler) { BaseAddress = new("http://localhost:4566") }); + await Assert.ThrowsAnyAsync(() => api.ResourcesAsync("s3", Account, "objects", null, cancellation.Token)); + Assert.Equal(1, calls); + } + + private static HttpResponseMessage Page(string[] keys, string? next) => new(HttpStatusCode.OK) + { + Content = JsonContent.Create(new AdminPage + { + Items = keys.Select(key => new AdminResourceSummary(new("objects", key), key)).ToArray(), + NextCursor = next + }, AdminJsonContext.Default.AdminPageAdminResourceSummary) + }; + + private static Dictionary Query(Uri uri) => uri.Query.TrimStart('?') + .Split('&', StringSplitOptions.RemoveEmptyEntries).Select(part => part.Split('=', 2)) + .ToDictionary(part => part[0], part => Uri.UnescapeDataString(part[1])); + + private sealed class Handler(Func> send) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + send(request, cancellationToken); + } +} diff --git a/tests/MicroStack.UI.Tests/DebouncedFilterTests.cs b/tests/MicroStack.UI.Tests/DebouncedFilterTests.cs new file mode 100644 index 0000000..ffc8c64 --- /dev/null +++ b/tests/MicroStack.UI.Tests/DebouncedFilterTests.cs @@ -0,0 +1,176 @@ +using Bunit; +using MicroStack.UI.Client.Components; +using MicroStack.UI.Client.Services; +using Microsoft.AspNetCore.Components.Web; +using Xunit; + +namespace MicroStack.UI.Tests; + +public sealed class DebouncedFilterTests +{ + [Fact] + public async Task Burst_waits_250ms_and_only_applies_latest_value() + { + var clock = new ManualClock(); + using var debounce = new DebouncedAction(clock); + var values = new List(); + var first = debounce.RunAsync(_ => { values.Add("first"); return Task.CompletedTask; }); + clock.Advance(200); + var second = debounce.RunAsync(_ => { values.Add("second"); return Task.CompletedTask; }); + clock.Advance(249); + Assert.Empty(values); + clock.Advance(1); + await Task.WhenAll(first, second); + Assert.Equal(["second"], values); + } + + [Fact] + public async Task Immediate_apply_cancels_pending_delay() + { + var clock = new ManualClock(); + using var debounce = new DebouncedAction(clock); + var values = new List(); + var pending = debounce.RunAsync(_ => { values.Add("delayed"); return Task.CompletedTask; }); + await debounce.RunAsync(_ => { values.Add("immediate"); return Task.CompletedTask; }, immediately: true); + clock.Advance(250); + await pending; + Assert.Equal(["immediate"], values); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Scope_cancellation_and_disposal_cancel_inflight_work(bool dispose) + { + var clock = new ManualClock(); + using var debounce = new DebouncedAction(clock); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + CancellationToken received = default; + var applied = false; + var work = debounce.RunAsync(async token => + { + received = token; + entered.SetResult(); + await release.Task; + if (!token.IsCancellationRequested) applied = true; + }); + clock.Advance(250); + await entered.Task; + if (dispose) debounce.Dispose(); else debounce.Cancel(); + Assert.True(received.IsCancellationRequested); + release.SetResult(); + await work; + Assert.False(applied); + } + + [Fact] + public async Task Enter_and_clear_apply_immediately_in_the_same_filter() + { + using var context = new BunitContext(); + context.JSInterop.Mode = JSRuntimeMode.Loose; + var clock = new ManualClock(); + var values = new List(); + var view = context.Render(parameters => parameters + .Add(component => component.Clock, clock) + .Add(component => component.Changed, value => values.Add(value))); + var input = view.Find("input"); + var typing = input.InputAsync(new() { Value = "orders" }); + Assert.Empty(values); + await input.KeyDownAsync(new KeyboardEventArgs { Key = "Enter" }); + await typing; + Assert.Equal(["orders"], values); + Assert.Equal("orders", view.Find("input").GetAttribute("value")); + await view.Find("button").ClickAsync(new()); + Assert.Equal(["orders", ""], values); + Assert.Equal("", view.Find("input").GetAttribute("value")); + Assert.Contains(context.JSInterop.Invocations, invocation => invocation.Identifier == "Blazor._internal.domWrapper.focus"); + } + + [Fact] + public async Task Scope_change_discards_pending_text() + { + using var context = new BunitContext(); + var clock = new ManualClock(); + var values = new List(); + var view = context.Render(parameters => parameters + .Add(component => component.Clock, clock) + .Add(component => component.Scope, "account-a") + .Add(component => component.Changed, value => values.Add(value))); + var typing = view.Find("input").InputAsync(new() { Value = "old-account" }); + view.Render(parameters => parameters.Add(component => component.Scope, "account-b").Add(component => component.Value, "new-account")); + clock.Advance(250); + await typing; + Assert.Empty(values); + Assert.Equal("new-account", view.Find("input").GetAttribute("value")); + } + + [Fact] + public async Task Acknowledging_an_older_submission_keeps_newer_typed_text() + { + using var context = new BunitContext(); + var clock = new ManualClock(); + var view = context.Render(parameters => parameters.Add(component => component.Clock, clock)); + var first = view.Find("input").InputAsync(new() { Value = "old" }); + await view.Find("input").KeyDownAsync(new KeyboardEventArgs { Key = "Enter" }); + await first; + var second = view.Find("input").InputAsync(new() { Value = "newer" }); + view.Render(parameters => parameters.Add(component => component.Value, "old")); + Assert.Equal("newer", view.Find("input").GetAttribute("value")); + clock.Advance(250); + await second; + } + + [Fact] + public async Task Disposing_filter_cancels_pending_callback() + { + using var context = new BunitContext(); + var clock = new ManualClock(); + var values = new List(); + var view = context.Render(parameters => parameters + .Add(component => component.Clock, clock) + .Add(component => component.Changed, value => values.Add(value))); + var typing = view.Find("input").InputAsync(new() { Value = "discard" }); + await view.InvokeAsync(() => view.Instance.Dispose()); + clock.Advance(250); + await typing; + Assert.Empty(values); + } + + private sealed class ManualClock : TimeProvider + { + private readonly List _timers = []; + private long _milliseconds; + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = new ManualTimer(this, callback, state); + _timers.Add(timer); + timer.Change(dueTime, period); + return timer; + } + public void Advance(long milliseconds) + { + _milliseconds += milliseconds; + foreach (var timer in _timers.ToArray()) timer.Tick(); + } + private sealed class ManualTimer(ManualClock clock, TimerCallback callback, object? state) : ITimer + { + private long _due = long.MaxValue; + private bool _disposed; + public bool Change(TimeSpan dueTime, TimeSpan period) + { + if (_disposed) return false; + _due = dueTime == Timeout.InfiniteTimeSpan ? long.MaxValue : clock._milliseconds + (long)dueTime.TotalMilliseconds; + return true; + } + public void Tick() + { + if (_disposed || _due > clock._milliseconds) return; + _due = long.MaxValue; + callback(state); + } + public void Dispose() => _disposed = true; + public ValueTask DisposeAsync() { Dispose(); return ValueTask.CompletedTask; } + } + } +} diff --git a/tests/MicroStack.UI.Tests/ExplorerAccountStateTests.cs b/tests/MicroStack.UI.Tests/ExplorerAccountStateTests.cs new file mode 100644 index 0000000..39c4739 --- /dev/null +++ b/tests/MicroStack.UI.Tests/ExplorerAccountStateTests.cs @@ -0,0 +1,49 @@ +using Bunit; +using MicroStack.UI.Client.Services; +using Xunit; + +namespace MicroStack.UI.Tests; + +public sealed class ExplorerAccountStateTests +{ + [Fact] + public async Task Late_storage_restore_does_not_overwrite_explicit_selection() + { + using var context = new BunitContext(); + context.JSInterop.Mode = JSRuntimeMode.Loose; + var pending = context.JSInterop.Setup("sessionStorage.getItem", ExplorerAccountState.StorageKey); + var state = new ExplorerAccountState(context.JSInterop.JSRuntime); + var restore = state.RestoreAsync(); + await state.RememberAsync("111111111111"); + pending.SetResult("000000000000"); + await restore; + Assert.Equal("111111111111", state.Account); + context.JSInterop.VerifyInvoke("sessionStorage.setItem"); + } + + [Theory] + [InlineData("not-an-account")] + [InlineData("123456789012")] + public async Task Invalid_saved_context_is_reported_and_never_used_as_account(string saved) + { + using var context = new BunitContext(); + context.JSInterop.Setup("sessionStorage.getItem", ExplorerAccountState.StorageKey).SetResult(saved); + var state = new ExplorerAccountState(context.JSInterop.JSRuntime); + await state.RestoreAsync(); + Assert.Null(state.Account); + Assert.Contains("invalid", state.PersistenceError, StringComparison.Ordinal); + await Assert.ThrowsAsync(() => state.RememberAsync(saved)); + } + + [Fact] + public async Task Restore_is_once_per_tab_state_and_does_not_write_storage() + { + using var context = new BunitContext(); + context.JSInterop.Setup("sessionStorage.getItem", ExplorerAccountState.StorageKey).SetResult("111111111111"); + var state = new ExplorerAccountState(context.JSInterop.JSRuntime); + await Task.WhenAll(state.RestoreAsync(), state.RestoreAsync()); + Assert.Equal("111111111111", state.Account); + context.JSInterop.VerifyInvoke("sessionStorage.getItem", 1); + Assert.DoesNotContain(context.JSInterop.Invocations, call => call.Identifier == "sessionStorage.setItem"); + } +} diff --git a/tests/MicroStack.UI.Tests/InspectionComponentTests.cs b/tests/MicroStack.UI.Tests/InspectionComponentTests.cs new file mode 100644 index 0000000..ea25931 --- /dev/null +++ b/tests/MicroStack.UI.Tests/InspectionComponentTests.cs @@ -0,0 +1,381 @@ +using Bunit; +using MicroStack.Admin.Contracts; +using MicroStack.UI.Client.Components; +using MicroStack.UI.Client.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; +using Xunit; + +namespace MicroStack.UI.Tests; + +public sealed class InspectionComponentTests +{ + [Fact] + public void Payload_precedes_key_facts_and_expandable_secondary_metadata() + { + using var context = new BunitContext(); + context.Services.AddSingleton(new AdminApiClient(new HttpClient())); + var detail = new AdminResourceDetail(new(new("items", "opaque"), "Item")) + { + HasContent = true, + Fields = [new("Partition key", "customer/1"), new AdminField("Stored settings", "{}") { Secondary = true }] + }; + var view = context.Render(parameters => parameters + .Add(component => component.Service, "dynamodb") + .Add(component => component.Detail, detail) + .Add(component => component.Content, new("json", "application/json", Text: """{"PK":{"S":"customer/1"},"active":{"BOOL":true},"count":{"N":"12"}}"""))); + Assert.True(view.Markup.IndexOf("Escaped content", StringComparison.Ordinal) < view.Markup.IndexOf("Partition key", StringComparison.Ordinal)); + Assert.Contains("DynamoDB JSON", view.Markup); + Assert.Contains("\"BOOL\": true", view.Find("pre").TextContent); + Assert.Contains("\"N\": \"12\"", view.Find("pre").TextContent); + Assert.Contains("Stored settings", view.Find("details").TextContent); + Assert.False(view.Find("details").HasAttribute("open")); + } + + [Fact] + public async Task Exact_summary_duplicates_appear_once_and_remain_fully_copyable() + { + using var context = new BunitContext(); + context.JSInterop.SetupVoid("microstack.copy", _ => true).SetVoidResult(); + context.Services.AddSingleton(new AdminApiClient(new HttpClient())); + var sent = new AdminField("Sent", "2026-09-13T10:00:00.1234567Z", Format: "datetime"); + var detail = new AdminResourceDetail(new(new("messages", "message"), "Message")) + { + Summary = [sent], + Fields = [sent, new("Receive count", "0"), new AdminField("Attributes", "{}") { Secondary = true }] + }; + var view = context.Render(parameters => parameters.Add(component => component.Detail, detail)); + Assert.Single(view.FindAll("dt").Where(element => element.TextContent == "Sent")); + Assert.DoesNotContain(view.FindAll(".metadata dt"), element => element.TextContent == "Sent"); + Assert.Contains("Receive count", view.Find(".entry-facts").TextContent); + Assert.Contains("Attributes", view.Find("details").TextContent); + await view.Find("button[aria-label='Copy Sent']").ClickAsync(new()); + Assert.Equal(sent.Value, context.JSInterop.Invocations["microstack.copy"][0].Arguments[0]); + } + + [Fact] + public void Summary_deduplication_keeps_different_values_secondary_and_sensitive_reveal_fields() + { + using var context = new BunitContext(); + context.Services.AddSingleton(new AdminApiClient(new HttpClient())); + var detail = new AdminResourceDetail(new(new("items", "item"), "Item")) + { + Summary = [new("Key", "short"), new("Settings", "{}"), new("Secret", null, Sensitive: true)], + Fields = + [ + new("Key", "full-key"), + new AdminField("Settings", "{}") { Secondary = true }, + new("Secret", null, Sensitive: true, CanReveal: true) + ] + }; + var view = context.Render(parameters => parameters.Add(component => component.Detail, detail)); + Assert.Contains("full-key", view.Find(".entry-facts").TextContent); + Assert.Contains("Settings", view.Find("details").TextContent); + Assert.Contains("Reveal Secret", view.Find(".entry-facts").TextContent); + Assert.Empty(view.FindAll(".resource-facts button[aria-label='Copy Secret']")); + } + + [Theory] + [InlineData("Standard", "available", null)] + [InlineData("FIFO", "Available", null)] + [InlineData(null, "available", "available")] + [InlineData(" ", "available", "available")] + [InlineData("Message", "In flight", "In flight")] + [InlineData("Rule", "DISABLED", "DISABLED")] + public void Meaningful_types_replace_only_generic_available_status(string? type, string status, string? expected) + { + var resource = new AdminResourceSummary(new("kind", "id"), "Resource", Status: status) { Type = type }; + Assert.Equal(expected, InspectionProfile.DisplayStatus(resource)); + } + + [Fact] + public void Generic_child_filter_uses_declared_kind_label_without_implying_body_search() + { + var detail = new AdminResourceDetail(new(new("roots", "root"), "Root")) + { + HasChildren = true, + ChildKinds = [new("opaque", "Configurations")] + }; + Assert.Equal("Filter configurations by name or identifier", InspectionProfile.ChildFilterLabel("custom", detail, null)); + Assert.Equal("Filter entries by name or identifier", InspectionProfile.ChildFilterLabel("custom", detail with { ChildKinds = [] }, null)); + } + + [Fact] + public void Configuration_only_resource_keeps_sensitive_reveal_controls_visible() + { + using var context = new BunitContext(); + context.Services.AddSingleton(new AdminApiClient(new HttpClient())); + var detail = new AdminResourceDetail(new(new("connections", "connection"), "Connection")) + { + Fields = [new AdminField("Authentication", "do-not-display", Sensitive: true, CanReveal: true) { Secondary = true }] + }; + var view = context.Render(parameters => parameters.Add(component => component.Detail, detail)); + Assert.Empty(view.FindAll("details")); + Assert.Empty(view.FindAll("section[aria-label='Content preview']")); + Assert.Contains("Reveal Authentication", view.Find("button").TextContent); + Assert.DoesNotContain("do-not-display", view.Markup); + } + + [Fact] + public async Task Icon_copy_preserves_full_identifier_and_accessible_feedback() + { + using var context = new BunitContext(); + context.JSInterop.SetupVoid("microstack.copy", _ => true).SetVoidResult(); + const string text = "arn:aws:s3:::bucket/folder/a?x=#文"; + var view = context.Render(parameters => parameters.Add(component => component.Text, text).Add(component => component.Label, "Copy identifier")); + Assert.Equal("Copy identifier", view.Find("button").GetAttribute("aria-label")); + Assert.Equal("Copy identifier", view.Find("button").GetAttribute("title")); + Assert.Single(view.FindAll("svg")); + var copy = view.Find("button").ClickAsync(new()); + view.WaitForAssertion(() => Assert.Equal("Copied.", view.Find("[role=status]").TextContent)); + Assert.Equal(text, context.JSInterop.Invocations["microstack.copy"][0].Arguments[0]); + Assert.Equal("Copied.", view.Find("[role=status]").TextContent); + view.Render(); + Assert.Equal("Copied.", view.Find("[role=status]").TextContent); + await copy; + Assert.Empty(view.Find("[role=status]").TextContent); + } + + [Fact] + public async Task Repeated_copy_cancels_previous_dismissal_without_clearing_new_feedback() + { + using var context = new BunitContext(); + context.JSInterop.SetupVoid("microstack.copy", _ => true).SetVoidResult(); + var view = context.Render(parameters => parameters.Add(component => component.Text, "identifier")); + var firstCopy = view.Find("button").ClickAsync(new()); + view.WaitForAssertion(() => Assert.Equal("Copied.", view.Find("[role=status]").TextContent)); + var secondCopy = view.Find("button").ClickAsync(new()); + await firstCopy; + Assert.False(secondCopy.IsCompleted); + Assert.Equal("Copied.", view.Find("[role=status]").TextContent); + await secondCopy; + Assert.Empty(view.Find("[role=status]").TextContent); + } + + [Fact] + public async Task Changing_copy_text_cancels_feedback_and_disposal_cancels_pending_dismissal() + { + using var context = new BunitContext(); + context.JSInterop.SetupVoid("microstack.copy", _ => true).SetVoidResult(); + var view = context.Render(parameters => parameters.Add(component => component.Text, "first")); + var copy = view.Find("button").ClickAsync(new()); + view.WaitForAssertion(() => Assert.Equal("Copied.", view.Find("[role=status]").TextContent)); + view.Render(parameters => parameters.Add(component => component.Text, "second")); + await copy; + Assert.Empty(view.Find("[role=status]").TextContent); + + var pending = view.Find("button").ClickAsync(new()); + view.WaitForAssertion(() => Assert.Equal("Copied.", view.Find("[role=status]").TextContent)); + await view.InvokeAsync(() => view.Instance.Dispose()); + await pending; + } + + [Fact] + public async Task Copy_failure_explains_manual_fallback() + { + using var context = new BunitContext(); + context.JSInterop.SetupVoid("microstack.copy", _ => true).SetException(new JSException("Denied")); + var view = context.Render(parameters => parameters.Add(component => component.Text, "identifier")); + await view.Find("button").ClickAsync(new()); + Assert.Contains("copy it manually", view.Find("[role=status]").TextContent); + } + + [Fact] + public void Summary_formats_typed_values_without_leaking_sensitive_values() + { + using var context = new BunitContext(); + var view = context.Render(parameters => parameters.Add(component => component.Fields, + [new("Size", "2048", Format: "bytes"), new("When", "2026-09-12T12:00:00+02:00", Format: "datetime"), new("Secret", "do-not-display", Sensitive: true)])); + Assert.Contains("2,048 bytes", view.Markup); + Assert.Contains("2026-09-12T10:00:00Z", view.Markup); + Assert.DoesNotContain("do-not-display", view.Markup); + } + + [Fact] + public void Connection_states_remain_truthful_and_external_endpoints_are_not_links() + { + using var context = new BunitContext(); + var view = context.Render(parameters => parameters + .Add(component => component.Destination, _ => "/ui/services/sqs") + .Add(component => component.Connections, + [ + new("Local", "target", "sqs", [new("queues", "q")]), + new("Missing", "target", "sqs", [new("queues", "gone")], State: "missing"), + new("External", "subscription", ExternalUri: "https://external.example", State: "external"), + new("Unavailable", "target", "sqs", [new("queues", "unavailable")], State: "unavailable") + ])); + Assert.Single(view.FindAll("a")); + Assert.Contains("missing", view.Markup); + Assert.Contains("external", view.Markup); + Assert.Contains("unavailable", view.Markup); + } + + [Fact] + public void Parent_connection_exposes_original_configuration_even_when_destination_is_missing() + { + using var context = new BunitContext(); + var view = context.Render(parameters => parameters + .Add(component => component.Destination, _ => "/ui/services/sqs") + .Add(component => component.Source, _ => "/ui/services/events?path=opaque") + .Add(component => component.Connections, + [ + new AdminConnection("Queue", "target", "sqs", [new("queues", "deleted")], State: "missing") + { SourceServiceId = "events", SourcePath = [new("event-buses", "default"), new("rules", "orders"), new("targets", "target-1")] } + ])); + Assert.Equal("Inspect configuration →", view.Find("a").TextContent); + Assert.Contains("target-1", view.Markup); + Assert.DoesNotContain("Inspect destination", view.Markup); + } + + [Fact] + public void Text_content_is_escaped_and_never_creates_elements() + { + using var context = new BunitContext(); + var view = context.Render(parameters => parameters.Add(component => component.Content, + new("text", "text/html", Text: ""))); + Assert.Empty(view.FindAll("script")); + Assert.Empty(view.FindAll("img")); + Assert.Contains("