Compare commits

..

10 Commits

Author SHA1 Message Date
movingsam
abe3456ccb feat[gateway]: add K8s service discovery with pending approval workflow
- Add PendingServiceDiscovery model and database migration
- Add PendingServices API controller for service assignment
- Add KubernetesPendingSyncService for background sync
- Add RBAC configuration for K8s service discovery
- Update Dockerfile and K8s deployment configs
- Add service discovery design documentation

Workflow: K8s services with label managed-by=yarp are discovered
and stored in pending table. Admin approves before they become
active gateway downstream services.
2026-02-22 22:14:54 +08:00
movingsam
a39824397c refactor: align TenantRepository with CleanDDD/NetCorePal规范
- remove duplicate ITenantRepository/TenantRepository from Console
- extend Platform ITenantRepository with GetByIdAsync, GetPagedAsync, CountAsync
- update Console services to use Platform.Infrastructure.Repositories
- fix nullable warnings (UserDto, OAuthClientService)
- fix YarpGateway Directory.Build.props duplicate import
- fix DynamicProxyConfigProvider CS8618 warning
2026-02-19 19:20:06 +08:00
movingsam
6f690c76fa chore: update docs, fix strongly typed ID, add docker config
- docs: move completed docs to completed folder (points-rule-system, migrate-to-vben-admin)
- docs: update project progress (Member 175 tests, Fengling.Console mostly done)
- fix: update CampaignExecutionService to use campaignId.Id instead of .Value
- chore: add dockerignore and docker config to YarpGateway
2026-02-19 18:45:48 +08:00
movingsam
110464e622 refactor: clean up Member module and update Console
- Remove redundant PointsRule repositories (use single PointsRuleRepository)
- Clean up Member migrations and consolidate to single Init migration
- Update Console frontend API and components for Tenant
- Add H5LinkService for member H5 integration
2026-02-18 23:34:40 +08:00
sam
ebd1dc3264 feat(gateway): integrate Kubernetes service discovery in YarpGateway
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-15 10:39:50 +08:00
sam
71a15fd9fb refactor: major project restructuring and cleanup
Changes:

- Remove deprecated Fengling.Activity and YarpGateway.Admin projects

- Add points processing services with distributed lock support

- Update Vben frontend with gateway management pages

- Add gateway config controller and database listener

- Update routing to use header-mixed-nav layout

- Add comprehensive test suites for Member services

- Add YarpGateway integration tests

- Update package versions in Directory.Packages.props

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-15 10:34:07 +08:00
sam
a6a7a5754e docs: reorganize documentation structure
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-15 10:32:23 +08:00
sam
1621f59e4a chore: update gitignore to allow Vben packages directory 2026-02-13 21:34:52 +08:00
Sam
3263a33bbd fix: configure TenantInfo as owned entity and use AsNoTracking
- Add AsNoTracking to tenant query to avoid tracking conflicts
- Configure TenantInfo as owned entity in ApplicationUser
- Map TenantInfo properties to separate columns in AspNetUsers table
2026-02-06 00:55:58 +08:00
Sam
df8b9589f9 chore: add .gitignore and remove tracked binary files (bin, obj, dll, pdb) 2026-02-02 10:20:22 +08:00
166 changed files with 1633 additions and 5580 deletions

View File

@ -0,0 +1,6 @@
namespace YarpGateway.Config;
public static class ConfigNotifyChannel
{
public const string GatewayConfigChanged = "gateway_config_changed";
}

View File

@ -2,7 +2,7 @@ namespace YarpGateway.Config;
public class RedisConfig public class RedisConfig
{ {
public string ConnectionString { get; set; } = "localhost:6379"; public string ConnectionString { get; set; } = "81.68.223.70:16379,password=sl52788542";
public int Database { get; set; } = 0; public int Database { get; set; } = 0;
public string InstanceName { get; set; } = "YarpGateway"; public string InstanceName { get; set; } = "YarpGateway";
} }

View File

