OPC # 0001: Extract OPC into standalone repo
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
**/.vs
|
||||
**/.git
|
||||
**/.idea
|
||||
**/bin
|
||||
**/obj
|
||||
**/node_modules
|
||||
**/.env
|
||||
**/npm-debug.log
|
||||
**/.dockerignore
|
||||
**/Dockerfile*
|
||||
**/docker-compose*
|
||||
ClientAssets/
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Oo]ut/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
|
||||
VaultData/
|
||||
ClientAssets/
|
||||
@@ -0,0 +1,14 @@
|
||||
using ControlPlane.Api.Services;
|
||||
using ControlPlane.Core.Messages;
|
||||
using MassTransit;
|
||||
|
||||
namespace ControlPlane.Api.Consumers;
|
||||
|
||||
public sealed class ProvisioningProgressConsumer(SseEventBus bus) : IConsumer<ProvisioningProgressEvent>
|
||||
{
|
||||
public Task Consume(ConsumeContext<ProvisioningProgressEvent> context)
|
||||
{
|
||||
bus.Publish(context.Message);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" />
|
||||
<PackageReference Include="MassTransit" />
|
||||
<PackageReference Include="MassTransit.RabbitMQ" />
|
||||
<PackageReference Include="Aspire.RabbitMQ.Client" />
|
||||
<PackageReference Include="Docker.DotNet" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="LibGit2Sharp" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Clarity.ServiceDefaults\Clarity.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\ControlPlane.Core\ControlPlane.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,108 @@
|
||||
-- =============================================================================
|
||||
-- OPC Seed Script – seeded from TODO.md backlog
|
||||
-- Run against the ControlPlane database.
|
||||
-- OPC # 0001 is already live; this starts at 0002.
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO opc (id, number, title, description, type, status, priority, assignee, created_at, updated_at)
|
||||
VALUES
|
||||
|
||||
-- ── Keycloak / Auth ───────────────────────────────────────────────────────────
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'OPC # 0002',
|
||||
'Fix KeycloakStep 401 on realm provisioning',
|
||||
'KeycloakStep is the current blocker in the provisioning saga. The step returns 401 when attempting to create the tenant realm. Investigate the admin-client credentials, token scope, and the endpoint URL used inside the Docker network.',
|
||||
'Bug',
|
||||
'In Progress',
|
||||
'High',
|
||||
'amadzarak',
|
||||
NOW(), NOW()
|
||||
),
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'OPC # 0003',
|
||||
'KeycloakStep: full realm + user provisioning flow',
|
||||
'After the 401 is resolved, implement the full flow: create realm {subdomain}.clarity.io, create the admin role, create the day-zero admin user from AdminEmail, assign the admin role, and trigger execute-actions-email (verify email + set password).',
|
||||
'Feature',
|
||||
'New',
|
||||
'High',
|
||||
'amadzarak',
|
||||
NOW(), NOW()
|
||||
),
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'OPC # 0004',
|
||||
'Keycloak JWT backchannel issuer cleanup',
|
||||
'Keycloak advertises its issuer based on the incoming request URL. When the backchannel hits http://keycloak:8080 directly it returns http://keycloak.clarity.test:8080 as the issuer, forcing layered workarounds in ValidIssuers and the rewrite handler. Clean fix: boot Keycloak with KC_HOSTNAME_URL=https://keycloak.clarity.test, verify via /.well-known/openid-configuration, then simplify ValidIssuers back to two entries. Deferred until next planned maintenance window (requires nuke to apply env var).',
|
||||
'Tech Debt',
|
||||
'New',
|
||||
'Medium',
|
||||
'amadzarak',
|
||||
NOW(), NOW()
|
||||
),
|
||||
|
||||
-- ── VaultStep ─────────────────────────────────────────────────────────────────
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'OPC # 0005',
|
||||
'VaultStep: read root token and write initial secrets',
|
||||
'Read the root token from /vault/file/init.json, enable KV-v2 secrets engine at {subdomain}/, then write the initial secrets: DB connection string and Keycloak client secret.',
|
||||
'Feature',
|
||||
'New',
|
||||
'High',
|
||||
'amadzarak',
|
||||
NOW(), NOW()
|
||||
),
|
||||
|
||||
-- ── MigrationStep ─────────────────────────────────────────────────────────────
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'OPC # 0006',
|
||||
'MigrationStep: run EF Core migrations per provisioning mode',
|
||||
'Wire up EF Core migrations inside MigrationStep for all three provisioning modes. Shared: run against the shared DB scoped to the tenant schema. Isolated: run against the dedicated Postgres container registered in SagaContext. Dedicated: run against the full dedicated Postgres instance.',
|
||||
'Feature',
|
||||
'New',
|
||||
'Medium',
|
||||
'amadzarak',
|
||||
NOW(), NOW()
|
||||
),
|
||||
|
||||
-- ── HandoffStep ───────────────────────────────────────────────────────────────
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'OPC # 0007',
|
||||
'HandoffStep: send magic-link email and mark saga complete',
|
||||
'Send a magic-link / welcome email to AdminEmail via SMTP or SendGrid, then mark CompletedSteps.HandoffSent on the provisioning job. Blocked until SMTP is wired (currently SendRequiredActionsEmailAsync is commented out in KeycloakStep.cs).',
|
||||
'Feature',
|
||||
'New',
|
||||
'Medium',
|
||||
'amadzarak',
|
||||
NOW(), NOW()
|
||||
),
|
||||
|
||||
-- ── Observability ─────────────────────────────────────────────────────────────
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'OPC # 0008',
|
||||
'Stream tenant container logs into Aspire dashboard',
|
||||
'Use the Docker SDK to tail fdev-app-clarity-* container logs and forward them to Aspire''s structured log stream. Currently these logs are only visible via docker logs on the host.',
|
||||
'Feature',
|
||||
'New',
|
||||
'Low',
|
||||
'amadzarak',
|
||||
NOW(), NOW()
|
||||
),
|
||||
|
||||
-- ── Kubernetes (backburner) ───────────────────────────────────────────────────
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'OPC # 0009',
|
||||
'Kubernetes migration path evaluation',
|
||||
'Currently managing containers directly via Docker.DotNet. Evaluate k8s when: scheduling across multiple nodes is needed, rolling deploys are required, or client count exceeds single-host capacity. Options: k3s (self-hosted), AKS/EKS (cloud), or keep Docker Compose per host for mid-scale. ClarityContainerService abstraction is intentional – swap Docker.DotNet for a k8s client without changing the saga.',
|
||||
'General',
|
||||
'New',
|
||||
'Low',
|
||||
'amadzarak',
|
||||
NOW(), NOW()
|
||||
);
|
||||
@@ -0,0 +1,119 @@
|
||||
-- =============================================================================
|
||||
-- OPC Seed Script 2 – completed work from TODO.md
|
||||
-- Run against the ControlPlane database.
|
||||
-- Picks up numbering at 0010 (0001–0009 covered in seed_opc.sql).
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO opc (id, number, title, description, type, status, priority, assignee, created_at, updated_at)
|
||||
VALUES
|
||||
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'OPC # 0010',
|
||||
'Aspire AppHost wired: Vault, MinIO, RabbitMQ, Postgres, Keycloak, Worker, API, UI',
|
||||
'Full Aspire AppHost configuration completed. All infrastructure services (Vault, MinIO, RabbitMQ, Postgres, Keycloak) and application services (Worker, API, UI) are registered and wired in the AppHost project.',
|
||||
'Feature',
|
||||
'Done',
|
||||
'High',
|
||||
'amadzarak',
|
||||
NOW(), NOW()
|
||||
),
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'OPC # 0011',
|
||||
'Fix CRLF → LF on entrypoint.sh (was breaking Vault container)',
|
||||
'entrypoint.sh had Windows-style CRLF line endings which caused the Vault container to fail on startup. Fixed by enforcing LF via .gitattributes.',
|
||||
'Bug',
|
||||
'Done',
|
||||
'High',
|
||||
'amadzarak',
|
||||
NOW(), NOW()
|
||||
),
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'OPC # 0012',
|
||||
'Vault initialises and unseals on first run',
|
||||
'Vault container now correctly initialises (generates root token + unseal keys) and auto-unseals on first run. Init output is written to /vault/file/init.json.',
|
||||
'Feature',
|
||||
'Done',
|
||||
'High',
|
||||
'amadzarak',
|
||||
NOW(), NOW()
|
||||
),
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'OPC # 0013',
|
||||
'Pin Keycloak bootstrap password (fix persistent container password drift)',
|
||||
'Keycloak was experiencing password drift between container restarts due to the bootstrap admin credentials not being pinned. Fixed by explicitly setting the admin password so it persists across restarts.',
|
||||
'Bug',
|
||||
'Done',
|
||||
'High',
|
||||
'amadzarak',
|
||||
NOW(), NOW()
|
||||
),
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'OPC # 0014',
|
||||
'Fix Keycloak endpoint name: tcp → http',
|
||||
'Keycloak Aspire resource was registered with a tcp endpoint name instead of http, causing service discovery failures. Renamed to http to align with the rest of the stack.',
|
||||
'Bug',
|
||||
'Done',
|
||||
'Medium',
|
||||
'amadzarak',
|
||||
NOW(), NOW()
|
||||
),
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'OPC # 0015',
|
||||
'Worker starts and correctly waits for all dependencies',
|
||||
'The Worker service was starting before infrastructure dependencies were healthy. Implemented proper wait/health-check logic so the Worker blocks until Postgres, Keycloak, Vault, RabbitMQ, and MinIO are all ready.',
|
||||
'Bug',
|
||||
'Done',
|
||||
'High',
|
||||
'amadzarak',
|
||||
NOW(), NOW()
|
||||
),
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'OPC # 0016',
|
||||
'MassTransit saga pipeline with compensation',
|
||||
'Implemented the full MassTransit-based provisioning saga with forward steps and compensating transactions. Each step registers its rollback so a mid-saga failure cleanly tears down any already-provisioned resources.',
|
||||
'Feature',
|
||||
'Done',
|
||||
'High',
|
||||
'amadzarak',
|
||||
NOW(), NOW()
|
||||
),
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'OPC # 0017',
|
||||
'SSE progress stream: Worker → RabbitMQ → API → browser',
|
||||
'Implemented a real-time Server-Sent Events pipeline. The Worker publishes step progress to RabbitMQ, the API consumes and streams events via SSE, and the browser receives live updates without polling.',
|
||||
'Feature',
|
||||
'Done',
|
||||
'High',
|
||||
'amadzarak',
|
||||
NOW(), NOW()
|
||||
),
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'OPC # 0018',
|
||||
'Frontend Diagnostics tab with full stack traces from worker',
|
||||
'Added a Diagnostics tab to the frontend that displays structured error messages and full stack traces forwarded from the Worker service, making provisioning failures debuggable directly in the UI.',
|
||||
'Feature',
|
||||
'Done',
|
||||
'Medium',
|
||||
'amadzarak',
|
||||
NOW(), NOW()
|
||||
),
|
||||
(
|
||||
gen_random_uuid(),
|
||||
'OPC # 0019',
|
||||
'Enforce LF line endings for *.sh and *.hcl via .gitattributes',
|
||||
'Added .gitattributes rules to enforce LF line endings for all *.sh and *.hcl files. Prevents CRLF issues from reappearing when contributors commit from Windows machines.',
|
||||
'Tech Debt',
|
||||
'Done',
|
||||
'Low',
|
||||
'amadzarak',
|
||||
NOW(), NOW()
|
||||
);
|
||||
@@ -0,0 +1,203 @@
|
||||
using ControlPlane.Core.Models;
|
||||
using LibGit2Sharp;
|
||||
|
||||
namespace ControlPlane.Api.Endpoints;
|
||||
|
||||
public static class GitEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapGitEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/git/log", GetLog);
|
||||
app.MapGet("/api/git/commits/{hash}", GetCommit);
|
||||
app.MapGet("/api/git/branches", GetBranches);
|
||||
app.MapGet("/api/git/branch-coverage", GetBranchCoverage);
|
||||
return app;
|
||||
}
|
||||
|
||||
// GET /api/git/log?grep=OPC+%23+0001&limit=50
|
||||
private static IResult GetLog(
|
||||
IConfiguration config,
|
||||
string? grep = null,
|
||||
int limit = 50)
|
||||
{
|
||||
var repoPath = ResolveRepo(config);
|
||||
if (repoPath is null)
|
||||
return Results.Problem("Could not locate a git repository. Set Git:RepoRoot in appsettings.");
|
||||
|
||||
using var repo = new Repository(repoPath);
|
||||
|
||||
var tips = repo.Branches
|
||||
.Where(b => b.Tip != null)
|
||||
.Select(b => (GitObject)b.Tip)
|
||||
.ToList();
|
||||
|
||||
var filter = new CommitFilter
|
||||
{
|
||||
SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time,
|
||||
IncludeReachableFrom = tips.Count > 0 ? tips : (object)repo.Head,
|
||||
};
|
||||
|
||||
IEnumerable<Commit> query = repo.Commits.QueryBy(filter);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(grep))
|
||||
query = query.Where(c => c.Message.Contains(grep, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var commits = query
|
||||
.Take(limit)
|
||||
.Select(c => ToGitCommit(repo, c))
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(commits);
|
||||
}
|
||||
|
||||
// GET /api/git/commits/{hash}
|
||||
private static IResult GetCommit(string hash, IConfiguration config)
|
||||
{
|
||||
var repoPath = ResolveRepo(config);
|
||||
if (repoPath is null)
|
||||
return Results.Problem("Could not locate a git repository.");
|
||||
|
||||
using var repo = new Repository(repoPath);
|
||||
var commit = repo.Lookup<Commit>(hash);
|
||||
if (commit is null) return Results.NotFound();
|
||||
|
||||
var parentTree = commit.Parents.FirstOrDefault()?.Tree;
|
||||
var changes = repo.Diff.Compare<TreeChanges>(parentTree, commit.Tree);
|
||||
var patch = repo.Diff.Compare<Patch>(parentTree, commit.Tree);
|
||||
|
||||
var files = changes.Select(c => new
|
||||
{
|
||||
path = c.Path,
|
||||
oldPath = c.OldPath,
|
||||
status = c.Status.ToString(),
|
||||
additions = patch[c.Path]?.LinesAdded ?? 0,
|
||||
deletions = patch[c.Path]?.LinesDeleted ?? 0,
|
||||
patch = patch[c.Path]?.Patch ?? string.Empty,
|
||||
}).ToList();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
hash = commit.Sha,
|
||||
shortHash = commit.Sha[..7],
|
||||
author = commit.Author.Name,
|
||||
email = commit.Author.Email,
|
||||
date = commit.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"),
|
||||
subject = commit.MessageShort,
|
||||
body = commit.Message,
|
||||
files,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/git/branches
|
||||
private static IResult GetBranches(IConfiguration config)
|
||||
{
|
||||
var repoPath = ResolveRepo(config);
|
||||
if (repoPath is null)
|
||||
return Results.Problem("Could not locate a git repository.");
|
||||
|
||||
using var repo = new Repository(repoPath);
|
||||
var branches = repo.Branches
|
||||
.Where(b => !b.IsRemote && b.Tip != null)
|
||||
.Select(b => new
|
||||
{
|
||||
name = b.FriendlyName,
|
||||
hash = b.Tip.Sha,
|
||||
shortHash = b.Tip.Sha[..7],
|
||||
subject = b.Tip.MessageShort,
|
||||
author = b.Tip.Author.Name,
|
||||
date = b.Tip.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"),
|
||||
isHead = b.IsCurrentRepositoryHead,
|
||||
})
|
||||
.OrderBy(b => b.name)
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(branches);
|
||||
}
|
||||
|
||||
// GET /api/git/branch-coverage?commits=hash1,hash2,hash3
|
||||
// Returns each local branch and whether it contains ALL of the given commits.
|
||||
private static IResult GetBranchCoverage(IConfiguration config, string? commits = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(commits)) return Results.Ok(Array.Empty<object>());
|
||||
|
||||
var hashes = commits.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (hashes.Length == 0) return Results.Ok(Array.Empty<object>());
|
||||
|
||||
var repoPath = ResolveRepo(config);
|
||||
if (repoPath is null)
|
||||
return Results.Problem("Could not locate a git repository.");
|
||||
|
||||
using var repo = new Repository(repoPath);
|
||||
|
||||
var targetCommits = hashes
|
||||
.Select(h => repo.Lookup<Commit>(h))
|
||||
.Where(c => c is not null)
|
||||
.ToList();
|
||||
|
||||
if (targetCommits.Count == 0) return Results.Ok(Array.Empty<object>());
|
||||
|
||||
var result = repo.Branches
|
||||
.Where(b => !b.IsRemote && b.Tip != null)
|
||||
.Select(b =>
|
||||
{
|
||||
var contains = targetCommits.All(tc =>
|
||||
{
|
||||
// If merge base of branch tip and target == target, then target is an ancestor
|
||||
var mergeBase = repo.ObjectDatabase.FindMergeBase(b.Tip, tc!);
|
||||
return mergeBase?.Sha == tc!.Sha;
|
||||
});
|
||||
return new
|
||||
{
|
||||
branch = b.FriendlyName,
|
||||
contains,
|
||||
tipHash = b.Tip.Sha[..7],
|
||||
isHead = b.IsCurrentRepositoryHead,
|
||||
};
|
||||
})
|
||||
.OrderBy(b => b.branch)
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Resolves the repo root: explicit config overrides, otherwise auto-discover
|
||||
/// from the running assembly directory upward via LibGit2Sharp.
|
||||
private static string? ResolveRepo(IConfiguration config)
|
||||
{
|
||||
var configured = config["Git:RepoRoot"] ?? config["Docker:RepoRoot"];
|
||||
if (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured))
|
||||
return configured;
|
||||
|
||||
// Auto-discover: walk up from the app's own directory
|
||||
var startPath = AppContext.BaseDirectory;
|
||||
var discovered = Repository.Discover(startPath);
|
||||
if (discovered is null) return null;
|
||||
|
||||
// Repository.Discover returns the .git directory path; get the working dir
|
||||
using var probe = new Repository(discovered);
|
||||
return probe.Info.WorkingDirectory;
|
||||
}
|
||||
|
||||
private static GitCommit ToGitCommit(Repository repo, Commit c)
|
||||
{
|
||||
string[] files;
|
||||
try
|
||||
{
|
||||
var parentTree = c.Parents.FirstOrDefault()?.Tree;
|
||||
var changes = repo.Diff.Compare<TreeChanges>(parentTree, c.Tree);
|
||||
files = changes.Select(ch => ch.Path).ToArray();
|
||||
}
|
||||
catch { files = []; }
|
||||
|
||||
return new GitCommit(
|
||||
Hash: c.Sha,
|
||||
ShortHash: c.Sha[..7],
|
||||
Author: c.Author.Name,
|
||||
Date: c.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"),
|
||||
Subject: c.MessageShort,
|
||||
Files: files
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using ControlPlane.Api.Services;
|
||||
using ControlPlane.Core.Models;
|
||||
|
||||
namespace ControlPlane.Api.Endpoints;
|
||||
|
||||
public static class GiteaEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapGiteaEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var g = app.MapGroup("/api/gitea").WithTags("Gitea");
|
||||
|
||||
g.MapGet ("/repo", GetRepo);
|
||||
g.MapGet ("/branches", ListBranches);
|
||||
g.MapPost("/branches", CreateBranch);
|
||||
g.MapGet ("/pulls", ListPulls);
|
||||
g.MapGet ("/pulls/{number:long}", GetPull);
|
||||
g.MapPost("/pulls", CreatePull);
|
||||
g.MapGet ("/tags", ListTags);
|
||||
g.MapPost("/tags", CreateTag);
|
||||
g.MapGet ("/webhooks", ListWebhooks);
|
||||
g.MapPost("/webhooks", RegisterWebhook);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRepo(GiteaService svc, CancellationToken ct)
|
||||
{
|
||||
var repo = await svc.GetRepoAsync(ct);
|
||||
return repo is null ? Results.StatusCode(503) : Results.Ok(repo);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListBranches(GiteaService svc, CancellationToken ct) =>
|
||||
Results.Ok(await svc.ListBranchesAsync(ct));
|
||||
|
||||
private static async Task<IResult> CreateBranch(
|
||||
CreateBranchRequest req, GiteaService svc, CancellationToken ct)
|
||||
{
|
||||
var branch = await svc.CreateBranchAsync(req, ct);
|
||||
return branch is null ? Results.BadRequest("Failed to create branch in Gitea.") : Results.Ok(branch);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListPulls(
|
||||
GiteaService svc, string state = "open", CancellationToken ct = default) =>
|
||||
Results.Ok(await svc.ListPullRequestsAsync(state, ct));
|
||||
|
||||
private static async Task<IResult> GetPull(
|
||||
long number, GiteaService svc, CancellationToken ct)
|
||||
{
|
||||
var pr = await svc.GetPullRequestAsync(number, ct);
|
||||
return pr is null ? Results.NotFound() : Results.Ok(pr);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreatePull(
|
||||
CreatePullRequestRequest req, GiteaService svc, CancellationToken ct)
|
||||
{
|
||||
var pr = await svc.CreatePullRequestAsync(req, ct);
|
||||
return pr is null ? Results.BadRequest("Failed to create PR in Gitea.") : Results.Ok(pr);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListTags(GiteaService svc, CancellationToken ct) =>
|
||||
Results.Ok(await svc.ListTagsAsync(ct));
|
||||
|
||||
private static async Task<IResult> CreateTag(
|
||||
CreateTagRequest req, GiteaService svc, CancellationToken ct)
|
||||
{
|
||||
var tag = await svc.CreateTagAsync(req, ct);
|
||||
return tag is null ? Results.BadRequest("Failed to create tag in Gitea.") : Results.Ok(tag);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListWebhooks(GiteaService svc, CancellationToken ct) =>
|
||||
Results.Ok(await svc.ListWebhooksAsync(ct));
|
||||
|
||||
private static async Task<IResult> RegisterWebhook(
|
||||
CreateWebhookRequest req, GiteaService svc, CancellationToken ct)
|
||||
{
|
||||
var hook = await svc.RegisterWebhookAsync(req, ct);
|
||||
return hook is null ? Results.BadRequest("Failed to register webhook in Gitea.") : Results.Ok(hook);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using ControlPlane.Api.Services;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ControlPlane.Api.Endpoints;
|
||||
|
||||
public static class ImageBuildEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public static IEndpointRouteBuilder MapImageBuildEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/image").WithTags("Image");
|
||||
|
||||
group.MapGet("/status", GetStatus);
|
||||
group.MapPost("/build", TriggerBuild);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>Returns the last known build status without triggering a new build.</summary>
|
||||
private static async Task<IResult> GetStatus(ImageBuildService svc) =>
|
||||
Results.Ok(await svc.GetStatusAsync());
|
||||
|
||||
/// <summary>
|
||||
/// Triggers a docker build and streams the output line-by-line as SSE.
|
||||
/// The build context is the repo root, which must be configured via
|
||||
/// Docker:RepoRoot in appsettings / environment.
|
||||
/// </summary>
|
||||
private static async Task TriggerBuild(
|
||||
HttpContext ctx,
|
||||
ImageBuildService svc,
|
||||
IConfiguration config,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var repoRoot = config["Docker:RepoRoot"];
|
||||
if (string.IsNullOrWhiteSpace(repoRoot) || !Directory.Exists(repoRoot))
|
||||
{
|
||||
ctx.Response.StatusCode = 400;
|
||||
await ctx.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Docker:RepoRoot is not configured or does not exist.",
|
||||
hint = "Add Docker__RepoRoot to the worker environment pointing at the repo root directory.",
|
||||
}, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Response.Headers.ContentType = "text/event-stream";
|
||||
ctx.Response.Headers.CacheControl = "no-cache";
|
||||
ctx.Response.Headers.Connection = "keep-alive";
|
||||
|
||||
// Use a Channel so the Progress<T> callback (sync) can safely hand lines
|
||||
// to the async SSE writer without blocking the Docker build thread.
|
||||
var channel = System.Threading.Channels.Channel.CreateUnbounded<string?>(
|
||||
new System.Threading.Channels.UnboundedChannelOptions { SingleWriter = true, SingleReader = true });
|
||||
|
||||
void OnLine(string line) => channel.Writer.TryWrite(line);
|
||||
|
||||
// Run the build on a background thread so we can drain the channel here
|
||||
var buildTask = Task.Run(() => svc.BuildAsync(repoRoot, OnLine, ct), ct)
|
||||
.ContinueWith(_ => channel.Writer.TryComplete(), TaskScheduler.Default);
|
||||
|
||||
await foreach (var line in channel.Reader.ReadAllAsync(ct))
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new { line }, JsonOpts);
|
||||
await ctx.Response.WriteAsync($"data: {json}\n\n", ct);
|
||||
await ctx.Response.Body.FlushAsync(ct);
|
||||
}
|
||||
|
||||
await buildTask; // ensure build is fully done
|
||||
|
||||
// Signal stream end
|
||||
await ctx.Response.WriteAsync("data: {\"done\":true}\n\n", ct);
|
||||
await ctx.Response.Body.FlushAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ControlPlane.Api.Endpoints;
|
||||
|
||||
public static class InfraEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapInfraEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var g = app.MapGroup("/api/infra").WithTags("Infrastructure");
|
||||
|
||||
g.MapGet ("/status", GetStatus);
|
||||
g.MapPost("/{container}/start", (string container) => ServiceAction(container, "start"));
|
||||
g.MapPost("/{container}/stop", (string container) => ServiceAction(container, "stop"));
|
||||
g.MapPost("/{container}/restart",(string container) => ServiceAction(container, "restart"));
|
||||
g.MapGet ("/compose/up/stream", ComposeUpStream);
|
||||
g.MapGet ("/compose/down/stream", ComposeDownStream);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// ── Known platform services ───────────────────────────────────────────────
|
||||
|
||||
private static readonly string[] PlatformContainers =
|
||||
[
|
||||
"clarity-postgres",
|
||||
"clarity-keycloak",
|
||||
"clarity-vault",
|
||||
"clarity-minio",
|
||||
"clarity-gitea",
|
||||
"clarity-nginx",
|
||||
"clarity-dnsmasq",
|
||||
];
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<IResult> GetStatus()
|
||||
{
|
||||
var services = new List<InfraService>();
|
||||
|
||||
foreach (var container in PlatformContainers)
|
||||
{
|
||||
var (code, output) = await DockerAsync(
|
||||
$"inspect --format={{{{json .}}}} {container}");
|
||||
|
||||
if (code != 0 || string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
services.Add(new InfraService(container, container, "stopped", [], null));
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(output.Trim());
|
||||
var root = doc.RootElement;
|
||||
var state = root.GetProperty("State").GetProperty("Status").GetString() ?? "unknown";
|
||||
var health = root.GetProperty("State").TryGetProperty("Health", out var h)
|
||||
? h.GetProperty("Status").GetString()
|
||||
: null;
|
||||
|
||||
var status = (state, health) switch
|
||||
{
|
||||
("running", "unhealthy") => "unhealthy",
|
||||
("running", _) => "running",
|
||||
("exited", _) => "stopped",
|
||||
_ => state
|
||||
};
|
||||
|
||||
// Ports
|
||||
var ports = new List<string>();
|
||||
if (root.TryGetProperty("NetworkSettings", out var ns) &&
|
||||
ns.TryGetProperty("Ports", out var portsEl))
|
||||
{
|
||||
foreach (var port in portsEl.EnumerateObject())
|
||||
{
|
||||
if (port.Value.ValueKind != JsonValueKind.Null)
|
||||
ports.Add(port.Name.Split('/')[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Uptime
|
||||
string? uptime = null;
|
||||
if (root.GetProperty("State").TryGetProperty("StartedAt", out var startedAt))
|
||||
{
|
||||
if (DateTime.TryParse(startedAt.GetString(), out var started) && state == "running")
|
||||
{
|
||||
var elapsed = DateTime.UtcNow - started.ToUniversalTime();
|
||||
uptime = elapsed.TotalDays >= 1
|
||||
? $"{(int)elapsed.TotalDays}d {elapsed.Hours}h"
|
||||
: elapsed.TotalHours >= 1
|
||||
? $"{(int)elapsed.TotalHours}h {elapsed.Minutes}m"
|
||||
: $"{elapsed.Minutes}m";
|
||||
}
|
||||
}
|
||||
|
||||
// Friendly name
|
||||
var name = root.TryGetProperty("Name", out var n)
|
||||
? n.GetString()?.TrimStart('/') ?? container
|
||||
: container;
|
||||
|
||||
services.Add(new InfraService(name, container, status, ports, uptime));
|
||||
}
|
||||
catch
|
||||
{
|
||||
services.Add(new InfraService(container, container, "unknown", [], null));
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Ok(new InfraStatusResponse(services, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ServiceAction(string container, string action)
|
||||
{
|
||||
if (!PlatformContainers.Contains(container))
|
||||
return Results.BadRequest($"Unknown platform container: {container}");
|
||||
|
||||
var (code, output) = await DockerAsync($"{action} {container}");
|
||||
return code == 0
|
||||
? Results.Ok()
|
||||
: Results.Problem(output ?? "Docker command failed", statusCode: 500);
|
||||
}
|
||||
|
||||
private static Task ComposeUpStream(HttpContext ctx, IConfiguration config, CancellationToken ct) =>
|
||||
StreamComposeOutput(ctx, config, "up --pull missing", ct);
|
||||
|
||||
private static Task ComposeDownStream(HttpContext ctx, IConfiguration config, CancellationToken ct) =>
|
||||
StreamComposeOutput(ctx, config, "down", ct);
|
||||
|
||||
private static async Task StreamComposeOutput(
|
||||
HttpContext ctx, IConfiguration config, string composeArgs, CancellationToken ct)
|
||||
{
|
||||
var infraDir = ResolveInfraPath(config);
|
||||
|
||||
ctx.Response.Headers.ContentType = "text/event-stream";
|
||||
ctx.Response.Headers.CacheControl = "no-cache";
|
||||
ctx.Response.Headers.Connection = "keep-alive";
|
||||
|
||||
var channel = System.Threading.Channels.Channel.CreateUnbounded<string?>(
|
||||
new System.Threading.Channels.UnboundedChannelOptions { SingleWriter = false, SingleReader = true });
|
||||
|
||||
var psi = new ProcessStartInfo("docker",
|
||||
$"compose -f \"{Path.Combine(infraDir, "docker-compose.yml")}\" {composeArgs}")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = infraDir,
|
||||
};
|
||||
|
||||
var proc = Process.Start(psi)!;
|
||||
|
||||
// Read stdout + stderr concurrently into the channel
|
||||
var stdoutTask = Task.Run(async () =>
|
||||
{
|
||||
while (await proc.StandardOutput.ReadLineAsync(ct) is { } line)
|
||||
channel.Writer.TryWrite(line);
|
||||
}, ct);
|
||||
|
||||
var stderrTask = Task.Run(async () =>
|
||||
{
|
||||
while (await proc.StandardError.ReadLineAsync(ct) is { } line)
|
||||
channel.Writer.TryWrite(line);
|
||||
}, ct);
|
||||
|
||||
_ = Task.WhenAll(stdoutTask, stderrTask)
|
||||
.ContinueWith(_ => channel.Writer.TryComplete(), TaskScheduler.Default);
|
||||
|
||||
// Stream lines to client as SSE
|
||||
await foreach (var line in channel.Reader.ReadAllAsync(ct))
|
||||
{
|
||||
if (line is null) continue;
|
||||
await ctx.Response.WriteAsync($"data: {line}\n\n", ct);
|
||||
await ctx.Response.Body.FlushAsync(ct);
|
||||
}
|
||||
|
||||
await proc.WaitForExitAsync(ct);
|
||||
var exitLine = proc.ExitCode == 0 ? "data: ✔ Done." : $"data: ✖ Exited with code {proc.ExitCode}";
|
||||
await ctx.Response.WriteAsync($"{exitLine}\n\n", ct);
|
||||
await ctx.Response.Body.FlushAsync(ct);
|
||||
proc.Dispose();
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ResolveInfraPath(IConfiguration config)
|
||||
{
|
||||
var repoRoot = config["Docker:RepoRoot"]
|
||||
?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
|
||||
return Path.GetFullPath(Path.Combine(repoRoot, "infra"));
|
||||
}
|
||||
|
||||
private static Task<(int Code, string? Output)> DockerAsync(string args) =>
|
||||
RunAsync("docker", args, null);
|
||||
|
||||
private static async Task<(int Code, string? Output)> ComposeAsync(string args, string infraDir)=>
|
||||
await RunAsync("docker", $"compose -f \"{Path.Combine(infraDir, "docker-compose.yml")}\" {args}", infraDir);
|
||||
|
||||
private static async Task<(int Code, string? Output)> RunAsync(
|
||||
string exe, string args, string? workingDir)
|
||||
{
|
||||
var psi = new ProcessStartInfo(exe, args)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
if (workingDir is not null) psi.WorkingDirectory = workingDir;
|
||||
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc is null) return (-1, null);
|
||||
var output = await proc.StandardOutput.ReadToEndAsync();
|
||||
var err = await proc.StandardError.ReadToEndAsync();
|
||||
await proc.WaitForExitAsync();
|
||||
return (proc.ExitCode, string.IsNullOrWhiteSpace(output) ? err : output);
|
||||
}
|
||||
|
||||
// ── Response models ───────────────────────────────────────────────────────
|
||||
|
||||
public record InfraService(
|
||||
string Name,
|
||||
string Container,
|
||||
string Status,
|
||||
List<string> Ports,
|
||||
string? Uptime);
|
||||
|
||||
public record InfraStatusResponse(
|
||||
List<InfraService> Services,
|
||||
DateTimeOffset CheckedAt);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
using ControlPlane.Api.Services;
|
||||
using ControlPlane.Core.Models;
|
||||
using LibGit2Sharp;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ControlPlane.Api.Endpoints;
|
||||
|
||||
public static class OpcEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts =
|
||||
new(JsonSerializerDefaults.Web) { WriteIndented = false };
|
||||
|
||||
public static IEndpointRouteBuilder MapOpcEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var g = app.MapGroup("/api/opc").WithTags("OPC");
|
||||
|
||||
// ── OPC CRUD ──────────────────────────────────────────────────────────
|
||||
g.MapGet ("", ListOpcs);
|
||||
g.MapGet ("/next-number", GetNextNumber);
|
||||
g.MapPost ("", CreateOpc);
|
||||
g.MapGet ("/{id:guid}", GetOpc);
|
||||
g.MapPatch ("/{id:guid}", UpdateOpc);
|
||||
g.MapDelete("/{id:guid}", DeleteOpc);
|
||||
|
||||
// ── Notes ─────────────────────────────────────────────────────────────
|
||||
g.MapGet ("/{id:guid}/notes", ListNotes);
|
||||
g.MapPost ("/{id:guid}/notes", AddNote);
|
||||
|
||||
// ── Artifacts ─────────────────────────────────────────────────────────
|
||||
g.MapGet ("/{id:guid}/artifacts", ListArtifacts);
|
||||
g.MapPost ("/{id:guid}/artifacts", CreateArtifact);
|
||||
g.MapPatch ("/artifacts/{artifactId:guid}", UpdateArtifact);
|
||||
g.MapDelete("/artifacts/{artifactId:guid}", DeleteArtifact);
|
||||
|
||||
// ── Pinned commits ────────────────────────────────────────────────────
|
||||
g.MapGet ("/{id:guid}/pinned-commits", ListPinnedCommits);
|
||||
g.MapPost ("/{id:guid}/pinned-commits", PinCommit);
|
||||
g.MapDelete("/{id:guid}/pinned-commits/{hash}", UnpinCommit);
|
||||
|
||||
// ── AI assist (proxies to OpenRouter, key stays on server) ────────────
|
||||
g.MapPost ("/ai-assist", AiAssist);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// ── OPC handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<IResult> ListOpcs(
|
||||
OpcService svc,
|
||||
string? type = null, string? status = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var list = await svc.ListAsync(type, status, ct);
|
||||
return Results.Ok(list);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetNextNumber(
|
||||
OpcService svc, CancellationToken ct)
|
||||
{
|
||||
var number = await svc.NextNumberAsync(ct);
|
||||
return Results.Ok(new { number });
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateOpc(
|
||||
CreateOpcRequest req, OpcService svc, CancellationToken ct)
|
||||
{
|
||||
var opc = await svc.CreateAsync(req, ct);
|
||||
return Results.Created($"/api/opc/{opc.Id}", opc);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetOpc(
|
||||
Guid id, OpcService svc, CancellationToken ct)
|
||||
{
|
||||
var opc = await svc.GetAsync(id, ct);
|
||||
return opc is null ? Results.NotFound() : Results.Ok(opc);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateOpc(
|
||||
Guid id, UpdateOpcRequest req, OpcService svc, CancellationToken ct)
|
||||
{
|
||||
var opc = await svc.UpdateAsync(id, req, ct);
|
||||
return opc is null ? Results.NotFound() : Results.Ok(opc);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteOpc(
|
||||
Guid id, OpcService svc, CancellationToken ct)
|
||||
{
|
||||
return await svc.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound();
|
||||
}
|
||||
|
||||
// ── Note handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<IResult> ListNotes(
|
||||
Guid id, OpcService svc, CancellationToken ct)
|
||||
{
|
||||
var notes = await svc.ListNotesAsync(id, ct);
|
||||
return Results.Ok(notes);
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddNote(
|
||||
Guid id, AddNoteRequest req, OpcService svc, CancellationToken ct)
|
||||
{
|
||||
var note = await svc.AddNoteAsync(id, req, ct);
|
||||
return Results.Created($"/api/opc/{id}/notes/{note.Id}", note);
|
||||
}
|
||||
|
||||
// ── Artifact handlers ─────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<IResult> ListArtifacts(
|
||||
Guid id, OpcService svc,
|
||||
string? type = null, CancellationToken ct = default)
|
||||
{
|
||||
var artifacts = await svc.ListArtifactsAsync(id, type, ct);
|
||||
return Results.Ok(artifacts);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateArtifact(
|
||||
Guid id, UpsertArtifactRequest req, OpcService svc, CancellationToken ct)
|
||||
{
|
||||
var artifact = await svc.UpsertArtifactAsync(id, req, ct);
|
||||
return Results.Created($"/api/opc/{id}/artifacts/{artifact.Id}", artifact);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateArtifact(
|
||||
Guid artifactId, UpsertArtifactRequest req, OpcService svc, CancellationToken ct)
|
||||
{
|
||||
var artifact = await svc.UpdateArtifactAsync(artifactId, req, ct);
|
||||
return artifact is null ? Results.NotFound() : Results.Ok(artifact);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteArtifact(
|
||||
Guid artifactId, OpcService svc, CancellationToken ct)
|
||||
{
|
||||
return await svc.DeleteArtifactAsync(artifactId, ct)
|
||||
? Results.NoContent()
|
||||
: Results.NotFound();
|
||||
}
|
||||
|
||||
// ── Pinned commit handlers ────────────────────────────────────────────────
|
||||
|
||||
private static async Task<IResult> ListPinnedCommits(
|
||||
Guid id, OpcService svc, CancellationToken ct)
|
||||
{
|
||||
var commits = await svc.ListPinnedCommitsAsync(id, ct);
|
||||
return Results.Ok(commits);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PinCommit(
|
||||
Guid id, PinCommitRequest req, OpcService svc, IConfiguration config, CancellationToken ct)
|
||||
{
|
||||
var repoPath = config["Docker:RepoRoot"];
|
||||
string fullHash = req.Hash;
|
||||
string shortHash = req.Hash.Length >= 7 ? req.Hash[..7] : req.Hash;
|
||||
string subject = string.Empty;
|
||||
string author = string.Empty;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(repoPath) && Directory.Exists(repoPath))
|
||||
{
|
||||
using var repo = new Repository(repoPath);
|
||||
var commit = repo.Lookup<Commit>(req.Hash);
|
||||
if (commit is null) return Results.NotFound("Commit not found in repository.");
|
||||
fullHash = commit.Sha;
|
||||
shortHash = commit.Sha[..7];
|
||||
subject = commit.MessageShort;
|
||||
author = commit.Author.Name;
|
||||
}
|
||||
|
||||
var pinned = await svc.PinCommitAsync(id, fullHash, shortHash, subject, author, req.PinnedBy, ct);
|
||||
return pinned is null
|
||||
? Results.NotFound()
|
||||
: Results.Created($"/api/opc/{id}/pinned-commits/{fullHash}", pinned);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UnpinCommit(
|
||||
Guid id, string hash, OpcService svc, CancellationToken ct)
|
||||
{
|
||||
return await svc.UnpinCommitAsync(id, hash, ct) ? Results.NoContent() : Results.NotFound();
|
||||
}
|
||||
|
||||
// ── AI assist ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<IResult> AiAssist(
|
||||
AiAssistRequest req,
|
||||
IConfiguration config,
|
||||
IHttpClientFactory http,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var apiKey = config["OpenRouter:ApiKey"];
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
return Results.Problem("OpenRouter API key not configured. Add OpenRouter:ApiKey to appsettings.");
|
||||
|
||||
var systemPrompt =
|
||||
"You are an assistant helping a software engineering team write clear, concise " +
|
||||
"OPC (Online Project Communication) content — requirements, change descriptions, " +
|
||||
"QA test paths, and specifications. Be direct, structured, and professional. " +
|
||||
"Respond with plain text only (no markdown wrapping).";
|
||||
|
||||
var messages = new List<object>
|
||||
{
|
||||
new { role = "system", content = systemPrompt },
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(req.Context))
|
||||
messages.Add(new { role = "user", content = $"Context:\n{req.Context}" });
|
||||
|
||||
messages.Add(new { role = "user", content = req.Prompt });
|
||||
|
||||
var body = new
|
||||
{
|
||||
model = "anthropic/claude-3.5-haiku",
|
||||
messages,
|
||||
max_tokens = 1024,
|
||||
};
|
||||
|
||||
var client = http.CreateClient("openrouter");
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
client.DefaultRequestHeaders.Add("HTTP-Referer", "https://controlplane.clarity.internal");
|
||||
client.DefaultRequestHeaders.Add("X-Title", "Clarity ControlPlane OPC");
|
||||
|
||||
var response = await client.PostAsync(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"),
|
||||
ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var err = await response.Content.ReadAsStringAsync(ct);
|
||||
return Results.Problem($"OpenRouter error {response.StatusCode}: {err}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var text = doc.RootElement
|
||||
.GetProperty("choices")[0]
|
||||
.GetProperty("message")
|
||||
.GetProperty("content")
|
||||
.GetString() ?? string.Empty;
|
||||
|
||||
return Results.Ok(new { text });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using ControlPlane.Api.Services;
|
||||
using ControlPlane.Core.Services;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ControlPlane.Api.Endpoints;
|
||||
|
||||
public static class ProjectBuildEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public static IEndpointRouteBuilder MapProjectBuildEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/builds").WithTags("Builds");
|
||||
group.MapGet("/projects", GetProjects);
|
||||
group.MapGet("/history", GetHistory);
|
||||
group.MapPost("/{projectName}", TriggerProjectBuild);
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>Returns the list of known projects the build monitor can track.</summary>
|
||||
private static IResult GetProjects(ProjectBuildService svc) =>
|
||||
Results.Ok(svc.GetProjects());
|
||||
|
||||
private static async Task<IResult> GetHistory(BuildHistoryService history) =>
|
||||
Results.Ok(await history.GetBuildsAsync());
|
||||
|
||||
/// <summary>
|
||||
/// Triggers a build for a named project and streams SSE output.
|
||||
/// projectName must match one of the names returned by GET /api/builds/projects.
|
||||
/// </summary>
|
||||
private static async Task TriggerProjectBuild(
|
||||
string projectName,
|
||||
HttpContext ctx,
|
||||
ProjectBuildService svc,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(svc.RepoRoot))
|
||||
{
|
||||
ctx.Response.StatusCode = 503;
|
||||
await ctx.Response.WriteAsJsonAsync(
|
||||
new { error = "Docker:RepoRoot is not configured on the server." }, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Response.Headers.ContentType = "text/event-stream";
|
||||
ctx.Response.Headers.CacheControl = "no-cache";
|
||||
ctx.Response.Headers.Connection = "keep-alive";
|
||||
|
||||
async Task Send(object payload)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(payload, JsonOpts);
|
||||
await ctx.Response.WriteAsync($"data: {json}\n\n", ct);
|
||||
await ctx.Response.Body.FlushAsync(ct);
|
||||
}
|
||||
|
||||
void OnLine(string line) => Send(new { line }).GetAwaiter().GetResult();
|
||||
|
||||
var record = await svc.BuildProjectAsync(projectName, OnLine, ct);
|
||||
|
||||
await Send(new { done = true, build = record });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using ControlPlane.Api.Services;
|
||||
using ControlPlane.Core.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ControlPlane.Api.Endpoints;
|
||||
|
||||
public static class PromotionEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public static IEndpointRouteBuilder MapPromotionEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var g = app.MapGroup("/api/promotions").WithTags("Promotions");
|
||||
|
||||
// GET /api/promotions/ladder — branch status for all 4 ladder branches
|
||||
g.MapGet("/ladder", async (PromotionService svc, CancellationToken ct) =>
|
||||
Results.Ok(await svc.GetLadderStatusAsync(ct)));
|
||||
|
||||
// GET /api/promotions/history
|
||||
g.MapGet("/history", async (PromotionService svc) =>
|
||||
Results.Ok(await svc.GetHistoryAsync()));
|
||||
|
||||
// POST /api/promotions/promote — body: { from, to, requestedBy, note }
|
||||
// Streams SSE log lines then sends {done, promotion} when complete
|
||||
g.MapPost("/promote", async (
|
||||
HttpContext ctx,
|
||||
PromotionService svc,
|
||||
PromoteRequest req,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
// Validate ladder step
|
||||
var ladder = PromotionService.Ladder;
|
||||
var fi = Array.IndexOf(ladder, req.From);
|
||||
var ti = Array.IndexOf(ladder, req.To);
|
||||
if (fi < 0 || ti < 0 || ti != fi + 1)
|
||||
{
|
||||
ctx.Response.StatusCode = 400;
|
||||
await ctx.Response.WriteAsJsonAsync(
|
||||
new { error = $"Invalid promotion step: {req.From} → {req.To}. Must be adjacent in ladder." }, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Response.Headers.ContentType = "text/event-stream";
|
||||
ctx.Response.Headers.CacheControl = "no-cache";
|
||||
ctx.Response.Headers.Connection = "keep-alive";
|
||||
|
||||
var channel = System.Threading.Channels.Channel.CreateUnbounded<string?>(
|
||||
new System.Threading.Channels.UnboundedChannelOptions { SingleWriter = true, SingleReader = true });
|
||||
|
||||
void OnLine(string line) => channel.Writer.TryWrite(line);
|
||||
|
||||
var promoteTask = Task.Run(() =>
|
||||
svc.PromoteAsync(req.From, req.To, req.RequestedBy ?? "system", req.Note, OnLine, ct), ct)
|
||||
.ContinueWith(t => channel.Writer.TryComplete(t.Exception), TaskScheduler.Default);
|
||||
|
||||
await foreach (var line in channel.Reader.ReadAllAsync(ct))
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new { line }, JsonOpts);
|
||||
await ctx.Response.WriteAsync($"data: {json}\n\n", ct);
|
||||
await ctx.Response.Body.FlushAsync(ct);
|
||||
}
|
||||
|
||||
var promotion = await promoteTask;
|
||||
var doneJson = JsonSerializer.Serialize(new { done = true, promotion }, JsonOpts);
|
||||
await ctx.Response.WriteAsync($"data: {doneJson}\n\n", ct);
|
||||
await ctx.Response.Body.FlushAsync(ct);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
public record PromoteRequest(string From, string To, string? RequestedBy, string? Note);
|
||||
@@ -0,0 +1,106 @@
|
||||
using ControlPlane.Api.Services;
|
||||
using ControlPlane.Core.Messages;
|
||||
using ControlPlane.Core.Models;
|
||||
using ControlPlane.Core.Services;
|
||||
using MassTransit;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ControlPlane.Api.Endpoints;
|
||||
|
||||
public static class ProvisioningEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public static IEndpointRouteBuilder MapProvisioningEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/provision").WithTags("Provisioning");
|
||||
|
||||
group.MapPost("/", QueueProvisioningJob);
|
||||
group.MapGet("/{id:guid}", GetJobStatus);
|
||||
group.MapGet("/{id:guid}/stream", StreamJobEvents);
|
||||
|
||||
app.MapGet("/api/tenants", GetTenants).WithTags("Tenants");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> QueueProvisioningJob(
|
||||
ProvisioningRequest request,
|
||||
Dictionary<Guid, ProvisioningJob> jobs,
|
||||
IPublishEndpoint bus)
|
||||
{
|
||||
var job = new ProvisioningJob
|
||||
{
|
||||
ClientName = request.ClientName,
|
||||
StateCode = request.StateCode.ToUpperInvariant(),
|
||||
Subdomain = request.Subdomain,
|
||||
AdminEmail = request.AdminEmail,
|
||||
SiteCode = request.SiteCode,
|
||||
Environment = request.Environment,
|
||||
Tier = request.Tier,
|
||||
Status = ProvisioningStatus.Pending
|
||||
};
|
||||
|
||||
jobs[job.Id] = job;
|
||||
|
||||
await bus.Publish(new ProvisionClientCommand
|
||||
{
|
||||
JobId = job.Id,
|
||||
ClientName = job.ClientName,
|
||||
StateCode = job.StateCode,
|
||||
Subdomain = job.Subdomain,
|
||||
AdminEmail = job.AdminEmail,
|
||||
SiteCode = job.SiteCode,
|
||||
Environment = job.Environment,
|
||||
Tier = job.Tier
|
||||
});
|
||||
|
||||
return Results.Accepted($"/api/provision/{job.Id}", new { job.Id, job.Status });
|
||||
}
|
||||
|
||||
private static IResult GetJobStatus(Guid id, Dictionary<Guid, ProvisioningJob> jobs) =>
|
||||
jobs.TryGetValue(id, out var job) ? Results.Ok(job) : Results.NotFound();
|
||||
|
||||
private static IResult GetTenants(TenantRegistryService registry) =>
|
||||
Results.Ok(registry.GetAll());
|
||||
|
||||
private static async Task StreamJobEvents(
|
||||
Guid id,
|
||||
SseEventBus bus,
|
||||
Dictionary<Guid, ProvisioningJob> jobs,
|
||||
HttpContext ctx,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!jobs.ContainsKey(id))
|
||||
{
|
||||
ctx.Response.StatusCode = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Response.Headers.ContentType = "text/event-stream";
|
||||
ctx.Response.Headers.CacheControl = "no-cache";
|
||||
ctx.Response.Headers.Connection = "keep-alive";
|
||||
|
||||
var channel = bus.Subscribe(id);
|
||||
try
|
||||
{
|
||||
await foreach (var evt in channel.Reader.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
var json = JsonSerializer.Serialize(evt, JsonOpts);
|
||||
await ctx.Response.WriteAsync($"data: {json}\n\n", cancellationToken);
|
||||
await ctx.Response.Body.FlushAsync(cancellationToken);
|
||||
|
||||
if (evt.Type is "job_complete" or "job_failed") break;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Client disconnected (e.g. browser refresh) — not an error.
|
||||
}
|
||||
finally
|
||||
{
|
||||
bus.Unsubscribe(id, channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using ControlPlane.Api.Services;
|
||||
using ControlPlane.Core.Services;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ControlPlane.Api.Endpoints;
|
||||
|
||||
public static class ReleaseEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public static IEndpointRouteBuilder MapReleaseEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/release").WithTags("Release");
|
||||
group.MapGet("/history", GetHistory);
|
||||
group.MapPost("/{env}", TriggerRelease);
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetHistory(BuildHistoryService history) =>
|
||||
Results.Ok(await history.GetReleasesAsync());
|
||||
|
||||
/// <summary>
|
||||
/// Triggers a rolling redeploy of all managed containers in the target env.
|
||||
/// Streams SSE lines until release is complete.
|
||||
/// Valid env values: fdev | uat | prod | all
|
||||
/// </summary>
|
||||
private static async Task TriggerRelease(
|
||||
string env,
|
||||
HttpContext ctx,
|
||||
ReleaseService releases,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var valid = new[] { "fdev", "uat", "prod", "all" };
|
||||
if (!valid.Contains(env, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
ctx.Response.StatusCode = 400;
|
||||
await ctx.Response.WriteAsJsonAsync(
|
||||
new { error = $"Invalid environment '{env}'. Valid: fdev, uat, prod, all." }, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Response.Headers.ContentType = "text/event-stream";
|
||||
ctx.Response.Headers.CacheControl = "no-cache";
|
||||
ctx.Response.Headers.Connection = "keep-alive";
|
||||
|
||||
async Task Send(object payload)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(payload, JsonOpts);
|
||||
await ctx.Response.WriteAsync($"data: {json}\n\n", ct);
|
||||
await ctx.Response.Body.FlushAsync(ct);
|
||||
}
|
||||
|
||||
void OnLine(string line) => Send(new { line }).GetAwaiter().GetResult();
|
||||
|
||||
var record = await releases.ReleaseAsync(env, OnLine, ct);
|
||||
|
||||
await Send(new { done = true, release = record });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using Docker.DotNet;
|
||||
using Docker.DotNet.Models;
|
||||
using ControlPlane.Core.Services;
|
||||
|
||||
namespace ControlPlane.Api.Endpoints;
|
||||
|
||||
public static class TenantLogEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapTenantLogEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/tenants/{subdomain}/logs", StreamTenantLogs).WithTags("Tenants");
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task StreamTenantLogs(
|
||||
string subdomain,
|
||||
IConfiguration config,
|
||||
TenantRegistryService registry,
|
||||
HttpContext ctx,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenant = registry.GetAll().FirstOrDefault(t => t.Subdomain == subdomain);
|
||||
if (tenant is null)
|
||||
{
|
||||
ctx.Response.StatusCode = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
var containerName = tenant.ContainerName;
|
||||
if (string.IsNullOrWhiteSpace(containerName))
|
||||
{
|
||||
ctx.Response.StatusCode = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Response.Headers.ContentType = "text/event-stream";
|
||||
ctx.Response.Headers.CacheControl = "no-cache";
|
||||
ctx.Response.Headers.Connection = "keep-alive";
|
||||
|
||||
var socketUri = config["Docker:Socket"] ?? "npipe://./pipe/docker_engine";
|
||||
using var docker = new DockerClientConfiguration(new Uri(socketUri)).CreateClient();
|
||||
|
||||
var logParams = new ContainerLogsParameters
|
||||
{
|
||||
ShowStdout = true,
|
||||
ShowStderr = true,
|
||||
Follow = true,
|
||||
Tail = "200",
|
||||
Timestamps = true,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = await docker.Containers.GetContainerLogsAsync(
|
||||
containerName, tty: false, logParams, cancellationToken);
|
||||
|
||||
// MultiplexedStream exposes CopyOutputToAsync which separates stdout/stderr
|
||||
var stdoutBuf = new System.IO.MemoryStream();
|
||||
var stderrBuf = new System.IO.MemoryStream();
|
||||
|
||||
// Stream with Follow=true won't complete until cancelled — use a pipe instead
|
||||
var stdoutPipe = new System.IO.Pipelines.Pipe();
|
||||
var stderrPipe = new System.IO.Pipelines.Pipe();
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await stream.CopyOutputToAsync(
|
||||
System.IO.Stream.Null,
|
||||
stdoutPipe.Writer.AsStream(),
|
||||
stderrPipe.Writer.AsStream(),
|
||||
cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
stdoutPipe.Writer.Complete();
|
||||
stderrPipe.Writer.Complete();
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
// Merge both pipes into SSE — read stdout line by line
|
||||
var stdoutReader = new System.IO.StreamReader(stdoutPipe.Reader.AsStream());
|
||||
var stderrReader = new System.IO.StreamReader(stderrPipe.Reader.AsStream());
|
||||
|
||||
var stdoutTask = ReadLinesAsync(stdoutReader, ctx, cancellationToken);
|
||||
var stderrTask = ReadLinesAsync(stderrReader, ctx, cancellationToken);
|
||||
await Task.WhenAll(stdoutTask, stderrTask);
|
||||
}
|
||||
catch (OperationCanceledException) { /* client disconnected — normal */ }
|
||||
}
|
||||
|
||||
private static async Task ReadLinesAsync(
|
||||
System.IO.StreamReader reader,
|
||||
HttpContext ctx,
|
||||
CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var line = await reader.ReadLineAsync(ct);
|
||||
if (line is null) break;
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
|
||||
await ctx.Response.WriteAsync($"data: {line}\n\n", ct);
|
||||
await ctx.Response.Body.FlushAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using ControlPlane.Api.Consumers;
|
||||
using ControlPlane.Api.Endpoints;
|
||||
using ControlPlane.Api.Services;
|
||||
using ControlPlane.Core.Models;
|
||||
using ControlPlane.Core.Services;
|
||||
using MassTransit;
|
||||
using Npgsql;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.ConfigureHttpJsonOptions(o =>
|
||||
o.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()));
|
||||
|
||||
// In-memory job store - swap for EF Core post-MVP
|
||||
builder.Services.AddSingleton<Dictionary<Guid, ProvisioningJob>>();
|
||||
|
||||
// Tenant registry - reads ClientAssets/{subdomain}.xml files
|
||||
builder.Services.AddSingleton<TenantRegistryService>();
|
||||
|
||||
// SSE event bus - ProgressConsumer writes here, SSE endpoint reads
|
||||
builder.Services.AddSingleton<SseEventBus>();
|
||||
|
||||
// Build + release pipeline services
|
||||
builder.Services.AddSingleton<BuildHistoryService>();
|
||||
builder.Services.AddSingleton<ImageBuildService>();
|
||||
builder.Services.AddSingleton<ReleaseService>();
|
||||
builder.Services.AddSingleton<ProjectBuildService>();
|
||||
builder.Services.AddSingleton<PromotionService>();
|
||||
|
||||
// OPC persistence (raw Npgsql)
|
||||
var opcConnStr = builder.Configuration.GetConnectionString("opcdb");
|
||||
if (!string.IsNullOrWhiteSpace(opcConnStr))
|
||||
builder.Services.AddSingleton(NpgsqlDataSource.Create(opcConnStr));
|
||||
else
|
||||
builder.Services.AddSingleton(NpgsqlDataSource.Create("Host=localhost;Database=opcdb;Username=postgres;Password=controlplane-dev"));
|
||||
builder.Services.AddScoped<OpcService>();
|
||||
|
||||
// Named HttpClient for OpenRouter AI assist proxy
|
||||
builder.Services.AddHttpClient("openrouter");
|
||||
|
||||
// Gitea integration
|
||||
builder.Services.AddHttpClient("gitea").ConfigurePrimaryHttpMessageHandler(() =>
|
||||
new HttpClientHandler { ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator });
|
||||
builder.Services.AddScoped<GiteaService>();
|
||||
|
||||
builder.Services.AddMassTransit(x =>
|
||||
{
|
||||
x.SetKebabCaseEndpointNameFormatter();
|
||||
|
||||
// Receives ProvisioningProgressEvent from Worker and pushes to SSE
|
||||
x.AddConsumer<ProvisioningProgressConsumer>();
|
||||
|
||||
x.UsingRabbitMq((ctx, cfg) =>
|
||||
{
|
||||
var connStr = builder.Configuration.GetConnectionString("rabbitmq");
|
||||
if (!string.IsNullOrWhiteSpace(connStr))
|
||||
cfg.Host(new Uri(connStr));
|
||||
cfg.ConfigureEndpoints(ctx);
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
app.MapOpenApi();
|
||||
|
||||
app.MapProvisioningEndpoints();
|
||||
app.MapTenantLogEndpoints();
|
||||
app.MapImageBuildEndpoints();
|
||||
app.MapReleaseEndpoints();
|
||||
app.MapProjectBuildEndpoints();
|
||||
app.MapGitEndpoints();
|
||||
app.MapPromotionEndpoints();
|
||||
app.MapOpcEndpoints();
|
||||
app.MapGiteaEndpoints();
|
||||
app.MapInfraEndpoints();
|
||||
|
||||
// Ensure OPC tables exist (idempotent — IF NOT EXISTS)
|
||||
var ds = app.Services.GetRequiredService<NpgsqlDataSource>();
|
||||
await using (var cmd = ds.CreateCommand("""
|
||||
CREATE TABLE IF NOT EXISTS opc (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
number VARCHAR(20) NOT NULL UNIQUE,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
type VARCHAR(50) NOT NULL DEFAULT 'General',
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'New',
|
||||
priority VARCHAR(20) NOT NULL DEFAULT 'Medium',
|
||||
assignee VARCHAR(200) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS opc_note (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
opc_id UUID NOT NULL REFERENCES opc(id) ON DELETE CASCADE,
|
||||
author VARCHAR(200) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS opc_artifact (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
opc_id UUID NOT NULL REFERENCES opc(id) ON DELETE CASCADE,
|
||||
artifact_type VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(500) NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS opc_pinned_commit (
|
||||
opc_id UUID NOT NULL REFERENCES opc(id) ON DELETE CASCADE,
|
||||
hash VARCHAR(40) NOT NULL,
|
||||
short_hash VARCHAR(10) NOT NULL DEFAULT '',
|
||||
subject VARCHAR(1000) NOT NULL DEFAULT '',
|
||||
author VARCHAR(200) NOT NULL DEFAULT '',
|
||||
pinned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
pinned_by VARCHAR(200) NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (opc_id, hash)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_opc_number ON opc(number);
|
||||
CREATE INDEX IF NOT EXISTS ix_opc_note_opc_id ON opc_note(opc_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_opc_artifact_opc_id ON opc_artifact(opc_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_opc_artifact_type ON opc_artifact(opc_id, artifact_type);
|
||||
CREATE INDEX IF NOT EXISTS ix_opc_pinned_commit_opc_id ON opc_pinned_commit(opc_id);
|
||||
"""))
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"ControlPlane.Api": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:7280;http://localhost:5280"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ControlPlane.Core.Models;
|
||||
|
||||
namespace ControlPlane.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin wrapper around the Gitea REST API v1.
|
||||
/// Configured via Gitea__BaseUrl, Gitea__Owner, and Gitea__Token in appsettings.
|
||||
/// </summary>
|
||||
public class GiteaService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly string _owner;
|
||||
private readonly string _repo;
|
||||
private readonly ILogger<GiteaService> _log;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public GiteaService(IHttpClientFactory factory, IConfiguration cfg, ILogger<GiteaService> log)
|
||||
{
|
||||
_log = log;
|
||||
_owner = cfg["Gitea:Owner"] ?? "Clarity";
|
||||
_repo = cfg["Gitea:Repo"] ?? "Clarity";
|
||||
|
||||
var baseUrl = cfg["Gitea:BaseUrl"] ?? "https://opc.clarity.test";
|
||||
var token = cfg["Gitea:Token"] ?? string.Empty;
|
||||
|
||||
_http = factory.CreateClient("gitea");
|
||||
_http.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/api/v1/");
|
||||
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", token);
|
||||
}
|
||||
|
||||
// ── Repos ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<GiteaRepo?> GetRepoAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _http.GetFromJsonAsync<GiteaRepo>($"repos/{_owner}/{_repo}", JsonOpts, ct);
|
||||
}
|
||||
catch (Exception ex) { _log.LogWarning(ex, "Gitea GetRepo failed"); return null; }
|
||||
}
|
||||
|
||||
// ── Branches ──────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<GiteaBranch>> ListBranchesAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _http.GetFromJsonAsync<List<GiteaBranch>>(
|
||||
$"repos/{_owner}/{_repo}/branches?limit=50", JsonOpts, ct) ?? [];
|
||||
}
|
||||
catch (Exception ex) { _log.LogWarning(ex, "Gitea ListBranches failed"); return []; }
|
||||
}
|
||||
|
||||
public async Task<GiteaBranch?> CreateBranchAsync(CreateBranchRequest req, CancellationToken ct = default)
|
||||
{
|
||||
// Slugify: "OPC # 0032" + title → "feature/OPC-0032-git-workflow-integration"
|
||||
var slug = SlugifyTitle(req.OpcTitle);
|
||||
var num = req.OpcNumber.Replace("OPC # ", "OPC-").Replace(" ", "");
|
||||
var branchName = $"feature/{num}-{slug}";
|
||||
|
||||
var body = JsonSerializer.Serialize(new
|
||||
{
|
||||
new_branch_name = branchName,
|
||||
old_branch_name = req.From,
|
||||
}, JsonOpts);
|
||||
|
||||
var res = await _http.PostAsync(
|
||||
$"repos/{_owner}/{_repo}/branches",
|
||||
new StringContent(body, Encoding.UTF8, "application/json"), ct);
|
||||
|
||||
if (!res.IsSuccessStatusCode)
|
||||
{
|
||||
var err = await res.Content.ReadAsStringAsync(ct);
|
||||
_log.LogWarning("Gitea CreateBranch failed {Status}: {Error}", res.StatusCode, err);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await res.Content.ReadFromJsonAsync<GiteaBranch>(JsonOpts, ct);
|
||||
}
|
||||
|
||||
// ── Pull Requests ─────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<GiteaPullRequest>> ListPullRequestsAsync(
|
||||
string state = "open", CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _http.GetFromJsonAsync<List<GiteaPullRequest>>(
|
||||
$"repos/{_owner}/{_repo}/pulls?state={state}&limit=50", JsonOpts, ct) ?? [];
|
||||
}
|
||||
catch (Exception ex) { _log.LogWarning(ex, "Gitea ListPRs failed"); return []; }
|
||||
}
|
||||
|
||||
public async Task<GiteaPullRequest?> GetPullRequestAsync(long number, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _http.GetFromJsonAsync<GiteaPullRequest>(
|
||||
$"repos/{_owner}/{_repo}/pulls/{number}", JsonOpts, ct);
|
||||
}
|
||||
catch (Exception ex) { _log.LogWarning(ex, "Gitea GetPR failed"); return null; }
|
||||
}
|
||||
|
||||
public async Task<GiteaPullRequest?> CreatePullRequestAsync(
|
||||
CreatePullRequestRequest req, CancellationToken ct = default)
|
||||
{
|
||||
var body = JsonSerializer.Serialize(new
|
||||
{
|
||||
title = req.Title,
|
||||
head = req.Head,
|
||||
@base = req.Base,
|
||||
body = req.Body,
|
||||
}, JsonOpts);
|
||||
|
||||
var res = await _http.PostAsync(
|
||||
$"repos/{_owner}/{_repo}/pulls",
|
||||
new StringContent(body, Encoding.UTF8, "application/json"), ct);
|
||||
|
||||
if (!res.IsSuccessStatusCode)
|
||||
{
|
||||
var err = await res.Content.ReadAsStringAsync(ct);
|
||||
_log.LogWarning("Gitea CreatePR failed {Status}: {Error}", res.StatusCode, err);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await res.Content.ReadFromJsonAsync<GiteaPullRequest>(JsonOpts, ct);
|
||||
}
|
||||
|
||||
// ── Tags ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<GiteaTag>> ListTagsAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _http.GetFromJsonAsync<List<GiteaTag>>(
|
||||
$"repos/{_owner}/{_repo}/tags?limit=20", JsonOpts, ct) ?? [];
|
||||
}
|
||||
catch (Exception ex) { _log.LogWarning(ex, "Gitea ListTags failed"); return []; }
|
||||
}
|
||||
|
||||
public async Task<GiteaTag?> CreateTagAsync(CreateTagRequest req, CancellationToken ct = default)
|
||||
{
|
||||
var body = JsonSerializer.Serialize(new
|
||||
{
|
||||
tag_name = req.TagName,
|
||||
message = req.Message,
|
||||
target = req.CommitSha,
|
||||
}, JsonOpts);
|
||||
|
||||
var res = await _http.PostAsync(
|
||||
$"repos/{_owner}/{_repo}/tags",
|
||||
new StringContent(body, Encoding.UTF8, "application/json"), ct);
|
||||
|
||||
if (!res.IsSuccessStatusCode)
|
||||
{
|
||||
var err = await res.Content.ReadAsStringAsync(ct);
|
||||
_log.LogWarning("Gitea CreateTag failed {Status}: {Error}", res.StatusCode, err);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await res.Content.ReadFromJsonAsync<GiteaTag>(JsonOpts, ct);
|
||||
}
|
||||
|
||||
// ── Webhooks ──────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<GiteaWebhook>> ListWebhooksAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _http.GetFromJsonAsync<List<GiteaWebhook>>(
|
||||
$"repos/{_owner}/{_repo}/hooks", JsonOpts, ct) ?? [];
|
||||
}
|
||||
catch (Exception ex) { _log.LogWarning(ex, "Gitea ListWebhooks failed"); return []; }
|
||||
}
|
||||
|
||||
public async Task<GiteaWebhook?> RegisterWebhookAsync(
|
||||
CreateWebhookRequest req, CancellationToken ct = default)
|
||||
{
|
||||
var body = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "gitea",
|
||||
active = true,
|
||||
config = new { url = req.TargetUrl, content_type = "json" },
|
||||
events = req.Events,
|
||||
}, JsonOpts);
|
||||
|
||||
var res = await _http.PostAsync(
|
||||
$"repos/{_owner}/{_repo}/hooks",
|
||||
new StringContent(body, Encoding.UTF8, "application/json"), ct);
|
||||
|
||||
if (!res.IsSuccessStatusCode)
|
||||
{
|
||||
var err = await res.Content.ReadAsStringAsync(ct);
|
||||
_log.LogWarning("Gitea RegisterWebhook failed {Status}: {Error}", res.StatusCode, err);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await res.Content.ReadFromJsonAsync<GiteaWebhook>(JsonOpts, ct);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static string SlugifyTitle(string title) =>
|
||||
System.Text.RegularExpressions.Regex
|
||||
.Replace(title.ToLowerInvariant(), @"[^a-z0-9]+", "-")
|
||||
.Trim('-')[..Math.Min(40, title.Length)];
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using ControlPlane.Core.Models;
|
||||
using ControlPlane.Core.Services;
|
||||
using Docker.DotNet;
|
||||
using Docker.DotNet.Models;
|
||||
|
||||
namespace ControlPlane.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Drives `docker build` for the clarity-server image via the Docker SDK.
|
||||
/// Streams each build log line to the provided callback so the API endpoint
|
||||
/// can forward it as SSE to the control plane UI in real time.
|
||||
/// Persists build history via BuildHistoryService.
|
||||
/// </summary>
|
||||
public class ImageBuildService(
|
||||
IConfiguration config,
|
||||
BuildHistoryService history,
|
||||
ILogger<ImageBuildService> logger)
|
||||
{
|
||||
private static readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
public bool IsBuilding => _lock.CurrentCount == 0;
|
||||
public string ImageName => config["Docker:ClarityServerImage"] ?? "clarity-server:latest";
|
||||
|
||||
public async Task<ImageBuildStatus> GetStatusAsync()
|
||||
{
|
||||
var builds = await history.GetBuildsAsync();
|
||||
var last = builds.FirstOrDefault(b => b.Kind == BuildKind.DockerImage);
|
||||
return new ImageBuildStatus(
|
||||
last?.Target,
|
||||
last?.FinishedAt,
|
||||
last?.Status.ToString() ?? "Never built",
|
||||
IsBuilding);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs docker build and streams each log line to <paramref name="onLine"/>.
|
||||
/// Returns true on success, false if the build failed or was already running.
|
||||
/// </summary>
|
||||
public async Task<bool> BuildAsync(
|
||||
string repoRoot,
|
||||
Action<string> onLine,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!await _lock.WaitAsync(TimeSpan.Zero, ct))
|
||||
{
|
||||
onLine("⚠️ A build is already in progress.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var record = await history.CreateBuildAsync(BuildKind.DockerImage, ImageName);
|
||||
|
||||
try
|
||||
{
|
||||
var socketUri = config["Docker:Socket"] ?? "npipe://./pipe/docker_engine";
|
||||
using var docker = new DockerClientConfiguration(new Uri(socketUri)).CreateClient();
|
||||
|
||||
var (repo, tag) = SplitImageTag(ImageName);
|
||||
var dockerfilePath = "Clarity.Server/Dockerfile";
|
||||
|
||||
void Log(string line) { onLine(line); record.Log.Add(line); }
|
||||
|
||||
Log($"▶ Building {ImageName} from {repoRoot}");
|
||||
Log($" Dockerfile: {dockerfilePath}");
|
||||
Log("──────────────────────────────────────");
|
||||
|
||||
var buildParams = new ImageBuildParameters
|
||||
{
|
||||
Dockerfile = dockerfilePath,
|
||||
Tags = [$"{repo}:{tag}"],
|
||||
Remove = true,
|
||||
ForceRemove = true,
|
||||
};
|
||||
|
||||
bool success = true;
|
||||
string? errorDetail = null;
|
||||
|
||||
await docker.Images.BuildImageFromDockerfileAsync(
|
||||
buildParams,
|
||||
await CreateTarballAsync(repoRoot, ct),
|
||||
authConfigs: null,
|
||||
headers: null,
|
||||
new Progress<JSONMessage>(msg =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(msg.Stream))
|
||||
Log(msg.Stream.TrimEnd('\n'));
|
||||
|
||||
if (msg.Error is not null)
|
||||
{
|
||||
success = false;
|
||||
errorDetail = msg.Error.Message;
|
||||
Log($"✖ {msg.Error.Message}");
|
||||
}
|
||||
}),
|
||||
ct);
|
||||
|
||||
Log("──────────────────────────────────────");
|
||||
if (success) Log($"✔ {ImageName} built successfully at {DateTimeOffset.UtcNow:u}");
|
||||
else Log($"✖ Build failed: {errorDetail}");
|
||||
|
||||
await history.CompleteBuildAsync(record, success ? BuildStatus.Succeeded : BuildStatus.Failed);
|
||||
logger.LogInformation("Image build {Result} for {Image}", success ? "succeeded" : "failed", ImageName);
|
||||
return success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
record.Log.Add($"Exception: {ex.Message}");
|
||||
await history.CompleteBuildAsync(record, BuildStatus.Failed);
|
||||
onLine($"✖ Exception during build: {ex.Message}");
|
||||
logger.LogError(ex, "Image build threw an exception.");
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Packs the entire repo root into a tar stream for the Docker build context.
|
||||
/// Respects .dockerignore if present.
|
||||
/// </summary>
|
||||
private static async Task<Stream> CreateTarballAsync(string repoRoot, CancellationToken ct)
|
||||
{
|
||||
// Use docker's own CLI to create the tarball via stdin — avoids reimplementing
|
||||
// .dockerignore parsing. Fall back to a pure managed tar if CLI isn't available.
|
||||
// For simplicity we use a managed approach: stream the directory as a tar.
|
||||
var ms = new MemoryStream();
|
||||
await Task.Run(() => TarHelper.Pack(repoRoot, ms), ct);
|
||||
ms.Position = 0;
|
||||
return ms;
|
||||
}
|
||||
|
||||
private static (string repo, string tag) SplitImageTag(string image)
|
||||
{
|
||||
var colon = image.LastIndexOf(':');
|
||||
return colon < 0 ? (image, "latest") : (image[..colon], image[(colon + 1)..]);
|
||||
}
|
||||
}
|
||||
|
||||
public record ImageBuildStatus(
|
||||
string? ImageName,
|
||||
DateTimeOffset? BuiltAt,
|
||||
string LastMessage,
|
||||
bool IsBuilding);
|
||||
@@ -0,0 +1,297 @@
|
||||
using ControlPlane.Core.Models;
|
||||
using Npgsql;
|
||||
|
||||
namespace ControlPlane.Api.Services;
|
||||
|
||||
public class OpcService(NpgsqlDataSource db)
|
||||
{
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static OpcRecord ReadOpc(NpgsqlDataReader r) => new(
|
||||
r.GetGuid(0),
|
||||
r.GetString(1),
|
||||
r.GetString(2),
|
||||
r.GetString(3),
|
||||
r.GetString(4),
|
||||
r.GetString(5),
|
||||
r.GetString(6),
|
||||
r.GetString(7),
|
||||
r.GetDateTime(8),
|
||||
r.GetDateTime(9)
|
||||
);
|
||||
|
||||
private static OpcNote ReadNote(NpgsqlDataReader r) => new(
|
||||
r.GetGuid(0),
|
||||
r.GetGuid(1),
|
||||
r.GetString(2),
|
||||
r.GetString(3),
|
||||
r.GetDateTime(4)
|
||||
);
|
||||
|
||||
private static OpcArtifact ReadArtifact(NpgsqlDataReader r) => new(
|
||||
r.GetGuid(0),
|
||||
r.GetGuid(1),
|
||||
r.GetString(2),
|
||||
r.GetString(3),
|
||||
r.GetString(4),
|
||||
r.GetDateTime(5),
|
||||
r.GetDateTime(6)
|
||||
);
|
||||
|
||||
// ── Next OPC number ───────────────────────────────────────────────────────
|
||||
|
||||
public async Task<string> NextNumberAsync(CancellationToken ct = default)
|
||||
{
|
||||
await using var cmd = db.CreateCommand(
|
||||
"SELECT number FROM opc ORDER BY CAST(TRIM(SUBSTRING(number FROM 7)) AS INTEGER) DESC LIMIT 1");
|
||||
var last = await cmd.ExecuteScalarAsync(ct) as string;
|
||||
if (last is null) return "OPC # 0001";
|
||||
if (int.TryParse(last[6..], out var n))
|
||||
return $"OPC # {n + 1:D4}";
|
||||
return "OPC # 0001";
|
||||
}
|
||||
|
||||
// ── OPC CRUD ──────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<OpcRecord>> ListAsync(
|
||||
string? typeFilter = null, string? statusFilter = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT id, number, title, description, type, status, priority, assignee,
|
||||
created_at, updated_at
|
||||
FROM opc
|
||||
WHERE ($1::text IS NULL OR type = $1)
|
||||
AND ($2::text IS NULL OR status = $2)
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
await using var cmd = db.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue(typeFilter ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue(statusFilter ?? (object)DBNull.Value);
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||
var list = new List<OpcRecord>();
|
||||
while (await r.ReadAsync(ct)) list.Add(ReadOpc(r));
|
||||
return list;
|
||||
}
|
||||
|
||||
public async Task<OpcRecord?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
await using var cmd = db.CreateCommand(
|
||||
"SELECT id, number, title, description, type, status, priority, assignee, created_at, updated_at FROM opc WHERE id = $1");
|
||||
cmd.Parameters.AddWithValue(id);
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||
return await r.ReadAsync(ct) ? ReadOpc(r) : null;
|
||||
}
|
||||
|
||||
public async Task<OpcRecord> CreateAsync(CreateOpcRequest req, CancellationToken ct = default)
|
||||
{
|
||||
var number = await NextNumberAsync(ct);
|
||||
var sql = """
|
||||
INSERT INTO opc (number, title, description, type, status, priority, assignee)
|
||||
VALUES ($1, $2, $3, $4, 'New', $5, $6)
|
||||
RETURNING id, number, title, description, type, status, priority, assignee,
|
||||
created_at, updated_at
|
||||
""";
|
||||
await using var cmd = db.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue(number);
|
||||
cmd.Parameters.AddWithValue(req.Title);
|
||||
cmd.Parameters.AddWithValue(req.Description);
|
||||
cmd.Parameters.AddWithValue(req.Type);
|
||||
cmd.Parameters.AddWithValue(req.Priority);
|
||||
cmd.Parameters.AddWithValue(req.Assignee);
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||
await r.ReadAsync(ct);
|
||||
return ReadOpc(r);
|
||||
}
|
||||
|
||||
public async Task<OpcRecord?> UpdateAsync(Guid id, UpdateOpcRequest req, CancellationToken ct = default)
|
||||
{
|
||||
var sql = """
|
||||
UPDATE opc SET
|
||||
title = COALESCE($2, title),
|
||||
description = COALESCE($3, description),
|
||||
type = COALESCE($4, type),
|
||||
status = COALESCE($5, status),
|
||||
priority = COALESCE($6, priority),
|
||||
assignee = COALESCE($7, assignee),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, number, title, description, type, status, priority, assignee,
|
||||
created_at, updated_at
|
||||
""";
|
||||
await using var cmd = db.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue(id);
|
||||
cmd.Parameters.AddWithValue(req.Title ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue(req.Description ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue(req.Type ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue(req.Status ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue(req.Priority ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue(req.Assignee ?? (object)DBNull.Value);
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||
return await r.ReadAsync(ct) ? ReadOpc(r) : null;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
await using var cmd = db.CreateCommand("DELETE FROM opc WHERE id = $1");
|
||||
cmd.Parameters.AddWithValue(id);
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}
|
||||
|
||||
// ── Notes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<OpcNote>> ListNotesAsync(Guid opcId, CancellationToken ct = default)
|
||||
{
|
||||
await using var cmd = db.CreateCommand(
|
||||
"SELECT id, opc_id, author, content, created_at FROM opc_note WHERE opc_id = $1 ORDER BY created_at ASC");
|
||||
cmd.Parameters.AddWithValue(opcId);
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||
var list = new List<OpcNote>();
|
||||
while (await r.ReadAsync(ct)) list.Add(ReadNote(r));
|
||||
return list;
|
||||
}
|
||||
|
||||
public async Task<OpcNote> AddNoteAsync(Guid opcId, AddNoteRequest req, CancellationToken ct = default)
|
||||
{
|
||||
var sql = """
|
||||
INSERT INTO opc_note (opc_id, author, content)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, opc_id, author, content, created_at
|
||||
""";
|
||||
await using var cmd = db.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue(opcId);
|
||||
cmd.Parameters.AddWithValue(req.Author);
|
||||
cmd.Parameters.AddWithValue(req.Content);
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||
await r.ReadAsync(ct);
|
||||
return ReadNote(r);
|
||||
}
|
||||
|
||||
// ── Artifacts ─────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<OpcArtifact>> ListArtifactsAsync(Guid opcId, string? artifactType = null, CancellationToken ct = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT id, opc_id, artifact_type, title, content, created_at, updated_at
|
||||
FROM opc_artifact
|
||||
WHERE opc_id = $1
|
||||
AND ($2::text IS NULL OR artifact_type = $2)
|
||||
ORDER BY created_at ASC
|
||||
""";
|
||||
await using var cmd = db.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue(opcId);
|
||||
cmd.Parameters.AddWithValue(artifactType ?? (object)DBNull.Value);
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||
var list = new List<OpcArtifact>();
|
||||
while (await r.ReadAsync(ct)) list.Add(ReadArtifact(r));
|
||||
return list;
|
||||
}
|
||||
|
||||
public async Task<OpcArtifact> UpsertArtifactAsync(Guid opcId, UpsertArtifactRequest req, CancellationToken ct = default)
|
||||
{
|
||||
var sql = """
|
||||
INSERT INTO opc_artifact (opc_id, artifact_type, title, content)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id, opc_id, artifact_type, title, content, created_at, updated_at
|
||||
""";
|
||||
// Simple insert; for updates use artifact id endpoint
|
||||
await using var cmd = db.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue(opcId);
|
||||
cmd.Parameters.AddWithValue(req.ArtifactType);
|
||||
cmd.Parameters.AddWithValue(req.Title);
|
||||
cmd.Parameters.AddWithValue(req.Content);
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||
await r.ReadAsync(ct);
|
||||
return ReadArtifact(r);
|
||||
}
|
||||
|
||||
public async Task<OpcArtifact?> UpdateArtifactAsync(Guid artifactId, UpsertArtifactRequest req, CancellationToken ct = default)
|
||||
{
|
||||
var sql = """
|
||||
UPDATE opc_artifact SET
|
||||
title = $2,
|
||||
content = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, opc_id, artifact_type, title, content, created_at, updated_at
|
||||
""";
|
||||
await using var cmd = db.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue(artifactId);
|
||||
cmd.Parameters.AddWithValue(req.Title);
|
||||
cmd.Parameters.AddWithValue(req.Content);
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||
return await r.ReadAsync(ct) ? ReadArtifact(r) : null;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteArtifactAsync(Guid artifactId, CancellationToken ct = default)
|
||||
{
|
||||
await using var cmd = db.CreateCommand("DELETE FROM opc_artifact WHERE id = $1");
|
||||
cmd.Parameters.AddWithValue(artifactId);
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}
|
||||
|
||||
// ── Pinned commits ────────────────────────────────────────────────────────
|
||||
|
||||
private static OpcPinnedCommit ReadPinnedCommit(NpgsqlDataReader r) => new(
|
||||
r.GetGuid(0),
|
||||
r.GetString(1),
|
||||
r.GetString(2),
|
||||
r.GetString(3),
|
||||
r.GetString(4),
|
||||
r.GetDateTime(5),
|
||||
r.GetString(6)
|
||||
);
|
||||
|
||||
public async Task<List<OpcPinnedCommit>> ListPinnedCommitsAsync(Guid opcId, CancellationToken ct = default)
|
||||
{
|
||||
await using var cmd = db.CreateCommand(
|
||||
"SELECT opc_id, hash, short_hash, subject, author, pinned_at, pinned_by FROM opc_pinned_commit WHERE opc_id = $1 ORDER BY pinned_at DESC");
|
||||
cmd.Parameters.AddWithValue(opcId);
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||
var list = new List<OpcPinnedCommit>();
|
||||
while (await r.ReadAsync(ct)) list.Add(ReadPinnedCommit(r));
|
||||
return list;
|
||||
}
|
||||
|
||||
public async Task<OpcPinnedCommit?> PinCommitAsync(
|
||||
Guid opcId, string hash, string shortHash, string subject, string author, string pinnedBy,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Verify the OPC exists
|
||||
await using var existsCmd = db.CreateCommand("SELECT 1 FROM opc WHERE id = $1");
|
||||
existsCmd.Parameters.AddWithValue(opcId);
|
||||
var exists = await existsCmd.ExecuteScalarAsync(ct);
|
||||
if (exists is null) return null;
|
||||
|
||||
var sql = """
|
||||
INSERT INTO opc_pinned_commit (opc_id, hash, short_hash, subject, author, pinned_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (opc_id, hash) DO UPDATE SET
|
||||
short_hash = EXCLUDED.short_hash,
|
||||
subject = EXCLUDED.subject,
|
||||
author = EXCLUDED.author,
|
||||
pinned_by = EXCLUDED.pinned_by,
|
||||
pinned_at = NOW()
|
||||
RETURNING opc_id, hash, short_hash, subject, author, pinned_at, pinned_by
|
||||
""";
|
||||
await using var cmd = db.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue(opcId);
|
||||
cmd.Parameters.AddWithValue(hash);
|
||||
cmd.Parameters.AddWithValue(shortHash);
|
||||
cmd.Parameters.AddWithValue(subject);
|
||||
cmd.Parameters.AddWithValue(author);
|
||||
cmd.Parameters.AddWithValue(pinnedBy);
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||
return await r.ReadAsync(ct) ? ReadPinnedCommit(r) : null;
|
||||
}
|
||||
|
||||
public async Task<bool> UnpinCommitAsync(Guid opcId, string hash, CancellationToken ct = default)
|
||||
{
|
||||
await using var cmd = db.CreateCommand(
|
||||
"DELETE FROM opc_pinned_commit WHERE opc_id = $1 AND hash = $2");
|
||||
cmd.Parameters.AddWithValue(opcId);
|
||||
cmd.Parameters.AddWithValue(hash);
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using ControlPlane.Core.Models;
|
||||
using ControlPlane.Core.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ControlPlane.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Runs dotnet build or npm run build for individual projects in the repo.
|
||||
/// Used by the Build Monitor tab in the control plane UI.
|
||||
/// </summary>
|
||||
public class ProjectBuildService(
|
||||
IConfiguration config,
|
||||
BuildHistoryService history,
|
||||
ILogger<ProjectBuildService> logger)
|
||||
{
|
||||
public string RepoRoot => config["Docker:RepoRoot"] ?? string.Empty;
|
||||
|
||||
/// <summary>Known projects in the solution, returned to the UI for the build monitor grid.</summary>
|
||||
public IReadOnlyList<ProjectDefinition> GetProjects()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(RepoRoot)) return [];
|
||||
|
||||
return
|
||||
[
|
||||
new("Clarity.Server", BuildKind.DotnetProject, "Clarity.Server/Clarity.Server.csproj"),
|
||||
new("Clarity.ServiceDefaults", BuildKind.DotnetProject, "Clarity.ServiceDefaults/Clarity.ServiceDefaults.csproj"),
|
||||
new("frontend (Clarity.Server)", BuildKind.NpmProject, "frontend"),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a single project and streams output to <paramref name="onLine"/>.
|
||||
/// </summary>
|
||||
public async Task<BuildRecord> BuildProjectAsync(
|
||||
string projectName,
|
||||
Action<string> onLine,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var projects = GetProjects();
|
||||
var def = projects.FirstOrDefault(p => p.Name == projectName);
|
||||
if (def is null)
|
||||
{
|
||||
var err = new BuildRecord { Kind = BuildKind.DotnetProject, Target = projectName, Status = BuildStatus.Failed };
|
||||
err.Log.Add($"Unknown project: {projectName}");
|
||||
return err;
|
||||
}
|
||||
|
||||
var record = await history.CreateBuildAsync(def.Kind, def.RelativePath);
|
||||
record.Log.Add($"▶ Building {def.Name} [{def.Kind}]");
|
||||
record.Log.Add($" Path: {def.RelativePath}");
|
||||
record.Log.Add("──────────────────────────────────────");
|
||||
onLine($"▶ Building {def.Name}");
|
||||
|
||||
try
|
||||
{
|
||||
var (exe, args, workDir) = def.Kind == BuildKind.NpmProject
|
||||
? BuildNpmCommand(def.RelativePath)
|
||||
: BuildDotnetCommand(def.RelativePath);
|
||||
|
||||
var psi = new ProcessStartInfo(exe, args)
|
||||
{
|
||||
WorkingDirectory = workDir,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
using var proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
||||
|
||||
void HandleLine(string? line)
|
||||
{
|
||||
if (line is null) return;
|
||||
record.Log.Add(line);
|
||||
onLine(line);
|
||||
// Non-blocking fire-and-forget flush
|
||||
_ = history.AppendBuildLogAsync(record, line);
|
||||
}
|
||||
|
||||
proc.OutputDataReceived += (_, e) => HandleLine(e.Data);
|
||||
proc.ErrorDataReceived += (_, e) => HandleLine(e.Data);
|
||||
|
||||
proc.Start();
|
||||
proc.BeginOutputReadLine();
|
||||
proc.BeginErrorReadLine();
|
||||
|
||||
await proc.WaitForExitAsync(ct);
|
||||
|
||||
var status = proc.ExitCode == 0 ? BuildStatus.Succeeded : BuildStatus.Failed;
|
||||
var summary = proc.ExitCode == 0 ? "✔ Build succeeded." : $"✖ Build failed (exit {proc.ExitCode}).";
|
||||
onLine("──────────────────────────────────────");
|
||||
onLine(summary);
|
||||
record.Log.Add(summary);
|
||||
|
||||
await history.CompleteBuildAsync(record, status);
|
||||
logger.LogInformation("Project build [{Name}] {Status}", def.Name, status);
|
||||
return record;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onLine($"✖ Exception: {ex.Message}");
|
||||
record.Log.Add($"Exception: {ex.Message}");
|
||||
await history.CompleteBuildAsync(record, BuildStatus.Failed);
|
||||
logger.LogError(ex, "Project build [{Name}] threw.", def.Name);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
private (string exe, string args, string workDir) BuildDotnetCommand(string relativePath)
|
||||
{
|
||||
var fullPath = Path.Combine(RepoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
return ("dotnet", $"build \"{fullPath}\" --configuration Release --nologo", RepoRoot);
|
||||
}
|
||||
|
||||
private (string exe, string args, string workDir) BuildNpmCommand(string relativePath)
|
||||
{
|
||||
var workDir = Path.Combine(RepoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
// npm on Windows needs cmd /c
|
||||
return (OperatingSystem.IsWindows() ? "cmd" : "sh",
|
||||
OperatingSystem.IsWindows() ? "/c npm run build" : "-c \"npm run build\"",
|
||||
workDir);
|
||||
}
|
||||
}
|
||||
|
||||
public record ProjectDefinition(string Name, BuildKind Kind, string RelativePath);
|
||||
@@ -0,0 +1,283 @@
|
||||
using ControlPlane.Core.Models;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ControlPlane.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Handles all git operations for the promotion workflow:
|
||||
/// branch status, diff summaries, merge + push, and promotion history persistence.
|
||||
/// All git commands run against the repo root configured in Docker:RepoRoot.
|
||||
/// </summary>
|
||||
public class PromotionService(IConfiguration config, ILogger<PromotionService> logger)
|
||||
{
|
||||
// The ordered promotion ladder — each step is a valid promotion.
|
||||
public static readonly string[] Ladder = ["develop", "staging", "uat", "master"];
|
||||
|
||||
private string RepoRoot => config["Docker:RepoRoot"] ?? string.Empty;
|
||||
|
||||
private static readonly SemaphoreSlim _lock = new(1, 1);
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
// ── Branch status ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns status for all ladder branches: last commit info + ahead/behind counts vs next branch.
|
||||
/// </summary>
|
||||
public async Task<List<BranchStatus>> GetLadderStatusAsync(CancellationToken ct = default)
|
||||
{
|
||||
var result = new List<BranchStatus>();
|
||||
|
||||
// Fetch to get up-to-date remote state, but don't fail if we're offline
|
||||
await RunGitAsync("fetch --all --quiet", ct, swallowErrors: true);
|
||||
|
||||
foreach (var branch in Ladder)
|
||||
{
|
||||
var exists = await BranchExistsAsync(branch, ct);
|
||||
if (!exists)
|
||||
{
|
||||
result.Add(new BranchStatus(branch, false, null, null, 0, 0, []));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Last commit on this branch
|
||||
var lastCommit = await GitOutputAsync($"log {branch} -1 --format=%h|%an|%ad|%s --date=short", ct);
|
||||
string? shortHash = null, author = null, date = null, subject = null;
|
||||
if (!string.IsNullOrWhiteSpace(lastCommit))
|
||||
{
|
||||
var p = lastCommit.Trim().Split('|', 4);
|
||||
if (p.Length == 4) (shortHash, author, date, subject) = (p[0], p[1], p[2], p[3]);
|
||||
}
|
||||
|
||||
// Ahead/behind vs the NEXT branch in the ladder
|
||||
int ahead = 0, behind = 0;
|
||||
var nextIdx = Array.IndexOf(Ladder, branch) + 1;
|
||||
if (nextIdx < Ladder.Length)
|
||||
{
|
||||
var next = Ladder[nextIdx];
|
||||
if (await BranchExistsAsync(next, ct))
|
||||
{
|
||||
var counts = await GitOutputAsync($"rev-list --left-right --count {next}...{branch}", ct);
|
||||
if (!string.IsNullOrWhiteSpace(counts))
|
||||
{
|
||||
var parts = counts.Trim().Split('\t');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
int.TryParse(parts[0], out behind);
|
||||
int.TryParse(parts[1], out ahead);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unreleased commit summaries (commits in this branch not yet in next)
|
||||
string[] unreleasedLines = [];
|
||||
if (ahead > 0 && nextIdx < Ladder.Length && await BranchExistsAsync(Ladder[nextIdx], ct))
|
||||
{
|
||||
var log = await GitOutputAsync($"log {Ladder[nextIdx]}..{branch} --oneline --no-decorate", ct);
|
||||
unreleasedLines = log.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
result.Add(new BranchStatus(branch, true, shortHash, $"{author} · {date} · {subject}",
|
||||
ahead, behind, unreleasedLines));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Promotion ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Merges <paramref name="from"/> into <paramref name="to"/> with a no-fast-forward merge commit,
|
||||
/// then pushes. Streams progress lines to <paramref name="onLine"/>.
|
||||
/// </summary>
|
||||
public async Task<PromotionRequest> PromoteAsync(
|
||||
string from,
|
||||
string to,
|
||||
string requestedBy,
|
||||
string? note,
|
||||
Action<string> onLine,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!await _lock.WaitAsync(TimeSpan.Zero, ct))
|
||||
{
|
||||
var busy = new PromotionRequest { FromBranch = from, ToBranch = to, Status = PromotionStatus.Failed };
|
||||
busy.Log.Add("⚠️ Another promotion is already in progress.");
|
||||
return busy;
|
||||
}
|
||||
|
||||
var req = new PromotionRequest
|
||||
{
|
||||
FromBranch = from,
|
||||
ToBranch = to,
|
||||
RequestedBy = requestedBy,
|
||||
Note = note,
|
||||
Status = PromotionStatus.Running,
|
||||
};
|
||||
|
||||
void Log(string line) { req.Log.Add(line); onLine(line); }
|
||||
|
||||
try
|
||||
{
|
||||
Log($"▶ Promoting {from} → {to}");
|
||||
if (!string.IsNullOrWhiteSpace(note)) Log($" Note: {note}");
|
||||
Log("──────────────────────────────────────");
|
||||
|
||||
// 1. Fetch latest
|
||||
Log(" git fetch --all");
|
||||
await RunGitAsync("fetch --all --quiet", ct);
|
||||
|
||||
// 2. Checkout target branch
|
||||
Log($" git checkout {to}");
|
||||
await RunGitAsync($"checkout {to}", ct);
|
||||
|
||||
// 3. Pull target to latest
|
||||
Log($" git pull origin {to}");
|
||||
await RunGitAsync($"pull origin {to} --quiet", ct);
|
||||
|
||||
// 4. Count commits being promoted
|
||||
var logOutput = await GitOutputAsync($"log {to}..{from} --oneline --no-decorate", ct);
|
||||
var commitLines = logOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
req.CommitCount = commitLines.Length;
|
||||
req.CommitLines = commitLines;
|
||||
Log($" Merging {commitLines.Length} commit(s) from {from}:");
|
||||
foreach (var cl in commitLines) Log($" {cl}");
|
||||
|
||||
// 5. Merge with --no-ff for a clean promotion commit
|
||||
var mergeMsg = $"chore: promote {from} → {to}" + (note != null ? $" — {note}" : "");
|
||||
Log($" git merge --no-ff {from}");
|
||||
await RunGitAsync($"merge --no-ff {from} -m \"{mergeMsg}\"", ct);
|
||||
|
||||
// 6. Push
|
||||
Log($" git push origin {to}");
|
||||
await RunGitAsync($"push origin {to}", ct);
|
||||
|
||||
// 7. Return to develop so the working tree stays clean
|
||||
await RunGitAsync("checkout develop", ct, swallowErrors: true);
|
||||
|
||||
Log("──────────────────────────────────────");
|
||||
Log($"✔ {from} → {to} promoted successfully at {DateTimeOffset.UtcNow:u}");
|
||||
req.Status = PromotionStatus.Succeeded;
|
||||
req.CompletedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"✖ Promotion failed: {ex.Message}");
|
||||
req.Status = PromotionStatus.Failed;
|
||||
req.CompletedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Try to abort any broken merge state
|
||||
await RunGitAsync("merge --abort", ct, swallowErrors: true);
|
||||
await RunGitAsync("checkout develop", ct, swallowErrors: true);
|
||||
|
||||
logger.LogError(ex, "Promotion {From}→{To} failed", from, to);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await SaveAsync(req);
|
||||
_lock.Release();
|
||||
}
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
// ── History persistence ──────────────────────────────────────────────────
|
||||
|
||||
private string HistoryPath
|
||||
{
|
||||
get
|
||||
{
|
||||
var folder = config["ClientAssets__Folder"] ?? config["ClientAssets:Folder"]
|
||||
?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "ClientAssets"));
|
||||
Directory.CreateDirectory(folder);
|
||||
return Path.Combine(folder, "promotions.json");
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly SemaphoreSlim _fileLock = new(1, 1);
|
||||
|
||||
private async Task SaveAsync(PromotionRequest req)
|
||||
{
|
||||
await _fileLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var all = LoadHistory();
|
||||
var idx = all.FindIndex(r => r.Id == req.Id);
|
||||
if (idx >= 0) all[idx] = req; else all.Insert(0, req);
|
||||
if (all.Count > 100) all = all[..100];
|
||||
await File.WriteAllTextAsync(HistoryPath, JsonSerializer.Serialize(all, JsonOpts));
|
||||
}
|
||||
finally { _fileLock.Release(); }
|
||||
}
|
||||
|
||||
public async Task<List<PromotionRequest>> GetHistoryAsync()
|
||||
{
|
||||
await _fileLock.WaitAsync();
|
||||
try { return LoadHistory(); }
|
||||
finally { _fileLock.Release(); }
|
||||
}
|
||||
|
||||
private List<PromotionRequest> LoadHistory()
|
||||
{
|
||||
if (!File.Exists(HistoryPath)) return [];
|
||||
try { return JsonSerializer.Deserialize<List<PromotionRequest>>(File.ReadAllText(HistoryPath), JsonOpts) ?? []; }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
// ── Git helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<bool> BranchExistsAsync(string branch, CancellationToken ct)
|
||||
{
|
||||
var output = await GitOutputAsync($"branch --list {branch}", ct);
|
||||
return !string.IsNullOrWhiteSpace(output);
|
||||
}
|
||||
|
||||
private async Task<string> GitOutputAsync(string args, CancellationToken ct)
|
||||
{
|
||||
var psi = MakePsi(args);
|
||||
using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start git");
|
||||
var output = await proc.StandardOutput.ReadToEndAsync(ct);
|
||||
await proc.WaitForExitAsync(ct);
|
||||
return output;
|
||||
}
|
||||
|
||||
private async Task RunGitAsync(string args, CancellationToken ct, bool swallowErrors = false)
|
||||
{
|
||||
var psi = MakePsi(args);
|
||||
using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start git");
|
||||
var stderr = await proc.StandardError.ReadToEndAsync(ct);
|
||||
await proc.WaitForExitAsync(ct);
|
||||
|
||||
if (!swallowErrors && proc.ExitCode != 0)
|
||||
throw new InvalidOperationException($"git {args} exited {proc.ExitCode}: {stderr.Trim()}");
|
||||
|
||||
logger.LogDebug("git {Args} → exit {Code}", args, proc.ExitCode);
|
||||
}
|
||||
|
||||
private ProcessStartInfo MakePsi(string args) => new("git", args)
|
||||
{
|
||||
WorkingDirectory = RepoRoot,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Current status of a single branch in the promotion ladder.</summary>
|
||||
public record BranchStatus(
|
||||
string Branch,
|
||||
bool Exists,
|
||||
string? ShortHash,
|
||||
string? LastCommitSummary,
|
||||
int AheadOfNext, // commits this branch has that the next doesn't
|
||||
int BehindNext, // commits next has that this branch doesn't (shouldn't happen in clean flow)
|
||||
string[] UnreleasedLines // oneline log of the ahead commits
|
||||
);
|
||||
@@ -0,0 +1,191 @@
|
||||
using ControlPlane.Core.Models;
|
||||
using ControlPlane.Core.Services;
|
||||
using Docker.DotNet;
|
||||
using Docker.DotNet.Models;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ControlPlane.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates a release: finds all managed tenant containers matching the target
|
||||
/// environment, removes each one, and restarts it from the latest clarity-server image.
|
||||
/// Does NOT re-run Keycloak/Vault/DB steps — the container env vars are preserved from
|
||||
/// the original provisioning and re-injected from the XML registry.
|
||||
/// </summary>
|
||||
public class ReleaseService(
|
||||
IConfiguration config,
|
||||
TenantRegistryService registry,
|
||||
BuildHistoryService history,
|
||||
ILogger<ReleaseService> logger)
|
||||
{
|
||||
private static readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
public bool IsReleasing => _lock.CurrentCount == 0;
|
||||
public string ImageName => config["Docker:ClarityServerImage"] ?? "clarity-server:latest";
|
||||
|
||||
/// <summary>
|
||||
/// Runs a release for the given environment and streams status lines to <paramref name="onLine"/>.
|
||||
/// </summary>
|
||||
public async Task<ReleaseRecord> ReleaseAsync(
|
||||
string targetEnv,
|
||||
Action<string> onLine,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!await _lock.WaitAsync(TimeSpan.Zero, ct))
|
||||
{
|
||||
onLine("⚠️ A release is already in progress.");
|
||||
var blocked = new ReleaseRecord
|
||||
{
|
||||
Environment = targetEnv,
|
||||
ImageName = ImageName,
|
||||
Status = ReleaseStatus.Failed,
|
||||
FinishedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
blocked.Tenants.Add(new TenantReleaseResult
|
||||
{
|
||||
Subdomain = "*", ContainerName = "*",
|
||||
Success = false, Error = "Release already in progress.",
|
||||
});
|
||||
return blocked;
|
||||
}
|
||||
|
||||
var record = await history.CreateReleaseAsync(targetEnv, ImageName);
|
||||
|
||||
try
|
||||
{
|
||||
onLine($"▶ Release to [{targetEnv}] using {ImageName}");
|
||||
onLine("──────────────────────────────────────");
|
||||
|
||||
var socketUri = config["Docker:Socket"] ?? "npipe://./pipe/docker_engine";
|
||||
using var docker = new DockerClientConfiguration(new Uri(socketUri)).CreateClient();
|
||||
|
||||
// Find all managed tenant containers for this environment
|
||||
var filterEnv = targetEnv == "all"
|
||||
? new Dictionary<string, IDictionary<string, bool>>
|
||||
{
|
||||
["label"] = new Dictionary<string, bool> { ["clarity.managed=true"] = true },
|
||||
}
|
||||
: new Dictionary<string, IDictionary<string, bool>>
|
||||
{
|
||||
["label"] = new Dictionary<string, bool>
|
||||
{
|
||||
["clarity.managed=true"] = true,
|
||||
[$"clarity.env={targetEnv}"] = true,
|
||||
},
|
||||
};
|
||||
|
||||
var containers = await docker.Containers.ListContainersAsync(
|
||||
new ContainersListParameters { All = true, Filters = filterEnv }, ct);
|
||||
|
||||
if (containers.Count == 0)
|
||||
{
|
||||
onLine($" No managed containers found for environment [{targetEnv}].");
|
||||
record.Status = ReleaseStatus.Succeeded;
|
||||
record.FinishedAt = DateTimeOffset.UtcNow;
|
||||
await history.UpdateReleaseAsync(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
onLine($" Found {containers.Count} container(s) to redeploy.");
|
||||
onLine("");
|
||||
|
||||
int succeeded = 0, failed = 0;
|
||||
|
||||
foreach (var container in containers)
|
||||
{
|
||||
var name = container.Names.FirstOrDefault()?.TrimStart('/') ?? container.ID[..12];
|
||||
var tenantResult = new TenantReleaseResult
|
||||
{
|
||||
ContainerName = name,
|
||||
Subdomain = container.Labels.TryGetValue("clarity.subdomain", out var sub) ? sub : name,
|
||||
};
|
||||
record.Tenants.Add(tenantResult);
|
||||
|
||||
try
|
||||
{
|
||||
onLine($" → {name}");
|
||||
|
||||
// Read env vars from existing container — preserve Keycloak/Vault/DB config
|
||||
var inspect = await docker.Containers.InspectContainerAsync(container.ID, ct);
|
||||
var env = inspect.Config.Env;
|
||||
var labels = inspect.Config.Labels;
|
||||
var network = inspect.HostConfig.NetworkMode;
|
||||
|
||||
// Stop and remove old container
|
||||
onLine($" Stopping...");
|
||||
try
|
||||
{
|
||||
await docker.Containers.StopContainerAsync(
|
||||
container.ID, new ContainerStopParameters { WaitBeforeKillSeconds = 8 }, ct);
|
||||
await docker.Containers.RemoveContainerAsync(
|
||||
container.ID, new ContainerRemoveParameters { Force = true }, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Stop/remove failed for {Name}, forcing removal.", name);
|
||||
await docker.Containers.RemoveContainerAsync(
|
||||
container.ID, new ContainerRemoveParameters { Force = true }, ct);
|
||||
}
|
||||
|
||||
// Create fresh container from latest image, preserving all env vars and labels
|
||||
onLine($" Creating from {ImageName}...");
|
||||
var created = await docker.Containers.CreateContainerAsync(
|
||||
new CreateContainerParameters
|
||||
{
|
||||
Name = name,
|
||||
Image = ImageName,
|
||||
Env = env,
|
||||
Labels = labels,
|
||||
HostConfig = new HostConfig
|
||||
{
|
||||
NetworkMode = network,
|
||||
RestartPolicy = new RestartPolicy { Name = RestartPolicyKind.UnlessStopped },
|
||||
},
|
||||
}, ct);
|
||||
|
||||
// Start it
|
||||
var started = await docker.Containers.StartContainerAsync(created.ID, null, ct);
|
||||
if (!started) throw new InvalidOperationException("Docker returned false for start.");
|
||||
|
||||
onLine($" ✔ {name} redeployed.");
|
||||
tenantResult.Success = true;
|
||||
succeeded++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to redeploy {Name}.", name);
|
||||
onLine($" ✖ {name} failed: {ex.Message}");
|
||||
tenantResult.Success = false;
|
||||
tenantResult.Error = ex.Message;
|
||||
failed++;
|
||||
}
|
||||
|
||||
await history.UpdateReleaseAsync(record);
|
||||
}
|
||||
|
||||
record.Status = failed == 0 ? ReleaseStatus.Succeeded
|
||||
: succeeded == 0 ? ReleaseStatus.Failed
|
||||
: ReleaseStatus.PartialFailure;
|
||||
record.FinishedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
onLine("");
|
||||
onLine("──────────────────────────────────────");
|
||||
onLine($"{(record.Status == ReleaseStatus.Succeeded ? "✔" : "⚠")} Release complete — {succeeded} succeeded, {failed} failed.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Release to [{Env}] threw an unhandled exception.", targetEnv);
|
||||
record.Status = ReleaseStatus.Failed;
|
||||
record.FinishedAt = DateTimeOffset.UtcNow;
|
||||
onLine($"✖ Release aborted: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await history.UpdateReleaseAsync(record);
|
||||
_lock.Release();
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using ControlPlane.Core.Messages;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace ControlPlane.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin in-process pub/sub for SSE. MassTransit consumer writes here;
|
||||
/// the SSE endpoint reads and streams to the browser.
|
||||
/// </summary>
|
||||
public sealed class SseEventBus
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, List<Channel<ProvisioningProgressEvent>>> _subs = new();
|
||||
|
||||
public void Publish(ProvisioningProgressEvent evt)
|
||||
{
|
||||
if (!_subs.TryGetValue(evt.JobId, out var channels)) return;
|
||||
lock (channels)
|
||||
foreach (var ch in channels)
|
||||
ch.Writer.TryWrite(evt);
|
||||
}
|
||||
|
||||
public Channel<ProvisioningProgressEvent> Subscribe(Guid jobId)
|
||||
{
|
||||
var ch = Channel.CreateUnbounded<ProvisioningProgressEvent>();
|
||||
_subs.GetOrAdd(jobId, _ => []).Add(ch);
|
||||
return ch;
|
||||
}
|
||||
|
||||
public void Unsubscribe(Guid jobId, Channel<ProvisioningProgressEvent> channel)
|
||||
{
|
||||
if (_subs.TryGetValue(jobId, out var channels))
|
||||
{
|
||||
lock (channels) channels.Remove(channel);
|
||||
channel.Writer.TryComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace ControlPlane.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a gzipped tar stream from a directory, respecting .dockerignore rules.
|
||||
/// Used to supply the Docker build context to the Docker SDK.
|
||||
/// </summary>
|
||||
internal static class TarHelper
|
||||
{
|
||||
private static readonly string[] DefaultIgnore =
|
||||
[
|
||||
".git", ".vs", ".vscode", "node_modules", "bin", "obj",
|
||||
"VaultData", "*.user", "*.suo",
|
||||
];
|
||||
|
||||
public static void Pack(string root, Stream destination)
|
||||
{
|
||||
var ignorePatterns = LoadDockerIgnore(root);
|
||||
|
||||
using var gz = new GZipStream(destination, CompressionLevel.Fastest, leaveOpen: true);
|
||||
using var tar = new TarWriter(gz, TarEntryFormat.Gnu, leaveOpen: false);
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(root, file).Replace('\\', '/');
|
||||
|
||||
if (ShouldIgnore(relative, ignorePatterns))
|
||||
continue;
|
||||
|
||||
var entry = new GnuTarEntry(TarEntryType.RegularFile, relative)
|
||||
{
|
||||
DataStream = File.OpenRead(file),
|
||||
};
|
||||
tar.WriteEntry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> LoadDockerIgnore(string root)
|
||||
{
|
||||
var path = Path.Combine(root, ".dockerignore");
|
||||
var patterns = new List<string>(DefaultIgnore);
|
||||
if (!File.Exists(path)) return patterns;
|
||||
|
||||
foreach (var line in File.ReadAllLines(path))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (!string.IsNullOrEmpty(trimmed) && !trimmed.StartsWith('#'))
|
||||
patterns.Add(trimmed);
|
||||
}
|
||||
return patterns;
|
||||
}
|
||||
|
||||
private static bool ShouldIgnore(string relativePath, List<string> patterns)
|
||||
{
|
||||
var segments = relativePath.Split('/');
|
||||
|
||||
foreach (var pattern in patterns)
|
||||
{
|
||||
var p = pattern.TrimStart('/').TrimEnd('/');
|
||||
|
||||
// Glob suffix match (e.g. *.user)
|
||||
if (p.StartsWith('*'))
|
||||
{
|
||||
if (relativePath.EndsWith(p[1..], StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Exact full-path match or root-anchored prefix (e.g. .git, .vs)
|
||||
if (relativePath.Equals(p, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
if (relativePath.StartsWith(p + "/", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
// Match any path segment so that nested bin/, obj/, node_modules/ etc. are caught
|
||||
// regardless of which project subdirectory they live in.
|
||||
if (segments.Any(seg => seg.Equals(p, StringComparison.OrdinalIgnoreCase)))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"OpenRouter": {
|
||||
"ApiKey": "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"
|
||||
},
|
||||
"Git": {
|
||||
"RepoRoot": "C:\\Users\\amadzarak\\source\\repos\\Clarity"
|
||||
},
|
||||
"Gitea": {
|
||||
"BaseUrl": "https://opc.clarity.test",
|
||||
"Owner": "Clarity",
|
||||
"Repo": "Clarity",
|
||||
"Token": "2ef325f682915c5959bf6a0dc73cec7034fcd2a2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using Scalar.Aspire;
|
||||
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Platform infrastructure (Keycloak, Vault, MinIO, Nginx, Dnsmasq) is
|
||||
// managed by infra/docker-compose.yml — NOT Aspire.
|
||||
// Run `docker compose up -d` from the infra/ folder before starting this host.
|
||||
//
|
||||
// Fixed dev URLs (hardcoded to match infra/docker-compose.yml):
|
||||
// Keycloak → http://localhost:8080
|
||||
// Vault → http://localhost:8200
|
||||
// MinIO → http://localhost:9000
|
||||
//
|
||||
// ControlPlane owns: opc-postgres (opcdb + giteadb), RabbitMQ, Gitea.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Shared paths
|
||||
var clientAssetsPath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "ClientAssets"));
|
||||
var nginxConfDPath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "infra", "nginx", "conf.d"));
|
||||
var vaultKeysFile = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "infra", "vault", "data", "init.json"));
|
||||
|
||||
#region CONTROLPLANE POSTGRES
|
||||
// ControlPlane owns this — isolated from platform infra postgres.
|
||||
// Override via: dotnet user-secrets set "Parameters:cp-postgres-password" "yourpassword"
|
||||
var cpPostgresPassword = builder.AddParameter("cp-postgres-password", "controlplane-dev", secret: true);
|
||||
var cpPostgres = builder.AddPostgres("opc-postgres", password: cpPostgresPassword)
|
||||
.WithLifetime(ContainerLifetime.Persistent)
|
||||
.WithDataVolume("opc-postgres-data")
|
||||
.WithHostPort(5433)
|
||||
.WithPgAdmin();
|
||||
|
||||
var controlPlaneDb = cpPostgres.AddDatabase("opcdb");
|
||||
var giteaDb = cpPostgres.AddDatabase("giteadb");
|
||||
#endregion
|
||||
|
||||
#region GITEA
|
||||
// Gitea is ControlPlane's code management component — owns its own DB on opc-postgres.
|
||||
var gitea = builder.AddContainer("gitea", "gitea/gitea", "latest")
|
||||
.WithHttpEndpoint(port: 3000, targetPort: 3000, name: "http")
|
||||
.WithEndpoint(port: 2222, targetPort: 22, name: "ssh")
|
||||
.WithVolume("clarity-gitea-data", "/data")
|
||||
.WithEnvironment("GITEA__database__DB_TYPE", "postgres")
|
||||
.WithEnvironment("GITEA__database__HOST", "host.docker.internal:5433")
|
||||
.WithEnvironment("GITEA__database__NAME", "giteadb")
|
||||
.WithEnvironment("GITEA__database__USER", "postgres")
|
||||
.WithEnvironment("GITEA__database__PASSWD", "controlplane-dev")
|
||||
.WithEnvironment("GITEA__server__DOMAIN", "opc.clarity.test")
|
||||
.WithEnvironment("GITEA__server__ROOT_URL", "http://opc.clarity.test")
|
||||
.WithEnvironment("GITEA__server__SSH_DOMAIN", "opc.clarity.test")
|
||||
.WithEnvironment("GITEA__server__SSH_PORT", "2222")
|
||||
.WithEnvironment("GITEA__service__DISABLE_REGISTRATION", "true")
|
||||
.WaitFor(giteaDb)
|
||||
.WithLifetime(ContainerLifetime.Persistent);
|
||||
#endregion
|
||||
|
||||
#region RABBITMQ
|
||||
var rabbitPassword = builder.AddParameter("rabbitmq-password", "clarity-rabbit", secret: true);
|
||||
var rabbit = builder.AddRabbitMQ("rabbitmq", password: rabbitPassword)
|
||||
.WithLifetime(ContainerLifetime.Persistent)
|
||||
.WithManagementPlugin();
|
||||
#endregion
|
||||
|
||||
#region CONTROLPLANE API
|
||||
var api = builder.AddProject<Projects.ControlPlane_Api>("controlplane-api")
|
||||
.WithReference(rabbit)
|
||||
.WaitFor(rabbit)
|
||||
.WithReference(controlPlaneDb)
|
||||
.WaitFor(controlPlaneDb)
|
||||
.WithEnvironment("Gitea__BaseUrl", gitea.GetEndpoint("http"))
|
||||
.WithEnvironment("ClientAssets__Folder", clientAssetsPath)
|
||||
.WithEnvironment("Docker__RepoRoot", builder.AppHostDirectory.Replace("ControlPlane.AppHost", "").TrimEnd('\\', '/'))
|
||||
.WithExternalHttpEndpoints();
|
||||
#endregion
|
||||
|
||||
#region PROVISIONING WORKER
|
||||
builder.AddProject<Projects.ControlPlane_Worker>("controlplane-worker")
|
||||
.WithReference(rabbit)
|
||||
.WaitFor(rabbit)
|
||||
// Vault — fixed dev address from infra/docker-compose.yml
|
||||
.WithEnvironment("Vault__Address", "http://localhost:8200")
|
||||
.WithEnvironment("Vault__ContainerAddress", "http://vault:8200")
|
||||
.WithEnvironment("Vault__KeysFile", vaultKeysFile)
|
||||
// Keycloak — fixed dev address from infra/docker-compose.yml
|
||||
.WithEnvironment("Keycloak__AuthServerUrl", "http://localhost:8080")
|
||||
.WithEnvironment("Keycloak__ContainerUrl", "https://keycloak.clarity.test")
|
||||
.WithEnvironment("Keycloak__Realm", "master")
|
||||
.WithEnvironment("Keycloak__Resource", "admin-cli")
|
||||
.WithEnvironment("Keycloak__AdminUser", "admin")
|
||||
.WithEnvironment("Keycloak__AdminPassword", "Admin1234!")
|
||||
// Gateway
|
||||
.WithEnvironment("Gateway__TenantBaseUrl", "https://{subdomain}.clarity.test")
|
||||
// ClarityInfraOptions
|
||||
.WithEnvironment("Clarity__Domain", "clarity.test")
|
||||
.WithEnvironment("Clarity__Network", "clarity-net")
|
||||
.WithEnvironment("Clarity__KeycloakPublicUrl", "https://keycloak.clarity.test")
|
||||
.WithEnvironment("Clarity__KeycloakInternalUrl", "http://keycloak:8080")
|
||||
.WithEnvironment("Clarity__VaultInternalUrl", "http://vault:8200")
|
||||
.WithEnvironment("Clarity__NginxCertPath", "/etc/nginx/certs/clarity.test.crt")
|
||||
.WithEnvironment("Clarity__NginxCertKeyPath", "/etc/nginx/certs/clarity.test.key")
|
||||
// Nginx conf.d — points to infra/nginx/conf.d so platform nginx picks up tenant configs
|
||||
.WithEnvironment("Nginx__ConfDPath", nginxConfDPath)
|
||||
.WithEnvironment("ClientAssets__Folder", clientAssetsPath)
|
||||
// Platform Postgres connection string for tenant database provisioning (infra/docker-compose.yml)
|
||||
.WithEnvironment("ConnectionStrings__platformdb",
|
||||
"Host=localhost;Port=5432;Username=postgres;Password=postgres")
|
||||
.WithReference(controlPlaneDb)
|
||||
.WaitFor(controlPlaneDb);
|
||||
#endregion
|
||||
|
||||
#region CONTROLPLANE UI
|
||||
builder.AddViteApp("controlplane-ui", "../clarity.controlplane")
|
||||
.WithReference((IResourceBuilder<IResourceWithServiceDiscovery>)api)
|
||||
.WaitFor(api);
|
||||
#endregion
|
||||
|
||||
#region CLARITY-NET — connect RabbitMQ to platform network
|
||||
// Ensures RabbitMQ (the one container Aspire owns) is reachable from tenant containers
|
||||
// on clarity-net. All other platform containers are already on clarity-net via docker-compose.
|
||||
builder.Eventing.Subscribe<AfterResourcesCreatedEvent>(async (@event, ct) =>
|
||||
{
|
||||
const string network = "clarity-net";
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(4), ct);
|
||||
|
||||
var (inspectCode, _) = await DockerOutputAsync($"network inspect {network}", ct);
|
||||
if (inspectCode != 0)
|
||||
await DockerOutputAsync($"network create {network}", ct);
|
||||
|
||||
var (idCode, idOut) = await DockerOutputAsync("ps --filter name=rabbitmq --format {{.ID}}", ct);
|
||||
if (idCode == 0 && !string.IsNullOrWhiteSpace(idOut))
|
||||
{
|
||||
var containerId = idOut.Trim().Split('\n')[0].Trim();
|
||||
await DockerOutputAsync($"network connect --alias rabbitmq {network} {containerId}", ct);
|
||||
}
|
||||
});
|
||||
#endregion
|
||||
|
||||
#region SCALAR API DOCS
|
||||
var scalar = builder.AddScalarApiReference();
|
||||
scalar.WithApiReference(api);
|
||||
#endregion
|
||||
|
||||
builder.Build().Run();
|
||||
|
||||
static async Task<(int ExitCode, string Output)> DockerOutputAsync(string args, CancellationToken ct)
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo("docker", args)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
using var proc = System.Diagnostics.Process.Start(psi)!;
|
||||
var output = await proc.StandardOutput.ReadToEndAsync(ct);
|
||||
await proc.WaitForExitAsync(ct);
|
||||
return (proc.ExitCode, output);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Aspire.AppHost.Sdk/13.2.2">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>controlplane-apphost-$(MSBuildProjectName)</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ControlPlane.Api\ControlPlane.Api.csproj" />
|
||||
<ProjectReference Include="..\ControlPlane.Worker\ControlPlane.Worker.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.JavaScript" />
|
||||
<PackageReference Include="Aspire.Hosting.PostgreSQL" />
|
||||
<PackageReference Include="Aspire.Hosting.Keycloak" />
|
||||
<PackageReference Include="Aspire.Hosting.RabbitMQ" />
|
||||
<PackageReference Include="CommunityToolkit.Aspire.Hosting.Minio" />
|
||||
<PackageReference Include="Scalar.Aspire" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="KeycloakConfig\realm-export.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="VaultConfig\vault.hcl">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="VaultConfig\entrypoint.sh">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,17 @@
|
||||
# Resolve all *.clarity.local subdomains to the loopback address.
|
||||
# nginx (bound to port 80 on the host) then routes by subdomain to the correct tenant container.
|
||||
address=/.clarity.test/127.0.0.1
|
||||
|
||||
# Don't read /etc/resolv.conf or /etc/hosts from the container — we are the resolver
|
||||
no-resolv
|
||||
no-hosts
|
||||
|
||||
# Forward everything that isn't clarity.local to Cloudflare DNS
|
||||
server=1.1.1.1
|
||||
server=8.8.8.8
|
||||
|
||||
# Listen on all interfaces inside the container
|
||||
listen-address=0.0.0.0
|
||||
|
||||
# Log queries — useful during initial setup, can be removed later
|
||||
log-queries
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDGzCCAgOgAwIBAgIUS0kgcdXIrlOk/K6g2bfLDRycqk8wDQYJKoZIhvcNAQEL
|
||||
BQAwGTEXMBUGA1UEAwwOKi5jbGFyaXR5LnRlc3QwHhcNMjYwNDI0MjIwMDUzWhcN
|
||||
MjgwNzI3MjIwMDUzWjAZMRcwFQYDVQQDDA4qLmNsYXJpdHkudGVzdDCCASIwDQYJ
|
||||
KoZIhvcNAQEBBQADggEPADCCAQoCggEBAMWAJ62tsrnMaMnF3NR2Yfv1LKS9IRfm
|
||||
sTtTWba7D8fcs9JXGlEn+vMa10AjV91yaSQoQdwLCOwkF58CmLBs0K+vvPoLgvcZ
|
||||
BQxVrBj0t1YlTwLcez8vEgb2tHKGo914T/YLh+clF8oig9tIIiTNbngUGabpWUym
|
||||
vPllDQ8nB0m4IkHbMAhgdDUG9X5Vc/lWHW6gxhRiUQt7HLqWJ2lLleQR5qEqRQx+
|
||||
RmtseS11jhzwDYf1VVzQ2AE2tUaq82p0cZAF8uFZnESuv1Hcu+1KBfjCaGXJ/485
|
||||
gg1q01sYhAkX0LAK/CqRBOd7zp9cDm3NX0tLBj4Gek6h0kFGkmRtAmcCAwEAAaNb
|
||||
MFkwHQYDVR0OBBYEFJNI82Atz7k2pa2IZECO9aG30dnHMA8GA1UdEwEB/wQFMAMB
|
||||
Af8wJwYDVR0RBCAwHoIOKi5jbGFyaXR5LnRlc3SCDGNsYXJpdHkudGVzdDANBgkq
|
||||
hkiG9w0BAQsFAAOCAQEAO5MyjFXcOZeEwPJRel8Mvg1HRwu97tL/BB9Hb13JWzdx
|
||||
FBBqwOdRrG8IB7byXLjH1ng4xMM+WI9yeZ29bV/PcrZwermGNzU+ob1SrvJYh0hb
|
||||
sX0zeXKjKDGMsdlyZAERnvGOxlPzNtYRpeSD7h3qKtuzJiReCNdGzSh+2bLfxEIb
|
||||
wTJJNgnXRA4GGK5zghmzOEpq/w8sqpB4hLz9OK8a33QOKp79LrfyT1B9uZq4uHZ8
|
||||
SvTX89KZOGmUQraF/6QvL3CcMutwzf4unKxyaStflrcGjCn/eEe8Ea3IWL1EwU8K
|
||||
9JvyDvWgv7oib7FA2BZGbYvT+wsFjiFBzTcWUX132g==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDFgCetrbK5zGjJ
|
||||
xdzUdmH79SykvSEX5rE7U1m2uw/H3LPSVxpRJ/rzGtdAI1fdcmkkKEHcCwjsJBef
|
||||
ApiwbNCvr7z6C4L3GQUMVawY9LdWJU8C3Hs/LxIG9rRyhqPdeE/2C4fnJRfKIoPb
|
||||
SCIkzW54FBmm6VlMprz5ZQ0PJwdJuCJB2zAIYHQ1BvV+VXP5Vh1uoMYUYlELexy6
|
||||
lidpS5XkEeahKkUMfkZrbHktdY4c8A2H9VVc0NgBNrVGqvNqdHGQBfLhWZxErr9R
|
||||
3LvtSgX4wmhlyf+POYINatNbGIQJF9CwCvwqkQTne86fXA5tzV9LSwY+BnpOodJB
|
||||
RpJkbQJnAgMBAAECggEAGc9MICXNb/t3DDtHxxorZuZc7bBrpTh4G9UiKb+badZ9
|
||||
R3UrksSDRobQ72hPALkFZXy/Upa8lUOINLb9pjyqLvNr4k9jz4/c+YYupdpBJUhd
|
||||
4XVXw+OOWwudfEP9ISGqbXCHU50k1T0adysfjyirkZSq34WqLlqx4nOit8K1cJwc
|
||||
5+jvApwOPz6zf9kFJYjybbUSPO8bFLVTpjs3hgUzaCMkYMn6R/5bR5SMeqCbZILB
|
||||
fkGm+KaeS3cIY7PhDhSoiWJUR5/ZsaoT5s1IM5aGTe62XVY5eoMixYEibx/e68XC
|
||||
eL3eWO304QU6AgMKHFhtTKFpnJHlyV/gu084/xWC7QKBgQD9lrkRgDDMXfuDtFRr
|
||||
LiQ3QFEmmj0m2ekHIpdZDY3rJ0bbQzTw4cqWs437qMKcTczK70mfxp/IjPoky+8i
|
||||
bSlm/pR+U/YwsgK0dxGLzHbIQYYQdI4BjBsysNOvxnKUxRciAMpIW5ULGKYUkCde
|
||||
dhH5c2Rmve0yq6MYJ8DCOTXCwwKBgQDHYOd50Tjw5i+a5wcHEsfY+r/Vsu1u1BrS
|
||||
/sdpJ+dKxx50TQO4F7tnrugwJ9cvxPDGQApDHFbIwn70zQuDNvYLD2CTtwHoJHx/
|
||||
wuP3p0Rw3DmhKI9CN0oXclqNV3PZ54PZ2M5HEl0zkpoIse4YtWc0uyO6RKVHHtPr
|
||||
jGjTKeZ/jQKBgAc7XinGmx2o7HxUDzhDR5sfxXCxY18RRdkDPoe2oD59j0K/hun7
|
||||
tnhXxIvRw0ML4PREoLfixTnF83hLLJWxwUWDqx5zLIk0+mjFIIX5HcYWQEmF2Wrn
|
||||
4PqwGklgAnKFsGQy25H2sqhvWoUpm0XRXi/b/5gCgJo6VNtiftfLI+JbAoGAC496
|
||||
3H1dJ9qw9/JdXfOg0tv3M5TkX4C87W8IcPh3WMai5Wtxw8Lcgu6JWAF3YLWyoEwm
|
||||
TC3gelOMuPUKrdkJ+yoxF1+NJMC410+dmEaCmWirjsSjSdua2DExPvDLLt9VrdP8
|
||||
YfKWpN7jP43RmG0sRspzD+HbE3yeHRJPIa9URiECgYEAyxOOXDCQSPifgIRZe5hr
|
||||
u+WsMukUypizXq36/ydCfMD7HcPOgO6bNkNsh6WlaaNrFQwR2O96V0BvrSAI242a
|
||||
bTEyUx7fTwoZmn/8O6/WIwkyYolixNYbClcAIopbOXxJ9bJ1KqS47mHv1RrQ8FqN
|
||||
OpJWMvrAktqNT5tjDeIj6mc=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,2 @@
|
||||
# Placeholder so the conf.d directory is tracked by git and exists at container mount time.
|
||||
# The provisioning worker writes per-tenant .conf files here at runtime.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Auto-generated by ControlPlane.Worker — do not edit manually.
|
||||
# Tenant: fdev-app-clarity-01000000
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name fdev-app-clarity-01000000.clarity.test;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/clarity.test.crt;
|
||||
ssl_certificate_key /etc/nginx/certs/clarity.test.key;
|
||||
|
||||
location / {
|
||||
# Docker DNS resolves the container name on the managed network
|
||||
set $upstream http://fdev-app-clarity-01000000:8080;
|
||||
proxy_pass $upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Auto-generated by ControlPlane.Worker — do not edit manually.
|
||||
# Tenant: fdev-app-clarity-02000000
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name fdev-app-clarity-02000000.clarity.test;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/clarity.test.crt;
|
||||
ssl_certificate_key /etc/nginx/certs/clarity.test.key;
|
||||
|
||||
location / {
|
||||
# Docker DNS resolves the container name on the managed network
|
||||
set $upstream http://fdev-app-clarity-02000000:8080;
|
||||
proxy_pass $upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Auto-generated by ControlPlane.Worker — do not edit manually.
|
||||
# Tenant: fdev-app-clarity-03000000
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name fdev-app-clarity-03000000.clarity.test;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/clarity.test.crt;
|
||||
ssl_certificate_key /etc/nginx/certs/clarity.test.key;
|
||||
|
||||
location / {
|
||||
# Docker DNS resolves the container name on the managed network
|
||||
set $upstream http://fdev-app-clarity-03000000:8080;
|
||||
proxy_pass $upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Auto-generated by ControlPlane.Worker — do not edit manually.
|
||||
# Tenant: fdev-app-clarity-04000000
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name fdev-app-clarity-04000000.clarity.test;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/clarity.test.crt;
|
||||
ssl_certificate_key /etc/nginx/certs/clarity.test.key;
|
||||
|
||||
location / {
|
||||
# Docker DNS resolves the container name on the managed network
|
||||
set $upstream http://fdev-app-clarity-04000000:8080;
|
||||
proxy_pass $upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name opc.clarity.test;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/clarity.test.crt;
|
||||
ssl_certificate_key /etc/nginx/certs/clarity.test.key;
|
||||
|
||||
# Git over HTTP needs larger body and longer timeouts
|
||||
client_max_body_size 100m;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
|
||||
location / {
|
||||
set $upstream http://gitea:3000;
|
||||
proxy_pass $upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name keycloak.clarity.test;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/clarity.test.crt;
|
||||
ssl_certificate_key /etc/nginx/certs/clarity.test.key;
|
||||
|
||||
location / {
|
||||
set $upstream http://keycloak:8080;
|
||||
proxy_pass $upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
# Use Docker's embedded DNS resolver so container names resolve dynamically.
|
||||
# This is critical — without it nginx resolves upstream names at startup only
|
||||
# and won't pick up newly provisioned tenant containers.
|
||||
resolver 127.0.0.11 valid=5s ipv6=off;
|
||||
|
||||
# Shared log format
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
# Redirect all HTTP → HTTPS
|
||||
server {
|
||||
listen 80 default_server;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# Pick up per-tenant server blocks dropped by the provisioning worker
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://controlplane.dev.localhost:17000;http://controlplane.dev.localhost:15000",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000",
|
||||
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:21001",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:21002"
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://controlplane.dev.localhost:15000",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:21000",
|
||||
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:21001",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:21002"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
KEYS_FILE="/vault/file/init.json"
|
||||
VAULT_ADDR="http://127.0.0.1:8200"
|
||||
export VAULT_ADDR
|
||||
|
||||
# Start Vault server in the background
|
||||
vault server -config=/vault/config/vault.hcl &
|
||||
VAULT_PID=$!
|
||||
|
||||
# Wait for Vault to be ready
|
||||
echo "[vault-init] Waiting for Vault to start..."
|
||||
until vault status > /dev/null 2>&1 || vault status 2>&1 | grep -q "Sealed\|Initialized"; do
|
||||
sleep 1
|
||||
done
|
||||
echo "[vault-init] Vault is up."
|
||||
|
||||
# Check if already initialised
|
||||
INIT_STATUS=$(vault status -format=json 2>/dev/null | grep '"initialized"' | grep -c "true" || true)
|
||||
|
||||
if [ "$INIT_STATUS" = "0" ]; then
|
||||
echo "[vault-init] First run — initialising Vault..."
|
||||
vault operator init -key-shares=1 -key-threshold=1 -format=json > "$KEYS_FILE"
|
||||
echo "[vault-init] Keys saved to $KEYS_FILE"
|
||||
fi
|
||||
|
||||
# Unseal using saved key
|
||||
UNSEAL_KEY=$(grep '"unseal_keys_b64"' "$KEYS_FILE" -A1 | grep '"' | tail -1 | tr -d ' ",' )
|
||||
ROOT_TOKEN=$(grep '"root_token"' "$KEYS_FILE" | sed 's/.*: *"\(.*\)".*/\1/')
|
||||
|
||||
echo "[vault-init] Unsealing..."
|
||||
vault operator unseal "$UNSEAL_KEY"
|
||||
echo "[vault-init] Vault is unsealed. Root token is stored in $KEYS_FILE"
|
||||
|
||||
# Authenticate and bootstrap Transit engine + master key (idempotent)
|
||||
export VAULT_TOKEN="$ROOT_TOKEN"
|
||||
|
||||
echo "[vault-init] Enabling Transit secrets engine..."
|
||||
vault secrets enable -path=clarity-transit transit 2>/dev/null || echo "[vault-init] clarity-transit already enabled."
|
||||
|
||||
echo "[vault-init] Creating master-key..."
|
||||
vault write -f clarity-transit/keys/master-key 2>/dev/null || echo "[vault-init] master-key already exists."
|
||||
|
||||
echo "[vault-init] Vault bootstrap complete."
|
||||
|
||||
# Keep container alive by waiting on the Vault process
|
||||
wait $VAULT_PID
|
||||
@@ -0,0 +1,13 @@
|
||||
storage "file" {
|
||||
path = "/vault/file"
|
||||
}
|
||||
|
||||
listener "tcp" {
|
||||
address = "0.0.0.0:8200"
|
||||
tls_disable = true
|
||||
}
|
||||
|
||||
ui = true
|
||||
disable_mlock = true
|
||||
|
||||
# Auto-unseal using a static shamir key — dev convenience only, never use in prod
|
||||
@@ -0,0 +1,19 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDDTCCAfWgAwIBAgIURU3028kH3veUBjTtDis5N5SYI9AwDQYJKoZIhvcNAQEL
|
||||
BQAwGTEXMBUGA1UEAwwOKi5sb2NhbHRlc3QubWUwHhcNMjYwNDI0MTYwNzU3WhcN
|
||||
MzYwNDIxMTYwNzU3WjAZMRcwFQYDVQQDDA4qLmxvY2FsdGVzdC5tZTCCASIwDQYJ
|
||||
KoZIhvcNAQEBBQADggEPADCCAQoCggEBALiZjuDCZ7uBicnk1ko6nlJIf/Zn2thr
|
||||
ArBA9FD1wtMm0tWMA66fQ+STlkTw2LOlsjIk9d4A3s7jGhVyAikLqylm8in3WVWT
|
||||
X4Ms5FB7lXqGEsuMI6Fq8l+Xw5boWE15XRGoOEPqaazfIvy4utF9Dk1TLXAv+Svv
|
||||
dTTek7phU3hzWxzOTdk9fVhHdYqJy0ZjaxJxyUbTDPRf+IHad/0iWWpZaRuP5QEz
|
||||
J0zujXEvJdFUVXOcPqSs0SdkaKqYbxegHwUK5ALQSVzH7CYHR4+Np6ChUw8+RFid
|
||||
b9dQH2pzm9h7iaKD58AWLLB/D2uHBnSPkOahWY8oizlNRxsSuY7/x4cCAwEAAaNN
|
||||
MEswJwYDVR0RBCAwHoIOKi5sb2NhbHRlc3QubWWCDGxvY2FsdGVzdC5tZTALBgNV
|
||||
HQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDQYJKoZIhvcNAQELBQADggEB
|
||||
ALZ/RP2JFDz4QODzy+ESg5DlgQQ3CTyDn9DwR8Pojzpq+MdJQ3+g48qsCS2FwR8W
|
||||
h18DCfeemrutGHGBcX6dNbjy43oFwbvdDEaK1/m82Rmr4F/u3AdpxJpXXGEBoO9O
|
||||
rg2+nXQEGFwZapUnAVGUB3Iihx5FRw1Rbi910aF6TN67Og6pUf/8Jut/M5TzAiDN
|
||||
scil2PpC2mWvHzGV+gBZT0lOpfo+dRlE+zzEBWt4WpZWj3bF+WbwzR2bsd2JGZsp
|
||||
OtV4ErupppsGYliKi2cJG9ceqG0zEc/hUtG2SfmZvfKOxZ2p0M6SXJDHueoAOkh1
|
||||
zu/AQ0cjPBLoOy6ahVHvg20=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC4mY7gwme7gYnJ
|
||||
5NZKOp5SSH/2Z9rYawKwQPRQ9cLTJtLVjAOun0Pkk5ZE8NizpbIyJPXeAN7O4xoV
|
||||
cgIpC6spZvIp91lVk1+DLORQe5V6hhLLjCOhavJfl8OW6FhNeV0RqDhD6mms3yL8
|
||||
uLrRfQ5NUy1wL/kr73U03pO6YVN4c1sczk3ZPX1YR3WKictGY2sScclG0wz0X/iB
|
||||
2nf9IllqWWkbj+UBMydM7o1xLyXRVFVznD6krNEnZGiqmG8XoB8FCuQC0Elcx+wm
|
||||
B0ePjaegoVMPPkRYnW/XUB9qc5vYe4mig+fAFiywfw9rhwZ0j5DmoVmPKIs5TUcb
|
||||
ErmO/8eHAgMBAAECggEAAnNe5AnCZXYCbBpQhv9XcG6BZgJRksJZd4D7Fm62G3XB
|
||||
T1pCs9IvwRujj8gsN6kIn1NI2xNOZWNZ7QpITovP6HOSRYbsElL34BXzQPiZT5gc
|
||||
ePtiR+0VQkt8vxf6lHNRWmDAPREQ3UxDs7zKhEqBCLzslXYkSH0892Ibf6nImF8w
|
||||
7meMsH4SxPFY16WBxWjyJNdy+TVw0BYFdPiUxE52PaIplgVZJqvmmuMUYcmOVale
|
||||
lXGeWGMdvFp3Tilbj2rpnJ5p7I5av59TmIzXon/bGguhYhwus+1e8rs3WYWqibHf
|
||||
bwB03kuGFaiSvuVncX3DvdBnvrz9tlCaipU+aciGUQKBgQDaQ378oDbmX1gk96/7
|
||||
3ZiU67Vqnone4X88SxiYOafwmT5NVnJYMjtbN775NCUK4aR7lYo2lodl9CW096UN
|
||||
Xic186jFGey3NoqCLoVodeFe/XscZMSS+TE5FLi4B4Ih/bgpcDzDQ8++5oRYiwWk
|
||||
Z1/GKOc8MxXhhZDf9wOhgWBfVwKBgQDYhBPbeJJaE3k7pREBF7abDERFbfruC4Xh
|
||||
181kCIZ3oMKGj4YKtIjoLnCocOAo/uhM9DnY/cBvR+CykWpH0nBfcDE9lknvpxUn
|
||||
fTitwytfjKWwE3/Z9BRK/ieBaYXwEn38KgYZJJNseZLlYTgDfAKKt4tppAQ3Tdww
|
||||
9DFo47IrUQKBgQCPSWBEWKmx80XafwB5SLCyk0s2A35fY4oz+tjaln855GCSRP4s
|
||||
CE4PRDmLQEBRNHDW8QUbcRbSR8W5WBpy/CyhrqRNQQe1/4hOjlvmh/y8b4wyx7SF
|
||||
CDLYVlIt/j/gMMCF87jwN8RaftrDhgDePT8SyCeFzcO/mf/SCEfJ7zVlYQKBgH5A
|
||||
be/RG83ogw3Tj9nKQRGiEoFFw0dhcr0hgEOvcPF6zVN3h1rgsOBqjAi8YQmmskCF
|
||||
POIZ/Ucma5DUmFuvCxWrrxrRcuWK0RwIua8hGj6KHedRR4EJAXhFQTYGGTLHJa2P
|
||||
t6SbnldngM++Y9IsUrMeme2M1WSGQzpMei9GbpMxAoGBAIgFav2bGZCScvp+Y917
|
||||
j5rKMLv8AN6nC3BQoraxMKl0YpCS8F58YHAfxKlmgR1Ll16reJLv5qAzSM7jViII
|
||||
7vmiPGrpRnz1mUHVhVBfNF1UKIRmmJKtARlrbrVibGtLubtzBZOLh0bfzmnYH9Z8
|
||||
ncBozZmPeJAtzGfvw+7BNoM9
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,16 @@
|
||||
[req]
|
||||
distinguished_name = req_distinguished_name
|
||||
x509_extensions = v3_req
|
||||
prompt = no
|
||||
|
||||
[req_distinguished_name]
|
||||
CN = *.localtest.me
|
||||
|
||||
[v3_req]
|
||||
subjectAltName = @alt_names
|
||||
keyUsage = digitalSignature, keyEncipherment
|
||||
extendedKeyUsage = serverAuth
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = *.localtest.me
|
||||
DNS.2 = localtest.me
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace ControlPlane.Core.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Central configuration for all infrastructure URLs, network names, and domain values.
|
||||
/// Bind from the "Clarity" section in appsettings.json or via AppHost environment variables.
|
||||
/// Eliminates hardcoded strings spread across Worker, AppHost, and generated configs.
|
||||
/// </summary>
|
||||
public sealed class ClarityInfraOptions
|
||||
{
|
||||
public const string Section = "Clarity";
|
||||
|
||||
// ── Domain ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>The base DNS domain for all tenant subdomains. e.g. "clarity.test"</summary>
|
||||
public string Domain { get; set; } = "clarity.test";
|
||||
|
||||
/// <summary>The Docker network all managed containers are attached to.</summary>
|
||||
public string Network { get; set; } = "clarity-net";
|
||||
|
||||
// ── Keycloak ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Public browser-facing Keycloak URL — used in redirect URIs and JWT iss claim.</summary>
|
||||
public string KeycloakPublicUrl { get; set; } = "https://keycloak.clarity.test";
|
||||
|
||||
/// <summary>Internal Docker DNS URL for server-side Keycloak calls (avoids self-signed cert).</summary>
|
||||
public string KeycloakInternalUrl { get; set; } = "http://keycloak:8080";
|
||||
|
||||
// ── Vault ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Internal Docker DNS URL for Vault — injected into tenant containers.</summary>
|
||||
public string VaultInternalUrl { get; set; } = "http://vault:8200";
|
||||
|
||||
// ── nginx SSL certs ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Path to the wildcard TLS cert inside the nginx container.</summary>
|
||||
public string NginxCertPath { get; set; } = "/etc/nginx/certs/clarity.test.crt";
|
||||
|
||||
/// <summary>Path to the wildcard TLS key inside the nginx container.</summary>
|
||||
public string NginxCertKeyPath { get; set; } = "/etc/nginx/certs/clarity.test.key";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Builds the public tenant URL for a given subdomain.</summary>
|
||||
public string TenantPublicUrl(string subdomain) => $"https://{subdomain}.{Domain}";
|
||||
|
||||
/// <summary>Builds the public Keycloak realm URL for a given realm (browser-facing).</summary>
|
||||
public string KeycloakRealmPublicUrl(string realm) => $"{KeycloakPublicUrl}/realms/{realm}";
|
||||
|
||||
/// <summary>Builds the internal Keycloak realm URL for a given realm (server-side).</summary>
|
||||
public string KeycloakRealmInternalUrl(string realm) => $"{KeycloakInternalUrl}/realms/{realm}";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ControlPlane.Core.Interfaces;
|
||||
|
||||
public interface ISagaStep
|
||||
{
|
||||
string StepName { get; }
|
||||
Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken);
|
||||
Task CompensateAsync(SagaContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using ControlPlane.Core.Models;
|
||||
|
||||
namespace ControlPlane.Core.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Mutable context bag passed through every saga step.
|
||||
/// Steps read inputs and write outputs here so downstream steps can consume them.
|
||||
/// </summary>
|
||||
public class SagaContext
|
||||
{
|
||||
public ProvisioningJob Job { get; init; } = default!;
|
||||
|
||||
// Written by DatabaseStep — connection string for the tenant's Postgres (shared or own)
|
||||
public string? TenantConnectionString { get; set; }
|
||||
public string? TenantStackName { get; set; }
|
||||
|
||||
// Written by KeycloakStep
|
||||
public string? DayZeroUserSubjectId { get; set; }
|
||||
public string? MagicLink { get; set; }
|
||||
|
||||
// Written by LaunchStep or PulumiStep — base URL for the provisioned tenant
|
||||
public string? TenantApiBaseUrl { get; set; }
|
||||
|
||||
// Written by LaunchStep — primary app container name
|
||||
public string? ContainerName { get; set; }
|
||||
|
||||
// Written by PulumiStep (DedicatedVM/Enterprise tier) — target host details for subsequent steps
|
||||
public string? VmIpAddress { get; set; }
|
||||
public string? VmSshKeyPath { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using ControlPlane.Core.Models;
|
||||
|
||||
namespace ControlPlane.Core.Messages;
|
||||
|
||||
/// <summary>API -> Worker: kick off the saga.</summary>
|
||||
public record ProvisionClientCommand
|
||||
{
|
||||
public Guid JobId { get; init; }
|
||||
public string ClientName { get; init; } = string.Empty;
|
||||
public string StateCode { get; init; } = string.Empty;
|
||||
public string Subdomain { get; init; } = string.Empty;
|
||||
public string AdminEmail { get; init; } = string.Empty;
|
||||
public string SiteCode { get; init; } = string.Empty;
|
||||
public string Environment { get; init; } = "fdev";
|
||||
public TenantTier Tier { get; init; } = TenantTier.Shared;
|
||||
}
|
||||
|
||||
/// <summary>Worker -> API/Gateway: one log event per saga step transition.</summary>
|
||||
public record ProvisioningProgressEvent
|
||||
{
|
||||
public Guid JobId { get; init; }
|
||||
public string Type { get; init; } = string.Empty; // step_started | step_complete | step_failed | job_complete | job_failed | diagnostic | compensation_started | compensation_complete
|
||||
public string? Step { get; init; }
|
||||
public string? Message { get; init; }
|
||||
/// <summary>Full exception string (stack trace) for diagnostic events.</summary>
|
||||
public string? Detail { get; init; }
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>Worker -> Gateway: published once when a job completes successfully. Triggers route registration.</summary>
|
||||
public record TenantProvisionedEvent
|
||||
{
|
||||
public Guid JobId { get; init; }
|
||||
public string Subdomain { get; init; } = string.Empty;
|
||||
public TenantTier Tier { get; init; }
|
||||
/// <summary>Base URL of the API instance for this tenant. For Shared/Isolated this is the shared API. For Dedicated it is the per-tenant instance.</summary>
|
||||
public string ApiBaseUrl { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ControlPlane.Core.Models;
|
||||
|
||||
public enum BuildStatus { Running, Succeeded, Failed }
|
||||
public enum BuildKind { DockerImage, DotnetProject, NpmProject }
|
||||
|
||||
/// <summary>
|
||||
/// Persisted record of a single build run — image build, dotnet build, or npm build.
|
||||
/// Stored in ClientAssets/builds.json.
|
||||
/// </summary>
|
||||
public class BuildRecord
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
|
||||
public BuildKind Kind { get; set; }
|
||||
public string Target { get; set; } = string.Empty; // image name or project path
|
||||
public BuildStatus Status { get; set; } = BuildStatus.Running;
|
||||
public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? FinishedAt { get; set; }
|
||||
public int? DurationMs { get; set; }
|
||||
public string? ImageDigest { get; set; } // populated for DockerImage builds
|
||||
public List<string> Log { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ControlPlane.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Defines where a specific infrastructure component (Postgres, Keycloak, Vault, MinIO)
|
||||
/// is hosted for a given tenant. Each component in a StackConfig is configured independently.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ComponentMode
|
||||
{
|
||||
/// <summary>Shared platform instance — logical slice only (realm, schema, bucket, namespace).</summary>
|
||||
SharedPlatform,
|
||||
|
||||
/// <summary>Baked into the app image itself via supervisord. Trial tier only.</summary>
|
||||
Bundled,
|
||||
|
||||
/// <summary>Own sidecar container on ControlPlane's shared Docker host.</summary>
|
||||
OwnContainer,
|
||||
|
||||
/// <summary>Own VM with the component running inside Docker on it.</summary>
|
||||
VpsDocker,
|
||||
|
||||
/// <summary>Own VM with the component running as a native OS process (no Docker).</summary>
|
||||
VpsBareMetal
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ControlPlane.Core.Models;
|
||||
|
||||
public record GitCommit(
|
||||
string Hash,
|
||||
string ShortHash,
|
||||
string Author,
|
||||
string Date,
|
||||
string Subject,
|
||||
string[] Files
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
namespace ControlPlane.Core.Models;
|
||||
|
||||
// ── Repository ────────────────────────────────────────────────────────────────
|
||||
|
||||
public record GiteaRepo(
|
||||
long Id,
|
||||
string Name,
|
||||
string FullName,
|
||||
string DefaultBranch,
|
||||
string CloneUrl,
|
||||
string SshUrl,
|
||||
bool Private
|
||||
);
|
||||
|
||||
// ── Branch ────────────────────────────────────────────────────────────────────
|
||||
|
||||
public record GiteaBranch(
|
||||
string Name,
|
||||
string CommitSha,
|
||||
bool Protected
|
||||
);
|
||||
|
||||
// ── Pull Request ──────────────────────────────────────────────────────────────
|
||||
|
||||
public record GiteaPullRequest(
|
||||
long Number,
|
||||
string Title,
|
||||
string State, // open | closed | merged
|
||||
string HeadBranch,
|
||||
string BaseBranch,
|
||||
string HtmlUrl,
|
||||
string CreatedAt,
|
||||
string UpdatedAt,
|
||||
GiteaUser? User,
|
||||
GiteaMergeInfo? MergeInfo
|
||||
);
|
||||
|
||||
public record GiteaUser(string Login, string AvatarUrl);
|
||||
|
||||
public record GiteaMergeInfo(bool Mergeable, bool Merged, string? MergedAt);
|
||||
|
||||
// ── Tag ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
public record GiteaTag(string Name, string CommitSha, string ZipUrl);
|
||||
|
||||
// ── Webhook ───────────────────────────────────────────────────────────────────
|
||||
|
||||
public record GiteaWebhook(long Id, string Url, bool Active, string[] Events);
|
||||
|
||||
// ── Request shapes ────────────────────────────────────────────────────────────
|
||||
|
||||
public record CreateBranchRequest(string OpcNumber, string OpcTitle, string From = "master");
|
||||
|
||||
public record CreatePullRequestRequest(
|
||||
string Title,
|
||||
string Head,
|
||||
string Base,
|
||||
string Body
|
||||
);
|
||||
|
||||
public record CreateTagRequest(string TagName, string Message, string CommitSha);
|
||||
|
||||
public record CreateWebhookRequest(string TargetUrl, string[] Events);
|
||||
@@ -0,0 +1,73 @@
|
||||
namespace ControlPlane.Core.Models;
|
||||
|
||||
public record OpcRecord(
|
||||
Guid Id,
|
||||
string Number,
|
||||
string Title,
|
||||
string Description,
|
||||
string Type,
|
||||
string Status,
|
||||
string Priority,
|
||||
string Assignee,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt
|
||||
);
|
||||
|
||||
public record OpcNote(
|
||||
Guid Id,
|
||||
Guid OpcId,
|
||||
string Author,
|
||||
string Content,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
public record OpcArtifact(
|
||||
Guid Id,
|
||||
Guid OpcId,
|
||||
string ArtifactType,
|
||||
string Title,
|
||||
string Content,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt
|
||||
);
|
||||
|
||||
// Request / response shapes used by the API endpoints
|
||||
|
||||
public record CreateOpcRequest(
|
||||
string Title,
|
||||
string Type,
|
||||
string Priority,
|
||||
string Assignee,
|
||||
string Description
|
||||
);
|
||||
|
||||
public record UpdateOpcRequest(
|
||||
string? Title,
|
||||
string? Description,
|
||||
string? Type,
|
||||
string? Status,
|
||||
string? Priority,
|
||||
string? Assignee
|
||||
);
|
||||
|
||||
public record AddNoteRequest(string Author, string Content);
|
||||
|
||||
public record UpsertArtifactRequest(
|
||||
string ArtifactType,
|
||||
string Title,
|
||||
string Content
|
||||
);
|
||||
|
||||
public record AiAssistRequest(string Prompt, string? Context);
|
||||
|
||||
public record OpcPinnedCommit(
|
||||
Guid OpcId,
|
||||
string Hash,
|
||||
string ShortHash,
|
||||
string Subject,
|
||||
string Author,
|
||||
DateTime PinnedAt,
|
||||
string PinnedBy
|
||||
);
|
||||
|
||||
public record PinCommitRequest(string Hash, string PinnedBy);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ControlPlane.Core.Models;
|
||||
|
||||
public enum PromotionStatus { Pending, Running, Succeeded, Failed }
|
||||
|
||||
/// <summary>
|
||||
/// Represents a request to promote (merge) one environment branch into the next.
|
||||
/// e.g. develop → staging, staging → uat, uat → main
|
||||
/// </summary>
|
||||
public class PromotionRequest
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
|
||||
public string FromBranch { get; set; } = string.Empty;
|
||||
public string ToBranch { get; set; } = string.Empty;
|
||||
public string RequestedBy { get; set; } = "system";
|
||||
public string? Note { get; set; }
|
||||
public PromotionStatus Status { get; set; } = PromotionStatus.Pending;
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
public List<string> Log { get; set; } = [];
|
||||
public int CommitCount { get; set; } // commits in from that are not in to
|
||||
public string[] CommitLines { get; set; } = []; // oneline summary of those commits
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace ControlPlane.Core.Models;
|
||||
|
||||
public enum ProvisioningStatus
|
||||
{
|
||||
Pending,
|
||||
Running,
|
||||
Compensating,
|
||||
Failed,
|
||||
Completed
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum CompletedSteps
|
||||
{
|
||||
None = 0,
|
||||
InfrastructureProvisioned = 1 << 0,
|
||||
KeycloakProvisioned = 1 << 1,
|
||||
VaultVerified = 1 << 2,
|
||||
DatabaseMigrated = 1 << 3,
|
||||
HandoffSent = 1 << 4
|
||||
}
|
||||
|
||||
public class ProvisioningJob
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string ClientName { get; set; } = string.Empty;
|
||||
public string StateCode { get; set; } = string.Empty;
|
||||
public string Subdomain { get; set; } = string.Empty;
|
||||
public string AdminEmail { get; set; } = string.Empty;
|
||||
public string SiteCode { get; set; } = string.Empty;
|
||||
public string Environment { get; set; } = "fdev";
|
||||
|
||||
public TenantTier Tier { get; set; } = TenantTier.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the StackConfig at the time provisioning was requested.
|
||||
/// Immutable after the job is created.
|
||||
/// </summary>
|
||||
public StackConfig StackConfig { get; set; } = StackConfig.DefaultForTier(TenantTier.Shared);
|
||||
|
||||
public ProvisioningStatus Status { get; set; } = ProvisioningStatus.Pending;
|
||||
public CompletedSteps CompletedSteps { get; set; } = CompletedSteps.None;
|
||||
|
||||
public string? FailureReason { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ControlPlane.Core.Models;
|
||||
|
||||
public class ProvisioningRequest
|
||||
{
|
||||
public string ClientName { get; set; } = string.Empty;
|
||||
public string StateCode { get; set; } = string.Empty;
|
||||
public string Subdomain { get; set; } = string.Empty;
|
||||
public string AdminEmail { get; set; } = string.Empty;
|
||||
public string SiteCode { get; set; } = string.Empty;
|
||||
public string Environment { get; set; } = "fdev";
|
||||
public TenantTier Tier { get; set; } = TenantTier.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Per-component infrastructure configuration. Defaults to the standard profile
|
||||
/// for the selected tier if not explicitly specified.
|
||||
/// </summary>
|
||||
public StackConfig StackConfig { get; set; } = StackConfig.DefaultForTier(TenantTier.Shared);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ControlPlane.Core.Models;
|
||||
|
||||
public enum ReleaseStatus { Running, Succeeded, PartialFailure, Failed }
|
||||
|
||||
/// <summary>
|
||||
/// Persisted record of a release — a coordinated redeploy of all tenant containers
|
||||
/// in a target environment to the latest clarity-server image.
|
||||
/// Stored in ClientAssets/releases.json.
|
||||
/// </summary>
|
||||
public class ReleaseRecord
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
|
||||
public string Environment { get; set; } = string.Empty; // fdev | uat | prod | all
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
public ReleaseStatus Status { get; set; } = ReleaseStatus.Running;
|
||||
public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? FinishedAt { get; set; }
|
||||
public List<TenantReleaseResult> Tenants { get; set; } = [];
|
||||
}
|
||||
|
||||
public class TenantReleaseResult
|
||||
{
|
||||
public string Subdomain { get; set; } = string.Empty;
|
||||
public string ContainerName { get; set; } = string.Empty;
|
||||
public bool Success { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace ControlPlane.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the exact infrastructure composition for a provisioned tenant.
|
||||
/// Each component is configured independently — the TenantTier gates which
|
||||
/// ComponentMode values are available in the UI.
|
||||
///
|
||||
/// Allowed modes per tier:
|
||||
///
|
||||
/// | Trial | Shared | Dedicated | Enterprise |
|
||||
/// SharedPlatform | ✅ | ✅ | ✅ | ✅ |
|
||||
/// Bundled | ✅ | ❌ | ❌ | ❌ |
|
||||
/// OwnContainer | ❌ | ❌ | ✅ | ✅ |
|
||||
/// VpsDocker | ❌ | ❌ | ❌ | ✅ |
|
||||
/// VpsBareMetal | ❌ | ❌ | ❌ | ✅ |
|
||||
/// </summary>
|
||||
public class StackConfig
|
||||
{
|
||||
public ComponentMode Postgres { get; set; } = ComponentMode.SharedPlatform;
|
||||
public ComponentMode Keycloak { get; set; } = ComponentMode.SharedPlatform;
|
||||
public ComponentMode Vault { get; set; } = ComponentMode.SharedPlatform;
|
||||
public ComponentMode Minio { get; set; } = ComponentMode.SharedPlatform;
|
||||
|
||||
/// <summary>Returns a default StackConfig for the given tier.</summary>
|
||||
public static StackConfig DefaultForTier(TenantTier tier) => tier switch
|
||||
{
|
||||
TenantTier.Trial => new StackConfig
|
||||
{
|
||||
Postgres = ComponentMode.Bundled,
|
||||
Keycloak = ComponentMode.SharedPlatform,
|
||||
Vault = ComponentMode.SharedPlatform,
|
||||
Minio = ComponentMode.SharedPlatform
|
||||
},
|
||||
TenantTier.Shared => new StackConfig(),
|
||||
TenantTier.Dedicated => new StackConfig
|
||||
{
|
||||
Postgres = ComponentMode.OwnContainer,
|
||||
Keycloak = ComponentMode.OwnContainer,
|
||||
Vault = ComponentMode.OwnContainer,
|
||||
Minio = ComponentMode.OwnContainer
|
||||
},
|
||||
TenantTier.Enterprise => new StackConfig
|
||||
{
|
||||
Postgres = ComponentMode.VpsDocker,
|
||||
Keycloak = ComponentMode.VpsDocker,
|
||||
Vault = ComponentMode.VpsDocker,
|
||||
Minio = ComponentMode.VpsDocker
|
||||
},
|
||||
_ => new StackConfig()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace ControlPlane.Core.Models;
|
||||
|
||||
[XmlRoot("Tenant")]
|
||||
public class TenantRecord
|
||||
{
|
||||
// ── Identity ──────────────────────────────────────────────────────────
|
||||
[XmlAttribute]
|
||||
public string Subdomain { get; set; } = string.Empty;
|
||||
|
||||
[XmlElement]
|
||||
public string ClientName { get; set; } = string.Empty;
|
||||
|
||||
[XmlElement]
|
||||
public string StateCode { get; set; } = string.Empty;
|
||||
|
||||
[XmlElement]
|
||||
public string AdminEmail { get; set; } = string.Empty;
|
||||
|
||||
[XmlElement]
|
||||
public string SiteCode { get; set; } = string.Empty;
|
||||
|
||||
[XmlElement]
|
||||
public string Environment { get; set; } = "fdev";
|
||||
|
||||
[XmlElement]
|
||||
public string Tier { get; set; } = string.Empty;
|
||||
|
||||
[XmlElement]
|
||||
public string Status { get; set; } = "Provisioning";
|
||||
|
||||
[XmlElement]
|
||||
public string ProvisionedAt { get; set; } = DateTimeOffset.UtcNow.ToString("o");
|
||||
|
||||
[XmlElement]
|
||||
public string JobId { get; set; } = string.Empty;
|
||||
|
||||
// ── Container (written by InfrastructureStep / LaunchStep) ────────────
|
||||
[XmlElement(IsNullable = true)]
|
||||
public string? ContainerName { get; set; }
|
||||
|
||||
[XmlElement(IsNullable = true)]
|
||||
public string? ContainerPort { get; set; }
|
||||
|
||||
[XmlElement(IsNullable = true)]
|
||||
public string? ContainerImage { get; set; }
|
||||
|
||||
[XmlElement(IsNullable = true)]
|
||||
public string? ContainerNetwork { get; set; }
|
||||
|
||||
[XmlElement(IsNullable = true)]
|
||||
public string? NginxConfPath { get; set; }
|
||||
|
||||
[XmlElement(IsNullable = true)]
|
||||
public string? ApiBaseUrl { get; set; }
|
||||
|
||||
[XmlElement(IsNullable = true)]
|
||||
public string? PublicUrl { get; set; }
|
||||
|
||||
[XmlElement(IsNullable = true)]
|
||||
public string? LastProvisioningStep { get; set; }
|
||||
|
||||
[XmlElement(IsNullable = true)]
|
||||
public string? ProvisioningNotes { get; set; }
|
||||
|
||||
// ── web.config-style sections ─────────────────────────────────────────
|
||||
|
||||
[XmlElement("ConnectionStrings")]
|
||||
public ConnectionStringsSection ConnectionStrings { get; set; } = new();
|
||||
|
||||
[XmlElement("AppSettings")]
|
||||
public AppSettingsSection AppSettings { get; set; } = new();
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
public void SetConnectionString(string name, string connectionString)
|
||||
{
|
||||
var existing = ConnectionStrings.Entries.FirstOrDefault(e => e.Name == name);
|
||||
if (existing is not null)
|
||||
existing.ConnectionString = connectionString;
|
||||
else
|
||||
ConnectionStrings.Entries.Add(new ConnectionStringEntry { Name = name, ConnectionString = connectionString });
|
||||
}
|
||||
|
||||
public string? GetConnectionString(string name) =>
|
||||
ConnectionStrings.Entries.FirstOrDefault(e => e.Name == name)?.ConnectionString;
|
||||
|
||||
public void SetAppSetting(string key, string value)
|
||||
{
|
||||
var existing = AppSettings.Entries.FirstOrDefault(e => e.Key == key);
|
||||
if (existing is not null)
|
||||
existing.Value = value;
|
||||
else
|
||||
AppSettings.Entries.Add(new AppSettingEntry { Key = key, Value = value });
|
||||
}
|
||||
|
||||
public string? GetAppSetting(string key) =>
|
||||
AppSettings.Entries.FirstOrDefault(e => e.Key == key)?.Value;
|
||||
}
|
||||
|
||||
// ── Section types ──────────────────────────────────────────────────────────
|
||||
|
||||
public class ConnectionStringsSection
|
||||
{
|
||||
[XmlElement("add")]
|
||||
public List<ConnectionStringEntry> Entries { get; set; } = [];
|
||||
}
|
||||
|
||||
public class AppSettingsSection
|
||||
{
|
||||
[XmlElement("add")]
|
||||
public List<AppSettingEntry> Entries { get; set; } = [];
|
||||
}
|
||||
|
||||
public class ConnectionStringEntry
|
||||
{
|
||||
[XmlAttribute("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[XmlAttribute("connectionString")]
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
|
||||
[XmlAttribute("providerName")]
|
||||
public string ProviderName { get; set; } = "System.Data.SqlClient";
|
||||
}
|
||||
|
||||
public class AppSettingEntry
|
||||
{
|
||||
[XmlAttribute("key")]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
[XmlAttribute("value")]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ControlPlane.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the billing and support level for a provisioned tenant.
|
||||
/// The tier gates which ComponentMode values are available per component in the StackConfig.
|
||||
///
|
||||
/// Trial - ephemeral sandbox, all-in-one image, no persistent data guarantee.
|
||||
/// Shared - real production data, shared platform infrastructure (logical slices only).
|
||||
/// Dedicated - full container isolation per component, still on ControlPlane's shared host.
|
||||
/// Enterprise - full VM isolation per component (VpsDocker or VpsBareMetal), Pulumi provisioned.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum TenantTier
|
||||
{
|
||||
Trial,
|
||||
Shared,
|
||||
Dedicated,
|
||||
Enterprise
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Text.Json;
|
||||
using ControlPlane.Core.Models;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ControlPlane.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persists build and release history to JSON files in the ClientAssets folder.
|
||||
/// Thread-safe — all writes go through a single lock per file.
|
||||
/// </summary>
|
||||
public class BuildHistoryService
|
||||
{
|
||||
private readonly string _buildsPath;
|
||||
private readonly string _releasesPath;
|
||||
private readonly ILogger<BuildHistoryService> _logger;
|
||||
|
||||
private static readonly SemaphoreSlim _buildLock = new(1, 1);
|
||||
private static readonly SemaphoreSlim _releaseLock = new(1, 1);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public BuildHistoryService(IConfiguration config, ILogger<BuildHistoryService> logger)
|
||||
{
|
||||
var folder = config["ClientAssets__Folder"] ?? config["ClientAssets:Folder"]
|
||||
?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "ClientAssets"));
|
||||
Directory.CreateDirectory(folder);
|
||||
_buildsPath = Path.Combine(folder, "builds.json");
|
||||
_releasesPath = Path.Combine(folder, "releases.json");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ── Builds ──────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<BuildRecord> CreateBuildAsync(BuildKind kind, string target)
|
||||
{
|
||||
var record = new BuildRecord { Kind = kind, Target = target };
|
||||
await SaveBuildAsync(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
public async Task CompleteBuildAsync(BuildRecord record, BuildStatus status, string? digest = null)
|
||||
{
|
||||
record.Status = status;
|
||||
record.FinishedAt = DateTimeOffset.UtcNow;
|
||||
record.DurationMs = (int)(record.FinishedAt.Value - record.StartedAt).TotalMilliseconds;
|
||||
record.ImageDigest = digest;
|
||||
await SaveBuildAsync(record);
|
||||
}
|
||||
|
||||
public async Task AppendBuildLogAsync(BuildRecord record, string line)
|
||||
{
|
||||
record.Log.Add(line);
|
||||
// Flush to disk every 20 lines to avoid excessive I/O but keep reasonable freshness
|
||||
if (record.Log.Count % 20 == 0)
|
||||
await SaveBuildAsync(record);
|
||||
}
|
||||
|
||||
public async Task<List<BuildRecord>> GetBuildsAsync()
|
||||
{
|
||||
await _buildLock.WaitAsync();
|
||||
try { return LoadJson<BuildRecord>(_buildsPath); }
|
||||
finally { _buildLock.Release(); }
|
||||
}
|
||||
|
||||
private async Task SaveBuildAsync(BuildRecord record)
|
||||
{
|
||||
await _buildLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var all = LoadJson<BuildRecord>(_buildsPath);
|
||||
var idx = all.FindIndex(b => b.Id == record.Id);
|
||||
if (idx >= 0) all[idx] = record;
|
||||
else all.Insert(0, record);
|
||||
|
||||
// Keep last 100 builds
|
||||
if (all.Count > 100) all = all[..100];
|
||||
await File.WriteAllTextAsync(_buildsPath, JsonSerializer.Serialize(all, JsonOpts));
|
||||
}
|
||||
finally { _buildLock.Release(); }
|
||||
}
|
||||
|
||||
// ── Releases ────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<ReleaseRecord> CreateReleaseAsync(string environment, string imageName)
|
||||
{
|
||||
var record = new ReleaseRecord { Environment = environment, ImageName = imageName };
|
||||
await SaveReleaseAsync(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
public async Task UpdateReleaseAsync(ReleaseRecord record)
|
||||
{
|
||||
record.FinishedAt = DateTimeOffset.UtcNow;
|
||||
await SaveReleaseAsync(record);
|
||||
}
|
||||
|
||||
public async Task<List<ReleaseRecord>> GetReleasesAsync()
|
||||
{
|
||||
await _releaseLock.WaitAsync();
|
||||
try { return LoadJson<ReleaseRecord>(_releasesPath); }
|
||||
finally { _releaseLock.Release(); }
|
||||
}
|
||||
|
||||
private async Task SaveReleaseAsync(ReleaseRecord record)
|
||||
{
|
||||
await _releaseLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var all = LoadJson<ReleaseRecord>(_releasesPath);
|
||||
var idx = all.FindIndex(r => r.Id == record.Id);
|
||||
if (idx >= 0) all[idx] = record;
|
||||
else all.Insert(0, record);
|
||||
|
||||
if (all.Count > 50) all = all[..50];
|
||||
await File.WriteAllTextAsync(_releasesPath, JsonSerializer.Serialize(all, JsonOpts));
|
||||
}
|
||||
finally { _releaseLock.Release(); }
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static List<T> LoadJson<T>(string path)
|
||||
{
|
||||
if (!File.Exists(path)) return [];
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<List<T>>(json, JsonOpts) ?? [];
|
||||
}
|
||||
catch { return []; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Xml.Serialization;
|
||||
using ControlPlane.Core.Models;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ControlPlane.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and writes per-tenant XML config files under the ClientAssets folder.
|
||||
/// One file per tenant: {subdomain}.xml
|
||||
/// Thread-safe for concurrent reads; writes are serialized per subdomain via per-file locking.
|
||||
/// </summary>
|
||||
public class TenantRegistryService
|
||||
{
|
||||
private readonly string _folder;
|
||||
private readonly ILogger<TenantRegistryService> _logger;
|
||||
private static readonly XmlSerializer Serializer = new(typeof(TenantRecord));
|
||||
|
||||
// One lock object per subdomain so writes to different tenants never block each other
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, object> _locks = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public TenantRegistryService(IConfiguration configuration, ILogger<TenantRegistryService> logger)
|
||||
{
|
||||
_folder = configuration["ClientAssets__Folder"] ?? configuration["ClientAssets:Folder"]
|
||||
?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "ClientAssets"));
|
||||
_logger = logger;
|
||||
Directory.CreateDirectory(_folder);
|
||||
}
|
||||
|
||||
// -- Write --
|
||||
|
||||
public void Save(TenantRecord record)
|
||||
{
|
||||
var path = FilePath(record.Subdomain);
|
||||
var gate = _locks.GetOrAdd(record.Subdomain, _ => new object());
|
||||
lock (gate)
|
||||
{
|
||||
using var writer = new StreamWriter(path, append: false, System.Text.Encoding.UTF8);
|
||||
Serializer.Serialize(writer, record);
|
||||
}
|
||||
_logger.LogInformation("Saved tenant record: {Path}", path);
|
||||
}
|
||||
|
||||
// -- Read --
|
||||
|
||||
public TenantRecord? TryGet(string subdomain)
|
||||
{
|
||||
var path = FilePath(subdomain);
|
||||
if (!File.Exists(path)) return null;
|
||||
using var reader = new StreamReader(path, System.Text.Encoding.UTF8);
|
||||
return (TenantRecord?)Serializer.Deserialize(reader);
|
||||
}
|
||||
|
||||
public IReadOnlyList<TenantRecord> GetAll()
|
||||
{
|
||||
var results = new List<TenantRecord>();
|
||||
foreach (var file in Directory.EnumerateFiles(_folder, "*.xml"))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(file, System.Text.Encoding.UTF8);
|
||||
if (Serializer.Deserialize(reader) is TenantRecord record)
|
||||
results.Add(record);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Skipping malformed tenant file: {File}", file);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public bool Exists(string subdomain) => File.Exists(FilePath(subdomain));
|
||||
|
||||
private string FilePath(string subdomain) =>
|
||||
Path.Combine(_folder, $"{subdomain.ToLowerInvariant()}.xml");
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>controlplane-worker-secrets</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Docker.DotNet" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Keycloak.AuthServices.Sdk" />
|
||||
<PackageReference Include="MassTransit" />
|
||||
<PackageReference Include="MassTransit.RabbitMQ" />
|
||||
<PackageReference Include="Aspire.RabbitMQ.Client" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Clarity.ServiceDefaults\Clarity.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\ControlPlane.Core\ControlPlane.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Properties\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,30 @@
|
||||
# ── Build stage ──────────────────────────────────────────────────────────────
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY ["ControlPlane.Worker/ControlPlane.Worker.csproj", "ControlPlane.Worker/"]
|
||||
COPY ["ControlPlane.Core/ControlPlane.Core.csproj", "ControlPlane.Core/"]
|
||||
COPY ["Clarity.ServiceDefaults/Clarity.ServiceDefaults.csproj", "Clarity.ServiceDefaults/"]
|
||||
COPY ["Directory.Packages.props", "./"]
|
||||
|
||||
RUN dotnet restore "ControlPlane.Worker/ControlPlane.Worker.csproj"
|
||||
|
||||
COPY . .
|
||||
RUN dotnet publish "ControlPlane.Worker/ControlPlane.Worker.csproj" \
|
||||
-c Release -o /app/publish --no-restore
|
||||
|
||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# Install Pulumi CLI so the Automation API can shell out to it
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& curl -fsSL https://get.pulumi.com | sh \
|
||||
&& apt-get purge -y curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/root/.pulumi/bin:${PATH}"
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
ENTRYPOINT ["dotnet", "ControlPlane.Worker.dll"]
|
||||
@@ -0,0 +1,64 @@
|
||||
using ControlPlane.Core.Config;
|
||||
using ControlPlane.Core.Interfaces;
|
||||
using ControlPlane.Core.Services;
|
||||
using ControlPlane.Worker;
|
||||
using ControlPlane.Worker.Services;
|
||||
using ControlPlane.Worker.Steps;
|
||||
using Keycloak.AuthServices.Sdk;
|
||||
using MassTransit;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
// Centralized infrastructure options — domain, network, internal URLs, cert paths
|
||||
builder.Services.Configure<ClarityInfraOptions>(
|
||||
builder.Configuration.GetSection(ClarityInfraOptions.Section));
|
||||
|
||||
// Keycloak Admin SDK client
|
||||
builder.Services.AddKeycloakAdminHttpClient(o =>
|
||||
{
|
||||
o.AuthServerUrl = builder.Configuration["Keycloak:AuthServerUrl"] ?? "http://localhost:8080";
|
||||
o.Realm = builder.Configuration["Keycloak:Realm"] ?? "master";
|
||||
o.Resource = builder.Configuration["Keycloak:Resource"] ?? "admin-cli";
|
||||
});
|
||||
|
||||
// Custom admin client - handles realm creation, roles, role assignment (not in SDK)
|
||||
builder.Services.AddSingleton<KeycloakAdminClient>();
|
||||
|
||||
// Docker container manager for per-tenant Clarity.Server instances
|
||||
builder.Services.AddSingleton<ClarityContainerService>();
|
||||
|
||||
// Tenant registry - persists provisioned tenant XML files to ClientAssets folder
|
||||
builder.Services.AddSingleton<TenantRegistryService>();
|
||||
|
||||
// Saga steps in execution order — container launches LAST once all context is populated
|
||||
builder.Services.AddSingleton<ISagaStep, KeycloakStep>();
|
||||
builder.Services.AddSingleton<ISagaStep, VaultStep>();
|
||||
builder.Services.AddSingleton<ISagaStep, MigrationStep>();
|
||||
builder.Services.AddSingleton<ISagaStep, LaunchStep>();
|
||||
builder.Services.AddSingleton<ISagaStep, HandoffStep>();
|
||||
|
||||
builder.Services.AddMassTransit(x =>
|
||||
{
|
||||
x.SetKebabCaseEndpointNameFormatter();
|
||||
|
||||
x.AddConsumer<ProvisioningConsumer>();
|
||||
|
||||
x.UsingRabbitMq((ctx, cfg) =>
|
||||
{
|
||||
cfg.Host(builder.Configuration.GetConnectionString("rabbitmq"));
|
||||
cfg.ConfigureEndpoints(ctx);
|
||||
});
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var host = builder.Build();
|
||||
host.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"FATAL WORKER CRASH: {ex}");
|
||||
throw;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using ControlPlane.Core.Config;
|
||||
using ControlPlane.Core.Interfaces;
|
||||
using ControlPlane.Core.Messages;
|
||||
using ControlPlane.Core.Models;
|
||||
using ControlPlane.Core.Services;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ControlPlane.Worker;
|
||||
|
||||
/// <summary>
|
||||
/// MassTransit consumer. Triggered by ProvisionClientCommand off RabbitMQ.
|
||||
/// Runs the saga and publishes ProvisioningProgressEvent for each step transition.
|
||||
/// </summary>
|
||||
public sealed class ProvisioningConsumer(
|
||||
IEnumerable<ISagaStep> steps,
|
||||
IPublishEndpoint bus,
|
||||
IConfiguration config,
|
||||
IOptions<ClarityInfraOptions> infraOptions,
|
||||
TenantRegistryService registry,
|
||||
ILogger<ProvisioningConsumer> logger) : IConsumer<ProvisionClientCommand>
|
||||
{
|
||||
public async Task Consume(ConsumeContext<ProvisionClientCommand> context)
|
||||
{
|
||||
var cmd = context.Message;
|
||||
var job = new ProvisioningJob
|
||||
{
|
||||
Id = cmd.JobId,
|
||||
ClientName = cmd.ClientName,
|
||||
StateCode = cmd.StateCode,
|
||||
Subdomain = cmd.Subdomain,
|
||||
AdminEmail = cmd.AdminEmail,
|
||||
SiteCode = cmd.SiteCode,
|
||||
Environment = cmd.Environment,
|
||||
Status = ProvisioningStatus.Running
|
||||
};
|
||||
|
||||
logger.LogInformation("Starting provisioning saga for job {JobId} ({Client})", job.Id, job.ClientName);
|
||||
await RunSagaAsync(job, context.CancellationToken);
|
||||
}
|
||||
|
||||
private async Task RunSagaAsync(ProvisioningJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
var sagaContext = new SagaContext { Job = job };
|
||||
var executedSteps = new Stack<ISagaStep>();
|
||||
|
||||
foreach (var step in steps)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Publish(job.Id, "step_started", step.StepName, $"Starting: {step.StepName}");
|
||||
logger.LogInformation("[{JobId}] Executing: {Step}", job.Id, step.StepName);
|
||||
|
||||
await step.ExecuteAsync(sagaContext, cancellationToken);
|
||||
executedSteps.Push(step);
|
||||
|
||||
await Publish(job.Id, "step_complete", step.StepName, $"Completed: {step.StepName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "[{JobId}] Step {Step} failed", job.Id, step.StepName);
|
||||
await Publish(job.Id, "step_failed", step.StepName, $"Failed: {step.StepName} - {ex.Message}");
|
||||
await PublishDiagnostic(job.Id, step.StepName, ex);
|
||||
|
||||
job.Status = ProvisioningStatus.Compensating;
|
||||
job.FailureReason = $"{step.StepName}: {ex.Message}";
|
||||
|
||||
await CompensateAsync(sagaContext, executedSteps, cancellationToken);
|
||||
|
||||
job.Status = ProvisioningStatus.Failed;
|
||||
await Publish(job.Id, "job_failed", null, job.FailureReason);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
job.Status = ProvisioningStatus.Completed;
|
||||
job.CompletedAt = DateTimeOffset.UtcNow;
|
||||
await Publish(job.Id, "job_complete", null, "All steps completed successfully.");
|
||||
|
||||
var infra = infraOptions.Value;
|
||||
var apiBaseUrl = sagaContext.TenantApiBaseUrl
|
||||
?? infra.TenantPublicUrl(job.Subdomain);
|
||||
|
||||
// Persist to ClientAssets/{subdomain}.xml
|
||||
var nginxConfPath = config["Nginx:ConfDPath"] is { } p
|
||||
? Path.Combine(p, $"{job.Subdomain}.conf")
|
||||
: null;
|
||||
|
||||
var record = new TenantRecord
|
||||
{
|
||||
JobId = job.Id.ToString(),
|
||||
Subdomain = job.Subdomain,
|
||||
ClientName = job.ClientName,
|
||||
StateCode = job.StateCode,
|
||||
AdminEmail = job.AdminEmail,
|
||||
SiteCode = job.SiteCode,
|
||||
Environment = job.Environment,
|
||||
Tier = job.Tier.ToString(),
|
||||
ApiBaseUrl = apiBaseUrl,
|
||||
Status = "Provisioned",
|
||||
ProvisionedAt = job.CompletedAt!.Value.ToString("o"),
|
||||
ContainerName = sagaContext.ContainerName,
|
||||
ContainerPort = null,
|
||||
ContainerImage = config["Docker:ClarityServerImage"] ?? "clarity-server:latest",
|
||||
ContainerNetwork = infra.Network,
|
||||
NginxConfPath = nginxConfPath,
|
||||
PublicUrl = infra.TenantPublicUrl(job.Subdomain),
|
||||
LastProvisioningStep = "LaunchStep",
|
||||
ProvisioningNotes = $"Provisioned at {job.CompletedAt:o}. All {job.CompletedSteps} steps completed.",
|
||||
};
|
||||
|
||||
// AppSettings — enriched by each step via SagaContext
|
||||
record.SetAppSetting("Keycloak:Realm", $"clarity-{job.Subdomain.ToLowerInvariant()}");
|
||||
record.SetAppSetting("Keycloak:BaseUrl", infra.KeycloakPublicUrl);
|
||||
record.SetAppSetting("Keycloak:InternalUrl", infra.KeycloakInternalUrl);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(sagaContext.TenantStackName))
|
||||
record.SetAppSetting("Pulumi:StackName", sagaContext.TenantStackName);
|
||||
|
||||
// ConnectionStrings — written by MigrationStep once DB is provisioned
|
||||
if (!string.IsNullOrWhiteSpace(sagaContext.TenantConnectionString))
|
||||
record.SetConnectionString("TenantDb", sagaContext.TenantConnectionString);
|
||||
|
||||
registry.Save(record);
|
||||
|
||||
logger.LogInformation("[{JobId}] Provisioning completed. Tenant record saved.", job.Id);
|
||||
}
|
||||
|
||||
private async Task CompensateAsync(SagaContext sagaContext, Stack<ISagaStep> executedSteps, CancellationToken cancellationToken)
|
||||
{
|
||||
while (executedSteps.TryPop(out var step))
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("[{JobId}] Compensating: {Step}", sagaContext.Job.Id, step.StepName);
|
||||
await Publish(sagaContext.Job.Id, "compensation_started", step.StepName, $"Rolling back: {step.StepName}");
|
||||
await step.CompensateAsync(sagaContext, cancellationToken);
|
||||
await Publish(sagaContext.Job.Id, "compensation_complete", step.StepName, $"Rolled back: {step.StepName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "[{JobId}] Compensation failed for {Step} - manual intervention required", sagaContext.Job.Id, step.StepName);
|
||||
await PublishDiagnostic(sagaContext.Job.Id, $"{step.StepName} (compensation)", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Task Publish(Guid jobId, string type, string? step, string? message) =>
|
||||
bus.Publish(new ProvisioningProgressEvent
|
||||
{
|
||||
JobId = jobId,
|
||||
Type = type,
|
||||
Step = step,
|
||||
Message = message
|
||||
});
|
||||
|
||||
private Task PublishDiagnostic(Guid jobId, string? step, Exception ex) =>
|
||||
bus.Publish(new ProvisioningProgressEvent
|
||||
{
|
||||
JobId = jobId,
|
||||
Type = "diagnostic",
|
||||
Step = step,
|
||||
Message = ex.Message,
|
||||
Detail = ex.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
using ControlPlane.Core.Config;
|
||||
using ControlPlane.Core.Messages;
|
||||
using Docker.DotNet;
|
||||
using Docker.DotNet.Models;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ControlPlane.Worker.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages Clarity.Server Docker containers for provisioned tenants.
|
||||
/// Container naming convention: {env}-app-clarity-{siteCode}
|
||||
/// e.g. fdev-app-clarity-01000014
|
||||
/// </summary>
|
||||
public class ClarityContainerService(
|
||||
IConfiguration config,
|
||||
IOptions<ClarityInfraOptions> infraOptions,
|
||||
IPublishEndpoint bus,
|
||||
ILogger<ClarityContainerService> logger)
|
||||
{
|
||||
private ClarityInfraOptions Infra => infraOptions.Value;
|
||||
|
||||
// The image to run - override via config for prod registries
|
||||
private string ImageName => config["Docker:ClarityServerImage"] ?? "clarity-server:latest";
|
||||
|
||||
private DockerClient CreateClient()
|
||||
{
|
||||
var uri = config["Docker:Socket"] ?? "npipe://./pipe/docker_engine";
|
||||
return new DockerClientConfiguration(new Uri(uri)).CreateClient();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives the container name from environment + siteCode.
|
||||
/// Convention: {env}-app-clarity-{siteCode}
|
||||
/// </summary>
|
||||
public static string ContainerName(string environment, string siteCode) =>
|
||||
$"{environment.ToLowerInvariant()}-app-clarity-{siteCode.ToLowerInvariant()}";
|
||||
|
||||
/// <summary>
|
||||
/// Pulls the image (if not present locally), starts the container on the managed network,
|
||||
/// and writes an nginx conf.d snippet so traffic routes in.
|
||||
/// No host port binding — nginx reaches the container via Docker DNS on the shared network.
|
||||
/// </summary>
|
||||
public async Task<string> StartTenantContainerAsync(
|
||||
string environment,
|
||||
string siteCode,
|
||||
string subdomain,
|
||||
string keycloakRealm,
|
||||
string? postgresConnectionString,
|
||||
string? vaultToken,
|
||||
Guid jobId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var docker = CreateClient();
|
||||
var name = ContainerName(environment, siteCode);
|
||||
|
||||
// Stop and remove any existing container with this name (idempotent reprovision)
|
||||
await TryRemoveExistingAsync(docker, name, cancellationToken);
|
||||
|
||||
// Pull image if not already local
|
||||
await EnsureImageAsync(docker, cancellationToken);
|
||||
|
||||
// All service URLs use stable Docker DNS names on the managed network — no host ports involved.
|
||||
var container = await docker.Containers.CreateContainerAsync(new CreateContainerParameters
|
||||
{
|
||||
Name = name,
|
||||
Image = ImageName,
|
||||
Env =
|
||||
[
|
||||
"ASPNETCORE_ENVIRONMENT=Production",
|
||||
"ASPNETCORE_URLS=http://+:8080",
|
||||
$"TenantSubdomain={subdomain}",
|
||||
$"Keycloak__BaseUrl={Infra.KeycloakPublicUrl}",
|
||||
$"Keycloak__InternalUrl={Infra.KeycloakInternalUrl}",
|
||||
$"Keycloak__Realm={keycloakRealm}",
|
||||
$"Vault__Address={Infra.VaultInternalUrl}",
|
||||
.. (vaultToken is not null
|
||||
? (string[])[$"Vault__Token={vaultToken}"]
|
||||
: []),
|
||||
.. (postgresConnectionString is not null
|
||||
? (string[])[$"ConnectionStrings__postgresdb={postgresConnectionString}"]
|
||||
: []),
|
||||
],
|
||||
HostConfig = new HostConfig
|
||||
{
|
||||
NetworkMode = Infra.Network,
|
||||
RestartPolicy = new RestartPolicy { Name = RestartPolicyKind.UnlessStopped },
|
||||
},
|
||||
Labels = new Dictionary<string, string>
|
||||
{
|
||||
["clarity.managed"] = "true",
|
||||
["clarity.subdomain"] = subdomain,
|
||||
["clarity.siteCode"] = siteCode,
|
||||
["clarity.env"] = environment,
|
||||
},
|
||||
}, cancellationToken);
|
||||
|
||||
// Ensure Keycloak and Vault are reachable on the managed network via their Docker DNS aliases.
|
||||
// Aspire places them on its own bridge; tenant containers on clarity-net need them aliased here.
|
||||
await EnsureContainerOnNetworkAsync(docker, "keycloak", Infra.Network, "keycloak", cancellationToken);
|
||||
await EnsureContainerOnNetworkAsync(docker, "vault", Infra.Network, "vault", cancellationToken);
|
||||
|
||||
var started = await docker.Containers.StartContainerAsync(container.ID, null, cancellationToken);
|
||||
if (!started)
|
||||
throw new InvalidOperationException($"Docker failed to start container {name} (id={container.ID}).");
|
||||
|
||||
logger.LogInformation("Started container {Name} on {Network} (image: {Image})", name, Infra.Network, ImageName);
|
||||
|
||||
await WriteNginxConfigAsync(subdomain, name, jobId, cancellationToken);
|
||||
|
||||
return name;
|
||||
}
|
||||
/// <summary>
|
||||
/// Stops and removes a tenant container. Called from InfrastructureStep.CompensateAsync.
|
||||
/// </summary>
|
||||
public async Task StopAndRemoveAsync(string containerName, CancellationToken cancellationToken)
|
||||
{
|
||||
using var docker = CreateClient();
|
||||
await TryRemoveExistingAsync(docker, containerName, cancellationToken);
|
||||
logger.LogInformation("Removed container {Name}", containerName);
|
||||
}
|
||||
|
||||
// -- helpers --
|
||||
|
||||
private async Task EnsureImageAsync(DockerClient docker, CancellationToken cancellationToken)
|
||||
{
|
||||
var images = await docker.Images.ListImagesAsync(new ImagesListParameters
|
||||
{
|
||||
Filters = new Dictionary<string, IDictionary<string, bool>>
|
||||
{
|
||||
["reference"] = new Dictionary<string, bool> { [ImageName] = true }
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
if (images.Count > 0)
|
||||
{
|
||||
logger.LogInformation("Image {Image} already present locally.", ImageName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Local image (no registry host) — pulling from Docker Hub will always fail.
|
||||
// The image must be built manually before provisioning.
|
||||
var isLocalOnly = !ImageName.Contains('/') || ImageName.StartsWith("localhost/");
|
||||
if (isLocalOnly)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Image '{ImageName}' was not found locally and cannot be pulled from a registry. " +
|
||||
$"Build it first from the repo root:{Environment.NewLine}" +
|
||||
$" docker build -f Clarity.Server/Dockerfile -t {ImageName} ." +
|
||||
$"{Environment.NewLine}Then retry provisioning.");
|
||||
}
|
||||
|
||||
// Registry image — attempt pull
|
||||
logger.LogInformation("Pulling image {Image} from registry...", ImageName);
|
||||
var (repo, tag) = SplitImageTag(ImageName);
|
||||
await docker.Images.CreateImageAsync(
|
||||
new ImagesCreateParameters { FromImage = repo, Tag = tag },
|
||||
null,
|
||||
new Progress<JSONMessage>(m =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(m.Status))
|
||||
logger.LogDebug("[docker pull] {Status} {Progress}", m.Status, m.ProgressMessage);
|
||||
}),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// -- nginx conf.d helpers --
|
||||
|
||||
/// <summary>
|
||||
/// Writes /NginxConfig/conf.d/{subdomain}.conf so nginx routes
|
||||
/// {subdomain}.clarity.test → the containe
|
||||
/// Then signals nginx to reload its config without dropping connections.
|
||||
/// </summary>
|
||||
private async Task WriteNginxConfigAsync(string subdomain, string containerName, Guid jobId, CancellationToken ct)
|
||||
{
|
||||
var confDPath = config["Nginx:ConfDPath"];
|
||||
if (string.IsNullOrWhiteSpace(confDPath))
|
||||
{
|
||||
logger.LogWarning("Nginx:ConfDPath is not configured — skipping nginx conf write for {Subdomain}.", subdomain);
|
||||
return;
|
||||
}
|
||||
|
||||
var confContent = $$$"""
|
||||
# Auto-generated by ControlPlane.Worker — do not edit manually.
|
||||
# Tenant: {{{subdomain}}}
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name {{{subdomain}}}.{{{Infra.Domain}}};
|
||||
|
||||
ssl_certificate {{{Infra.NginxCertPath}}};
|
||||
ssl_certificate_key {{{Infra.NginxCertKeyPath}}};
|
||||
|
||||
location / {
|
||||
# Docker DNS resolves the container name on the managed network
|
||||
set $upstream http://{{{containerName}}}:8080;
|
||||
proxy_pass $upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var confFile = Path.Combine(confDPath, $"{subdomain}.conf");
|
||||
await File.WriteAllTextAsync(confFile, confContent, ct);
|
||||
logger.LogInformation("Wrote nginx config for {Subdomain} → {Container}", subdomain, containerName);
|
||||
|
||||
await ReloadNginxAsync(jobId, subdomain, ct);
|
||||
}
|
||||
|
||||
public async Task RemoveNginxConfigAsync(string subdomain, CancellationToken ct)
|
||||
{
|
||||
var confDPath = config["Nginx:ConfDPath"];
|
||||
if (string.IsNullOrWhiteSpace(confDPath)) return;
|
||||
|
||||
var confFile = Path.Combine(confDPath, $"{subdomain}.conf");
|
||||
if (File.Exists(confFile))
|
||||
{
|
||||
File.Delete(confFile);
|
||||
logger.LogInformation("Removed nginx config for {Subdomain}", subdomain);
|
||||
await ReloadNginxAsync(Guid.Empty, subdomain, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends SIGHUP to the nginx container which triggers a graceful config reload.
|
||||
private async Task ReloadNginxAsync(Guid jobId, string subdomain, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var docker = CreateClient();
|
||||
|
||||
// Find the nginx container by image name — Aspire appends a random suffix to the name
|
||||
// so we can't rely on the static name "nginx".
|
||||
var containers = await docker.Containers.ListContainersAsync(
|
||||
new ContainersListParameters
|
||||
{
|
||||
Filters = new Dictionary<string, IDictionary<string, bool>>
|
||||
{
|
||||
["ancestor"] = new Dictionary<string, bool> { ["nginx"] = true }
|
||||
}
|
||||
}, ct);
|
||||
|
||||
var nginx = containers.FirstOrDefault();
|
||||
if (nginx is null)
|
||||
{
|
||||
logger.LogWarning("nginx container not found — skipping reload.");
|
||||
return;
|
||||
}
|
||||
|
||||
await docker.Containers.KillContainerAsync(nginx.ID, new ContainerKillParameters { Signal = "HUP" }, ct);
|
||||
var containerName = nginx.Names.FirstOrDefault() ?? nginx.ID;
|
||||
logger.LogInformation("nginx reloaded (container: {Name}).", containerName);
|
||||
|
||||
if (jobId != Guid.Empty)
|
||||
{
|
||||
await bus.Publish(new ProvisioningProgressEvent
|
||||
{
|
||||
JobId = jobId,
|
||||
Type = "nginx_reloaded",
|
||||
Step = "Container Launch",
|
||||
Message = $"nginx reloaded — route for {subdomain}.{Infra.Domain} is live.",
|
||||
}, ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to reload nginx — new tenant route may not be active until next nginx restart.");
|
||||
|
||||
if (jobId != Guid.Empty)
|
||||
{
|
||||
await bus.Publish(new ProvisioningProgressEvent
|
||||
{
|
||||
JobId = jobId,
|
||||
Type = "diagnostic",
|
||||
Step = "Container Launch",
|
||||
Message = "nginx reload failed — route may not be active.",
|
||||
Detail = ex.ToString(),
|
||||
}, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- docker helpers --
|
||||
|
||||
private static async Task TryRemoveExistingAsync(DockerClient docker, string name, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await docker.Containers.StopContainerAsync(name,
|
||||
new ContainerStopParameters { WaitBeforeKillSeconds = 5 }, cancellationToken);
|
||||
await docker.Containers.RemoveContainerAsync(name,
|
||||
new ContainerRemoveParameters { Force = true }, cancellationToken);
|
||||
}
|
||||
catch (DockerContainerNotFoundException) { /* already gone - fine */ }
|
||||
catch (DockerApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) { /* same */ }
|
||||
}
|
||||
|
||||
private static (string repo, string tag) SplitImageTag(string image)
|
||||
{
|
||||
var colon = image.LastIndexOf(':');
|
||||
return colon < 0 ? (image, "latest") : (image[..colon], image[(colon + 1)..]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects <paramref name="containerName"/> to <paramref name="network"/> with the given
|
||||
/// <paramref name="alias"/> if it isn't already connected.
|
||||
/// Silently no-ops if the container isn't found (it may not be running in all environments).
|
||||
/// </summary>
|
||||
private async Task EnsureContainerOnNetworkAsync(
|
||||
DockerClient docker,
|
||||
string containerName,
|
||||
string network,
|
||||
string alias,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var inspect = await docker.Containers.InspectContainerAsync(containerName, cancellationToken);
|
||||
|
||||
if (inspect.NetworkSettings.Networks.TryGetValue(network, out var existing))
|
||||
{
|
||||
// Already connected — check whether our alias is present.
|
||||
var hasAlias = existing.Aliases?.Contains(alias, StringComparer.OrdinalIgnoreCase) == true;
|
||||
if (hasAlias) return;
|
||||
|
||||
// Connected but without the alias — disconnect so we can reconnect with it.
|
||||
await docker.Networks.DisconnectNetworkAsync(network, new NetworkDisconnectParameters
|
||||
{
|
||||
Container = inspect.ID,
|
||||
Force = true,
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
await docker.Networks.ConnectNetworkAsync(network, new NetworkConnectParameters
|
||||
{
|
||||
Container = inspect.ID,
|
||||
EndpointConfig = new EndpointSettings
|
||||
{
|
||||
Aliases = [alias],
|
||||
},
|
||||
}, cancellationToken);
|
||||
logger.LogInformation("Connected container '{Container}' to network '{Network}' with alias '{Alias}'.", containerName, network, alias);
|
||||
}
|
||||
catch (DockerContainerNotFoundException)
|
||||
{
|
||||
logger.LogWarning("Container '{Container}' not found — skipping network connect.", containerName);
|
||||
}
|
||||
catch (DockerApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
logger.LogWarning("Container '{Container}' not found — skipping network connect.", containerName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not connect '{Container}' to '{Network}' — tenant JWT validation may fail.", containerName, network);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ControlPlane.Worker.Services;
|
||||
|
||||
public class KeycloakAdminClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly string _baseUrl;
|
||||
private readonly string _adminUser;
|
||||
private readonly string _adminPassword;
|
||||
private readonly ILogger<KeycloakAdminClient> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public KeycloakAdminClient(IConfiguration config, ILogger<KeycloakAdminClient> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_baseUrl = (config["Keycloak:AuthServerUrl"] ?? config["Keycloak:BaseUrl"])?.TrimEnd('/') ?? "http://localhost:8080";
|
||||
_adminUser = config["Keycloak:AdminUser"] ?? "admin";
|
||||
_adminPassword = config["Keycloak:AdminPassword"] ?? "admin";
|
||||
|
||||
var maskedPw = _adminPassword.Length > 2 ? $"{_adminPassword[0]}***{_adminPassword[^1]}" : "***";
|
||||
_logger.LogInformation("KeycloakAdminClient base URL: {Url}, user: {User}, password: {Password}",
|
||||
_baseUrl, _adminUser, maskedPw);
|
||||
|
||||
_http = new HttpClient { BaseAddress = new Uri(_baseUrl) };
|
||||
}
|
||||
|
||||
private async Task AuthorizeAsync(CancellationToken ct)
|
||||
{
|
||||
var form = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "password",
|
||||
["client_id"] = "admin-cli",
|
||||
["username"] = _adminUser,
|
||||
["password"] = _adminPassword,
|
||||
});
|
||||
|
||||
var res = await _http.PostAsync("/realms/master/protocol/openid-connect/token", form, ct);
|
||||
res.EnsureSuccessStatusCode();
|
||||
|
||||
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct));
|
||||
var token = doc.RootElement.GetProperty("access_token").GetString()!;
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
public async Task CreateRealmAsync(string realmId, string displayName, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var res = await _http.PostAsync("/admin/realms", Json(new
|
||||
{
|
||||
realm = realmId,
|
||||
displayName = displayName,
|
||||
enabled = true,
|
||||
registrationAllowed = true,
|
||||
registrationEmailAsUsername = false,
|
||||
loginWithEmailAllowed = true,
|
||||
resetPasswordAllowed = true,
|
||||
verifyEmail = false,
|
||||
sslRequired = "external",
|
||||
}), ct);
|
||||
|
||||
if (res.StatusCode == System.Net.HttpStatusCode.Conflict)
|
||||
{
|
||||
_logger.LogWarning("Realm {Realm} already exists - skipping.", realmId);
|
||||
return;
|
||||
}
|
||||
|
||||
res.EnsureSuccessStatusCode();
|
||||
_logger.LogInformation("Realm {Realm} created.", realmId);
|
||||
}
|
||||
|
||||
public async Task DeleteRealmAsync(string realmId, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var res = await _http.DeleteAsync($"/admin/realms/{realmId}", ct);
|
||||
if (res.StatusCode != System.Net.HttpStatusCode.NotFound)
|
||||
res.EnsureSuccessStatusCode();
|
||||
_logger.LogInformation("Realm {Realm} deleted.", realmId);
|
||||
}
|
||||
|
||||
public async Task CreateRealmRoleAsync(string realmId, string roleName, string description, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var res = await _http.PostAsync($"/admin/realms/{realmId}/roles",
|
||||
Json(new { name = roleName, description }), ct);
|
||||
if (res.StatusCode != System.Net.HttpStatusCode.Conflict)
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<string> CreateUserAsync(string realmId, string email, string firstName, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var res = await _http.PostAsync($"/admin/realms/{realmId}/users",
|
||||
Json(new { username = email, email, firstName, enabled = true, emailVerified = true }), ct);
|
||||
if (res.StatusCode != System.Net.HttpStatusCode.Conflict)
|
||||
res.EnsureSuccessStatusCode();
|
||||
return await GetUserIdAsync(realmId, email, ct);
|
||||
}
|
||||
|
||||
public async Task<string> GetUserIdAsync(string realmId, string email, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var res = await _http.GetAsync(
|
||||
$"/admin/realms/{realmId}/users?email={Uri.EscapeDataString(email)}&exact=true", ct);
|
||||
res.EnsureSuccessStatusCode();
|
||||
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct));
|
||||
var users = doc.RootElement.EnumerateArray().ToList();
|
||||
if (users.Count == 0)
|
||||
throw new InvalidOperationException($"User {email} not found in realm {realmId}.");
|
||||
return users[0].GetProperty("id").GetString()!;
|
||||
}
|
||||
|
||||
public async Task AssignRealmRoleAsync(string realmId, string userId, string roleName, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var roleRes = await _http.GetAsync($"/admin/realms/{realmId}/roles/{roleName}", ct);
|
||||
roleRes.EnsureSuccessStatusCode();
|
||||
var roleJson = await roleRes.Content.ReadAsStringAsync(ct);
|
||||
var res = await _http.PostAsync(
|
||||
$"/admin/realms/{realmId}/users/{userId}/role-mappings/realm",
|
||||
new StringContent($"[{roleJson}]", Encoding.UTF8, "application/json"), ct);
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task CreateClientAsync(string realmId, object clientRepresentation, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var res = await _http.PostAsync($"/admin/realms/{realmId}/clients",
|
||||
Json(clientRepresentation), ct);
|
||||
if (res.StatusCode != System.Net.HttpStatusCode.Conflict)
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the internal Keycloak UUID for a client by its clientId string.
|
||||
/// </summary>
|
||||
public async Task<string> GetClientUuidAsync(string realmId, string clientId, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var res = await _http.GetAsync(
|
||||
$"/admin/realms/{realmId}/clients?clientId={Uri.EscapeDataString(clientId)}&search=false", ct);
|
||||
res.EnsureSuccessStatusCode();
|
||||
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct));
|
||||
var clients = doc.RootElement.EnumerateArray().ToList();
|
||||
if (clients.Count == 0)
|
||||
throw new InvalidOperationException($"Client '{clientId}' not found in realm '{realmId}'.");
|
||||
return clients[0].GetProperty("id").GetString()!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an audience protocol mapper to a client so that the named audience is included in every
|
||||
/// access token issued by that client.
|
||||
/// </summary>
|
||||
public async Task AddAudienceMapperAsync(string realmId, string clientUuid, string audienceName, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var res = await _http.PostAsync(
|
||||
$"/admin/realms/{realmId}/clients/{clientUuid}/protocol-mappers/models",
|
||||
Json(new
|
||||
{
|
||||
name = $"audience-{audienceName}",
|
||||
protocol = "openid-connect",
|
||||
protocolMapper = "oidc-audience-mapper",
|
||||
consentRequired = false,
|
||||
config = new Dictionary<string, string>
|
||||
{
|
||||
["included.client.audience"] = audienceName,
|
||||
["id.token.claim"] = "false",
|
||||
["access.token.claim"] = "true",
|
||||
},
|
||||
}), ct);
|
||||
if (res.StatusCode != System.Net.HttpStatusCode.Conflict)
|
||||
res.EnsureSuccessStatusCode();
|
||||
_logger.LogInformation("Added audience mapper '{Audience}' to client {ClientUuid} in realm {Realm}.",
|
||||
audienceName, clientUuid, realmId);
|
||||
}
|
||||
|
||||
public async Task SendRequiredActionsEmailAsync(
|
||||
string realmId, string userId, IEnumerable<string> actions, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var res = await _http.PutAsync(
|
||||
$"/admin/realms/{realmId}/users/{userId}/execute-actions-email?lifespan=86400",
|
||||
new StringContent(JsonSerializer.Serialize(actions, JsonOpts), Encoding.UTF8, "application/json"),
|
||||
ct);
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static StringContent Json(object payload) =>
|
||||
new(JsonSerializer.Serialize(payload, JsonOpts), Encoding.UTF8, "application/json");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using ControlPlane.Core.Interfaces;
|
||||
using ControlPlane.Core.Models;
|
||||
|
||||
namespace ControlPlane.Worker.Steps;
|
||||
|
||||
public class HandoffStep(ILogger<HandoffStep> logger) : ISagaStep
|
||||
{
|
||||
public string StepName => "Handoff (Email Magic Link)";
|
||||
|
||||
public Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: SendGrid / AWS SES
|
||||
// 1. Send email to context.Job.AdminEmail
|
||||
// 2. Include context.MagicLink for password setup
|
||||
// 3. Include login URL: https://{context.Job.Subdomain}
|
||||
logger.LogInformation("[{JobId}] Handoff step is a stub - email provider not yet wired.", context.Job.Id);
|
||||
context.Job.CompletedSteps |= CompletedSteps.HandoffSent;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CompensateAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
// Email already sent cannot be recalled - log only
|
||||
logger.LogWarning("[{JobId}] Handoff step: email cannot be compensated if already sent.", context.Job.Id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using ControlPlane.Core.Config;
|
||||
using ControlPlane.Core.Interfaces;
|
||||
using ControlPlane.Core.Models;
|
||||
using ControlPlane.Worker.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ControlPlane.Worker.Steps;
|
||||
|
||||
public class KeycloakStep(
|
||||
KeycloakAdminClient adminClient,
|
||||
IConfiguration config,
|
||||
IOptions<ClarityInfraOptions> infraOptions,
|
||||
ILogger<KeycloakStep> logger) : ISagaStep
|
||||
{
|
||||
public string StepName => "Identity Bootstrapping (Keycloak)";
|
||||
|
||||
public async Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var realmId = RealmId(context);
|
||||
|
||||
logger.LogInformation("[{JobId}] Creating Keycloak realm {Realm}.", context.Job.Id, realmId);
|
||||
await adminClient.CreateRealmAsync(realmId, context.Job.ClientName, cancellationToken);
|
||||
|
||||
logger.LogInformation("[{JobId}] Creating AgencyAdmin role.", context.Job.Id);
|
||||
await adminClient.CreateRealmRoleAsync(realmId, "AgencyAdmin", "Day-zero administrator for this Clarity tenant.", cancellationToken);
|
||||
|
||||
// Derive the tenant's public-facing origin from ClarityInfraOptions.
|
||||
var tenantOrigin = infraOptions.Value.TenantPublicUrl(context.Job.Subdomain);
|
||||
|
||||
logger.LogInformation("[{JobId}] Creating Keycloak clients for realm {Realm} (origin: {Origin}).",
|
||||
context.Job.Id, realmId, tenantOrigin);
|
||||
|
||||
// clarity-rest-api: bearer-only resource server — just registers the audience so JWT validation passes.
|
||||
await adminClient.CreateClientAsync(realmId, new
|
||||
{
|
||||
clientId = "clarity-rest-api",
|
||||
name = "Clarity REST API",
|
||||
enabled = true,
|
||||
bearerOnly = true,
|
||||
publicClient = false,
|
||||
}, cancellationToken);
|
||||
|
||||
// clarity-web-app: public OIDC client used by the React frontend.
|
||||
await adminClient.CreateClientAsync(realmId, new
|
||||
{
|
||||
clientId = "clarity-web-app",
|
||||
name = "Clarity Web App",
|
||||
enabled = true,
|
||||
publicClient = true,
|
||||
standardFlowEnabled = true,
|
||||
directAccessGrantsEnabled = false,
|
||||
rootUrl = tenantOrigin,
|
||||
baseUrl = "/",
|
||||
redirectUris = new[] { $"{tenantOrigin}/*" },
|
||||
webOrigins = new[] { tenantOrigin },
|
||||
}, cancellationToken);
|
||||
|
||||
// Ensure tokens issued by clarity-web-app include "clarity-rest-api" in the `aud` claim
|
||||
// so that Clarity.Server JWT bearer validation (Audience = "clarity-rest-api") passes.
|
||||
logger.LogInformation("[{JobId}] Adding audience mapper for clarity-rest-api on clarity-web-app.", context.Job.Id);
|
||||
var webAppUuid = await adminClient.GetClientUuidAsync(realmId, "clarity-web-app", cancellationToken);
|
||||
await adminClient.AddAudienceMapperAsync(realmId, webAppUuid, "clarity-rest-api", cancellationToken);
|
||||
|
||||
logger.LogInformation("[{JobId}] Creating day-zero user {Email}.", context.Job.Id, context.Job.AdminEmail);
|
||||
var userId = await adminClient.CreateUserAsync(realmId, context.Job.AdminEmail, context.Job.ClientName, cancellationToken);
|
||||
|
||||
logger.LogInformation("[{JobId}] Assigning AgencyAdmin role.", context.Job.Id);
|
||||
await adminClient.AssignRealmRoleAsync(realmId, userId, "AgencyAdmin", cancellationToken);
|
||||
|
||||
// TODO No SMTP right now
|
||||
//logger.LogInformation("[{JobId}] Sending required actions email to {Email}.", context.Job.Id, context.Job.AdminEmail);
|
||||
//await adminClient.SendRequiredActionsEmailAsync(realmId, userId, ["UPDATE_PASSWORD", "VERIFY_EMAIL"], cancellationToken);
|
||||
|
||||
context.DayZeroUserSubjectId = userId;
|
||||
//context.MagicLink = $"Action email sent to {context.Job.AdminEmail} for realm '{realmId}'.";
|
||||
context.Job.CompletedSteps |= CompletedSteps.KeycloakProvisioned;
|
||||
|
||||
logger.LogInformation("[{JobId}] Keycloak provisioning complete for realm {Realm}.", context.Job.Id, realmId);
|
||||
}
|
||||
|
||||
public async Task CompensateAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var realmId = RealmId(context);
|
||||
logger.LogWarning("[{JobId}] Compensating Keycloak - deleting realm {Realm}.", context.Job.Id, realmId);
|
||||
await adminClient.DeleteRealmAsync(realmId, cancellationToken);
|
||||
}
|
||||
|
||||
private static string RealmId(SagaContext context) =>
|
||||
$"clarity-{context.Job.Subdomain.ToLowerInvariant()}";
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using ControlPlane.Core.Config;
|
||||
using ControlPlane.Core.Interfaces;
|
||||
using ControlPlane.Core.Models;
|
||||
using ControlPlane.Worker.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ControlPlane.Worker.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Final saga step — launches the clarity-server Docker container with the fully
|
||||
/// enriched SagaContext (connection strings, Keycloak realm, etc. all known).
|
||||
/// Runs LAST so all env vars are available at container start.
|
||||
/// </summary>
|
||||
public class LaunchStep(
|
||||
ILogger<LaunchStep> logger,
|
||||
IConfiguration config,
|
||||
IOptions<ClarityInfraOptions> infraOptions,
|
||||
ClarityContainerService containers) : ISagaStep
|
||||
{
|
||||
public string StepName => "Container Launch";
|
||||
|
||||
public async Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = context.Job;
|
||||
|
||||
logger.LogInformation("[{JobId}] Launching container {Env}-app-clarity-{Site}",
|
||||
job.Id, job.Environment, job.SiteCode);
|
||||
|
||||
var containerName = await containers.StartTenantContainerAsync(
|
||||
environment: job.Environment,
|
||||
siteCode: job.SiteCode,
|
||||
subdomain: job.Subdomain,
|
||||
keycloakRealm: $"clarity-{job.Subdomain.ToLowerInvariant()}",
|
||||
postgresConnectionString: context.TenantConnectionString,
|
||||
vaultToken: ReadVaultToken(config),
|
||||
jobId: job.Id,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
context.ContainerName = containerName;
|
||||
context.TenantApiBaseUrl = infraOptions.Value.TenantPublicUrl(job.Subdomain);
|
||||
|
||||
logger.LogInformation("[{JobId}] Container {Name} live at {Url}",
|
||||
job.Id, containerName, context.TenantApiBaseUrl);
|
||||
|
||||
context.Job.CompletedSteps |= CompletedSteps.InfrastructureProvisioned;
|
||||
}
|
||||
|
||||
public async Task CompensateAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.ContainerName)) return;
|
||||
|
||||
logger.LogWarning("[{JobId}] Compensating: removing container {Name}", context.Job.Id, context.ContainerName);
|
||||
await containers.StopAndRemoveAsync(context.ContainerName, cancellationToken);
|
||||
await containers.RemoveNginxConfigAsync(context.Job.Subdomain, cancellationToken);
|
||||
}
|
||||
|
||||
// Reads the Vault root token from the persisted init.json on the Vault volume.
|
||||
// Falls back to config["Vault:Token"] then "root" for local dev.
|
||||
private static string? ReadVaultToken(IConfiguration config)
|
||||
{
|
||||
var keysFile = config["Vault:KeysFile"];
|
||||
if (!string.IsNullOrWhiteSpace(keysFile) && File.Exists(keysFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(keysFile);
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("root_token", out var tok))
|
||||
return tok.GetString();
|
||||
}
|
||||
catch { /* fall through */ }
|
||||
}
|
||||
|
||||
return config["Vault:Token"] ?? "root";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using ControlPlane.Core.Interfaces;
|
||||
using ControlPlane.Core.Models;
|
||||
using Npgsql;
|
||||
|
||||
namespace ControlPlane.Worker.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a per-tenant Postgres database on the shared Postgres instance.
|
||||
/// Writes TenantConnectionString to SagaContext for downstream steps (LaunchStep).
|
||||
/// Compensation drops the database.
|
||||
/// </summary>
|
||||
public class MigrationStep(
|
||||
IConfiguration config,
|
||||
ILogger<MigrationStep> logger) : ISagaStep
|
||||
{
|
||||
public string StepName => "Database Migration & Seeding (EF Core)";
|
||||
|
||||
public async Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = context.Job;
|
||||
var dbName = TenantDbName(job.Subdomain);
|
||||
|
||||
var adminConnStr = config.GetConnectionString("postgres")
|
||||
?? throw new InvalidOperationException(
|
||||
"ConnectionStrings:postgres is missing. " +
|
||||
"Ensure ControlPlane.Worker has .WithReference(postgres) in AppHost.");
|
||||
|
||||
logger.LogInformation("[{JobId}] Provisioning database '{Db}'.", job.Id, dbName);
|
||||
await CreateDatabaseIfNotExistsAsync(adminConnStr, dbName, cancellationToken);
|
||||
|
||||
context.TenantConnectionString = BuildTenantConnectionString(adminConnStr, dbName);
|
||||
logger.LogInformation("[{JobId}] Database '{Db}' ready.", job.Id, dbName);
|
||||
|
||||
// TODO: Run EF Core migrations once dynamic DbContext is wired:
|
||||
// var opts = new DbContextOptionsBuilder<ApplicationDbContext>().UseNpgsql(context.TenantConnectionString).Options;
|
||||
// await using var db = new ApplicationDbContext(opts);
|
||||
// await db.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
context.Job.CompletedSteps |= CompletedSteps.DatabaseMigrated;
|
||||
}
|
||||
|
||||
public async Task CompensateAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.TenantConnectionString)) return;
|
||||
|
||||
var dbName = TenantDbName(context.Job.Subdomain);
|
||||
var adminConnStr = config.GetConnectionString("postgres");
|
||||
if (string.IsNullOrWhiteSpace(adminConnStr)) return;
|
||||
|
||||
logger.LogWarning("[{JobId}] Compensating: dropping database '{Db}'.", context.Job.Id, dbName);
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(adminConnStr);
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
await using var terminate = conn.CreateCommand();
|
||||
terminate.CommandText = $"""
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = '{dbName}' AND pid <> pg_backend_pid();
|
||||
""";
|
||||
await terminate.ExecuteNonQueryAsync(cancellationToken);
|
||||
|
||||
await using var drop = conn.CreateCommand();
|
||||
drop.CommandText = $"DROP DATABASE IF EXISTS \"{dbName}\";";
|
||||
await drop.ExecuteNonQueryAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("[{JobId}] Dropped database '{Db}'.", context.Job.Id, dbName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "[{JobId}] Failed to drop database '{Db}' during compensation.", context.Job.Id, dbName);
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Deterministic DB name from subdomain: fdev-app-clarity-01000014 → clarity_fdev_app_clarity_01000014
|
||||
internal static string TenantDbName(string subdomain) =>
|
||||
$"clarity_{subdomain.Replace('-', '_').ToLowerInvariant()}";
|
||||
|
||||
private static async Task CreateDatabaseIfNotExistsAsync(
|
||||
string adminConnStr, string dbName, CancellationToken ct)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(adminConnStr);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
await using var check = conn.CreateCommand();
|
||||
check.CommandText = "SELECT 1 FROM pg_database WHERE datname = $1;";
|
||||
check.Parameters.AddWithValue(dbName);
|
||||
var exists = await check.ExecuteScalarAsync(ct) is not null;
|
||||
|
||||
if (!exists)
|
||||
{
|
||||
await using var create = conn.CreateCommand();
|
||||
// DB name is internally derived, not user input — safe to interpolate
|
||||
create.CommandText = $"CREATE DATABASE \"{dbName}\";";
|
||||
await create.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildTenantConnectionString(string adminConnStr, string dbName)
|
||||
{
|
||||
var b = new NpgsqlConnectionStringBuilder(adminConnStr) { Database = dbName };
|
||||
|
||||
// Tenant containers reach Postgres via the Aspire shared network using the stable
|
||||
// DNS alias "postgres" (the Aspire resource name) at the standard port 5432.
|
||||
// The port in the admin connection string is Aspire's random host-side proxy port —
|
||||
// reset it to 5432 so the in-network address is correct.
|
||||
if (b.Host is "localhost" or "127.0.0.1")
|
||||
{
|
||||
b.Host = "postgres";
|
||||
b.Port = 5432;
|
||||
}
|
||||
|
||||
return b.ConnectionString;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using ControlPlane.Core.Interfaces;
|
||||
using ControlPlane.Core.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ControlPlane.Worker.Steps;
|
||||
|
||||
public class VaultStep(ILogger<VaultStep> logger, IConfiguration config) : ISagaStep
|
||||
{
|
||||
public string StepName => "Cryptographic Pre-Flight (Vault)";
|
||||
|
||||
public Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: VaultSharp
|
||||
// 1. Assert Transit engine is active and healthy
|
||||
// 2. Derive/validate TenantContextId (e.g. FL_COM_001)
|
||||
// 3. Register TenantContextId in a KV entry or TenantRegistry table
|
||||
// so Clarity.Server can resolve the derivation path later
|
||||
//
|
||||
// Root token is read at runtime from the persisted init.json on the Vault volume:
|
||||
// var token = ReadRootToken();
|
||||
logger.LogInformation("[{JobId}] Vault step is a stub - VaultSharp not yet wired.", context.Job.Id);
|
||||
context.Job.CompletedSteps |= CompletedSteps.VaultVerified;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CompensateAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogInformation("[{JobId}] Vault step: no compensation needed.", context.Job.Id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the root token from the init.json written by the Vault entrypoint on first boot.
|
||||
/// Path is injected via Vault__KeysFile config.
|
||||
/// </summary>
|
||||
internal string ReadRootToken()
|
||||
{
|
||||
var path = config["Vault__KeysFile"]
|
||||
?? throw new InvalidOperationException("Vault__KeysFile is not configured.");
|
||||
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
return doc.RootElement.GetProperty("root_token").GetString()
|
||||
?? throw new InvalidOperationException("root_token not found in Vault init.json.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Solution>
|
||||
<Folder Name="/ControlPlane (Factory)/">
|
||||
<Project Path="ControlPlane.AppHost/ControlPlane.AppHost.csproj" />
|
||||
<Project Path="ControlPlane.Core/ControlPlane.Core.csproj" />
|
||||
<Project Path="ControlPlane.Api/ControlPlane.Api.csproj" />
|
||||
<Project Path="ControlPlane.Worker/ControlPlane.Worker.csproj" />
|
||||
<Project Path="clarity.controlplane/clarity.controlplane.esproj">
|
||||
<Build />
|
||||
<Deploy />
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/">
|
||||
<File Path="Directory.Packages.props" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,53 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Enable central package management, https://learn.microsoft.com/en-us/nuget/consume-packages/Central-Package-Management -->
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<!-- Aspire Packages -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Aspire.Hosting.PostgreSQL" Version="13.2.2" />
|
||||
<PackageVersion Include="Aspire.Keycloak.Authentication" Version="13.2.2-preview.1.26207.2" />
|
||||
<PackageVersion Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.2.2" />
|
||||
<PackageVersion Include="Aspire.StackExchange.Redis.OutputCaching" Version="13.2.2" />
|
||||
<PackageVersion Include="Aspire.Hosting.JavaScript" Version="13.2.2" />
|
||||
<PackageVersion Include="Aspire.Hosting.Keycloak" Version="13.2.2-preview.1.26207.2" />
|
||||
<PackageVersion Include="Aspire.Hosting.Redis" Version="13.2.2" />
|
||||
<PackageVersion Include="CommunityToolkit.Aspire.Hosting.Minio" Version="13.2.1-beta.532" />
|
||||
<PackageVersion Include="CommunityToolkit.Aspire.Minio.Client" Version="13.2.1-beta.532" />
|
||||
<PackageVersion Include="Docker.DotNet" Version="3.125.15" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Npgsql" Version="10.0.2" />
|
||||
<PackageVersion Include="LibGit2Sharp" Version="0.31.0" />
|
||||
<PackageVersion Include="Scalar.Aspire" Version="0.9.24" />
|
||||
<PackageVersion Include="VaultSharp" Version="1.17.5.1" />
|
||||
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
<!-- Clarity.MigrationService -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.6" />
|
||||
</ItemGroup>
|
||||
<!-- ControlPlane -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Aspire.Hosting.Sdk" Version="13.2.2" />
|
||||
<PackageVersion Include="Aspire.Hosting.RabbitMQ" Version="13.2.2" />
|
||||
<PackageVersion Include="Aspire.RabbitMQ.Client" Version="13.2.2" />
|
||||
<PackageVersion Include="Keycloak.AuthServices.Sdk" Version="2.9.0" />
|
||||
<PackageVersion Include="MassTransit" Version="8.4.1" />
|
||||
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.4.1" />
|
||||
</ItemGroup>
|
||||
<!-- Clarity.Server -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.5.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.5.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.2" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.2" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,2 @@
|
||||
VITE_CLARITY_DOMAIN=clarity.test
|
||||
OPEN_ROUTER_KEY=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
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,12 @@
|
||||
This file explains how Visual Studio created the project.
|
||||
|
||||
The following tools were used to generate this project:
|
||||
- create-vite
|
||||
|
||||
The following steps were used to generate this project:
|
||||
- Create react project with create-vite: `npm init --yes vite@latest clarity.controlplane -- --template=react-ts --no-rolldown --no-immediate`.
|
||||
- Updating `vite.config.ts` with port.
|
||||
- Create project file (`clarity.controlplane.esproj`).
|
||||
- Create `launch.json` to enable debugging.
|
||||
- Add project to solution.
|
||||
- Write this file.
|
||||
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.VisualStudio.JavaScript.Sdk/1.0.4902498">
|
||||
<PropertyGroup>
|
||||
<StartupCommand>npm run dev</StartupCommand>
|
||||
<JavaScriptTestRoot>src\</JavaScriptTestRoot>
|
||||
<JavaScriptTestFramework>Vitest</JavaScriptTestFramework>
|
||||
<!-- Allows the build (or compile) script located on package.json to run on Build -->
|
||||
<ShouldRunBuildScript>false</ShouldRunBuildScript>
|
||||
<!-- Folder where production build objects will be placed -->
|
||||
<BuildOutputFolder>$(MSBuildProjectDirectory)\dist</BuildOutputFolder>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>clarity.controlplane</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3537
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "clarity.controlplane",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blueprintjs/core": "^6.12.0",
|
||||
"diff2html": "^3.4.56",
|
||||
"highlight.js": "^11.11.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-multistep": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.9"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1 @@
|
||||
/* App-level overrides — component styles live in index.css */
|
||||
@@ -0,0 +1,76 @@
|
||||
import '@blueprintjs/core/lib/css/blueprint.css';
|
||||
import './App.css';
|
||||
import { useState } from 'react';
|
||||
import { Menu, MenuItem, MenuDivider } from '@blueprintjs/core';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import PipelinesPage from './pages/PipelinesPage';
|
||||
import BuildMonitorPage from './pages/BuildMonitorPage';
|
||||
import ImageBuildPage from './pages/ImageBuildPage';
|
||||
import BranchPage from './pages/BranchPage';
|
||||
import OpcPage from './opc/OpcPage';
|
||||
import InfraPage from './pages/InfraPage';
|
||||
|
||||
function App() {
|
||||
const [activeNav, setActiveNav] = useState('opc');
|
||||
|
||||
return (
|
||||
<div className="cp-shell">
|
||||
{/* ── Sidebar ── */}
|
||||
<aside className="cp-sidebar">
|
||||
<div className="cp-sidebar-brand">
|
||||
<span className="brand-mark">CP</span>
|
||||
<span className="brand-name">Control Plane</span>
|
||||
</div>
|
||||
|
||||
<div className="cp-sidebar-nav">
|
||||
<Menu className="cp-sidebar-menu">
|
||||
<MenuItem icon="cloud-upload" text="Deployments" active={activeNav === 'deployments'} onClick={() => setActiveNav('deployments')} />
|
||||
<MenuItem icon="git-branch" text="Pipelines" active={activeNav === 'pipelines'} onClick={() => setActiveNav('pipelines')} />
|
||||
<MenuItem icon="git-merge" text="Branch Ladder" active={activeNav === 'branches'} onClick={() => setActiveNav('branches')} />
|
||||
<MenuItem icon="build" text="Image Build" active={activeNav === 'image-build'} onClick={() => setActiveNav('image-build')} />
|
||||
<MenuItem icon="pulse" text="Build Monitor" active={activeNav === 'build-monitor'} onClick={() => setActiveNav('build-monitor')} />
|
||||
<MenuDivider />
|
||||
<MenuItem icon="heat-grid" text="Infrastructure" active={activeNav === 'infra'} onClick={() => setActiveNav('infra')} />
|
||||
<MenuItem icon="clipboard" text="OPC" active={activeNav === 'opc'} onClick={() => setActiveNav('opc')} />
|
||||
<MenuItem icon="people" text="Clients" active={activeNav === 'clients'} onClick={() => setActiveNav('clients')} />
|
||||
<MenuItem icon="cog" text="Settings" active={activeNav === 'settings'} onClick={() => setActiveNav('settings')} />
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
<div className="cp-sidebar-footer">
|
||||
<div className="cp-sidebar-user">
|
||||
<div className="user-avatar">A</div>
|
||||
<div className="user-info">
|
||||
<span className="user-name">Platform Admin</span>
|
||||
<span className="user-role">Clarity Internal</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<main className="cp-main">
|
||||
{activeNav === 'deployments' && <DashboardPage />}
|
||||
{activeNav === 'pipelines' && <PipelinesPage />}
|
||||
{activeNav === 'branches' && <BranchPage />}
|
||||
{activeNav === 'image-build' && <ImageBuildPage />}
|
||||
{activeNav === 'build-monitor' && <BuildMonitorPage />}
|
||||
{activeNav === 'infra' && <InfraPage />}
|
||||
{activeNav === 'opc' && <OpcPage />}
|
||||
{activeNav === 'clients' && <PlaceholderPage title="Clients" />}
|
||||
{activeNav === 'settings' && <PlaceholderPage title="Settings" />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceholderPage({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="page-header">
|
||||
<h1>{title}</h1>
|
||||
<p>Coming soon.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,44 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
|
||||
|
||||
export type ServiceStatus = 'running' | 'stopped' | 'unhealthy' | 'unknown';
|
||||
|
||||
export interface InfraService {
|
||||
name: string;
|
||||
container: string;
|
||||
status: ServiceStatus;
|
||||
ports: string[];
|
||||
uptime?: string;
|
||||
}
|
||||
|
||||
export interface InfraStatusResponse {
|
||||
services: InfraService[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export async function getInfraStatus(): Promise<InfraStatusResponse> {
|
||||
const res = await fetch(`${BASE_URL}/api/infra/status`);
|
||||
if (!res.ok) throw new Error('Failed to fetch infra status');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function infraServiceAction(
|
||||
service: string,
|
||||
action: 'start' | 'stop' | 'restart'
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${BASE_URL}/api/infra/${service}/${action}`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`Failed to ${action} ${service}`);
|
||||
}
|
||||
|
||||
export function streamComposeUp(onLine: (line: string) => void, onDone: () => void): EventSource {
|
||||
const src = new EventSource(`${BASE_URL}/api/infra/compose/up/stream`);
|
||||
src.onmessage = (e) => onLine(e.data);
|
||||
src.onerror = () => { onDone(); src.close(); };
|
||||
return src;
|
||||
}
|
||||
|
||||
export function streamComposeDown(onLine: (line: string) => void, onDone: () => void): EventSource {
|
||||
const src = new EventSource(`${BASE_URL}/api/infra/compose/down/stream`);
|
||||
src.onmessage = (e) => onLine(e.data);
|
||||
src.onerror = () => { onDone(); src.close(); };
|
||||
return src;
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import type { Opc, OpcNote, OpcArtifact, OpcType, OpcStatus, OpcPriority } from '../types/opc';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
|
||||
|
||||
// ── OPC CRUD ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getNextNumber(): Promise<string> {
|
||||
const res = await fetch(`${BASE_URL}/api/opc/next-number`);
|
||||
if (!res.ok) throw new Error('Failed to fetch next OPC number');
|
||||
const data = await res.json();
|
||||
return data.number as string;
|
||||
}
|
||||
|
||||
export async function listOpcs(type?: string, status?: string): Promise<Opc[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (type && type !== 'all') params.set('type', type);
|
||||
if (status && status !== 'all') params.set('status', status);
|
||||
const res = await fetch(`${BASE_URL}/api/opc?${params}`);
|
||||
if (!res.ok) throw new Error(`Failed to load OPCs: ${res.statusText}`);
|
||||
// API returns OpcRecord (camelCase from .NET JsonSerializerDefaults.Web)
|
||||
return (await res.json()).map(mapRecord);
|
||||
}
|
||||
|
||||
export async function getOpc(id: string): Promise<Opc> {
|
||||
const res = await fetch(`${BASE_URL}/api/opc/${id}`);
|
||||
if (!res.ok) throw new Error(`Failed to load OPC: ${res.statusText}`);
|
||||
return mapRecord(await res.json());
|
||||
}
|
||||
|
||||
export async function createOpc(req: {
|
||||
title: string; type: OpcType; priority: OpcPriority;
|
||||
assignee: string; description: string;
|
||||
}): Promise<Opc> {
|
||||
const res = await fetch(`${BASE_URL}/api/opc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to create OPC: ${res.statusText}`);
|
||||
return mapRecord(await res.json());
|
||||
}
|
||||
|
||||
export async function updateOpc(id: string, req: {
|
||||
title?: string; description?: string; type?: OpcType;
|
||||
status?: OpcStatus; priority?: OpcPriority; assignee?: string;
|
||||
}): Promise<Opc> {
|
||||
const res = await fetch(`${BASE_URL}/api/opc/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to update OPC: ${res.statusText}`);
|
||||
return mapRecord(await res.json());
|
||||
}
|
||||
|
||||
// ── Notes ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listNotes(opcId: string): Promise<OpcNote[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/notes`);
|
||||
if (!res.ok) throw new Error(`Failed to load notes: ${res.statusText}`);
|
||||
return (await res.json()).map(mapNote);
|
||||
}
|
||||
|
||||
export async function addNote(opcId: string, author: string, content: string): Promise<OpcNote> {
|
||||
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ author, content }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to add note: ${res.statusText}`);
|
||||
return mapNote(await res.json());
|
||||
}
|
||||
|
||||
// ── Artifacts ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listArtifacts(opcId: string, type?: string): Promise<OpcArtifact[]> {
|
||||
const params = type ? `?type=${type}` : '';
|
||||
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/artifacts${params}`);
|
||||
if (!res.ok) throw new Error(`Failed to load artifacts: ${res.statusText}`);
|
||||
return (await res.json()).map(mapArtifact);
|
||||
}
|
||||
|
||||
export async function createArtifact(opcId: string, req: {
|
||||
artifactType: string; title: string; content: string;
|
||||
}): Promise<OpcArtifact> {
|
||||
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/artifacts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to create artifact: ${res.statusText}`);
|
||||
return mapArtifact(await res.json());
|
||||
}
|
||||
|
||||
export async function updateArtifact(artifactId: string, req: {
|
||||
artifactType: string; title: string; content: string;
|
||||
}): Promise<OpcArtifact> {
|
||||
const res = await fetch(`${BASE_URL}/api/opc/artifacts/${artifactId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to update artifact: ${res.statusText}`);
|
||||
return mapArtifact(await res.json());
|
||||
}
|
||||
|
||||
export async function deleteArtifact(artifactId: string): Promise<void> {
|
||||
const res = await fetch(`${BASE_URL}/api/opc/artifacts/${artifactId}`, { method: 'DELETE' });
|
||||
if (!res.ok && res.status !== 404) throw new Error(`Failed to delete artifact: ${res.statusText}`);
|
||||
}
|
||||
|
||||
// ── Git commit linkage ────────────────────────────────────────────────────────
|
||||
// Commits are linked by convention: developers include "OPC # XXXX" in their commit message.
|
||||
// The git log endpoint supports ?grep=OPC+%230001 to filter.
|
||||
|
||||
export interface LinkedCommit {
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
author: string;
|
||||
date: string;
|
||||
subject: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export async function getLinkedCommits(opcNumber: string): Promise<LinkedCommit[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/git/log?grep=${encodeURIComponent(opcNumber)}&limit=50`);
|
||||
if (!res.ok) throw new Error(`Failed to load commits: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Pinned commits ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PinnedCommit {
|
||||
opcId: string;
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
subject: string;
|
||||
author: string;
|
||||
pinnedAt: string;
|
||||
pinnedBy: string;
|
||||
}
|
||||
|
||||
function mapPinnedCommit(d: Record<string, unknown>): PinnedCommit {
|
||||
return {
|
||||
opcId: d.opcId as string,
|
||||
hash: d.hash as string,
|
||||
shortHash: d.shortHash as string,
|
||||
subject: d.subject as string,
|
||||
author: d.author as string,
|
||||
pinnedAt: d.pinnedAt as string,
|
||||
pinnedBy: d.pinnedBy as string,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPinnedCommits(opcId: string): Promise<PinnedCommit[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/pinned-commits`);
|
||||
if (!res.ok) throw new Error(`Failed to load pinned commits: ${res.statusText}`);
|
||||
return (await res.json()).map(mapPinnedCommit);
|
||||
}
|
||||
|
||||
export async function pinCommit(opcId: string, hash: string, pinnedBy: string): Promise<PinnedCommit> {
|
||||
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/pinned-commits`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hash, pinnedBy }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => res.statusText);
|
||||
throw new Error(body || res.statusText);
|
||||
}
|
||||
return mapPinnedCommit(await res.json());
|
||||
}
|
||||
|
||||
export async function unpinCommit(opcId: string, hash: string): Promise<void> {
|
||||
const res = await fetch(`${BASE_URL}/api/opc/${opcId}/pinned-commits/${hash}`, { method: 'DELETE' });
|
||||
if (!res.ok && res.status !== 404) throw new Error(`Failed to unpin commit: ${res.statusText}`);
|
||||
}
|
||||
|
||||
// ── Branch coverage ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface BranchCoverage {
|
||||
branch: string;
|
||||
contains: boolean;
|
||||
tipHash: string;
|
||||
isHead: boolean;
|
||||
}
|
||||
|
||||
export async function getBranchCoverage(hashes: string[]): Promise<BranchCoverage[]> {
|
||||
if (hashes.length === 0) return [];
|
||||
const res = await fetch(`${BASE_URL}/api/git/branch-coverage?commits=${hashes.join(',')}`);
|
||||
if (!res.ok) throw new Error(`Failed to get branch coverage: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Commit detail (full diff) ─────────────────────────────────────────────────
|
||||
|
||||
export interface CommitFile {
|
||||
path: string;
|
||||
oldPath: string;
|
||||
status: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
patch: string;
|
||||
}
|
||||
|
||||
export interface CommitDetail {
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
author: string;
|
||||
email: string;
|
||||
date: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
files: CommitFile[];
|
||||
}
|
||||
|
||||
export async function getCommitDetail(hash: string): Promise<CommitDetail> {
|
||||
const res = await fetch(`${BASE_URL}/api/git/commits/${hash}`);
|
||||
if (!res.ok) throw new Error(`Failed to load commit ${hash}: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── AI assist ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function aiAssist(prompt: string, context?: string): Promise<string> {
|
||||
const res = await fetch(`${BASE_URL}/api/opc/ai-assist`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt, context }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`AI assist failed: ${err}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.text as string;
|
||||
}
|
||||
|
||||
// ── Field mappers (snake_case/PascalCase → camelCase) ─────────────────────────
|
||||
// .NET JsonSerializerDefaults.Web produces camelCase already, so these are
|
||||
// lightweight guards in case of null/missing fields.
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function mapRecord(r: any): Opc {
|
||||
return {
|
||||
id: r.id,
|
||||
number: r.number,
|
||||
title: r.title,
|
||||
description: r.description ?? '',
|
||||
type: r.type as OpcType,
|
||||
status: r.status as OpcStatus,
|
||||
priority: r.priority as OpcPriority,
|
||||
assignee: r.assignee ?? '',
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
notes: [], // loaded separately on drawer open
|
||||
commits: [], // loaded separately on drawer open
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function mapNote(r: any): OpcNote {
|
||||
return {
|
||||
id: r.id,
|
||||
author: r.author,
|
||||
timestamp: r.createdAt,
|
||||
content: r.content,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function mapArtifact(r: any): OpcArtifact {
|
||||
return {
|
||||
id: r.id,
|
||||
opcId: r.opcId,
|
||||
artifactType: r.artifactType,
|
||||
title: r.title,
|
||||
content: r.content,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Gitea branch integration ───────────────────────────────────────────────────
|
||||
|
||||
export interface GiteaBranch {
|
||||
name: string;
|
||||
commitSha: string;
|
||||
protected: boolean;
|
||||
}
|
||||
|
||||
export async function listGiteaBranches(): Promise<GiteaBranch[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/gitea/branches`);
|
||||
if (!res.ok) throw new Error(`Failed to load Gitea branches: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createGiteaBranch(
|
||||
opcNumber: string,
|
||||
opcTitle: string,
|
||||
from = 'master',
|
||||
): Promise<GiteaBranch> {
|
||||
const res = await fetch(`${BASE_URL}/api/gitea/branches`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ opcNumber, opcTitle, from }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => res.statusText);
|
||||
throw new Error(body || res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { ProvisioningProgressEvent, ProvisioningRequest, TenantRecord } from '../types/provisioning';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
|
||||
|
||||
export async function submitProvisioningJob(request: ProvisioningRequest): Promise<string> {
|
||||
const res = await fetch(`${BASE_URL}/api/provision`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Failed to queue job: ${res.statusText}`);
|
||||
|
||||
const data = await res.json();
|
||||
return data.id as string;
|
||||
}
|
||||
|
||||
export async function getTenants(): Promise<TenantRecord[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/tenants`);
|
||||
if (!res.ok) throw new Error(`Failed to load tenants: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function subscribeToTenantLogs(
|
||||
subdomain: string,
|
||||
onLine: (line: string) => void,
|
||||
onError: (err: Event) => void
|
||||
): EventSource {
|
||||
const source = new EventSource(`${BASE_URL}/api/tenants/${subdomain}/logs`);
|
||||
source.onmessage = (e) => { if (e.data) onLine(e.data); };
|
||||
source.onerror = onError;
|
||||
return source;
|
||||
}
|
||||
|
||||
export function subscribeToJobStream(
|
||||
jobId: string,
|
||||
onEvent: (event: ProvisioningProgressEvent) => void,
|
||||
onError: (err: Event) => void
|
||||
): EventSource {
|
||||
const source = new EventSource(`${BASE_URL}/api/provision/${jobId}/stream`);
|
||||
|
||||
source.onmessage = (e) => {
|
||||
try { onEvent(JSON.parse(e.data)); } catch { /* ignore */ }
|
||||
};
|
||||
|
||||
source.onerror = onError;
|
||||
return source;
|
||||
}
|
||||
|
||||
export interface ImageBuildStatus {
|
||||
imageName: string | null;
|
||||
builtAt: string | null;
|
||||
lastMessage: string;
|
||||
isBuilding: boolean;
|
||||
}
|
||||
|
||||
export async function getImageStatus(): Promise<ImageBuildStatus> {
|
||||
const res = await fetch(`${BASE_URL}/api/image/status`);
|
||||
if (!res.ok) throw new Error(`Failed to get image status: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Triggers a build and streams log lines. Calls onLine for each log chunk, onDone when finished. */
|
||||
export function triggerImageBuild(
|
||||
onLine: (line: string) => void,
|
||||
onDone: (success: boolean) => void,
|
||||
onError: (err: Event) => void
|
||||
): EventSource {
|
||||
const source = new EventSource(`${BASE_URL}/api/image/build-stream`);
|
||||
|
||||
source.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.done) { onDone(true); source.close(); }
|
||||
else if (msg.line) onLine(msg.line);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
source.onerror = (e) => { onDone(false); onError(e); };
|
||||
return source;
|
||||
}
|
||||
|
||||
/** POST to kick off the build — returns immediately; use subscribeToJobStream for progress */
|
||||
export async function startImageBuild(): Promise<void> {
|
||||
const res = await fetch(`${BASE_URL}/api/image/build`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`Build trigger failed: ${res.statusText}`);
|
||||
}
|
||||
|
||||
// ── Release API ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TenantReleaseResult {
|
||||
subdomain: string;
|
||||
containerName: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ReleaseRecord {
|
||||
id: string;
|
||||
environment: string;
|
||||
imageName: string;
|
||||
status: 'Running' | 'Succeeded' | 'PartialFailure' | 'Failed';
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
tenants: TenantReleaseResult[];
|
||||
}
|
||||
|
||||
export async function getReleaseHistory(): Promise<ReleaseRecord[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/release/history`);
|
||||
if (!res.ok) throw new Error(`Failed to get release history: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Triggers a release to the given environment and streams log lines as SSE. */
|
||||
export function triggerRelease(
|
||||
env: string,
|
||||
onLine: (line: string) => void,
|
||||
onDone: (record: ReleaseRecord) => void,
|
||||
onError: (err: Event) => void
|
||||
): EventSource {
|
||||
const source = new EventSource(`${BASE_URL}/api/release/${env}`);
|
||||
source.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.done && msg.release) { onDone(msg.release as ReleaseRecord); source.close(); }
|
||||
else if (typeof msg.line === 'string') onLine(msg.line);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
source.onerror = (e) => { onError(e); };
|
||||
return source;
|
||||
}
|
||||
|
||||
// ── Project Build API ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProjectDefinition {
|
||||
name: string;
|
||||
kind: 'DotnetProject' | 'NpmProject';
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
export interface BuildRecord {
|
||||
id: string;
|
||||
kind: 'DockerImage' | 'DotnetProject' | 'NpmProject';
|
||||
target: string;
|
||||
status: 'Running' | 'Succeeded' | 'Failed';
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
durationMs?: number;
|
||||
log: string[];
|
||||
}
|
||||
|
||||
export async function getProjects(): Promise<ProjectDefinition[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/builds/projects`);
|
||||
if (!res.ok) throw new Error(`Failed to get projects: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getBuildHistory(): Promise<BuildRecord[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/builds/history`);
|
||||
if (!res.ok) throw new Error(`Failed to get build history: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Triggers a project build and streams log lines. */
|
||||
export function triggerProjectBuild(
|
||||
projectName: string,
|
||||
onLine: (line: string) => void,
|
||||
onDone: (record: BuildRecord) => void,
|
||||
onError: (err: Event) => void
|
||||
): EventSource {
|
||||
const source = new EventSource(`${BASE_URL}/api/builds/${encodeURIComponent(projectName)}`);
|
||||
source.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.done && msg.build) { onDone(msg.build as BuildRecord); source.close(); }
|
||||
else if (typeof msg.line === 'string') onLine(msg.line);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
source.onerror = (e) => { onError(e); };
|
||||
return source;
|
||||
}
|
||||
|
||||
// ── Git History API ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface GitCommit {
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
author: string;
|
||||
date: string;
|
||||
subject: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export async function getGitLog(path?: string, limit = 20): Promise<GitCommit[]> {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (path) params.set('path', path);
|
||||
const res = await fetch(`${BASE_URL}/api/git/log?${params}`);
|
||||
if (!res.ok) throw new Error(`Failed to get git log: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Promotion / Branch Ladder API ────────────────────────────────────────────
|
||||
|
||||
export interface BranchStatus {
|
||||
branch: string;
|
||||
exists: boolean;
|
||||
shortHash: string | null;
|
||||
lastCommitSummary: string | null;
|
||||
aheadOfNext: number;
|
||||
behindNext: number;
|
||||
unreleasedLines: string[];
|
||||
}
|
||||
|
||||
export interface PromotionRecord {
|
||||
id: string;
|
||||
fromBranch: string;
|
||||
toBranch: string;
|
||||
requestedBy: string;
|
||||
note: string | null;
|
||||
status: 'Pending' | 'Running' | 'Succeeded' | 'Failed';
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
commitCount: number;
|
||||
commitLines: string[];
|
||||
log: string[];
|
||||
}
|
||||
|
||||
export async function getLadderStatus(): Promise<BranchStatus[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/promotions/ladder`);
|
||||
if (!res.ok) throw new Error(`Failed to get ladder status: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getPromotionHistory(): Promise<PromotionRecord[]> {
|
||||
const res = await fetch(`${BASE_URL}/api/promotions/history`);
|
||||
if (!res.ok) throw new Error(`Failed to get promotion history: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Triggers a promotion and streams SSE lines. Calls onDone with the final record. */
|
||||
export function triggerPromotion(
|
||||
from: string,
|
||||
to: string,
|
||||
requestedBy: string,
|
||||
note: string | undefined,
|
||||
onLine: (line: string) => void,
|
||||
onDone: (record: PromotionRecord) => void,
|
||||
onError: (err: string) => void,
|
||||
): () => void {
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/api/promotions/promote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from, to, requestedBy, note }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) { onError(res.statusText); return; }
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (!cancelled) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parts = buffer.split('\n\n');
|
||||
buffer = parts.pop() ?? '';
|
||||
for (const chunk of parts) {
|
||||
const dataLine = chunk.replace(/^data:\s*/m, '').trim();
|
||||
if (!dataLine) continue;
|
||||
try {
|
||||
const msg = JSON.parse(dataLine);
|
||||
if (msg.done && msg.promotion) onDone(msg.promotion as PromotionRecord);
|
||||
else if (typeof msg.line === 'string') onLine(msg.line);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) onError(e instanceof Error ? e.message : 'Unknown error');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; controller.abort(); };
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Button, Drawer, Intent, NonIdealState, Spinner, Tag, Tooltip } from '@blueprintjs/core';
|
||||
import { html as diff2htmlHtml } from 'diff2html';
|
||||
import 'diff2html/bundles/css/diff2html.min.css';
|
||||
import hljs from 'highlight.js';
|
||||
import 'highlight.js/styles/github.css';
|
||||
import { getCommitDetail, type CommitDetail } from '../api/opcApi';
|
||||
|
||||
interface Props {
|
||||
hash: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GitCommitDrawer({ hash, onClose }: Props) {
|
||||
const [detail, setDetail] = useState<CommitDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const diffRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hash) { setDetail(null); setError(null); return; }
|
||||
setLoading(true); setDetail(null); setError(null);
|
||||
getCommitDetail(hash)
|
||||
.then(setDetail)
|
||||
.catch(e => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [hash]);
|
||||
|
||||
// After diff HTML is injected, run highlight.js over code blocks
|
||||
useEffect(() => {
|
||||
if (detail && diffRef.current) {
|
||||
diffRef.current.querySelectorAll<HTMLElement>('code[class]').forEach(el => {
|
||||
hljs.highlightElement(el);
|
||||
});
|
||||
}
|
||||
}, [detail]);
|
||||
|
||||
const combinedPatch = detail?.files.map(f => f.patch).join('\n') ?? '';
|
||||
const diffHtml = combinedPatch
|
||||
? diff2htmlHtml(combinedPatch, {
|
||||
drawFileList: true,
|
||||
matching: 'lines',
|
||||
outputFormat: 'line-by-line',
|
||||
renderNothingWhenEmpty: false,
|
||||
})
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
isOpen={!!hash}
|
||||
onClose={onClose}
|
||||
title={detail ? (
|
||||
<span className="git-drawer-title">
|
||||
<code className="git-drawer-hash">{detail.shortHash}</code>
|
||||
<span className="git-drawer-subject">{detail.subject}</span>
|
||||
</span>
|
||||
) : 'Commit Diff'}
|
||||
size="70%"
|
||||
position="right"
|
||||
className="git-commit-drawer"
|
||||
>
|
||||
<div className="git-drawer-body">
|
||||
{loading && <NonIdealState icon={<Spinner size={24} />} title="Loading diff…" />}
|
||||
{error && <NonIdealState icon="error" intent={Intent.DANGER} title="Failed to load commit" description={error} />}
|
||||
|
||||
{detail && (
|
||||
<>
|
||||
{/* Metadata bar */}
|
||||
<div className="git-commit-meta-bar">
|
||||
<div className="git-commit-meta-left">
|
||||
<Tooltip content="Copy full hash">
|
||||
<code
|
||||
className="git-commit-hash-chip"
|
||||
onClick={() => navigator.clipboard.writeText(detail.hash)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{detail.shortHash}
|
||||
</code>
|
||||
</Tooltip>
|
||||
<span className="git-commit-author">{detail.author}</span>
|
||||
<span className="git-commit-date">{detail.date}</span>
|
||||
</div>
|
||||
<div className="git-commit-meta-right">
|
||||
<Tag intent={Intent.SUCCESS} minimal round icon="add">
|
||||
+{detail.files.reduce((a, f) => a + f.additions, 0)}
|
||||
</Tag>
|
||||
<Tag intent={Intent.DANGER} minimal round icon="remove">
|
||||
-{detail.files.reduce((a, f) => a + f.deletions, 0)}
|
||||
</Tag>
|
||||
<Tag minimal round>{detail.files.length} file{detail.files.length !== 1 ? 's' : ''}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commit body if multiline */}
|
||||
{detail.body.trim() !== detail.subject.trim() && (
|
||||
<pre className="git-commit-body">{detail.body.trim()}</pre>
|
||||
)}
|
||||
|
||||
{/* Diff */}
|
||||
{diffHtml
|
||||
? <div ref={diffRef} className="git-diff-container" dangerouslySetInnerHTML={{ __html: diffHtml }} />
|
||||
: <NonIdealState icon="git-commit" title="No diff" description="This commit has no file changes." />
|
||||
}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !error && !detail && hash && (
|
||||
<NonIdealState icon={<Spinner size={20} />} title="Loading…" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="git-drawer-footer">
|
||||
<Button text="Close" onClick={onClose} />
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Callout, Intent, Tag } from '@blueprintjs/core';
|
||||
import { getImageStatus, type ImageBuildStatus } from '../api/provisioningApi';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
|
||||
|
||||
export default function ImageBuildPanel() {
|
||||
const [status, setStatus] = useState<ImageBuildStatus | null>(null);
|
||||
const [building, setBuilding] = useState(false);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const logRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getImageStatus().then(setStatus).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Auto-scroll log panel
|
||||
useEffect(() => {
|
||||
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||
}, [logs]);
|
||||
|
||||
const handleBuild = async () => {
|
||||
if (building) return;
|
||||
setBuilding(true);
|
||||
setOpen(true);
|
||||
setLogs([]);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// POST /api/image/build — the response body IS the SSE stream
|
||||
const res = await fetch(`${BASE_URL}/api/image/build`, { method: 'POST' });
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
setError(`Build failed to start: ${res.statusText}`);
|
||||
setBuilding(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parts = buffer.split('\n\n');
|
||||
buffer = parts.pop() ?? '';
|
||||
|
||||
for (const chunk of parts) {
|
||||
const dataLine = chunk.replace(/^data:\s*/m, '').trim();
|
||||
if (!dataLine) continue;
|
||||
try {
|
||||
const msg = JSON.parse(dataLine);
|
||||
if (msg.done) {
|
||||
// Build finished — refresh status
|
||||
getImageStatus().then(setStatus).catch(() => {});
|
||||
} else if (typeof msg.line === 'string') {
|
||||
setLogs((prev) => [...prev.slice(-1000), msg.line]);
|
||||
}
|
||||
} catch { /* ignore non-JSON */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error during build');
|
||||
} finally {
|
||||
setBuilding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const lastBuilt = status?.builtAt
|
||||
? new Date(status.builtAt).toLocaleString()
|
||||
: 'Never';
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<Button
|
||||
icon="build"
|
||||
intent={Intent.WARNING}
|
||||
loading={building}
|
||||
onClick={handleBuild}
|
||||
text="Build Image"
|
||||
/>
|
||||
{!building && (
|
||||
<Tag minimal intent={Intent.NONE} style={{ fontFamily: 'monospace', fontSize: '0.7rem' }}>
|
||||
{status?.imageName ?? 'clarity-server:latest'} · last built {lastBuilt}
|
||||
</Tag>
|
||||
)}
|
||||
{building && (
|
||||
<Tag minimal intent={Intent.WARNING}>Building…</Tag>
|
||||
)}
|
||||
{logs.length > 0 && !building && (
|
||||
<Button
|
||||
icon={open ? 'chevron-up' : 'chevron-down'}
|
||||
minimal
|
||||
small
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
text={open ? 'Hide log' : 'Show log'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Callout intent={Intent.DANGER} compact>{error}</Callout>
|
||||
)}
|
||||
|
||||
{open && logs.length > 0 && (
|
||||
<div
|
||||
ref={logRef}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.72rem',
|
||||
background: '#111',
|
||||
color: '#d4d4d4',
|
||||
padding: '0.6rem 0.8rem',
|
||||
borderRadius: '4px',
|
||||
height: '220px',
|
||||
overflowY: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{logs.map((l, i) => <div key={i}>{l}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user