OPC # 0001: Extract OPC into standalone repo
This commit is contained in:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user