@ -4,7 +4,6 @@ using YarpGateway.Data;
using YarpGateway.Config; using YarpGateway.Config;
using YarpGateway.Models; using YarpGateway.Models;
using YarpGateway.Services; using YarpGateway.Services;
using Yarp.ReverseProxy.Configuration;
namespace YarpGateway.Controllers; namespace YarpGateway.Controllers;
@ -29,33 +28,63 @@ public class GatewayConfigController : ControllerBase
_routeCache = routeCache; _routeCache = routeCache;
} }
#region Tenants
[HttpGet("tenants")] [HttpGet("tenants")]
public async Task<IActionResult> GetTenants() public async Task<IActionResult> GetTenants([FromQuery] int page = 1, [FromQuery] int pageSize = 10, [FromQuery] string? keyword = null)
{ {
await using var db = _dbContextFactory.CreateDbContext(); await using var db = _dbContextFactory.CreateDbContext();
var tenants = await db.Tenants var query = db.Tenants.Where(t => !t.IsDeleted);
.Where(t => !t.IsDeleted)
if (!string.IsNullOrEmpty(keyword))
{
query = query.Where(t => t.TenantCode.Contains(keyword) || t.TenantName.Contains(keyword));
}
var total = await query.CountAsync();
var items = await query
.OrderByDescending(t => t.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(t => new
{
t.Id,
t.TenantCode,
t.TenantName,
t.Status,
RouteCount = db.TenantRoutes.Count(r => r.TenantCode == t.TenantCode && !r.IsDeleted),
t.Version,
t.CreatedTime,
t.UpdatedTime
})
.ToListAsync(); .ToListAsync();
return Ok(tenants);
return Ok(new { items, total, page, pageSize, totalPages = (int)Math.Ceiling(total / (double)pageSize) });
}
[HttpGet("tenants/{id}")]
public async Task<IActionResult> GetTenant(long id)
{
await using var db = _dbContextFactory.CreateDbContext();
var tenant = await db.Tenants.FindAsync(id);
if (tenant == null) return NotFound();
return Ok(tenant);
} }
[HttpPost("tenants")] [HttpPost("tenants")]
public async Task<IActionResult> CreateTenant([FromBody] CreateTenantDto dto) public async Task<IActionResult> CreateTenant([FromBody] CreateTenantDto dto)
{ {
await using var db = _dbContextFactory.CreateDbContext(); await using var db = _dbContextFactory.CreateDbContext();
var existing = await db.Tenants var existing = await db.Tenants.FirstOrDefaultAsync(t => t.TenantCode == dto.TenantCode);
.FirstOrDefaultAsync(t => t.TenantCode == dto.TenantCode); if (existing != null) return BadRequest($"Tenant code {dto.TenantCode} already exists");
if (existing != null)
{
return BadRequest($"Tenant code {dto.TenantCode} already exists");
}
var tenant = new GwTenant var tenant = new GwTenant
{ {
Id = GenerateId(), Id = GenerateId(),
TenantCode = dto.TenantCode, TenantCode = dto.TenantCode,
TenantName = dto.TenantName, TenantName = dto.TenantName,
Status = 1 Status = 1,
Version = 1
}; };
await db.Tenants.AddAsync(tenant); await db.Tenants.AddAsync(tenant);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@ -63,55 +92,110 @@ public class GatewayConfigController : ControllerBase
return Ok(tenant); return Ok(tenant);
} }
[HttpPut("tenants/{id}")]
public async Task<IActionResult> UpdateTenant(long id, [FromBody] UpdateTenantDto dto)
{
await using var db = _dbContextFactory.CreateDbContext();
var tenant = await db.Tenants.FindAsync(id);
if (tenant == null) return NotFound();
if (!string.IsNullOrEmpty(dto.TenantName)) tenant.TenantName = dto.TenantName;
if (dto.Status != null) tenant.Status = dto.Status.Value;
tenant.Version++;
tenant.UpdatedTime = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(tenant);
}
[HttpDelete("tenants/{id}")] [HttpDelete("tenants/{id}")]
public async Task<IActionResult> DeleteTenant(long id) public async Task<IActionResult> DeleteTenant(long id)
{ {
await using var db = _dbContextFactory.CreateDbContext(); await using var db = _dbContextFactory.CreateDbContext();
var tenant = await db.Tenants.FindAsync(id); var tenant = await db.Tenants.FindAsync(id);
if (tenant == null) if (tenant == null) return NotFound();
return NotFound();
tenant.IsDeleted = true; tenant.IsDeleted = true;
tenant.UpdatedTime = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(); return Ok();
} }
[HttpGet("tenants/{tenantCode}/routes")] #endregion
public async Task<IActionResult> GetTenantRoutes(string tenantCode)
#region Routes
[HttpGet("routes")]
public async Task<IActionResult> GetRoutes([FromQuery] int page = 1, [FromQuery] int pageSize = 10, [FromQuery] string? tenantCode = null, [FromQuery] bool? isGlobal = null)
{ {
await using var db = _dbContextFactory.CreateDbContext(); await using var db = _dbContextFactory.CreateDbContext();
var routes = await db.TenantRoutes var query = db.TenantRoutes.Where(r => !r.IsDeleted);
.Where(r => r.TenantCode == tenantCode && !r.IsDeleted)
if (!string.IsNullOrEmpty(tenantCode))
query = query.Where(r => r.TenantCode == tenantCode);
if (isGlobal != null)
query = query.Where(r => r.IsGlobal == isGlobal.Value);
var total = await query.CountAsync();
var items = await query
.OrderBy(r => r.Priority)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(); .ToListAsync();
return Ok(new { items, total, page, pageSize, totalPages = (int)Math.Ceiling(total / (double)pageSize) });
}
[HttpGet("routes/global")]
public async Task<IActionResult> GetGlobalRoutes()
{
await using var db = _dbContextFactory.CreateDbContext();
var routes = await db.TenantRoutes.Where(r => r.IsGlobal && !r.IsDeleted).ToListAsync();
return Ok(routes); return Ok(routes);
} }
[HttpPost("tenants/{tenantCode}/routes")] [HttpGet("routes/tenant/{tenantCode}")]
public async Task<IActionResult> CreateTenantRoute(string tenantCode, [FromBody] CreateTenantRouteDto dto) public async Task<IActionResult> GetTenantRoutes(string tenantCode)
{ {
await using var db = _dbContextFactory.CreateDbContext(); await using var db = _dbContextFactory.CreateDbContext();
var tenant = await db.Tenants var routes = await db.TenantRoutes.Where(r => r.TenantCode == tenantCode && !r.IsDeleted).ToListAsync();
.FirstOrDefaultAsync(t => t.TenantCode == tenantCode); return Ok(routes);
if (tenant == null) }
return BadRequest($"Tenant {tenantCode} not found");
var clusterId = $"{tenantCode}-{dto.ServiceName}"; [HttpGet("routes/{id}")]
var existing = await db.TenantRoutes public async Task<IActionResult> GetRoute(long id)
.FirstOrDefaultAsync(r => r.ClusterId == clusterId); {
if (existing != null) await using var db = _dbContextFactory.CreateDbContext();
return BadRequest($"Route for {tenantCode}/{dto.ServiceName} already exists"); var route = await db.TenantRoutes.FindAsync(id);
if (route == null) return NotFound();
return Ok(route);
}
[HttpPost("routes")]
public async Task<IActionResult> CreateRoute([FromBody] CreateRouteDto dto)
{
await using var db = _dbContextFactory.CreateDbContext();
if ((dto.IsGlobal != true) && !string.IsNullOrEmpty(dto.TenantCode))
{
var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.TenantCode == dto.TenantCode);
if (tenant == null) return BadRequest($"Tenant {dto.TenantCode} not found");
}
var route = new GwTenantRoute var route = new GwTenantRoute
{ {
Id = GenerateId(), Id = GenerateId(),
TenantCode = tenantCode, TenantCode = dto.TenantCode ?? string.Empty,
ServiceName = dto.ServiceName, ServiceName = dto.ServiceName,
ClusterId = clusterId, ClusterId = dto.ClusterId,
PathPattern = dto.PathPattern, PathPattern = dto.PathPattern,
Priority = 10, Priority = dto.Priority ?? 10,
Status = 1, Status = 1,
IsGlobal = false IsGlobal = dto.IsGlobal ?? false,
Version = 1,
CreatedTime = DateTime.UtcNow
}; };
await db.TenantRoutes.AddAsync(route); await db.TenantRoutes.AddAsync(route);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@ -121,41 +205,21 @@ public class GatewayConfigController : ControllerBase
return Ok(route); return Ok(route);
} }
[HttpGet("routes/global")] [HttpPut("routes/{id}")]
public async Task<IActionResult> GetGlobalRoutes() public async Task<IActionResult> UpdateRoute(long id, [FromBody] CreateRouteDto dto)
{ {
await using var db = _dbContextFactory.CreateDbContext(); await using var db = _dbContextFactory.CreateDbContext();
var routes = await db.TenantRoutes var route = await db.TenantRoutes.FindAsync(id);
.Where(r => r.IsGlobal && !r.IsDeleted) if (route == null) return NotFound();
.ToListAsync();
return Ok(routes);
}
[HttpPost("routes/global")] route.ServiceName = dto.ServiceName;
public async Task<IActionResult> CreateGlobalRoute([FromBody] CreateGlobalRouteDto dto) route.ClusterId = dto.ClusterId;
{ route.PathPattern = dto.PathPattern;
await using var db = _dbContextFactory.CreateDbContext(); if (dto.Priority != null) route.Priority = dto.Priority.Value;
var existing = await db.TenantRoutes route.Version++;
.FirstOrDefaultAsync(r => r.ServiceName == dto.ServiceName && r.IsGlobal); route.UpdatedTime = DateTime.UtcNow;
if (existing != null)
{
return BadRequest($"Global route for {dto.ServiceName} already exists");
}
var route = new GwTenantRoute
{
Id = GenerateId(),
TenantCode = string.Empty,
ServiceName = dto.ServiceName,
ClusterId = dto.ClusterId,
PathPattern = dto.PathPattern,
Priority = 0,
Status = 1,
IsGlobal = true
};
await db.TenantRoutes.AddAsync(route);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await _routeCache.ReloadAsync(); await _routeCache.ReloadAsync();
return Ok(route); return Ok(route);
@ -166,10 +230,10 @@ public class GatewayConfigController : ControllerBase
{ {
await using var db = _dbContextFactory.CreateDbContext(); await using var db = _dbContextFactory.CreateDbContext();
var route = await db.TenantRoutes.FindAsync(id); var route = await db.TenantRoutes.FindAsync(id);
if (route == null) if (route == null) return NotFound();
return NotFound();
route.IsDeleted = true; route.IsDeleted = true;
route.UpdatedTime = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await _routeCache.ReloadAsync(); await _routeCache.ReloadAsync();
@ -177,24 +241,94 @@ public class GatewayConfigController : ControllerBase
return Ok(); return Ok();
} }
#endregion
#region Clusters
[HttpGet("clusters")]
public async Task<IActionResult> GetClusters()
{
await using var db = _dbContextFactory.CreateDbContext();
var clusters = await db.ServiceInstances
.Where(i => !i.IsDeleted)
.GroupBy(i => i.ClusterId)
.Select(g => new
{
ClusterId = g.Key,
ClusterName = g.Key,
InstanceCount = g.Count(),
HealthyInstanceCount = g.Count(i => i.Health == 1),
Instances = g.ToList()
})
.ToListAsync();
return Ok(clusters);
}
[HttpGet("clusters/{clusterId}")]
public async Task<IActionResult> GetCluster(string clusterId)
{
await using var db = _dbContextFactory.CreateDbContext();
var instances = await db.ServiceInstances.Where(i => i.ClusterId == clusterId && !i.IsDeleted).ToListAsync();
if (!instances.Any()) return NotFound();
return Ok(new
{
ClusterId = clusterId,
ClusterName = clusterId,
InstanceCount = instances.Count,
HealthyInstanceCount = instances.Count(i => i.Health == 1),
Instances = instances
});
}
[HttpPost("clusters")]
public async Task<IActionResult> CreateCluster([FromBody] CreateClusterDto dto)
{
return Ok(new { message = "Cluster created", clusterId = dto.ClusterId });
}
[HttpDelete("clusters/{clusterId}")]
public async Task<IActionResult> DeleteCluster(string clusterId)
{
await using var db = _dbContextFactory.CreateDbContext();
var instances = await db.ServiceInstances.Where(i => i.ClusterId == clusterId).ToListAsync();
foreach (var instance in instances)
{
instance.IsDeleted = true;
}
await db.SaveChangesAsync();
await _clusterProvider.ReloadAsync();
return Ok();
}
#endregion
#region Instances
[HttpGet("clusters/{clusterId}/instances")] [HttpGet("clusters/{clusterId}/instances")]
public async Task<IActionResult> GetInstances(string clusterId) public async Task<IActionResult> GetInstances(string clusterId)
{ {
await using var db = _dbContextFactory.CreateDbContext(); await using var db = _dbContextFactory.CreateDbContext();
var instances = await db.ServiceInstances var instances = await db.ServiceInstances.Where(i => i.ClusterId == clusterId && !i.IsDeleted).ToListAsync();
.Where(i => i.ClusterId == clusterId && !i.IsDeleted)
.ToListAsync();
return Ok(instances); return Ok(instances);
} }
[HttpPost("clusters/{clusterId}/instances")] [HttpGet("instances/{id}")]
public async Task<IActionResult> AddInstance(string clusterId, [FromBody] CreateInstanceDto dto) public async Task<IActionResult> GetInstance(long id)
{ {
await using var db = _dbContextFactory.CreateDbContext(); await using var db = _dbContextFactory.CreateDbContext();
var existing = await db.ServiceInstances var instance = await db.ServiceInstances.FindAsync(id);
.FirstOrDefaultAsync(i => i.ClusterId == clusterId && i.DestinationId == dto.DestinationId); if (instance == null) return NotFound();
if (existing != null) return Ok(instance);
return BadRequest($"Instance {dto.DestinationId} already exists in cluster {clusterId}"); }
[HttpPost("clusters/{clusterId}/instances")]
public async Task<IActionResult> CreateInstance(string clusterId, [FromBody] CreateInstanceDto dto)
{
await using var db = _dbContextFactory.CreateDbContext();
var existing = await db.ServiceInstances.FirstOrDefaultAsync(i => i.ClusterId == clusterId && i.DestinationId == dto.DestinationId);
if (existing != null) return BadRequest($"Instance {dto.DestinationId} already exists");
var instance = new GwServiceInstance var instance = new GwServiceInstance
{ {
@ -202,9 +336,11 @@ public class GatewayConfigController : ControllerBase
ClusterId = clusterId, ClusterId = clusterId,
DestinationId = dto.DestinationId, DestinationId = dto.DestinationId,
Address = dto.Address, Address = dto.Address,
Weight = dto.Weight, Weight = dto.Weight ?? 1,
Health = 1, Health = dto.IsHealthy == true ? 1 : 0,
Status = 1 Status = 1,
Version = 1,
CreatedTime = DateTime.UtcNow
}; };
await db.ServiceInstances.AddAsync(instance); await db.ServiceInstances.AddAsync(instance);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@ -219,10 +355,10 @@ public class GatewayConfigController : ControllerBase
{ {
await using var db = _dbContextFactory.CreateDbContext(); await using var db = _dbContextFactory.CreateDbContext();
var instance = await db.ServiceInstances.FindAsync(id); var instance = await db.ServiceInstances.FindAsync(id);
if (instance == null) if (instance == null) return NotFound();
return NotFound();
instance.IsDeleted = true; instance.IsDeleted = true;
instance.UpdatedTime = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await _clusterProvider.ReloadAsync(); await _clusterProvider.ReloadAsync();
@ -230,43 +366,123 @@ public class GatewayConfigController : ControllerBase
return Ok(); return Ok();
} }
[HttpPost("reload")] #endregion
#region Config & Stats
[HttpPost("config/reload")]
public async Task<IActionResult> ReloadConfig() public async Task<IActionResult> ReloadConfig()
{ {
await _routeCache.ReloadAsync(); await _routeCache.ReloadAsync();
await _routeProvider.ReloadAsync(); await _routeProvider.ReloadAsync();
await _clusterProvider.ReloadAsync(); await _clusterProvider.ReloadAsync();
return Ok(new { message = "Config reloaded successfully" }); return Ok(new { message = "Config reloaded successfully", timestamp = DateTime.UtcNow });
} }
private long GenerateId() [HttpGet("config/status")]
public async Task<IActionResult> GetConfigStatus()
{ {
return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); await using var db = _dbContextFactory.CreateDbContext();
var routeCount = await db.TenantRoutes.CountAsync(r => r.Status == 1 && !r.IsDeleted);
var instanceCount = await db.ServiceInstances.CountAsync(i => i.Status == 1 && !i.IsDeleted);
var healthyCount = await db.ServiceInstances.CountAsync(i => i.Health == 1 && !i.IsDeleted);
return Ok(new
{
routeCount,
clusterCount = await db.ServiceInstances.Where(i => !i.IsDeleted).GroupBy(i => i.ClusterId).CountAsync(),
instanceCount,
healthyInstanceCount = healthyCount,
lastReloadTime = DateTime.UtcNow,
isListening = true,
listenerStatus = "Active"
});
} }
[HttpGet("config/versions")]
public async Task<IActionResult> GetVersionInfo()
{
await using var db = _dbContextFactory.CreateDbContext();
var routeVersion = await db.TenantRoutes.OrderByDescending(r => r.Version).Select(r => r.Version).FirstOrDefaultAsync();
var clusterVersion = await db.ServiceInstances.OrderByDescending(i => i.Version).Select(i => i.Version).FirstOrDefaultAsync();
return Ok(new
{
routeVersion,
clusterVersion,
routeVersionUpdatedAt = DateTime.UtcNow,
clusterVersionUpdatedAt = DateTime.UtcNow
});
}
[HttpGet("stats/overview")]
public async Task<IActionResult> GetOverviewStats()
{
await using var db = _dbContextFactory.CreateDbContext();
var totalTenants = await db.Tenants.CountAsync(t => !t.IsDeleted);
var activeTenants = await db.Tenants.CountAsync(t => !t.IsDeleted && t.Status == 1);
var totalRoutes = await db.TenantRoutes.CountAsync(r => r.Status == 1 && !r.IsDeleted);
var totalInstances = await db.ServiceInstances.CountAsync(i => i.Status == 1 && !i.IsDeleted);
var healthyInstances = await db.ServiceInstances.CountAsync(i => i.Health == 1 && !i.IsDeleted);
return Ok(new
{
totalTenants,
activeTenants,
totalRoutes,
totalClusters = await db.ServiceInstances.Where(i => !i.IsDeleted).GroupBy(i => i.ClusterId).CountAsync(),
totalInstances,
healthyInstances,
lastUpdated = DateTime.UtcNow
});
}
#endregion
#region DTOs
public class CreateTenantDto public class CreateTenantDto
{ {
public string TenantCode { get; set; } = string.Empty; public string TenantCode { get; set; } = string.Empty;
public string TenantName { get; set; } = string.Empty; public string TenantName { get; set; } = string.Empty;
} }
public class CreateTenantRouteDto public class UpdateTenantDto
{ {
public string ServiceName { get; set; } = string.Empty; public string? TenantName { get; set; }
public string PathPattern { get; set; } = string.Empty; public int? Status { get; set; }
} }
public class CreateGlobalRouteDto public class CreateRouteDto
{ {
public string? TenantCode { get; set; }
public string ServiceName { get; set; } = string.Empty; public string ServiceName { get; set; } = string.Empty;
public string ClusterId { get; set; } = string.Empty; public string ClusterId { get; set; } = string.Empty;
public string PathPattern { get; set; } = string.Empty; public string PathPattern { get; set; } = string.Empty;
public int? Priority { get; set; }
public bool? IsGlobal { get; set; }
}
public class CreateClusterDto
{
public string ClusterId { get; set; } = string.Empty;
public string ClusterName { get; set; } = string.Empty;
public string? Description { get; set; }
public string? LoadBalancingPolicy { get; set; }
} }
public class CreateInstanceDto public class CreateInstanceDto
{ {
public string DestinationId { get; set; } = string.Empty; public string DestinationId { get; set; } = string.Empty;
public string Address { get; set; } = string.Empty; public string Address { get; set; } = string.Empty;
public int Weight { get; set; } = 1; public int? Weight { get; set; }
public bool? IsHealthy { get; set; }
}
#endregion
private long GenerateId()
{
return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
} }
} }

View File

@ -0,0 +1,209 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using YarpGateway.Data;
using YarpGateway.Models;
namespace YarpGateway.Controllers;
[ApiController]
[Route("api/gateway/pending-services")]
public class PendingServicesController : ControllerBase
{
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;
private readonly ILogger<PendingServicesController> _logger;
public PendingServicesController(
IDbContextFactory<GatewayDbContext> dbContextFactory,
ILogger<PendingServicesController> logger)
{
_dbContextFactory = dbContextFactory;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> GetPendingServices(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 10,
[FromQuery] int? status = null)
{
await using var db = _dbContextFactory.CreateDbContext();
var query = db.PendingServiceDiscoveries.Where(p => !p.IsDeleted);
if (status.HasValue)
{
query = query.Where(p => p.Status == status.Value);
}
var total = await query.CountAsync();
var items = await query
.OrderByDescending(p => p.DiscoveredAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(p => new
{
p.Id,
p.K8sServiceName,
p.K8sNamespace,
p.K8sClusterIP,
DiscoveredPorts = System.Text.Json.JsonSerializer.Deserialize<List<int>>(p.DiscoveredPorts) ?? new List<int>(),
Labels = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(p.Labels) ?? new Dictionary<string, string>(),
p.PodCount,
Status = (PendingServiceStatus)p.Status,
p.AssignedClusterId,
p.AssignedBy,
p.AssignedAt,
p.DiscoveredAt
})
.ToListAsync();
return Ok(new { items, total, page, pageSize });
}
[HttpGet("{id}")]
public async Task<IActionResult> GetPendingService(long id)
{
await using var db = _dbContextFactory.CreateDbContext();
var service = await db.PendingServiceDiscoveries.FindAsync(id);
if (service == null || service.IsDeleted)
{
return NotFound(new { message = "Pending service not found" });
}
return Ok(new
{
service.Id,
service.K8sServiceName,
service.K8sNamespace,
service.K8sClusterIP,
DiscoveredPorts = System.Text.Json.JsonSerializer.Deserialize<List<int>>(service.DiscoveredPorts) ?? new List<int>(),
Labels = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(service.Labels) ?? new Dictionary<string, string>(),
service.PodCount,
Status = (PendingServiceStatus)service.Status,
service.AssignedClusterId,
service.AssignedBy,
service.AssignedAt,
service.DiscoveredAt
});
}
[HttpPost("{id}/assign")]
public async Task<IActionResult> AssignService(long id, [FromBody] AssignServiceRequest request)
{
await using var db = _dbContextFactory.CreateDbContext();
var pendingService = await db.PendingServiceDiscoveries.FindAsync(id);
if (pendingService == null || pendingService.IsDeleted)
{
return NotFound(new { message = "Pending service not found" });
}
if (pendingService.Status != (int)PendingServiceStatus.Pending)
{
return BadRequest(new { message = $"Service is already {((PendingServiceStatus)pendingService.Status)}, cannot assign" });
}
if (string.IsNullOrEmpty(request.ClusterId))
{
return BadRequest(new { message = "ClusterId is required" });
}
var existingCluster = await db.ServiceInstances
.AnyAsync(i => i.ClusterId == request.ClusterId && !i.IsDeleted);
if (!existingCluster)
{
return BadRequest(new { message = $"Cluster '{request.ClusterId}' does not exist. Please create the cluster first." });
}
var discoveredPorts = System.Text.Json.JsonSerializer.Deserialize<List<int>>(pendingService.DiscoveredPorts) ?? new List<int>();
var primaryPort = discoveredPorts.FirstOrDefault() > 0 ? discoveredPorts.First() : 80;
var instanceNumber = await db.ServiceInstances
.CountAsync(i => i.ClusterId == request.ClusterId && !i.IsDeleted);
var newInstance = new GwServiceInstance
{
ClusterId = request.ClusterId,
DestinationId = $"{pendingService.K8sServiceName}-{instanceNumber + 1}",
Address = $"http://{pendingService.K8sClusterIP}:{primaryPort}",
Health = 1,
Weight = 100,
Status = 1,
CreatedTime = DateTime.UtcNow,
Version = 1
};
db.ServiceInstances.Add(newInstance);
pendingService.Status = (int)PendingServiceStatus.Approved;
pendingService.AssignedClusterId = request.ClusterId;
pendingService.AssignedBy = "admin";
pendingService.AssignedAt = DateTime.UtcNow;
pendingService.Version++;
await db.SaveChangesAsync();
_logger.LogInformation("Service {ServiceName} assigned to cluster {ClusterId} by admin",
pendingService.K8sServiceName, request.ClusterId);
return Ok(new
{
success = true,
message = $"Service '{pendingService.K8sServiceName}' assigned to cluster '{request.ClusterId}'",
instanceId = newInstance.Id
});
}
[HttpPost("{id}/reject")]
public async Task<IActionResult> RejectService(long id)
{
await using var db = _dbContextFactory.CreateDbContext();
var pendingService = await db.PendingServiceDiscoveries.FindAsync(id);
if (pendingService == null || pendingService.IsDeleted)
{
return NotFound(new { message = "Pending service not found" });
}
if (pendingService.Status != (int)PendingServiceStatus.Pending)
{
return BadRequest(new { message = $"Service is already {((PendingServiceStatus)pendingService.Status)}, cannot reject" });
}
pendingService.Status = (int)PendingServiceStatus.Rejected;
pendingService.AssignedBy = "admin";
pendingService.AssignedAt = DateTime.UtcNow;
pendingService.Version++;
await db.SaveChangesAsync();
_logger.LogInformation("Service {ServiceName} rejected by admin", pendingService.K8sServiceName);
return Ok(new { success = true, message = $"Service '{pendingService.K8sServiceName}' rejected" });
}
[HttpGet("clusters")]
public async Task<IActionResult> GetClusters()
{
await using var db = _dbContextFactory.CreateDbContext();
var clusters = await db.ServiceInstances
.Where(i => !i.IsDeleted)
.GroupBy(i => i.ClusterId)
.Select(g => new
{
ClusterId = g.Key,
InstanceCount = g.Count(),
HealthyCount = g.Count(i => i.Health == 1)
})
.ToListAsync();
return Ok(clusters);
}
}
public class AssignServiceRequest
{
public string ClusterId { get; set; } = string.Empty;
}

View File

@ -1,4 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Npgsql;
using YarpGateway.Config;
using YarpGateway.Models; using YarpGateway.Models;
namespace YarpGateway.Data; namespace YarpGateway.Data;
@ -13,6 +15,78 @@ public class GatewayDbContext : DbContext
public DbSet<GwTenant> Tenants => Set<GwTenant>(); public DbSet<GwTenant> Tenants => Set<GwTenant>();
public DbSet<GwTenantRoute> TenantRoutes => Set<GwTenantRoute>(); public DbSet<GwTenantRoute> TenantRoutes => Set<GwTenantRoute>();
public DbSet<GwServiceInstance> ServiceInstances => Set<GwServiceInstance>(); public DbSet<GwServiceInstance> ServiceInstances => Set<GwServiceInstance>();
public DbSet<GwPendingServiceDiscovery> PendingServiceDiscoveries => Set<GwPendingServiceDiscovery>();
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
DetectConfigChanges();
var result = base.SaveChanges(acceptAllChangesOnSuccess);
if (_configChangeDetected)
{
NotifyConfigChangedSync();
}
return result;
}
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
DetectConfigChanges();
var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
if (_configChangeDetected)
{
await NotifyConfigChangedAsync(cancellationToken);
}
return result;
}
private bool _configChangeDetected;
private void DetectConfigChanges()
{
var entries = ChangeTracker.Entries()
.Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
.Where(e => e.Entity is GwTenantRoute or GwServiceInstance or GwTenant);
_configChangeDetected = entries.Any();
}
private bool IsRelationalDatabase()
{
try
{
return Database.IsRelational();
}
catch
{
return false;
}
}
private void NotifyConfigChangedSync()
{
if (!IsRelationalDatabase()) return;
var connectionString = Database.GetConnectionString();
if (string.IsNullOrEmpty(connectionString)) return;
using var connection = new NpgsqlConnection(connectionString);
connection.Open();
using var cmd = new NpgsqlCommand($"NOTIFY {ConfigNotifyChannel.GatewayConfigChanged}", connection);
cmd.ExecuteNonQuery();
}
private async Task NotifyConfigChangedAsync(CancellationToken cancellationToken)
{
if (!IsRelationalDatabase()) return;
var connectionString = Database.GetConnectionString();
if (string.IsNullOrEmpty(connectionString)) return;
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);
await using var cmd = new NpgsqlCommand($"NOTIFY {ConfigNotifyChannel.GatewayConfigChanged}", connection);
await cmd.ExecuteNonQueryAsync(cancellationToken);
}
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@ -47,6 +121,21 @@ public class GatewayDbContext : DbContext
entity.HasIndex(e => e.Health); entity.HasIndex(e => e.Health);
}); });
modelBuilder.Entity<GwPendingServiceDiscovery>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.K8sServiceName).HasMaxLength(255).IsRequired();
entity.Property(e => e.K8sNamespace).HasMaxLength(255).IsRequired();
entity.Property(e => e.K8sClusterIP).HasMaxLength(50);
entity.Property(e => e.DiscoveredPorts).HasMaxLength(500);
entity.Property(e => e.Labels).HasMaxLength(2000);
entity.Property(e => e.AssignedClusterId).HasMaxLength(100);
entity.Property(e => e.AssignedBy).HasMaxLength(100);
entity.HasIndex(e => new { e.K8sServiceName, e.K8sNamespace, e.IsDeleted }).IsUnique();
entity.HasIndex(e => e.Status);
entity.HasIndex(e => e.DiscoveredAt);
});
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
} }
} }

