Skip to content

About

Self-service infrastructure portal for platform teams to manage requests, approvals, deployments, and operational workflows.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

InfraPilot

InfraPilot is an open-source infrastructure portal for self-service requests, approvals, deployments, and operational workflows.

Quick Start

If you just want to run the app locally, use Docker Compose.

Prerequisites

  • Docker
  • Docker Compose

Start The App

From the repository root:

docker compose up -d

Then open:

  • frontend: http://localhost:5173
  • API health endpoint: http://localhost:5259/health

Stop The App

docker compose down

If you also want to remove the local Postgres volume:

docker compose down -v

Developing Against A Local Stack (Windows, PowerShell)

docker compose up runs everything in containers, which is fine for a look around but slow to iterate on. For development there are three scripts that keep Postgres in Docker and run the API and the web dev server natively, so both reload on save:

./scripts/start.ps1

Starts Postgres, applies migrations, seeds demo data into an empty database, and prints the URLs and the dev sign-in accounts. Safe to re-run — anything already listening on its port is left alone. -DbOnly starts just the database, for when the servers are launched from an IDE.

./scripts/reseed.ps1

Drops the local database and rebuilds it: migrate, re-seed, then report the row counts. Seeding only happens on an empty database, so this is the way back to a clean demo dataset. Destructive — it prompts unless given -Force.

./scripts/stop.ps1

Stops the servers and the database, keeping the volume. -KeepDb leaves Postgres up; -RemoveData deletes the volume as well.

Logs and pid files go to .local/ (gitignored).

Tutorial Environment (A Copy Of A Live Instance)

./scripts/tutorial/seed-tutorial.ps1

Builds the environment used for presentations and training: the local database is dropped and refilled with a copy of a real instance's active data — the current version of every service in every environment, the last week of deploys, the registered builds and every open promotion — and then a scripted storyline is staged on top (a promotion signed off and waiting for its release approval, one with an issue raised, one rejected, one approved and waiting for its deploy, a failed deploy with pipeline logs, a rollback request, a release note, a webhook subscription). Three accounts come with it: admin@localhost / admin123 (Admin), qa@localhost / qa123 (QA) and user@localhost / user123 (plain User).

The copy is taken by scripts/tutorial/export-snapshot.ps1 through the public API, so it needs DEPLOYMENTS_URL and DEPLOYMENTS_API_KEY for the source instance; the result lands in the gitignored scripts/tutorial/snapshot/ and is reused by later runs (-RefreshSnapshot pulls again). Everything else is written through the local API the way pipelines and users write it, so the data is exactly what the product does with real payloads. The run ends with .local/tutorial-cheatsheet.md — the promotion ids, links and curl commands the live parts of a demo need — and docs/tutorial/presentation-guide.md is the matching presenter's script. scripts/tutorial/webhook-listener.ps1 is a terminal that shows the seeded webhook's deliveries arriving.

scripts/tutorial/capture (Node + Playwright) produces the offline version of the presentation: an annotated screenshot walkthrough of every part, as PDF and HTML, from each actor's point of view, with the terminal output of the live steps. npm run all there, after a fresh seed; output lands in .local/tutorial-walkthrough/.

The script also writes src/Platform.Api/appsettings.Development.json (backing up an existing one): the demo deployment seeder is switched off there (Seed:DemoDeployments), a tutorial-pipeline-key API key is added for the curl commands, and the ingest rate limit is lifted. reseed.ps1 afterwards gives a database with only the catalog and the users; delete the Seed block to get the built-in demo dataset back.

Syncing MPT Versions

./scripts/sync-mpt-versions.ps1 -WhatIf

