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>(); // Tenant registry - reads ClientAssets/{subdomain}.xml files builder.Services.AddSingleton(); // SSE event bus - ProgressConsumer writes here, SSE endpoint reads builder.Services.AddSingleton(); // Build + release pipeline services builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // OPC persistence (raw Npgsql) var opcConnStr = builder.Configuration.GetConnectionString("opcdb"); if (!string.IsNullOrWhiteSpace(opcConnStr)) // Replace 'localhost' with '127.0.0.1' to avoid Npgsql trying [::1] first on Windows builder.Services.AddSingleton(NpgsqlDataSource.Create(opcConnStr.Replace("localhost", "127.0.0.1"))); else builder.Services.AddSingleton(NpgsqlDataSource.Create("Host=127.0.0.1;Port=5433;Database=opcdb;Username=postgres;Password=controlplane-dev")); builder.Services.AddScoped(); // 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(); builder.Services.AddMassTransit(x => { x.SetKebabCaseEndpointNameFormatter(); // Receives ProvisioningProgressEvent from Worker and pushes to SSE x.AddConsumer(); 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(); 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); -- ── Build + Release history ──────────────────────────────────────────── CREATE TABLE IF NOT EXISTS build_record ( id VARCHAR(8) PRIMARY KEY, kind VARCHAR(20) NOT NULL, target VARCHAR(500) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'Running', started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), finished_at TIMESTAMPTZ, duration_ms INTEGER, image_digest VARCHAR(200), commit_sha VARCHAR(40), log TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS release_record ( id VARCHAR(8) PRIMARY KEY, environment VARCHAR(50) NOT NULL, image_name VARCHAR(200) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'Running', started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), finished_at TIMESTAMPTZ ); CREATE TABLE IF NOT EXISTS release_tenant_result ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), release_id VARCHAR(8) NOT NULL REFERENCES release_record(id) ON DELETE CASCADE, subdomain VARCHAR(200) NOT NULL, container_name VARCHAR(200) NOT NULL, success BOOLEAN NOT NULL DEFAULT FALSE, error TEXT ); CREATE INDEX IF NOT EXISTS ix_build_record_started_at ON build_record(started_at DESC); CREATE INDEX IF NOT EXISTS ix_build_record_kind ON build_record(kind); CREATE INDEX IF NOT EXISTS ix_release_record_started_at ON release_record(started_at DESC); CREATE INDEX IF NOT EXISTS ix_release_tenant_release_id ON release_tenant_result(release_id); """)) await cmd.ExecuteNonQueryAsync(); // Idempotent column additions for schema migrations await using (var migCmd = ds.CreateCommand(""" ALTER TABLE release_record ADD COLUMN IF NOT EXISTS opc_numbers TEXT[] NOT NULL DEFAULT '{}'; ALTER TABLE release_record ADD COLUMN IF NOT EXISTS commit_sha VARCHAR(40); """)) await migCmd.ExecuteNonQueryAsync(); app.Run();