6
Directory.Build.props Normal file
View File

@ -0,0 +1,6 @@
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@ -1,14 +1,29 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER $APP_UID
WORKDIR /app WORKDIR /app
EXPOSE 8080 EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
# Copy Directory.Packages.props for centralized version management
COPY ["Directory.Packages.props", "./"]
COPY ["src/Directory.Packages.props", "src/"]
COPY ["src/YarpGateway/YarpGateway.csproj", "src/YarpGateway/"]
COPY ["src/Fengling.ServiceDiscovery/Fengling.ServiceDiscovery.Core/Fengling.ServiceDiscovery.Core.csproj", "src/Fengling.ServiceDiscovery/Fengling.ServiceDiscovery.Core/"]
COPY ["src/Fengling.ServiceDiscovery/Fengling.ServiceDiscovery.Kubernetes/Fengling.ServiceDiscovery.Kubernetes.csproj", "src/Fengling.ServiceDiscovery/Fengling.ServiceDiscovery.Kubernetes/"]
COPY ["src/Fengling.ServiceDiscovery/Fengling.ServiceDiscovery.Static/Fengling.ServiceDiscovery.Static.csproj", "src/Fengling.ServiceDiscovery/Fengling.ServiceDiscovery.Static/"]
RUN dotnet restore "src/YarpGateway/YarpGateway.csproj"
COPY . . COPY . .
RUN dotnet restore WORKDIR "/src/src/YarpGateway"
RUN dotnet publish -c Release -o /app/publish RUN dotnet build "./YarpGateway.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./YarpGateway.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final FROM base AS final
WORKDIR /app WORKDIR /app
COPY --from=build /app/publish . COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "YarpGateway.dll"] ENTRYPOINT ["dotnet", "YarpGateway.dll"]