Catches InfraPortal up after an outage or a restore. Reads the version manifests MPT publishes for staging and production, confirms each version against the AKS cluster behind the environment (mpt-staging-r1-aks, mpt-prod-r1-aks), and records a manual deployment for every component whose version has moved forward. It never creates services, never records an older version, and never writes a version the cluster disagrees with; each of those is reported instead. Services are updated under the product InfraPortal already files them under (mpt, or whatever an admin's service product override says), never under the manifest's obsolete marketplace.

Only the drift is written, so re-running it changes nothing. Unlike the scripts above this one talks to a real instance: connection comes from DEPLOYMENTS_URL and DEPLOYMENTS_API_KEY (or -ApiBaseUrl / -ApiKey), and the cluster check needs kubectl credentials for both clusters (az aks get-credentials) or -NoClusterCheck. -WhatIf prints what it would record, -Target staging|production limits it to one environment, and -Service 'mpt-web-*' to a subset of components. Get-Help ./scripts/sync-mpt-versions.ps1 -Full has the rest.

What Runs Locally

docker compose starts three services:

  • postgres: PostgreSQL database on localhost:5433
  • api: ASP.NET Core backend on http://localhost:5259
  • frontend: React/Vite frontend on http://localhost:5173

The frontend is configured for local development through src/Platform.Web/public/config.json, which points API calls to http://localhost:5259.

Local Configuration

For the default local Docker setup, you do not need to provide extra environment variables.

The local stack already uses:

  • Postgres database: swo_platform
  • Postgres user: postgres
  • Postgres password: postgres
  • API connection string: Host=postgres;Database=swo_platform;Username=postgres;Password=postgres

If you want to change local settings, edit docker-compose.yml.

Production Deployment

Production uses a single container image:

  • Nginx serves the frontend on port 8080
  • ASP.NET API runs inside the same container on 127.0.0.1:8081
  • Nginx proxies /api, /agent, and /health to the API

Build it from the repository root:

docker build -t infrapilot .

Production Variables

These are the most important environment variables for a real deployment.

Core Runtime Variables

Variable Required Default Why it is needed
ConnectionStrings__Platform Yes none Tells the API how to connect to the database. The app cannot start correctly without a database connection. Format depends on Database__Provider (see below).
Database__Provider No Postgres Selects the EF Core provider. Accepted values: Postgres, SqlServer. Must match the format of ConnectionStrings__Platform.
ASPNETCORE_ENVIRONMENT Recommended Production in the container image Controls ASP.NET runtime behavior and environment-specific configuration.
CatalogPath No /app/catalog Tells the API where to load catalog YAML definitions from.

Frontend Runtime And Branding Variables

These variables are used to generate config.json inside the container at startup.

Variable Required Default Why it is needed
BACKEND_BASE_URL No empty Lets the frontend call a different public backend origin. Leave it empty for same-origin deployments.
APP_NAME No InfraPilot Sets the main product name shown in the UI.
APP_SUBTITLE No Infrastructure Portal Sets the smaller subtitle shown in the sidebar.
ASSISTANT_NAME No InfraPilot Assistant Sets the label used in the assistant/chat area.
PAGE_TITLE No InfraPilot | Infrastructure Portal Sets the browser tab title.
AZURE_CLIENT_ID No empty Entra app (client) ID for SPA sign-in. Empty disables MSAL and falls back to a local dev user — use that for local runs only.
AZURE_TENANT_ID No empty Entra tenant ID that hosts the SPA app registration. Required when AZURE_CLIENT_ID is set.

MSAL config is read from /config.json at page load, so the same container image can be pointed at a different tenant by changing env vars at deploy time (no rebuild).

Example Production Configuration

ASPNETCORE_ENVIRONMENT=Production
ConnectionStrings__Platform="Host=my-postgres;Database=infrapilot;Username=postgres;Password=secret"
CatalogPath=/app/catalog

APP_NAME="Contoso Platform"
APP_SUBTITLE="Operations Portal"
ASSISTANT_NAME="Contoso Assistant"
PAGE_TITLE="Contoso Platform | Operations Portal"
BACKEND_BASE_URL=""

Using Azure SQL instead of Postgres

InfraPilot supports Azure SQL Database as an alternative to PostgreSQL. Switch by setting Database__Provider=SqlServer and using a SQL Server-format connection string. Both providers share the same schema — migrations for each set live under Migrations/Postgres and Migrations/SqlServer and are applied automatically on startup in Development.

Database__Provider=SqlServer
ConnectionStrings__Platform="Server=tcp:<server>.database.windows.net,1433;Database=infrapilot;Authentication=Active Directory Default;Encrypt=True;TrustServerCertificate=False;"

Notes:

  • Azure SQL requires Encrypt=True.
  • Azure AD authentication (Authentication=Active Directory Default) is recommended over SQL auth when running on Container Apps with managed identity.
  • On SQL Server, JSON payload columns are stored as nvarchar(max) (on Postgres they use jsonb). The application serialises JSON itself so there is no behavioural difference.

Optional Integrations

You only need these if you want to enable the related features.

Variable Required Why it is needed
AzureAd__TenantId Only if using Entra ID auth Identifies the Microsoft Entra tenant used for user authentication.
AzureAd__ClientId Only if using Entra ID auth Identifies the API application registration.
AzureAd__Audience Only if using Entra ID auth Defines the expected token audience for API auth validation.
Graph__TenantId Only if using Microsoft Graph integration Identifies the tenant for Graph client-credentials access.
Graph__ClientId Only if using Microsoft Graph integration Identifies the Graph app registration.
Graph__ClientSecret Only if using Microsoft Graph integration Secret used to authenticate to Microsoft Graph.
AzureOpenAI__Endpoint Only if using AI features Tells the app which Azure OpenAI resource to call.
AzureOpenAI__ApiKey Only if using AI features Authenticates requests to Azure OpenAI.
AzureOpenAI__DeploymentName Only if using AI features Selects the model deployment used by the app.
AzureDevOps__Connections__default__OrganizationUrl Only if using Azure DevOps executors Points to the Azure DevOps organization.
AzureDevOps__Connections__default__Project Only if using Azure DevOps executors Selects the Azure DevOps project used by the executor.
AzureDevOps__Connections__default__Pat Only if using Azure DevOps executors Personal access token used to call Azure DevOps APIs.
Jira__Connections__default__BaseUrl Only if using Jira executors or lookups Points to the Jira instance.
Jira__Connections__default__Email Only if using Jira executors or lookups Jira account email used for API authentication.
Jira__Connections__default__ApiToken Only if using Jira executors or lookups Jira API token used for authentication.
AzureBlob__ConnectionString Only if using file attachments Connects the app to Azure Blob Storage.
AzureBlob__ContainerName Only if using file attachments Selects the blob container for uploaded files.
ServiceBus__ConnectionString Only if using Service Bus execution flow Connects the app to Azure Service Bus.
ServiceBus__ExecutionQueueName Only if using Service Bus execution flow Selects the queue used for request execution.
Notifications__PortalBaseUrl Recommended if using email/webhook notifications Lets notifications link users back to the correct portal URL.
Notifications__Channels__Email__Enabled Only if using email notifications Enables or disables the email channel.
Notifications__Channels__Email__SmtpHost Only if using email notifications SMTP server hostname for sending emails.
Notifications__Channels__Email__SmtpPort Only if using email notifications SMTP server port.
Notifications__Channels__Email__From Only if using email notifications Sender address used in notification emails.
Notifications__Channels__Email__UseSsl Only if using email notifications Enables SSL/TLS for SMTP.
Notifications__Channels__Webhook__Enabled Only if using outbound notification webhooks Enables or disables the webhook channel.
Notifications__Channels__Webhook__Url Only if using outbound notification webhooks Destination URL for generic webhook notifications.
APPLICATIONINSIGHTS_CONNECTION_STRING Only if using Application Insights Enables Azure Monitor / Application Insights telemetry (distributed tracing, dependency tracking, live metrics). When empty or absent, OpenTelemetry is not registered and there is zero overhead.
Deployments__Enrichment__Enabled Only if using deployment enrichment Turns background deployment enrichment on or off.
Deployments__Enrichment__IntervalSeconds Only if using deployment enrichment Controls how often enrichment runs.
Deployments__Enrichment__MaxEventsPerCycle Only if using deployment enrichment Limits how many deployment events are processed per cycle.
Deployments__Enrichment__LookbackHours Only if using deployment enrichment Controls how far back the enrichment worker searches for events.

Entra ID App Registration

InfraPilot uses two Entra ID app registrations: one for user-facing authentication (SPA + API) and one for server-to-server Graph API calls. Without auth configured the app runs in open development mode — all endpoints are accessible and a stub dev user with admin rights is injected automatically.

App 1 — InfraPilot (SPA + API)

This is the main app registration used by both the React frontend (MSAL) and the ASP.NET API (JWT validation).

Create the registration

  1. Go to Azure Portal > Microsoft Entra ID > App registrations > New registration
  2. Name: InfraPilot (or your preferred name)
  3. Supported account types: Single tenant
  4. Redirect URI: Single-page application (SPA) — http://localhost:5173 for dev, add your production URL later

Expose an API scope

  1. Go to Expose an API
  2. Set the Application ID URI to api://<client-id> (the default)
  3. Add a scope:
    • Scope name: access_as_user
    • Who can consent: Admins and users
    • Admin consent display name: Access InfraPilot as user
  4. The frontend requests this scope when acquiring tokens:
    api://<client-id>/access_as_user
    

Define app roles

Go to App roles > Create app role and add:

Display name Value Allowed member types Description
InfraPilot Admin InfraPortal.Admin Users/Groups Full admin access — catalog sync, audit log viewer

The role value must be exactly InfraPortal.Admin (case-sensitive). This role controls:

What it unlocks Where it's enforced
Catalog sync trigger (POST /api/catalog/sync) Backend policy CatalogAdmin
Audit log access (GET /api/audit) Backend policy AuditViewer
"Admin" badge in sidebar Frontend user.isAdmin check

Users without this role can still browse the catalog, submit requests, approve requests, and view deployments.

Assign users to the role

  1. Go to Enterprise applications > find InfraPilot > Users and groups
  2. Click Add user/group
  3. Select users or a security group, then select the InfraPilot Admin role
  4. Click Assign

Token configuration (optional but recommended)

To include group memberships in tokens (used for approval routing):

  1. Go to Token configuration > Add groups claim
  2. Select Security groups
  3. This populates the groups claim in the JWT, which the backend reads via CurrentUser.Groups

Backend environment variables

AzureAd__TenantId=<your-tenant-id>
AzureAd__ClientId=<client-id-from-above>
AzureAd__Audience=api://<client-id-from-above>

Frontend environment variables

Set these in src/Platform.Web/.env (or as build-time vars):

VITE_AZURE_CLIENT_ID=<client-id-from-above>
VITE_AZURE_TENANT_ID=<your-tenant-id>

If these are empty or start with <, MSAL is disabled and the app falls back to the stub dev user.

App 2 — InfraPilot Graph (server-to-server)

A separate app registration used by the backend to call Microsoft Graph with client credentials. This is needed for resolving approval group members (e.g. looking up who belongs to platform-infra-approvers).

Create the registration

  1. App registrations > New registration
  2. Name: InfraPilot Graph
  3. Supported account types: Single tenant
  4. No redirect URI needed

Add API permissions

  1. Go to API permissions > Add a permission > Microsoft Graph > Application permissions
  2. Add:
    • GroupMember.Read.All — read group memberships
    • User.Read.All — read user profiles
  3. Click Grant admin consent

Create a client secret

  1. Go to Certificates & secrets > New client secret
  2. Copy the secret value immediately (it won't be shown again)

Backend environment variables

Graph__TenantId=<your-tenant-id>
Graph__ClientId=<graph-app-client-id>
Graph__ClientSecret=<secret-from-above>

If these are empty or start with <, the backend falls back to StubIdentityService which returns an empty member list for all groups.

How approvals use groups

Catalog YAML items reference Entra ID security groups by name in the approver_group field:

approval:
  required: true
  strategy: any
  approver_group: "platform-infra-approvers"

When a request needs approval, the backend calls IIdentityService.GetGroupMembers(groupId) to resolve who can approve. In production this uses Graph API; in development it returns a stub list.

Summary

Registration Used by Purpose Required for
InfraPilot (SPA + API) Frontend + Backend User login, JWT validation, role-based access Auth in production
InfraPilot Graph Backend only Resolve group members via Graph API Approval routing

Without either registration configured, the app runs fully functional in open development mode.

Deployment Ingestion API Keys

Pipelines post deployment events to POST /api/deployments/events with the header X-Api-Key: <key>. Keys are configured via Deployments:ApiKeys:

# Minimum — plaintext key (fine for dev, OK for prod if secrets are in a vault)
Deployments__ApiKeys__0__Name=azure-devops-pipeline
Deployments__ApiKeys__0__Key=dpk-a1b2c3d4e5f6g7h8i9j0-ado

# Production — hashed key; restricted to specific products; revocable
Deployments__ApiKeys__1__Name=github-actions-platform
Deployments__ApiKeys__1__KeyHash=9a86f1a7e8c6... # lowercase SHA-256 hex of the real key
Deployments__ApiKeys__1__AllowedProducts__0=platform
Deployments__ApiKeys__1__AllowedProducts__1=billing
Deployments__ApiKeys__1__Revoked=false

Hardening:

  • KeyHash (preferred for prod) — store sha256(key) instead of the raw key. Generate with printf '%s' 'dpk-...' | shasum -a 256. If KeyHash is set, Key is ignored.
  • AllowedProducts — restrict a key so it can only post events for specific products. Empty list = any product. Requests for other products get 403 Forbidden.
  • Revoked — set to true to instantly kill a key without removing the entry (keeps audit history aligned).
  • Rate limit — each authenticated key is limited to 120 requests/minute (sliding window). Unauthenticated callers get a stricter shared 10/min bucket. Excess returns 429.
  • Constant-time compare — both plaintext and hash comparisons use CryptographicOperations.FixedTimeEquals to resist timing attacks.
  • HTTPS — keys travel in a header, so always terminate TLS before the API. Never expose /api/deployments/events over plain HTTP.

Event Payload Shape

A deployment event is a small JSON document. Only product, service, environment, version, source, and deployedAt are required; everything else is optional enrichment that the UI uses to render richer cards and links.

{
  "product": "platform",
  "service": "platform-api",
  "environment": "production",
  "version": "2.4.1",
  "source": "azure-devops",              // free-form tag (e.g. pipeline name)
  "deployedAt": "2026-04-15T09:12:00Z",
  "status": "succeeded",                  // "succeeded" | "failed" | "in_progress"
  "isRollback": false,                    // set true when this deploy reverted to a prior version
  "references": [
    { "type": "pull-request", "url": "https://github.com/acme/platform-api/pull/482", "provider": "github", "key": "482", "title": "Add idempotency key to checkout" },
    { "type": "work-item",    "url": "https://acme.atlassian.net/browse/PLAT-1234",   "provider": "jira",   "key": "PLAT-1234", "title": "Add idempotency key to checkout endpoint" },
    { "type": "repository",   "url": "https://github.com/acme/platform-api",           "provider": "github", "key": "acme/platform-api", "revision": "a1b2c3d4" },
    { "type": "pipeline",     "url": "https://dev.azure.com/acme/_build/results?buildId=98765", "provider": "azure-devops", "key": "98765" }
  ],
  "participants": [
    { "role": "PR Author",   "displayName": "Sylwester Grabowski", "email": "sg@acme.com" },
    { "role": "PR Reviewer", "displayName": "Alex Kim",             "email": "ak@acme.com" },
    { "role": "QA",          "displayName": "Jordan Lee",           "email": "jl@acme.com" }
  ],
  "metadata": { "runId": "20260415.1", "releaseNotes": "hotfix for auth cache" }
}

Reference types the UI recognises with a dedicated icon and label:

type Icon Label preference
work-item work item key (e.g. PLAT-1234) — shows the inbound title when supplied, otherwise the Jira title fetched server-side
pull-request PR labels.prTitle → key
repository branch key (e.g. acme/platform-api) → parsed from url → short revision
pipeline workflow key → provider

Unknown types render with a generic external-link icon. Always include url when you have it — the UI turns the label into a link.

Commit deep-linking. A repository reference that includes both url and revision is rendered as a link directly to that commit, derived from the provider:

Provider Resolved URL
github, azure-devops {url}/commit/{revision}
gitlab {url}/-/commit/{revision}
bitbucket {url}/commits/{revision}
other / omitted falls back to url

So a payload like { "type": "repository", "provider": "github", "url": "https://github.com/acme/platform-api", "revision": "a1b2c3d4" } deep-links to https://github.com/acme/platform-api/commit/a1b2c3d4. No org/repo names are hardcoded — the URL is derived purely from the inbound url.

Minimal curl example:

curl -X POST "$PLATFORM_URL/api/deployments/events" \
  -H "X-Api-Key: $DEPLOY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "product": "platform",
    "service": "platform-api",
    "environment": "production",
    "version": "2.4.1",
    "source": "github-actions",
    "deployedAt": "2026-04-15T09:12:00Z",
    "references": [
      { "type": "repository", "url": "https://github.com/acme/platform-api", "provider": "github", "key": "acme/platform-api", "revision": "a1b2c3d4" }
    ]
  }'

previousVersion is computed automatically by the server from the last event for the same (product, service, environment) tuple — publishers never send it. Set isRollback: true when the new version is a re-deploy of a prior version (the UI then renders an Undo2 icon next to the version with a Rolled back from v{previousVersion} tooltip).

Release Notes

Release Notes turn a stream of DeployEvents into structured, human-readable summaries — one note per (product, environment, window) — and broadcast them as a webhook so downstream consumers (Teams, Confluence, an email blast) can publish without a second call back to InfraPilot.

The feature is gated by the features.releaseNotes flag and is off by default. Flip it on per environment from Settings → Feature Flags.

Workflow

Step Endpoint Persists Webhook
1. Raw aggregation GET /api/release-notes/preview/raw no no
2. Templated preview GET /api/release-notes/preview no no
3. Publish POST /api/release-notes/generate yes yes (release_note.generated)
4. List GET /api/release-notes — —
5. Detail GET /api/release-notes/{id} — —

In the UI, the list page (/release-notes/:product) shows the form for picking environment + window. The "Preview" button navigates to a dedicated draft route (/release-notes/:product/new?env=…&from=…&to=…) where the rendered markdown can be edited side-by-side with a live HTML preview. "Publish" persists the (possibly edited) markdown and redirects to the permanent detail URL.

Templates

Release notes are rendered with Handlebars.Net against the aggregated services. Templates are stored in platform_settings at one of three scopes; resolution picks the most-specific row that exists:

Scope platform_settings.Key
Per (product, environment) release-notes.template.{product}.{environment}
Per product release-notes.template.{product}
Global default release-notes.template.default

Edit via Settings → Release Notes Template (admin only). The editor loads the exact row for the chosen scope; if nothing is saved there yet it shows the inherited template so you can fork it.

Available template fields

Top level: product, environment, date, from, to, services (array).

Per service (inside {{#each services}}): service, previousVersion, currentVersion, isRollback, deployedAt, workItems[], pullRequests[], pipelines[], participants[], plus the first-match shortcuts pullRequest, pipeline, author, qa, triggeredBy (each { displayName, email }).

Use {{{name}}} (triple-mustache) for content that should not be HTML-escaped (e.g. display names with diacritics).

Webhook payload

The release_note.generated event fires once a note is persisted and is subject to the standard webhook subscription filters (Product, Environment):

{
  "id": "ae1fa7ef-...",
  "product": "identity-platform",
  "environment": "production",
  "from": "2026-05-06T21:12:17Z",
  "to":   "2026-05-07T14:00:00Z",
  "generatedAt": "2026-05-07T14:05:00Z",
  "renderedContent": "# 🛠️ Release: identity-platform — production\n...",
  "services": [
    {
      "service": "auth-api",
      "previousVersion": "1.8.5",
      "currentVersion": "1.10.0",
      "isRollback": false,
      "workItems":    [{ "key": "IDP-2946", "title": "...", "url": "..." }],
      "pullRequests": [{ "key": "888",      "title": "...", "url": "..." }],
      "pipelines":    [{ "key": "build-79588", "url": "..." }],
      "participants": [{ "role": "author", "displayName": "...", "email": "..." }]
    }
  ]
}

Because renderedContent is included, a Logic App or Azure Pipeline can post directly to Teams without calling back to InfraPilot.

A parallel event release_note.generated.html fires with the same payload plus a renderedHtml field (rendered server-side by Markdig). Subscribe to whichever event suits the downstream — markdown subscribers (Teams, Slack) stay on release_note.generated; HTML consumers (Confluence storage format, HTML email templates, SharePoint pages) subscribe to release_note.generated.html. Markdown subscribers don't pay the size cost of the HTML payload.

Secrets

Do not commit real secrets to the repository.

Keep these out of source control:

  • Graph__ClientSecret
  • AzureOpenAI__ApiKey
  • AzureDevOps__Connections__*__Pat
  • Jira__Connections__*__ApiToken
  • Deployments__ApiKeys__*__Key

For real deployments:

  • use environment variables or a secret store
  • use Azure Container Apps secrets if deploying to Azure
  • rotate any secrets that were previously committed or shared

Azure Container Apps

The simplest recommended Azure setup is:

  • one Azure Container App
  • external ingress on port 8080
  • one custom domain
  • managed PostgreSQL outside the container

This gives you:

  • one deployment unit
  • same-origin frontend and API
  • no extra routing layer

CI/CD

This repository uses two GitHub Actions workflows:

  • CI workflow Validates that the single production image builds successfully on pull requests and pushes to main / master.
  • Release workflow Publishes the production image to ghcr.io/<owner>/<repo> when you push a Git tag like v0.1.0.

Example release flow:

git tag v0.1.0
git push origin v0.1.0

That publishes image tags such as:

  • ghcr.io/<owner>/<repo>:v0.1.0
  • ghcr.io/<owner>/<repo>:latest

Repository Structure

src/
  Platform.Api/
  Platform.Web/
catalog/
guides/        assistant walkthroughs  (GUIDES_PATH)
knowledge/     assistant reference topics (KNOWLEDGE_PATH)
playbooks/     assistant failure causes  (PLAYBOOKS_PATH)
infra/
docs/
Dockerfile
docker-compose.yml

Assistant Content

Three YAML corpora feed the in-app assistant. All are loaded once at startup, ship in the image, and are validated by tests — a broken reference fails the build rather than degrading an answer silently.

Directory Answers Shape
guides/ "How do I roll back?" Ordered steps, each optionally naming a data-guide-anchor the UI spotlights
knowledge/ "What does this webhook do?", "Why does prod need sign-off?" Markdown prose with source and as_of attribution
playbooks/ "Why is this promotion stuck?" Causes selected by observation flags the diagnostics compute from live state

Adding a guide

Write a YAML file in guides/ with id, title, route, and steps. To point at a control, add data-guide-anchor="some-name" to the element and name it in the step's anchor. Nav items are anchored automatically as nav-<route>. GuideCorpusTests fails if an anchor, route or related id does not exist.

How the assistant sees the screen

Every chat turn carries the page the user has open: its name and state (published by the page's HelpButton, or by calling usePageContext directly), and the list of every data-guide-anchor in the DOM. The assistant is instructed to treat that as the primary referent — "how do I approve that?" means the record on screen, not the one the conversation was about — and to ring what it talks about with its highlight tool, which only accepts anchors from that list.

To make something pointable, give it a data-guide-anchor. Fixed controls take a fixed name (promotion-approve-button); elements whose identity is data follow a pattern the server also knows (PortalAnchors): service-row:<service>, env-cell:<service>:<environment>, env-column:<environment>, promotion-row:<id>. Asking for a service's version navigates to its page and rings those cells without the model having to ask.

Adding a knowledge topic

Write a YAML file in knowledge/ with id, title, summary, body (Markdown), plus source and as_of. Those last two are required: these topics describe pipelines in other repositories (mpt-release, marketplace, ops-build-templates-aks-releases), so every claim has to be traceable and dated. Add aliases for how people actually phrase the question — that is what the keyword search ranks on.

Adding a failure cause

Write a cause under the relevant playbooks/ file with matches_when listing observation flags from Observations. The diagnostics compute those flags from live state and only offer causes whose flags hold, so the assistant cannot invent an explanation. A cause keyed on a flag nothing emits is dead — DiagnosticsCorpusTests fails the build for exactly that.

To make a new condition diagnosable: add the flag to Observations, emit it in DiagnosticsService, and write the cause that uses it.

Troubleshooting

The frontend loads but API calls fail

Check that:

  • the API container is running: docker compose ps
  • the API health endpoint responds: http://localhost:5259/health
  • src/Platform.Web/public/config.json still points to http://localhost:5259

Postgres port conflict

This project maps Postgres to localhost:5433 to avoid conflicts with a local 5432 instance.

Start from a clean local database

docker compose down -v
docker compose up -d

About

Self-service infrastructure portal for platform teams to manage requests, approvals, deployments, and operational workflows.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages