From 3ec055b871f178fad54c296badc682b3d9f51bc4 Mon Sep 17 00:00:00 2001 From: movingsam Date: Sun, 8 Mar 2026 00:32:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway-config):=20=E5=AE=9E=E7=8E=B0=20K8?= =?UTF-8?q?s=20=E6=9C=8D=E5=8A=A1=E7=9B=91=E5=90=AC=E5=92=8C=E5=BE=85?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E9=85=8D=E7=BD=AE=E7=AE=A1=E7=90=86=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交包含网关配置重构的第一阶段实现(IMPL-1, IMPL-4): ## 新增功能 ### 1. K8s 服务监听服务 (IMPL-1) - 新增 K8sServiceWatchService - 后台服务监听 Kubernetes Service 变化 - 支持 Watch API 和自动重连机制(指数退避策略) - 使用 Channel 处理事件,防止内存无限增长 - 支持集群内配置和本地 kubeconfig 两种模式 ### 2. 待确认配置管理 (IMPL-4) - 新增 PendingConfig 实体,用于存储待用户确认的配置变更 - 新增 PendingConfigType 枚举(Route, Cluster, Destination) - 新增 PendingConfigSource 枚举(K8sDiscovery, Manual) - 新增 PendingConfigStatus 枚举(Pending, Confirmed, Rejected, Modified) - 生成 EF Core 迁移 AddPendingConfig,创建 gw_pending_configs 表 ## 技术变更 ### NuGet 包 - 添加 KubernetesClient 16.0.2 包引用 ### 数据库 - 更新 ConsoleDbContext,添加 PendingConfigs DbSet - 配置 PendingConfig 表索引(Status, ServiceName, CreatedAt) ### EF Core 兼容性修复 - 修复 EF Core 10 中 JSON 值对象的映射问题 - 使用值转换器替代 OwnsOne().ToJson() 配置 ## 文件变更 - 新增: src/Services/K8sServiceWatchService.cs - 新增: src/Models/Entities/PendingConfig.cs - 新增: src/Models/Entities/PendingConfigEnums.cs - 新增: src/Migrations/20260307161935_AddPendingConfig.cs - 修改: src/Program.cs (注册 K8sServiceWatch) - 修改: src/Data/ConsoleDbContext.cs (添加实体配置) - 修改: Directory.Packages.props (添加 KubernetesClient) 关联任务: IMPL-1, IMPL-4 关联重构计划: WFS-gateway-refactor --- Directory.Packages.props | 2 + src/Data/ConsoleDbContext.cs | 38 +- src/Fengling.Console.csproj | 1 + ...0260307161935_AddPendingConfig.Designer.cs | 868 ++++++++++++++++++ .../20260307161935_AddPendingConfig.cs | 281 ++++++ .../ConsoleDbContextModelSnapshot.cs | 266 ++++-- src/Models/Entities/PendingConfig.cs | 78 ++ src/Models/Entities/PendingConfigEnums.cs | 64 ++ src/Program.cs | 16 +- src/Services/K8sServiceWatchService.cs | 427 +++++++++ 10 files changed, 1960 insertions(+), 81 deletions(-) create mode 100644 src/Migrations/20260307161935_AddPendingConfig.Designer.cs create mode 100644 src/Migrations/20260307161935_AddPendingConfig.cs create mode 100644 src/Models/Entities/PendingConfig.cs create mode 100644 src/Models/Entities/PendingConfigEnums.cs create mode 100644 src/Services/K8sServiceWatchService.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 2cb65f2..d0dbd7e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,6 +23,8 @@ + + diff --git a/src/Data/ConsoleDbContext.cs b/src/Data/ConsoleDbContext.cs index 23bc7de..bb3cfe4 100644 --- a/src/Data/ConsoleDbContext.cs +++ b/src/Data/ConsoleDbContext.cs @@ -1,3 +1,4 @@ +using Fengling.Console.Models.Entities; using Fengling.Platform.Domain.AggregatesModel.GatewayAggregate; using Fengling.Platform.Domain.AggregatesModel.RoleAggregate; using Fengling.Platform.Domain.AggregatesModel.TenantAggregate; @@ -6,6 +7,7 @@ using Fengling.Platform.Infrastructure; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; +using System.Text.Json; namespace Fengling.Console.Data; @@ -19,16 +21,46 @@ public class ConsoleDbContext : PlatformDbContext { } + /// + /// 待确认配置表 + /// + public DbSet PendingConfigs { get; set; } = null!; + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - // ========== Gateway 模块 ========== - modelBuilder.Entity(entity => + // ========== PendingConfig 模块 ========== + modelBuilder.Entity(entity => { - entity.ToTable("gw_tenant_routes"); + entity.ToTable("gw_pending_configs"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).HasMaxLength(32); + entity.Property(e => e.SourceId).HasMaxLength(128); + entity.Property(e => e.ServiceName).HasMaxLength(256); + entity.Property(e => e.TenantCode).HasMaxLength(64); + entity.Property(e => e.ClusterId).HasMaxLength(64); + entity.Property(e => e.ConfirmedBy).HasMaxLength(128); + entity.Property(e => e.ConfigJson).HasColumnType("text"); + + // 创建索引 + entity.HasIndex(e => e.Status); + entity.HasIndex(e => e.Source); + entity.HasIndex(e => e.Type); + entity.HasIndex(e => new { e.TenantCode, e.Status }); + entity.HasIndex(e => new { e.ServiceName, e.Status }); }); + // ========== Gateway 模块 ========== + // 忽略这些类型,让它们只作为 JSON 值对象使用(与 PlatformDbContext 保持一致) + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + + // GwTenantRoute 已在 PlatformDbContext 中配置,这里只修改表名 + modelBuilder.Entity().ToTable("gw_tenant_routes"); + // ========== Tenant 模块 ========== modelBuilder.Entity(entity => { diff --git a/src/Fengling.Console.csproj b/src/Fengling.Console.csproj index 4220245..ef36b75 100644 --- a/src/Fengling.Console.csproj +++ b/src/Fengling.Console.csproj @@ -29,6 +29,7 @@ + diff --git a/src/Migrations/20260307161935_AddPendingConfig.Designer.cs b/src/Migrations/20260307161935_AddPendingConfig.Designer.cs new file mode 100644 index 0000000..f16bc4a --- /dev/null +++ b/src/Migrations/20260307161935_AddPendingConfig.Designer.cs @@ -0,0 +1,868 @@ +// +using System; +using System.Collections.Generic; +using Fengling.Console.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fengling.Console.Migrations +{ + [DbContext(typeof(ConsoleDbContext))] + [Migration("20260307161935_AddPendingConfig")] + partial class AddPendingConfig + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Fengling.Console.Models.Entities.PendingConfig", b => + { + b.Property("Id") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConfirmedBy") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsModified") + .HasColumnType("boolean"); + + b.Property("IsNew") + .HasColumnType("boolean"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("SourceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Source"); + + b.HasIndex("Status"); + + b.HasIndex("Type"); + + b.HasIndex("ServiceName", "Status"); + + b.HasIndex("TenantCode", "Status"); + + b.ToTable("gw_pending_configs", (string)null); + }); + + modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwCluster", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LoadBalancingPolicy") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedBy") + .HasColumnType("bigint"); + + b.Property("UpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId") + .IsUnique(); + + b.HasIndex("Name"); + + b.HasIndex("Status"); + + b.ToTable("GwClusters"); + }); + + modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwTenantRoute", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AuthorizationPolicy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CorsPolicy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsGlobal") + .HasColumnType("boolean"); + + b.Property("LoadBalancingPolicy") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("RateLimiterPolicy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("UpdatedBy") + .HasColumnType("bigint"); + + b.Property("UpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("ServiceName"); + + b.HasIndex("TenantCode"); + + b.HasIndex("ServiceName", "IsGlobal", "Status"); + + b.ToTable("gw_tenant_routes", (string)null); + }); + + modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.RoleAggregate.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.PrimitiveCollection>("Permissions") + .HasColumnType("text[]"); + + b.Property("TenantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("idn_roles", (string)null); + }); + + modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.TenantAggregate.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ContactPhone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MaxUsers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RowVersion") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TenantCode") + .IsUnique(); + + b.ToTable("sys_tenants", (string)null); + }); + + modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.UserAggregate.AccessLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("integer"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Method") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("RequestData") + .HasColumnType("text"); + + b.Property("Resource") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ResponseData") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UserName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserName"); + + b.ToTable("sys_access_logs", (string)null); + }); + + modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.UserAggregate.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RealName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("PhoneNumber") + .IsUnique(); + + b.ToTable("idn_users", (string)null); + }); + + modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.UserAggregate.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NewValue") + .HasColumnType("text"); + + b.Property("OldValue") + .HasColumnType("text"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Operator") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TargetId") + .HasColumnType("bigint"); + + b.Property("TargetName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Operation"); + + b.HasIndex("Operator"); + + b.HasIndex("TenantId"); + + b.ToTable("sys_audit_logs", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("idn_role_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("idn_user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("idn_user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("idn_user_roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("idn_user_tokens", (string)null); + }); + + modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwCluster", b => + { + b.OwnsMany("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwDestination", "Destinations", b1 => + { + b1.Property("ClusterId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Address") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b1.Property("DestinationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("Health") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b1.Property("HealthStatus") + .HasColumnType("integer"); + + b1.Property("Status") + .HasColumnType("integer"); + + b1.Property("Weight") + .HasColumnType("integer"); + + b1.HasKey("ClusterId", "Id"); + + b1.HasIndex("ClusterId", "DestinationId"); + + b1.ToTable("GwDestination"); + + b1.WithOwner() + .HasForeignKey("ClusterId"); + }); + + b.OwnsOne("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwHealthCheckConfig", "HealthCheck", b1 => + { + b1.Property("GwClusterId"); + + b1.Property("Enabled"); + + b1.Property("IntervalSeconds"); + + b1.Property("Path"); + + b1.Property("TimeoutSeconds"); + + b1.HasKey("GwClusterId"); + + b1.ToTable("GwClusters"); + + b1.ToJson("HealthCheck"); + + b1.WithOwner() + .HasForeignKey("GwClusterId"); + }); + + b.OwnsOne("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwSessionAffinityConfig", "SessionAffinity", b1 => + { + b1.Property("GwClusterId"); + + b1.Property("AffinityKeyName") + .IsRequired(); + + b1.Property("Enabled"); + + b1.Property("Policy") + .IsRequired(); + + b1.HasKey("GwClusterId"); + + b1.ToTable("GwClusters"); + + b1.ToJson("SessionAffinity"); + + b1.WithOwner() + .HasForeignKey("GwClusterId"); + }); + + b.Navigation("Destinations"); + + b.Navigation("HealthCheck"); + + b.Navigation("SessionAffinity"); + }); + + modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.UserAggregate.ApplicationUser", b => + { + b.OwnsOne("Fengling.Platform.Domain.AggregatesModel.TenantAggregate.TenantInfo", "TenantInfo", b1 => + { + b1.Property("ApplicationUserId") + .HasColumnType("bigint"); + + b1.Property("TenantCode") + .HasColumnType("text") + .HasColumnName("TenantCode"); + + b1.Property("TenantId") + .HasColumnType("bigint") + .HasColumnName("TenantId"); + + b1.Property("TenantName") + .HasColumnType("text") + .HasColumnName("TenantName"); + + b1.HasKey("ApplicationUserId"); + + b1.ToTable("idn_users"); + + b1.WithOwner() + .HasForeignKey("ApplicationUserId"); + }); + + b.Navigation("TenantInfo"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Fengling.Platform.Domain.AggregatesModel.RoleAggregate.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Fengling.Platform.Domain.AggregatesModel.UserAggregate.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Fengling.Platform.Domain.AggregatesModel.UserAggregate.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Fengling.Platform.Domain.AggregatesModel.RoleAggregate.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Fengling.Platform.Domain.AggregatesModel.UserAggregate.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Fengling.Platform.Domain.AggregatesModel.UserAggregate.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Migrations/20260307161935_AddPendingConfig.cs b/src/Migrations/20260307161935_AddPendingConfig.cs new file mode 100644 index 0000000..3745bea --- /dev/null +++ b/src/Migrations/20260307161935_AddPendingConfig.cs @@ -0,0 +1,281 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fengling.Console.Migrations +{ + /// + public partial class AddPendingConfig : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "gw_service_instances"); + + migrationBuilder.DropTable( + name: "gw_tenants"); + + migrationBuilder.DropColumn( + name: "PathPattern", + table: "gw_tenant_routes"); + + migrationBuilder.AddColumn( + name: "AuthorizationPolicy", + table: "gw_tenant_routes", + type: "character varying(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "CorsPolicy", + table: "gw_tenant_routes", + type: "character varying(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "LoadBalancingPolicy", + table: "gw_tenant_routes", + type: "character varying(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "RateLimiterPolicy", + table: "gw_tenant_routes", + type: "character varying(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "TimeoutSeconds", + table: "gw_tenant_routes", + type: "integer", + nullable: true); + + migrationBuilder.CreateTable( + name: "gw_pending_configs", + columns: table => new + { + Id = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Type = table.Column(type: "integer", nullable: false), + Source = table.Column(type: "integer", nullable: false), + SourceId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + ConfigJson = table.Column(type: "text", nullable: false), + ServiceName = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + TenantCode = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + ClusterId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Status = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ConfirmedAt = table.Column(type: "timestamp with time zone", nullable: true), + ConfirmedBy = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + IsNew = table.Column(type: "boolean", nullable: false), + IsModified = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_gw_pending_configs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "GwClusters", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ClusterId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Description = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + LoadBalancingPolicy = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Status = table.Column(type: "integer", nullable: false), + CreatedBy = table.Column(type: "bigint", nullable: true), + CreatedTime = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "bigint", nullable: true), + UpdatedTime = table.Column(type: "timestamp with time zone", nullable: true), + IsDeleted = table.Column(type: "boolean", nullable: false), + Version = table.Column(type: "integer", nullable: false), + HealthCheck = table.Column(type: "jsonb", nullable: true), + SessionAffinity = table.Column(type: "jsonb", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_GwClusters", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "GwDestination", + columns: table => new + { + ClusterId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + DestinationId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Address = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Health = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Weight = table.Column(type: "integer", nullable: false), + HealthStatus = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GwDestination", x => new { x.ClusterId, x.Id }); + table.ForeignKey( + name: "FK_GwDestination_GwClusters_ClusterId", + column: x => x.ClusterId, + principalTable: "GwClusters", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_gw_pending_configs_ServiceName_Status", + table: "gw_pending_configs", + columns: new[] { "ServiceName", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_gw_pending_configs_Source", + table: "gw_pending_configs", + column: "Source"); + + migrationBuilder.CreateIndex( + name: "IX_gw_pending_configs_Status", + table: "gw_pending_configs", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_gw_pending_configs_TenantCode_Status", + table: "gw_pending_configs", + columns: new[] { "TenantCode", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_gw_pending_configs_Type", + table: "gw_pending_configs", + column: "Type"); + + migrationBuilder.CreateIndex( + name: "IX_GwClusters_ClusterId", + table: "GwClusters", + column: "ClusterId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_GwClusters_Name", + table: "GwClusters", + column: "Name"); + + migrationBuilder.CreateIndex( + name: "IX_GwClusters_Status", + table: "GwClusters", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_GwDestination_ClusterId_DestinationId", + table: "GwDestination", + columns: new[] { "ClusterId", "DestinationId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "gw_pending_configs"); + + migrationBuilder.DropTable( + name: "GwDestination"); + + migrationBuilder.DropTable( + name: "GwClusters"); + + migrationBuilder.DropColumn( + name: "AuthorizationPolicy", + table: "gw_tenant_routes"); + + migrationBuilder.DropColumn( + name: "CorsPolicy", + table: "gw_tenant_routes"); + + migrationBuilder.DropColumn( + name: "LoadBalancingPolicy", + table: "gw_tenant_routes"); + + migrationBuilder.DropColumn( + name: "RateLimiterPolicy", + table: "gw_tenant_routes"); + + migrationBuilder.DropColumn( + name: "TimeoutSeconds", + table: "gw_tenant_routes"); + + migrationBuilder.AddColumn( + name: "PathPattern", + table: "gw_tenant_routes", + type: "character varying(200)", + maxLength: 200, + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "gw_service_instances", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Address = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + ClusterId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedBy = table.Column(type: "bigint", nullable: true), + CreatedTime = table.Column(type: "timestamp with time zone", nullable: false), + DestinationId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Health = table.Column(type: "integer", nullable: false), + IsDeleted = table.Column(type: "boolean", nullable: false), + Status = table.Column(type: "integer", nullable: false), + UpdatedBy = table.Column(type: "bigint", nullable: true), + UpdatedTime = table.Column(type: "timestamp with time zone", nullable: true), + Version = table.Column(type: "integer", nullable: false), + Weight = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_gw_service_instances", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "gw_tenants", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CreatedBy = table.Column(type: "bigint", nullable: true), + CreatedTime = table.Column(type: "timestamp with time zone", nullable: false), + IsDeleted = table.Column(type: "boolean", nullable: false), + Status = table.Column(type: "integer", nullable: false), + TenantCode = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + TenantName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + UpdatedBy = table.Column(type: "bigint", nullable: true), + UpdatedTime = table.Column(type: "timestamp with time zone", nullable: true), + Version = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_gw_tenants", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_gw_service_instances_ClusterId_DestinationId", + table: "gw_service_instances", + columns: new[] { "ClusterId", "DestinationId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_gw_service_instances_Health", + table: "gw_service_instances", + column: "Health"); + + migrationBuilder.CreateIndex( + name: "IX_gw_tenants_TenantCode", + table: "gw_tenants", + column: "TenantCode", + unique: true); + } + } +} diff --git a/src/Migrations/ConsoleDbContextModelSnapshot.cs b/src/Migrations/ConsoleDbContextModelSnapshot.cs index 9a36b70..c102f32 100644 --- a/src/Migrations/ConsoleDbContextModelSnapshot.cs +++ b/src/Migrations/ConsoleDbContextModelSnapshot.cs @@ -23,16 +23,80 @@ namespace Fengling.Console.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwServiceInstance", b => + modelBuilder.Entity("Fengling.Console.Models.Entities.PendingConfig", b => + { + b.Property("Id") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConfirmedBy") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsModified") + .HasColumnType("boolean"); + + b.Property("IsNew") + .HasColumnType("boolean"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("SourceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Source"); + + b.HasIndex("Status"); + + b.HasIndex("Type"); + + b.HasIndex("ServiceName", "Status"); + + b.HasIndex("TenantCode", "Status"); + + b.ToTable("gw_pending_configs", (string)null); + }); + + modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwCluster", b => { b.Property("Id") .HasColumnType("text"); - b.Property("Address") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - b.Property("ClusterId") .IsRequired() .HasMaxLength(100) @@ -44,72 +108,26 @@ namespace Fengling.Console.Migrations b.Property("CreatedTime") .HasColumnType("timestamp with time zone"); - b.Property("DestinationId") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Health") - .HasColumnType("integer"); + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); b.Property("IsDeleted") .HasColumnType("boolean"); - b.Property("Status") - .HasColumnType("integer"); - - b.Property("UpdatedBy") - .HasColumnType("bigint"); - - b.Property("UpdatedTime") - .HasColumnType("timestamp with time zone"); - - b.Property("Version") - .HasColumnType("integer"); - - b.Property("Weight") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("Health"); - - b.HasIndex("ClusterId", "DestinationId") - .IsUnique(); - - b.ToTable("gw_service_instances", (string)null); - }); - - modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwTenant", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedBy") - .HasColumnType("bigint"); - - b.Property("CreatedTime") - .HasColumnType("timestamp with time zone"); - - b.Property("IsDeleted") - .HasColumnType("boolean"); - - b.Property("Status") - .HasColumnType("integer"); - - b.Property("TenantCode") + b.Property("LoadBalancingPolicy") .IsRequired() .HasMaxLength(50) .HasColumnType("character varying(50)"); - b.Property("TenantName") + b.Property("Name") .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("Status") + .HasColumnType("integer"); + b.Property("UpdatedBy") .HasColumnType("bigint"); @@ -121,10 +139,14 @@ namespace Fengling.Console.Migrations b.HasKey("Id"); - b.HasIndex("TenantCode") + b.HasIndex("ClusterId") .IsUnique(); - b.ToTable("gw_tenants", (string)null); + b.HasIndex("Name"); + + b.HasIndex("Status"); + + b.ToTable("GwClusters"); }); modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwTenantRoute", b => @@ -132,11 +154,19 @@ namespace Fengling.Console.Migrations b.Property("Id") .HasColumnType("text"); + b.Property("AuthorizationPolicy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + b.Property("ClusterId") .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("CorsPolicy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + b.Property("CreatedBy") .HasColumnType("bigint"); @@ -149,14 +179,17 @@ namespace Fengling.Console.Migrations b.Property("IsGlobal") .HasColumnType("boolean"); - b.Property("PathPattern") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); + b.Property("LoadBalancingPolicy") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); b.Property("Priority") .HasColumnType("integer"); + b.Property("RateLimiterPolicy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + b.Property("ServiceName") .IsRequired() .HasMaxLength(100) @@ -170,6 +203,9 @@ namespace Fengling.Console.Migrations .HasMaxLength(50) .HasColumnType("character varying(50)"); + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + b.Property("UpdatedBy") .HasColumnType("bigint"); @@ -645,6 +681,104 @@ namespace Fengling.Console.Migrations b.ToTable("idn_user_tokens", (string)null); }); + modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwCluster", b => + { + b.OwnsMany("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwDestination", "Destinations", b1 => + { + b1.Property("ClusterId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Address") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b1.Property("DestinationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b1.Property("Health") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b1.Property("HealthStatus") + .HasColumnType("integer"); + + b1.Property("Status") + .HasColumnType("integer"); + + b1.Property("Weight") + .HasColumnType("integer"); + + b1.HasKey("ClusterId", "Id"); + + b1.HasIndex("ClusterId", "DestinationId"); + + b1.ToTable("GwDestination"); + + b1.WithOwner() + .HasForeignKey("ClusterId"); + }); + + b.OwnsOne("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwHealthCheckConfig", "HealthCheck", b1 => + { + b1.Property("GwClusterId"); + + b1.Property("Enabled"); + + b1.Property("IntervalSeconds"); + + b1.Property("Path"); + + b1.Property("TimeoutSeconds"); + + b1.HasKey("GwClusterId"); + + b1.ToTable("GwClusters"); + + b1.ToJson("HealthCheck"); + + b1.WithOwner() + .HasForeignKey("GwClusterId"); + }); + + b.OwnsOne("Fengling.Platform.Domain.AggregatesModel.GatewayAggregate.GwSessionAffinityConfig", "SessionAffinity", b1 => + { + b1.Property("GwClusterId"); + + b1.Property("AffinityKeyName") + .IsRequired(); + + b1.Property("Enabled"); + + b1.Property("Policy") + .IsRequired(); + + b1.HasKey("GwClusterId"); + + b1.ToTable("GwClusters"); + + b1.ToJson("SessionAffinity"); + + b1.WithOwner() + .HasForeignKey("GwClusterId"); + }); + + b.Navigation("Destinations"); + + b.Navigation("HealthCheck"); + + b.Navigation("SessionAffinity"); + }); + modelBuilder.Entity("Fengling.Platform.Domain.AggregatesModel.UserAggregate.ApplicationUser", b => { b.OwnsOne("Fengling.Platform.Domain.AggregatesModel.TenantAggregate.TenantInfo", "TenantInfo", b1 => diff --git a/src/Models/Entities/PendingConfig.cs b/src/Models/Entities/PendingConfig.cs new file mode 100644 index 0000000..401da7b --- /dev/null +++ b/src/Models/Entities/PendingConfig.cs @@ -0,0 +1,78 @@ +namespace Fengling.Console.Models.Entities; + +/// +/// 待确认配置实体 +/// 用于存储从 K8s 服务发现或手动添加的待确认网关配置 +/// +public class PendingConfig +{ + /// + /// 唯一标识 + /// + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + + /// + /// 配置类型(Route, Cluster, Destination) + /// + public PendingConfigType Type { get; set; } + + /// + /// 配置来源(K8sDiscovery, Manual) + /// + public PendingConfigSource Source { get; set; } + + /// + /// 来源标识(K8s Service UID 或手动标识) + /// + public string SourceId { get; set; } = ""; + + /// + /// 配置内容(JSON 格式) + /// + public string ConfigJson { get; set; } = ""; + + /// + /// 服务名称 + /// + public string ServiceName { get; set; } = ""; + + /// + /// 租户编码 + /// + public string? TenantCode { get; set; } + + /// + /// 集群ID + /// + public string ClusterId { get; set; } = ""; + + /// + /// 配置状态 + /// + public PendingConfigStatus Status { get; set; } = PendingConfigStatus.Pending; + + /// + /// 创建时间 + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// 确认时间 + /// + public DateTime? ConfirmedAt { get; set; } + + /// + /// 确认人 + /// + public string? ConfirmedBy { get; set; } + + /// + /// 是否为新配置 + /// + public bool IsNew { get; set; } + + /// + /// 是否已修改 + /// + public bool IsModified { get; set; } +} diff --git a/src/Models/Entities/PendingConfigEnums.cs b/src/Models/Entities/PendingConfigEnums.cs new file mode 100644 index 0000000..ba93329 --- /dev/null +++ b/src/Models/Entities/PendingConfigEnums.cs @@ -0,0 +1,64 @@ +namespace Fengling.Console.Models.Entities; + +/// +/// 待确认配置类型 +/// +public enum PendingConfigType +{ + /// + /// 路由配置 + /// + Route = 0, + + /// + /// 集群配置 + /// + Cluster = 1, + + /// + /// 目标地址配置 + /// + Destination = 2 +} + +/// +/// 待确认配置来源 +/// +public enum PendingConfigSource +{ + /// + /// K8s 服务发现 + /// + K8sDiscovery = 0, + + /// + /// 手动创建 + /// + Manual = 1 +} + +/// +/// 待确认配置状态 +/// +public enum PendingConfigStatus +{ + /// + /// 待确认 + /// + Pending = 0, + + /// + /// 已确认 + /// + Confirmed = 1, + + /// + /// 已拒绝 + /// + Rejected = 2, + + /// + /// 已修改 + /// + Modified = 3 +} diff --git a/src/Program.cs b/src/Program.cs index 501960d..c2b868c 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -12,6 +12,7 @@ using Microsoft.IdentityModel.Tokens; using System.Text; using NetCorePal.Extensions.DependencyInjection; using OpenIddict.Validation.AspNetCore; +using k8s; var builder = WebApplication.CreateBuilder(args); @@ -28,16 +29,6 @@ builder.Services.AddDbContext(options => } options.EnableDetailedErrors(); }); -builder.Services.AddDbContext(options => -{ - options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")); - if (builder.Environment.IsDevelopment()) - { - options.EnableSensitiveDataLogging(); - } - options.EnableDetailedErrors(); -}); - // Use Platform's identity builder.Services.AddIdentity() @@ -67,7 +58,6 @@ builder.Services.AddScoped(); builder.Services.AddOpenIddict() .AddCore(options => { options.UseEntityFrameworkCore().UseDbContext(); }) - .AddCore(options => { options.UseEntityFrameworkCore().UseDbContext(); }) .AddValidation(options => { options.SetIssuer("http://localhost:5132/"); @@ -105,7 +95,9 @@ builder.Services.AddSwaggerGen(c => c.CustomSchemaIds(type => type.FullName); }); -builder.Services.AddRepositories(typeof(ConsoleDbContext).Assembly); + +// 添加 K8s 服务监视 +builder.Services.AddK8sServiceWatch(); var app = builder.Build(); diff --git a/src/Services/K8sServiceWatchService.cs b/src/Services/K8sServiceWatchService.cs new file mode 100644 index 0000000..fd436c7 --- /dev/null +++ b/src/Services/K8sServiceWatchService.cs @@ -0,0 +1,427 @@ +using System.Threading.Channels; +using k8s; +using k8s.Models; + +namespace Fengling.Console.Services; + +/// +/// Kubernetes 服务变更事件 +/// +public sealed record K8sServiceEvent +{ + /// + /// 事件类型:Added, Modified, Deleted, Error, Bookmark + /// + public required WatchEventType EventType { get; init; } + + /// + /// 服务对象 + /// + public required V1Service Service { get; init; } + + /// + /// 事件发生时间 + /// + public DateTime Timestamp { get; init; } = DateTime.UtcNow; +} + +/// +/// Kubernetes 服务监视服务 +/// 监视 K8s Service 资源变化并发布通知 +/// +public sealed class K8sServiceWatchService : BackgroundService, IDisposable +{ + private readonly ILogger _logger; + private readonly IKubernetes _kubernetesClient; + private readonly INotificationService _notificationService; + private readonly K8sServiceWatchOptions _options; + + // 用于通知新事件的通道 + private readonly Channel _eventChannel; + + // 取消令牌源,用于控制重连循环 + private CancellationTokenSource? _watchCts; + + // 当前退避延迟 + private TimeSpan _currentBackoff; + + // 是否已释放 + private bool _disposed; + + public K8sServiceWatchService( + ILogger logger, + IKubernetes kubernetesClient, + INotificationService notificationService, + IConfiguration configuration) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _kubernetesClient = kubernetesClient ?? throw new ArgumentNullException(nameof(kubernetesClient)); + _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService)); + + _options = new K8sServiceWatchOptions(); + configuration.GetSection("K8sServiceWatch").Bind(_options); + + // 创建有界通道,防止内存无限增长 + var channelOptions = new BoundedChannelOptions(_options.ChannelCapacity) + { + FullMode = BoundedChannelFullMode.DropOldest + }; + _eventChannel = Channel.CreateBounded(channelOptions); + + _currentBackoff = _options.InitialBackoff; + } + + /// + /// 主执行循环 + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("K8sServiceWatchService 启动,开始监视 Service 资源..."); + + // 启动事件处理任务 + var processTask = ProcessEventsAsync(stoppingToken); + + try + { + // 主重连循环 + while (!stoppingToken.IsCancellationRequested) + { + try + { + await RunWatchLoopAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + _logger.LogInformation("Watch 循环因服务停止而取消"); + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Watch 循环发生错误,将在 {BackoffMs}ms 后重试", _currentBackoff.TotalMilliseconds); + await WaitWithBackoffAsync(stoppingToken); + } + } + } + finally + { + // 确保事件处理任务也停止 + _eventChannel.Writer.TryComplete(); + try + { + await processTask.WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None); + } + catch (TimeoutException) + { + _logger.LogWarning("事件处理任务未在 5 秒内完成"); + } + catch (Exception ex) + { + _logger.LogError(ex, "等待事件处理任务时发生错误"); + } + } + + _logger.LogInformation("K8sServiceWatchService 已停止"); + } + + /// + /// 运行单次 Watch 循环 + /// + private async Task RunWatchLoopAsync(CancellationToken stoppingToken) + { + // 创建本次 Watch 的取消令牌源 + _watchCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + var watchToken = _watchCts.Token; + + _logger.LogInformation("开始 Watch Kubernetes Services (命名空间: {Namespace})", + string.IsNullOrEmpty(_options.Namespace) ? "全部" : _options.Namespace); + + try + { + // 构建标签选择器 + var labelSelector = _options.LabelSelector; + + // 启动 Watch + var responseTask = string.IsNullOrEmpty(_options.Namespace) + ? _kubernetesClient.CoreV1.ListServiceForAllNamespacesWithHttpMessagesAsync( + watch: true, + labelSelector: labelSelector, + cancellationToken: watchToken) + : _kubernetesClient.CoreV1.ListNamespacedServiceWithHttpMessagesAsync( + namespaceParameter: _options.Namespace, + watch: true, + labelSelector: labelSelector, + cancellationToken: watchToken); + + var response = await responseTask; + + // 使用扩展方法 Watch + var watchable = response.Watch( + onEvent: (type, service) => + { + _ = HandleServiceEventAsync(type, service); + }, + onError: (ex) => + { + _logger.LogError(ex, "Watch 发生错误"); + }, + onClosed: () => + { + _logger.LogWarning("Watch 连接已关闭"); + }); + + // 等待直到取消 + await Task.Delay(Timeout.Infinite, watchToken); + } + catch (OperationCanceledException) when (watchToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Watch 过程中发生异常"); + throw; + } + finally + { + _watchCts?.Cancel(); + _watchCts?.Dispose(); + _watchCts = null; + } + } + + /// + /// 处理 Service 事件 + /// + private async Task HandleServiceEventAsync(WatchEventType type, V1Service service) + { + try + { + var @event = new K8sServiceEvent + { + EventType = type, + Service = service + }; + + // 尝试写入通道,不阻塞 + await _eventChannel.Writer.WriteAsync(@event); + + _logger.LogDebug("Service 事件已入队: {Name}/{Namespace}, 类型: {Type}", + service.Metadata?.Name, + service.Metadata?.NamespaceProperty, + type); + } + catch (ChannelClosedException) + { + _logger.LogWarning("事件通道已关闭,无法写入事件"); + } + catch (Exception ex) + { + _logger.LogError(ex, "处理 Service 事件时发生错误"); + } + } + + /// + /// 处理事件队列 + /// + private async Task ProcessEventsAsync(CancellationToken stoppingToken) + { + await foreach (var @event in _eventChannel.Reader.ReadAllAsync(stoppingToken)) + { + try + { + await ProcessSingleEventAsync(@event, stoppingToken); + + // 成功处理后重置退避 + ResetBackoff(); + } + catch (Exception ex) + { + _logger.LogError(ex, "处理单个事件时发生错误"); + } + } + } + + /// + /// 处理单个事件 + /// + private async Task ProcessSingleEventAsync(K8sServiceEvent @event, CancellationToken cancellationToken) + { + var service = @event.Service; + var metadata = service.Metadata; + + if (metadata == null) + { + _logger.LogWarning("收到无效的 Service 事件:Metadata 为空"); + return; + } + + var eventAction = @event.EventType switch + { + WatchEventType.Added => "create", + WatchEventType.Modified => "update", + WatchEventType.Deleted => "delete", + _ => "unknown" + }; + + _logger.LogInformation("Service {Action}: {Name}/{Namespace}", + eventAction, + metadata.Name, + metadata.NamespaceProperty); + + // 发布配置变更通知 + var details = new + { + ServiceName = metadata.Name, + Namespace = metadata.NamespaceProperty, + ClusterIP = service.Spec?.ClusterIP, + ExternalIPs = service.Spec?.ExternalIPs, + Ports = service.Spec?.Ports?.Select(p => new { p.Name, p.Port, p.TargetPort, p.Protocol }), + Labels = metadata.Labels, + EventTimestamp = @event.Timestamp + }; + + await _notificationService.PublishConfigChangeAsync( + "service", + eventAction, + details, + cancellationToken); + } + + /// + /// 带指数退避的等待 + /// + private async Task WaitWithBackoffAsync(CancellationToken cancellationToken) + { + try + { + await Task.Delay(_currentBackoff, cancellationToken); + } + catch (OperationCanceledException) + { + // 正常取消,不做处理 + } + + // 增加退避时间 + _currentBackoff = TimeSpan.FromMilliseconds( + Math.Min( + _currentBackoff.TotalMilliseconds * _options.BackoffMultiplier, + _options.MaxBackoff.TotalMilliseconds)); + } + + /// + /// 重置退避时间 + /// + private void ResetBackoff() + { + _currentBackoff = _options.InitialBackoff; + } + + /// + /// 停止服务 + /// + public override async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("正在停止 K8sServiceWatchService..."); + + // 取消当前的 Watch + _watchCts?.Cancel(); + + // 关闭事件通道 + _eventChannel.Writer.TryComplete(); + + await base.StopAsync(cancellationToken); + } + + /// + /// 释放资源 + /// + public override void Dispose() + { + if (_disposed) + { + return; + } + + _watchCts?.Cancel(); + _watchCts?.Dispose(); + _eventChannel.Writer.TryComplete(); + + _disposed = true; + + base.Dispose(); + GC.SuppressFinalize(this); + } +} + +/// +/// K8s 服务监视配置选项 +/// +public sealed class K8sServiceWatchOptions +{ + /// + /// 要监视的命名空间,空字符串表示监视所有命名空间 + /// + public string Namespace { get; set; } = ""; + + /// + /// 标签选择器,用于过滤 Service + /// + public string? LabelSelector { get; set; } + + /// + /// 初始退避时间 + /// + public TimeSpan InitialBackoff { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// 最大退避时间 + /// + public TimeSpan MaxBackoff { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// 退避乘数 + /// + public double BackoffMultiplier { get; set; } = 2.0; + + /// + /// 事件通道容量 + /// + public int ChannelCapacity { get; set; } = 1000; +} + +/// +/// 服务扩展 +/// +public static class K8sServiceWatchServiceExtensions +{ + /// + /// 添加 K8s 服务监视服务 + /// + public static IServiceCollection AddK8sServiceWatch(this IServiceCollection services) + { + services.AddSingleton(provider => + { + var logger = provider.GetRequiredService>(); + + try + { + // 尝试加载集群内配置 + var config = KubernetesClientConfiguration.InClusterConfig(); + logger.LogInformation("使用集群内 Kubernetes 配置"); + return new Kubernetes(config); + } + catch (Exception ex) + { + logger.LogWarning(ex, "无法加载集群内配置,尝试加载本地 kubeconfig"); + + // 回退到本地 kubeconfig + var config = KubernetesClientConfiguration.BuildConfigFromConfigFile(); + logger.LogInformation("使用本地 Kubernetes 配置"); + return new Kubernetes(config); + } + }); + + services.AddHostedService(); + return services; + } +}