View File

@ -10,6 +10,7 @@ public class DynamicProxyConfigProvider : IProxyConfigProvider
private readonly DatabaseRouteConfigProvider _routeProvider; private readonly DatabaseRouteConfigProvider _routeProvider;
private readonly DatabaseClusterConfigProvider _clusterProvider; private readonly DatabaseClusterConfigProvider _clusterProvider;
private readonly object _lock = new(); private readonly object _lock = new();
private CancellationTokenSource? _cts;
public DynamicProxyConfigProvider( public DynamicProxyConfigProvider(
DatabaseRouteConfigProvider routeProvider, DatabaseRouteConfigProvider routeProvider,
@ -17,6 +18,8 @@ public class DynamicProxyConfigProvider : IProxyConfigProvider
{ {
_routeProvider = routeProvider; _routeProvider = routeProvider;
_clusterProvider = clusterProvider; _clusterProvider = clusterProvider;
_cts = new CancellationTokenSource();
_config = null!;
UpdateConfig(); UpdateConfig();
} }
@ -29,13 +32,17 @@ public class DynamicProxyConfigProvider : IProxyConfigProvider
{ {
lock (_lock) lock (_lock)
{ {
_cts?.Cancel();
_cts = new CancellationTokenSource();
var routes = _routeProvider.GetRoutes(); var routes = _routeProvider.GetRoutes();
var clusters = _clusterProvider.GetClusters(); var clusters = _clusterProvider.GetClusters();
_config = new InMemoryProxyConfig( _config = new InMemoryProxyConfig(
routes, routes,
clusters, clusters,
Array.Empty<IReadOnlyDictionary<string, string>>() Array.Empty<IReadOnlyDictionary<string, string>>(),
_cts.Token
); );
} }
} }
@ -49,21 +56,23 @@ public class DynamicProxyConfigProvider : IProxyConfigProvider
private class InMemoryProxyConfig : IProxyConfig private class InMemoryProxyConfig : IProxyConfig
{ {
private static readonly CancellationChangeToken _nullChangeToken = new(new CancellationToken()); private readonly CancellationChangeToken _changeToken;
public InMemoryProxyConfig( public InMemoryProxyConfig(
IReadOnlyList<RouteConfig> routes, IReadOnlyList<RouteConfig> routes,
IReadOnlyList<ClusterConfig> clusters, IReadOnlyList<ClusterConfig> clusters,
IReadOnlyList<IReadOnlyDictionary<string, string>> transforms) IReadOnlyList<IReadOnlyDictionary<string, string>> transforms,
CancellationToken token)
{ {
Routes = routes; Routes = routes;
Clusters = clusters; Clusters = clusters;
Transforms = transforms; Transforms = transforms;
_changeToken = new CancellationChangeToken(token);
} }
public IReadOnlyList<RouteConfig> Routes { get; } public IReadOnlyList<RouteConfig> Routes { get; }
public IReadOnlyList<ClusterConfig> Clusters { get; } public IReadOnlyList<ClusterConfig> Clusters { get; }
public IReadOnlyList<IReadOnlyDictionary<string, string>> Transforms { get; } public IReadOnlyList<IReadOnlyDictionary<string, string>> Transforms { get; }
public IChangeToken ChangeToken => _nullChangeToken; public IChangeToken ChangeToken => _changeToken;
} }
} }

View File

@ -0,0 +1,275 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using YarpGateway.Data;
#nullable disable
namespace YarpGateway.Migrations
{
[DbContext(typeof(GatewayDbContext))]
[Migration("20260222134342_AddPendingServiceDiscovery")]
partial class AddPendingServiceDiscovery
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("YarpGateway.Models.GwPendingServiceDiscovery", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime?>("AssignedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("AssignedBy")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("AssignedClusterId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("DiscoveredAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DiscoveredPorts")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<string>("K8sClusterIP")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("K8sNamespace")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("K8sServiceName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Labels")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<int>("PodCount")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("Version")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("DiscoveredAt");
b.HasIndex("Status");
b.HasIndex("K8sServiceName", "K8sNamespace", "IsDeleted")
.IsUnique();
b.ToTable("PendingServiceDiscoveries");
});
modelBuilder.Entity("YarpGateway.Models.GwServiceInstance", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Address")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("DestinationId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Health")
.HasColumnType("integer");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint");
b.Property<DateTime?>("UpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.HasColumnType("integer");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Health");
b.HasIndex("ClusterId", "DestinationId")
.IsUnique();
b.ToTable("ServiceInstances");
});
modelBuilder.Entity("YarpGateway.Models.GwTenant", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long?>("CreatedBy")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("TenantCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("TenantName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint");
b.Property<DateTime?>("UpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TenantCode")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("YarpGateway.Models.GwTenantRoute", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsGlobal")
.HasColumnType("boolean");
b.Property<string>("PathPattern")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("Priority")
.HasColumnType("integer");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("TenantCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint");
b.Property<DateTime?>("UpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ClusterId");
b.HasIndex("ServiceName");
b.HasIndex("TenantCode");
b.HasIndex("ServiceName", "IsGlobal", "Status");
b.ToTable("TenantRoutes");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,64 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace YarpGateway.Migrations
{
/// <inheritdoc />
public partial class AddPendingServiceDiscovery : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PendingServiceDiscoveries",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
K8sServiceName = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
K8sNamespace = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
K8sClusterIP = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
DiscoveredPorts = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
Labels = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false),
PodCount = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
AssignedClusterId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
AssignedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
AssignedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
DiscoveredAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
Version = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PendingServiceDiscoveries", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_PendingServiceDiscoveries_DiscoveredAt",
table: "PendingServiceDiscoveries",
column: "DiscoveredAt");
migrationBuilder.CreateIndex(
name: "IX_PendingServiceDiscoveries_K8sServiceName_K8sNamespace_IsDel~",
table: "PendingServiceDiscoveries",
columns: new[] { "K8sServiceName", "K8sNamespace", "IsDeleted" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PendingServiceDiscoveries_Status",
table: "PendingServiceDiscoveries",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PendingServiceDiscoveries");
}
}
}

View File

@ -17,11 +17,81 @@ namespace YarpGateway.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "9.0.0") .HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("YarpGateway.Models.GwPendingServiceDiscovery", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime?>("AssignedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("AssignedBy")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("AssignedClusterId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("DiscoveredAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DiscoveredPorts")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<string>("K8sClusterIP")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("K8sNamespace")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("K8sServiceName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Labels")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<int>("PodCount")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("Version")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("DiscoveredAt");
b.HasIndex("Status");
b.HasIndex("K8sServiceName", "K8sNamespace", "IsDeleted")
.IsUnique();
b.ToTable("PendingServiceDiscoveries");
});
modelBuilder.Entity("YarpGateway.Models.GwServiceInstance", b => modelBuilder.Entity("YarpGateway.Models.GwServiceInstance", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")

View File

@ -0,0 +1,122 @@
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
START TRANSACTION;
CREATE TABLE "ServiceInstances" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"ClusterId" character varying(100) NOT NULL,
"DestinationId" character varying(100) NOT NULL,
"Address" character varying(200) NOT NULL,
"Health" integer NOT NULL,
"Weight" integer NOT NULL,
"Status" integer NOT NULL,
"CreatedBy" bigint,
"CreatedTime" timestamp with time zone NOT NULL,
"UpdatedBy" bigint,
"UpdatedTime" timestamp with time zone,
"IsDeleted" boolean NOT NULL,
"Version" integer NOT NULL,
CONSTRAINT "PK_ServiceInstances" PRIMARY KEY ("Id")
);
CREATE TABLE "Tenants" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"TenantCode" character varying(50) NOT NULL,
"TenantName" character varying(100) NOT NULL,
"Status" integer NOT NULL,
"CreatedBy" bigint,
"CreatedTime" timestamp with time zone NOT NULL,
"UpdatedBy" bigint,
"UpdatedTime" timestamp with time zone,
"IsDeleted" boolean NOT NULL,
"Version" integer NOT NULL,
CONSTRAINT "PK_Tenants" PRIMARY KEY ("Id"),
CONSTRAINT "AK_Tenants_TenantCode" UNIQUE ("TenantCode")
);
CREATE TABLE "TenantRoutes" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"TenantCode" character varying(50) NOT NULL,
"ServiceName" character varying(100) NOT NULL,
"ClusterId" character varying(100) NOT NULL,
"PathPattern" character varying(200) NOT NULL,
"Priority" integer NOT NULL,
"Status" integer NOT NULL,
"CreatedBy" bigint,
"CreatedTime" timestamp with time zone NOT NULL,
"UpdatedBy" bigint,
"UpdatedTime" timestamp with time zone,
"IsDeleted" boolean NOT NULL,
"Version" integer NOT NULL,
CONSTRAINT "PK_TenantRoutes" PRIMARY KEY ("Id"),
CONSTRAINT "FK_TenantRoutes_Tenants_TenantCode" FOREIGN KEY ("TenantCode") REFERENCES "Tenants" ("TenantCode") ON DELETE RESTRICT
);
CREATE UNIQUE INDEX "IX_ServiceInstances_ClusterId_DestinationId" ON "ServiceInstances" ("ClusterId", "DestinationId");
CREATE INDEX "IX_ServiceInstances_Health" ON "ServiceInstances" ("Health");
CREATE INDEX "IX_TenantRoutes_ClusterId" ON "TenantRoutes" ("ClusterId");
CREATE UNIQUE INDEX "IX_TenantRoutes_TenantCode_ServiceName" ON "TenantRoutes" ("TenantCode", "ServiceName");
CREATE UNIQUE INDEX "IX_Tenants_TenantCode" ON "Tenants" ("TenantCode");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260201120312_InitialCreate', '10.0.2');
COMMIT;
START TRANSACTION;
ALTER TABLE "TenantRoutes" DROP CONSTRAINT "FK_TenantRoutes_Tenants_TenantCode";
ALTER TABLE "Tenants" DROP CONSTRAINT "AK_Tenants_TenantCode";
DROP INDEX "IX_TenantRoutes_TenantCode_ServiceName";
ALTER TABLE "TenantRoutes" ADD "IsGlobal" boolean NOT NULL DEFAULT FALSE;
CREATE INDEX "IX_TenantRoutes_ServiceName" ON "TenantRoutes" ("ServiceName");
CREATE INDEX "IX_TenantRoutes_ServiceName_IsGlobal_Status" ON "TenantRoutes" ("ServiceName", "IsGlobal", "Status");
CREATE INDEX "IX_TenantRoutes_TenantCode" ON "TenantRoutes" ("TenantCode");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260201133826_AddIsGlobalToTenantRoute', '10.0.2');
COMMIT;
START TRANSACTION;
CREATE TABLE "PendingServiceDiscoveries" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"K8sServiceName" character varying(255) NOT NULL,
"K8sNamespace" character varying(255) NOT NULL,
"K8sClusterIP" character varying(50),
"DiscoveredPorts" character varying(500) NOT NULL,
"Labels" character varying(2000) NOT NULL,
"PodCount" integer NOT NULL,
"Status" integer NOT NULL,
"AssignedClusterId" character varying(100),
"AssignedBy" character varying(100),
"AssignedAt" timestamp with time zone,
"DiscoveredAt" timestamp with time zone NOT NULL,
"IsDeleted" boolean NOT NULL,
"Version" integer NOT NULL,
CONSTRAINT "PK_PendingServiceDiscoveries" PRIMARY KEY ("Id")
);
CREATE INDEX "IX_PendingServiceDiscoveries_DiscoveredAt" ON "PendingServiceDiscoveries" ("DiscoveredAt");
CREATE UNIQUE INDEX "IX_PendingServiceDiscoveries_K8sServiceName_K8sNamespace_IsDel~" ON "PendingServiceDiscoveries" ("K8sServiceName", "K8sNamespace", "IsDeleted");
CREATE INDEX "IX_PendingServiceDiscoveries_Status" ON "PendingServiceDiscoveries" ("Status");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260222134342_AddPendingServiceDiscovery', '10.0.2');
COMMIT;

View File

@ -0,0 +1,27 @@
namespace YarpGateway.Models;
public class GwPendingServiceDiscovery
{
public long Id { get; set; }
public string K8sServiceName { get; set; } = string.Empty;
public string K8sNamespace { get; set; } = string.Empty;
public string? K8sClusterIP { get; set; }
public string DiscoveredPorts { get; set; } = "[]";
public string Labels { get; set; } = "{}";
public int PodCount { get; set; } = 0;
public int Status { get; set; } = 0;
public string? AssignedClusterId { get; set; }
public string? AssignedBy { get; set; }
public DateTime? AssignedAt { get; set; }
public DateTime DiscoveredAt { get; set; } = DateTime.UtcNow;
public bool IsDeleted { get; set; } = false;
public int Version { get; set; } = 0;
}
public enum PendingServiceStatus
{
Pending = 0,
Approved = 1,
Rejected = 2,
K8sServiceNotFound = 3
}

View File

@ -10,6 +10,8 @@ using YarpGateway.LoadBalancing;
using YarpGateway.Middleware; using YarpGateway.Middleware;
using YarpGateway.Services; using YarpGateway.Services;
using StackExchange.Redis; using StackExchange.Redis;
using Fengling.ServiceDiscovery.Extensions;
using Fengling.ServiceDiscovery.Kubernetes.Extensions;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -62,6 +64,20 @@ builder.Services.AddSingleton<ILoadBalancingPolicy, DistributedWeightedRoundRobi
builder.Services.AddSingleton<DynamicProxyConfigProvider>(); builder.Services.AddSingleton<DynamicProxyConfigProvider>();
builder.Services.AddSingleton<IProxyConfigProvider>(sp => sp.GetRequiredService<DynamicProxyConfigProvider>()); builder.Services.AddSingleton<IProxyConfigProvider>(sp => sp.GetRequiredService<DynamicProxyConfigProvider>());
builder.Services.AddHostedService<PgSqlConfigChangeListener>();
// 添加 Kubernetes 服务发现
var useInClusterConfig = builder.Configuration.GetValue<bool>("ServiceDiscovery:UseInClusterConfig", true);
builder.Services.AddKubernetesServiceDiscovery(options =>
{
options.LabelSelector = "app.kubernetes.io/managed-by=yarp";
options.UseInClusterConfig = useInClusterConfig;
});
builder.Services.AddServiceDiscovery();
builder.Services.AddHostedService<KubernetesPendingSyncService>();
var corsSettings = builder.Configuration.GetSection("Cors"); var corsSettings = builder.Configuration.GetSection("Cors");
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
@ -86,6 +102,9 @@ builder.Services.AddCors(options =>
}); });
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddHttpForwarder();
builder.Services.AddRouting();
builder.Services.AddReverseProxy();
var app = builder.Build(); var app = builder.Build();
@ -93,6 +112,8 @@ app.UseCors("AllowFrontend");
app.UseMiddleware<JwtTransformMiddleware>(); app.UseMiddleware<JwtTransformMiddleware>();
app.UseMiddleware<TenantRoutingMiddleware>(); app.UseMiddleware<TenantRoutingMiddleware>();
app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow }));
app.MapControllers(); app.MapControllers();
app.MapReverseProxy(); app.MapReverseProxy();

View File

@ -0,0 +1,161 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using YarpGateway.Data;
using YarpGateway.Models;
namespace YarpGateway.Services;
public class KubernetesPendingSyncService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<KubernetesPendingSyncService> _logger;
private readonly TimeSpan _syncInterval = TimeSpan.FromSeconds(30);
private readonly TimeSpan _staleThreshold = TimeSpan.FromHours(24);
public KubernetesPendingSyncService(
IServiceProvider serviceProvider,
ILogger<KubernetesPendingSyncService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Starting K8s pending service sync background task");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await SyncPendingServicesAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during K8s pending service sync");
}
await Task.Delay(_syncInterval, stoppingToken);
}
}
private async Task SyncPendingServicesAsync(CancellationToken ct)
{
using var scope = _serviceProvider.CreateScope();
var providers = scope.ServiceProvider.GetServices<Fengling.ServiceDiscovery.IServiceDiscoveryProvider>();
var k8sProvider = providers.FirstOrDefault(p => p.ProviderName == "Kubernetes");
if (k8sProvider == null)
{
_logger.LogWarning("No Kubernetes service discovery provider found");
return;
}
var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<GatewayDbContext>>();
var discoveredServices = await k8sProvider.GetServicesAsync(ct);
await using var db = await dbContextFactory.CreateDbContextAsync(ct);
var existingPending = await db.PendingServiceDiscoveries
.Where(p => !p.IsDeleted && p.Status == (int)PendingServiceStatus.Pending)
.ToListAsync(ct);
var existingDict = existingPending
.ToDictionary(p => $"{p.K8sServiceName}|{p.K8sNamespace}");
var discoveredSet = discoveredServices
.Select(s => $"{s.Name}|{s.Namespace}")
.ToHashSet();
var addedCount = 0;
var updatedCount = 0;
var cleanedCount = 0;
foreach (var item in existingDict)
{
var key = item.Key;
if (!discoveredSet.Contains(key))
{
var pending = item.Value;
if (DateTime.UtcNow - pending.DiscoveredAt > _staleThreshold)
{
pending.IsDeleted = true;
pending.Version++;
cleanedCount++;
_logger.LogInformation("Cleaned up stale pending service {ServiceName} in namespace {Namespace}",
pending.K8sServiceName, pending.K8sNamespace);
}
else
{
pending.Status = (int)PendingServiceStatus.K8sServiceNotFound;
pending.Version++;
_logger.LogInformation("Pending service {ServiceName} in namespace {Namespace} not found in K8s, marked as not found",
pending.K8sServiceName, pending.K8sNamespace);
}
}
}
if (discoveredServices.Count > 0)
{
var discoveredDict = discoveredServices.ToDictionary(
s => $"{s.Name}|{s.Namespace}",
s => s);
foreach (var item in discoveredDict)
{
var key = item.Key;
var service = item.Value;
if (existingDict.TryGetValue(key, out var existing))
{
if (existing.Status == (int)PendingServiceStatus.K8sServiceNotFound)
{
existing.Status = (int)PendingServiceStatus.Pending;
existing.Version++;
updatedCount++;
}
var portsJson = JsonSerializer.Serialize(service.Ports);
var labelsJson = JsonSerializer.Serialize(service.Labels);
if (existing.DiscoveredPorts != portsJson || existing.Labels != labelsJson)
{
existing.DiscoveredPorts = portsJson;
existing.Labels = labelsJson;
existing.K8sClusterIP = service.ClusterIP;
existing.PodCount = service.Ports.Count;
existing.Version++;
updatedCount++;
}
}
else
{
var newPending = new GwPendingServiceDiscovery
{
K8sServiceName = service.Name,
K8sNamespace = service.Namespace,
K8sClusterIP = service.ClusterIP,
DiscoveredPorts = JsonSerializer.Serialize(service.Ports),
Labels = JsonSerializer.Serialize(service.Labels),
PodCount = service.Ports.Count,
Status = (int)PendingServiceStatus.Pending,
DiscoveredAt = DateTime.UtcNow,
Version = 1
};
db.PendingServiceDiscoveries.Add(newPending);
addedCount++;
}
}
}
if (addedCount > 0 || updatedCount > 0 || cleanedCount > 0)
{
await db.SaveChangesAsync(ct);
_logger.LogInformation("K8s sync completed: {Added} new, {Updated} updated, {Cleaned} cleaned",
addedCount, updatedCount, cleanedCount);
}
}
}

View File

@ -0,0 +1,222 @@
using System.Threading.Channels;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using YarpGateway.Config;
using YarpGateway.Data;
using YarpGateway.DynamicProxy;
namespace YarpGateway.Services;
public class PgSqlConfigChangeListener : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<PgSqlConfigChangeListener> _logger;
private readonly TimeSpan _fallbackInterval = TimeSpan.FromMinutes(5);
private readonly string _connectionString;
private NpgsqlConnection? _connection;
private int _lastRouteVersion;
private int _lastClusterVersion;
private readonly Channel<bool> _reloadChannel = Channel.CreateBounded<bool>(1);
public PgSqlConfigChangeListener(
IServiceProvider serviceProvider,
ILogger<PgSqlConfigChangeListener> logger,
IConfiguration configuration)
{
_serviceProvider = serviceProvider;
_logger = logger;
_connectionString = configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("DefaultConnection is not configured");
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Starting PgSql config change listener");
await InitializeListenerAsync(stoppingToken);
_ = FallbackPollingAsync(stoppingToken);
await ListenAsync(stoppingToken);
}
private async Task InitializeListenerAsync(CancellationToken stoppingToken)
{
try
{
_connection = new NpgsqlConnection(_connectionString);
_connection.Notification += OnNotification;
await _connection.OpenAsync(stoppingToken);
await using var cmd = _connection.CreateCommand();
cmd.CommandText = $"LISTEN {ConfigNotifyChannel.GatewayConfigChanged}";
await cmd.ExecuteNonQueryAsync(stoppingToken);
_logger.LogInformation("Listening on {Channel}", ConfigNotifyChannel.GatewayConfigChanged);
await UpdateVersionAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize PgSql listener");
}
}
private void OnNotification(object sender, NpgsqlNotificationEventArgs e)
{
_logger.LogInformation("Received config change notification: {Payload}", e.Payload);
_ = _reloadChannel.Writer.WriteAsync(true);
}
private async Task ListenAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
if (_connection == null || _connection.State != System.Data.ConnectionState.Open)
{
await ReconnectAsync(stoppingToken);
}
await Task.Delay(Timeout.Infinite, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in listener loop, reconnecting...");
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
await ReconnectAsync(stoppingToken);
}
}
}
private async Task ReconnectAsync(CancellationToken stoppingToken)
{
try
{
_connection?.Dispose();
_connection = new NpgsqlConnection(_connectionString);
_connection.Notification += OnNotification;
await _connection.OpenAsync(stoppingToken);
await using var cmd = _connection.CreateCommand();
cmd.CommandText = $"LISTEN {ConfigNotifyChannel.GatewayConfigChanged}";
await cmd.ExecuteNonQueryAsync(stoppingToken);
_logger.LogInformation("Reconnected to PostgreSQL and listening");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to reconnect");
}
}
private async Task FallbackPollingAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(_fallbackInterval, stoppingToken);
await CheckAndReloadAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in fallback polling");
}
}
}
private async Task CheckAndReloadAsync(CancellationToken stoppingToken)
{
try
{
await using var scope = _serviceProvider.CreateAsyncScope();
await using var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
var currentRouteVersion = await db.TenantRoutes
.OrderByDescending(r => r.Version)
.Select(r => r.Version)
.FirstOrDefaultAsync(stoppingToken);
var currentClusterVersion = await db.ServiceInstances
.OrderByDescending(i => i.Version)
.Select(i => i.Version)
.FirstOrDefaultAsync(stoppingToken);
if (currentRouteVersion != _lastRouteVersion || currentClusterVersion != _lastClusterVersion)
{
_logger.LogInformation("Version change detected via fallback: route {RouteVer}->{NewRouteVer}, cluster {ClusterVer}->{NewClusterVer}",
_lastRouteVersion, currentRouteVersion, _lastClusterVersion, currentClusterVersion);
await ReloadConfigAsync(stoppingToken);
_lastRouteVersion = currentRouteVersion;
_lastClusterVersion = currentClusterVersion;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking version for fallback");
}
}
private async Task UpdateVersionAsync(CancellationToken stoppingToken)
{
try
{
await using var scope = _serviceProvider.CreateAsyncScope();
await using var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
_lastRouteVersion = await db.TenantRoutes
.OrderByDescending(r => r.Version)
.Select(r => r.Version)
.FirstOrDefaultAsync(stoppingToken);
_lastClusterVersion = await db.ServiceInstances
.OrderByDescending(i => i.Version)
.Select(i => i.Version)
.FirstOrDefaultAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating initial version");
}
}
private async Task ReloadConfigAsync(CancellationToken stoppingToken)
{
try
{
await using var scope = _serviceProvider.CreateAsyncScope();
var routeCache = scope.ServiceProvider.GetRequiredService<IRouteCache>();
await routeCache.ReloadAsync();
var configProvider = scope.ServiceProvider.GetRequiredService<DynamicProxyConfigProvider>();
configProvider.UpdateConfig();
_logger.LogInformation("Configuration reloaded successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reloading configuration");
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping PgSql config change listener");
_reloadChannel.Writer.Complete();
_connection?.Dispose();
await base.StopAsync(cancellationToken);
}
}

View File

@ -4,20 +4,33 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles, analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles, analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="StackExchange.Redis" Version="2.8.24" /> <PackageReference Include="StackExchange.Redis" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.2.0" /> <PackageReference Include="Yarp.ReverseProxy" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Fengling.ServiceDiscovery\Fengling.ServiceDiscovery.Core\Fengling.ServiceDiscovery.Core.csproj" />
<ProjectReference Include="..\Fengling.ServiceDiscovery\Fengling.ServiceDiscovery.Kubernetes\Fengling.ServiceDiscovery.Kubernetes.csproj" />
<ProjectReference Include="..\Fengling.ServiceDiscovery\Fengling.ServiceDiscovery.Static\Fengling.ServiceDiscovery.Static.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -16,7 +16,7 @@
"AllowAnyOrigin": false "AllowAnyOrigin": false
}, },
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Host=192.168.100.10;Port=5432;Database=fengling_gateway;Username=movingsam;Password=sl52788542" "DefaultConnection": "Host=81.68.223.70;Port=15432;Database=fengling_gateway;Username=movingsam;Password=sl52788542"
}, },
"Jwt": { "Jwt": {
"Authority": "https://your-auth-server.com", "Authority": "https://your-auth-server.com",
@ -25,7 +25,7 @@
"ValidateAudience": true "ValidateAudience": true
}, },
"Redis": { "Redis": {
"ConnectionString": "192.168.100.10:6379", "ConnectionString": "81.68.223.70:6379",
"Database": 0, "Database": 0,
"InstanceName": "YarpGateway" "InstanceName": "YarpGateway"
}, },

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

View File

@ -1,20 +0,0 @@
{
"runtimeOptions": {
"tfm": "net10.0",
"frameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "10.0.0"
},
{
"name": "Microsoft.AspNetCore.App",
"version": "10.0.0"
}
],
"configProperties": {
"System.GC.Server": true,
"System.Reflection.NullabilityInfoContext.IsSupported": true,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}

View File

@ -1 +0,0 @@
{"Version":1,"ManifestType":"Build","Endpoints":[]}

View File

@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -1,64 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Yarp.ReverseProxy": "Information"
}
},
"AllowedHosts": "*",
"Cors": {
"AllowedOrigins": [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:5174"
],
"AllowAnyOrigin": false
},
"ConnectionStrings": {
"DefaultConnection": "Host=192.168.100.10;Port=5432;Database=fengling_gateway;Username=movingsam;Password=sl52788542"
},
"Jwt": {
"Authority": "https://your-auth-server.com",
"Audience": "fengling-gateway",
"ValidateIssuer": true,
"ValidateAudience": true
},
"Redis": {
"ConnectionString": "192.168.100.10:6379",
"Database": 0,
"InstanceName": "YarpGateway"
},
"ReverseProxy": {
"Routes": {},
"Clusters": {}
},
"Serilog": {
"Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"],
"MinimumLevel": "Information",
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
"path": "logs/gateway-.log",
"rollingInterval": "Day",
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
}
}
],
"Enrich": ["FromLogContext", "WithMachineName", "WithThreadId"]
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://0.0.0.0:8080"
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More