Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e587224
feat: Add SyncPlugin infrastructure (draft version)
super-niuma-001 Feb 19, 2026
df55c9f
feat: Integrate SyncPlugin with event system
super-niuma-001 Feb 19, 2026
00b2768
feat: Add built-in plugin registration helpers
super-niuma-001 Feb 19, 2026
75a4050
fix: Fix entity property names (Group instead of GroupName)
super-niuma-001 Feb 20, 2026
577ce9b
fix: Fix etcd client package name (dotnet.etcd -> Etcd.Client)
super-niuma-001 Feb 20, 2026
d01bd49
fix: Fix build errors and compile successfully
super-niuma-001 Feb 20, 2026
4677d86
feat: Implement replace-all sync strategy with retry mechanism
super-niuma-001 Feb 20, 2026
e17e251
feat: Implement etcd plugin with replace-all sync strategy
super-niuma-001 Feb 20, 2026
9b1897a
feat: Implement etcd plugin using HTTP API
super-niuma-001 Feb 21, 2026
c708d9d
refactor: extract ISyncPlugin and models to Contracts project to reso…
super-niuma-001 Feb 21, 2026
b930b33
集成 SyncPlugin 到 Apisite 项目
super-niuma-001 Feb 21, 2026
c0a3bec
修复 SyncPlugin DI 问题:SyncRetryService 使用 IServiceProvider 解析 scoped 服务
super-niuma-001 Feb 22, 2026
57bfeb7
修复本地环境 cgroup 兼容性问题
super-niuma-001 Feb 22, 2026
5296072
chore: remove Consul sync plugin
super-niuma-001 Feb 23, 2026
b84426a
fix: remove remaining Consul references from solution file
super-niuma-001 Feb 23, 2026
ddae5de
Add SyncPlugin unit tests for ConfigSyncEventHandler
super-niuma-001 Feb 23, 2026
eaaa08c
Fix SyncPlugin configuration and add Contracts to solution
super-niuma-001 Feb 24, 2026
3e5f13b
fix(docker): include SyncPlugin projects in build
super-niuma-001 Feb 25, 2026
af830db
fix(sync): stabilize event bus DI and etcd delete endpoint
super-niuma-001 Mar 1, 2026
2d7442f
feat: Etcd sync plugin full optimization
Mar 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions AgileConfig.sln

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ COPY ["src/AgileConfig.Server.IService/AgileConfig.Server.IService.csproj", "Agi
COPY ["src/AgileConfig.Server.Data.Freesql/AgileConfig.Server.Data.Freesql.csproj", "AgileConfig.Server.Data.Freesql/"]
COPY ["src/AgileConfig.Server.Common/AgileConfig.Server.Common.csproj", "AgileConfig.Server.Common/"]
COPY ["src/AgileConfig.Server.OIDC/AgileConfig.Server.OIDC.csproj", "AgileConfig.Server.OIDC/"]
# SyncPlugin projects (required for etcd sync)
COPY ["src/AgileConfig.Server.SyncPlugin.Contracts/AgileConfig.Server.SyncPlugin.Contracts.csproj", "AgileConfig.Server.SyncPlugin.Contracts/"]
COPY ["src/AgileConfig.Server.SyncPlugin/AgileConfig.Server.SyncPlugin.csproj", "AgileConfig.Server.SyncPlugin/"]
COPY ["src/AgileConfig.Server.SyncPlugin.Plugins.Etcd/AgileConfig.Server.SyncPlugin.Plugins.Etcd.csproj", "AgileConfig.Server.SyncPlugin.Plugins.Etcd/"]

RUN dotnet restore "AgileConfig.Server.Apisite/AgileConfig.Server.Apisite.csproj"
COPY src/. .
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@
<ProjectReference Include="..\AgileConfig.Server.Event\AgileConfig.Server.Event.csproj" />
<ProjectReference Include="..\AgileConfig.Server.OIDC\AgileConfig.Server.OIDC.csproj" />
<ProjectReference Include="..\AgileConfig.Server.Service\AgileConfig.Server.Service.csproj" />
<ProjectReference Include="..\AgileConfig.Server.SyncPlugin\AgileConfig.Server.SyncPlugin.csproj" />
<ProjectReference Include="..\AgileConfig.Server.SyncPlugin.Plugins.Etcd\AgileConfig.Server.SyncPlugin.Plugins.Etcd.csproj" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using AgileConfig.Server.Data.Entity;
using AgileConfig.Server.IService;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;

namespace AgileConfig.Server.Apisite.Filters;

Expand All @@ -11,25 +12,21 @@ namespace AgileConfig.Server.Apisite.Filters;
/// </summary>
public class PermissionCheckByBasicAttribute : PermissionCheckAttribute
{
protected IAdmBasicAuthService _basicAuthService;
protected IUserService _userService;

public PermissionCheckByBasicAttribute(
IPermissionService permissionService,
IConfigService configService,
IAdmBasicAuthService basicAuthService,
IUserService userService,
string actionName,
string functionKey) : base(permissionService, configService, functionKey)
{
_userService = userService;
_basicAuthService = basicAuthService;
}

protected override async Task<string> GetUserId(ActionExecutingContext context)
{
var userName = _basicAuthService.GetUserNamePassword(context.HttpContext.Request).Item1;
var user = (await _userService.GetUsersByNameAsync(userName)).FirstOrDefault(x =>
var services = context.HttpContext.RequestServices;
var basicAuthService = services.GetRequiredService<IAdmBasicAuthService>();
var userService = services.GetRequiredService<IUserService>();

var userName = basicAuthService.GetUserNamePassword(context.HttpContext.Request).Item1;
var user = (await userService.GetUsersByNameAsync(userName)).FirstOrDefault(x =>
x.Status == UserStatus.Normal);

return user?.Id;
Expand Down
3 changes: 3 additions & 0 deletions src/AgileConfig.Server.Apisite/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using AgileConfig.Server.Data.Repository.Selector;
using AgileConfig.Server.OIDC;
using AgileConfig.Server.Service;
using AgileConfig.Server.SyncPlugin;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
Expand Down Expand Up @@ -83,6 +84,8 @@ public void ConfigureServices(IServiceCollection services)

services.AddMeterService();

services.AddSyncPlugin();

services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService(Program.AppName,
null, null, string.IsNullOrEmpty(Appsettings.OtlpInstanceId), Appsettings.OtlpInstanceId))
Expand Down
13 changes: 12 additions & 1 deletion src/AgileConfig.Server.Apisite/StartupExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,18 @@ public static IOpenTelemetryBuilder AddOtlpMetrics(this IOpenTelemetryBuilder bu

public static IServiceCollection AddMeterService(this IServiceCollection services)
{
if (!string.IsNullOrEmpty(Appsettings.OtlpMetricsEndpoint)) services.AddResourceMonitoring();
// Note: AddResourceMonitoring() requires cgroup support, skip on systems without it
// if (!string.IsNullOrEmpty(Appsettings.OtlpMetricsEndpoint))
// {
// try
// {
// services.AddResourceMonitoring();
// }
// catch
// {
// // Ignore - may fail in environments without cgroup support
// }
// }

services.AddSingleton<IMeterService, MeterService>();

Expand Down
12 changes: 12 additions & 0 deletions src/AgileConfig.Server.Apisite/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,17 @@
"userNameClaim": "name", // Claim key for the user name in the ID token.
"scope": "openid profile" // Requested scopes.
}
},
"SyncPlugin": {
"Enabled": true,
"Plugins": {
"etcd": {
"Enabled": "true",
"Settings": {
"endpoints": "http://localhost:2379",
"keyPrefix": "/agileconfig"
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@ public static class ServiceCollectionExt
{
public static IServiceCollection AddTinyEventBus(this IServiceCollection sc)
{
sc.AddSingleton<ITinyEventBus, TinyEventBus>(sp =>
new TinyEventBus(sc));

sc.AddSingleton<ITinyEventBus, TinyEventBus>();
return sc;
}
}
25 changes: 10 additions & 15 deletions src/AgileConfig.Server.Common/EventBus/TinyEventBus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,13 @@ namespace AgileConfig.Server.Common.EventBus;
public class TinyEventBus : ITinyEventBus
{
private static readonly ConcurrentDictionary<Type, List<Type>> EventHandlerMap = new();
private readonly ILogger _logger;
private readonly IServiceCollection _serviceCollection;
private IServiceProvider _localServiceProvider;
private readonly ILogger<TinyEventBus> _logger;
private readonly IServiceProvider _serviceProvider;

public TinyEventBus(IServiceCollection serviceCollection)
public TinyEventBus(IServiceProvider serviceProvider, ILogger<TinyEventBus> logger)
{
_serviceCollection = serviceCollection;
_logger = _serviceCollection.BuildServiceProvider().GetService<ILoggerFactory>().CreateLogger<TinyEventBus>();
_serviceProvider = serviceProvider;
_logger = logger;
}

public void Register<T>() where T : class, IEventHandler
Expand All @@ -30,7 +29,6 @@ public void Register<T>() where T : class, IEventHandler
handlerTypes.Add(handlerType);
else
EventHandlerMap.TryAdd(eventType, [handlerType]);
_serviceCollection.AddScoped<T>();
}

/// <summary>
Expand All @@ -40,33 +38,30 @@ public void Register<T>() where T : class, IEventHandler
/// <param name="evt">Event payload instance to dispatch to handlers.</param>
public void Fire<TEvent>(TEvent evt) where TEvent : IEvent
{
_localServiceProvider ??= _serviceCollection.BuildServiceProvider();

_logger.LogInformation($"Event fired: {typeof(TEvent).Name}");
_logger.LogInformation("Event fired: {EventType}", typeof(TEvent).Name);

var eventType = typeof(TEvent);
if (EventHandlerMap.TryGetValue(eventType, out var handlers))
{
if (handlers.Count == 0)
{
_logger.LogInformation($"Event fired: {typeof(TEvent).Name}, but no handlers.");
_logger.LogInformation("Event fired: {EventType}, but no handlers.", typeof(TEvent).Name);
return;
}

foreach (var handlerType in handlers)
_ = Task.Run(async () =>
{
using var sc = _localServiceProvider.CreateScope();
var handler = sc.ServiceProvider.GetService(handlerType);
using var scope = _serviceProvider.CreateScope();
var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider, handlerType);

try
{
await (handler as IEventHandler)?.Handle(evt)!;
}
catch (Exception ex)
{
_logger
.LogError(ex, "try run {handler} occur error.", handlerType);
_logger.LogError(ex, "try run {handler} occur error.", handlerType);
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<ProjectReference Include="..\AgileConfig.Server.Data.Entity\AgileConfig.Server.Data.Entity.csproj" />
<ProjectReference Include="..\AgileConfig.Server.Event\AgileConfig.Server.Event.csproj" />
<ProjectReference Include="..\AgileConfig.Server.IService\AgileConfig.Server.IService.csproj" />
<ProjectReference Include="..\AgileConfig.Server.SyncPlugin\AgileConfig.Server.SyncPlugin.csproj" />
</ItemGroup>

</Project>
99 changes: 99 additions & 0 deletions src/AgileConfig.Server.EventHandler/SyncEventHandlers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using AgileConfig.Server.Common.EventBus;
using AgileConfig.Server.Data.Entity;
using AgileConfig.Server.Event;
using AgileConfig.Server.IService;
using AgileConfig.Server.SyncPlugin;
using AgileConfig.Server.SyncPlugin.Contracts;
using AgileConfig.Server.SyncPlugin.Retry;
using Microsoft.Extensions.Logging;

namespace AgileConfig.Server.EventHandler;

/// <summary>
/// Event handler that syncs published configs to external systems via SyncPlugin
/// Uses "replace all" strategy - always fetches latest configs and replaces all
/// </summary>
public class ConfigSyncEventHandler : IEventHandler<PublishConfigSuccessful>
{
private readonly IConfigService _configService;
private readonly SyncEngine _syncEngine;
private readonly SyncRetryService _retryService;
private readonly Microsoft.Extensions.Logging.ILogger<ConfigSyncEventHandler> _logger;

public ConfigSyncEventHandler(
IConfigService configService,
SyncEngine syncEngine,
SyncRetryService retryService,
Microsoft.Extensions.Logging.ILogger<ConfigSyncEventHandler> logger)
{
_configService = configService;
_syncEngine = syncEngine;
_retryService = retryService;
_logger = logger;
}

public async Task Handle(IEvent evt)
{
var evtInstance = evt as PublishConfigSuccessful;
var timeline = evtInstance.PublishTimeline;

if (timeline == null)
{
_logger.LogWarning("PublishConfigSuccessful event has no timeline");
return;
}

try
{
// Get all published configs for this app and env
var configs = await _configService.GetPublishedConfigsAsync(timeline.AppId, timeline.Env);

if (configs == null || !configs.Any())
{
_logger.LogInformation("No published configs found for app {AppId} env {Env}", timeline.AppId, timeline.Env);
return;
}

// Clear existing failed records for this app+env before new sync
_retryService.ClearFailedRecord(timeline.AppId, timeline.Env);

// Convert to sync contexts
var contexts = configs.Select(c => new SyncContext
{
AppId = c.AppId,
AppName = timeline.AppId,
Env = c.Env,
Key = c.Key,
Value = c.Value ?? "",
Group = c.Group,
OperationType = SyncOperationType.Add,
Timestamp = DateTimeOffset.UtcNow
}).ToArray();

// Full sync using "replace all" strategy
var result = await _syncEngine.SyncAllAsync(contexts);

if (result.Success)
{
_logger.LogInformation("Successfully synced {Count} configs for app {AppId} env {Env}",
contexts.Length, timeline.AppId, timeline.Env);
}
else
{
_logger.LogWarning("Failed to sync configs for app {AppId} env {Env}: {Message}",
timeline.AppId, timeline.Env, result.Message);

// Record for retry
_retryService.RecordFailed(timeline.AppId, timeline.Env, result.Message);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception during config sync for app {AppId} env {Env}",
timeline.AppId, timeline.Env);

// Record for retry
_retryService.RecordFailed(timeline.AppId, timeline.Env, ex.Message);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<ProjectReference Include="..\AgileConfig.Server.EventHandler\AgileConfig.Server.EventHandler.csproj" />
<ProjectReference Include="..\AgileConfig.Server.Event\AgileConfig.Server.Event.csproj" />
<ProjectReference Include="..\AgileConfig.Server.IService\AgileConfig.Server.IService.csproj" />
<ProjectReference Include="..\AgileConfig.Server.SyncPlugin\AgileConfig.Server.SyncPlugin.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using AgileConfig.Server.EventHandler;
using AgileConfig.Server.EventHandler;
using AgileConfig.Server.IService;
using ITinyEventBus = AgileConfig.Server.Common.EventBus.ITinyEventBus;

Expand Down Expand Up @@ -30,5 +30,9 @@ public void Register()
tinyEventBus.Register<AddUserEventHandler>();
tinyEventBus.Register<EditUserEventHandler>();
tinyEventBus.Register<DeleteUserEventHandler>();

// SyncPlugin event handlers
tinyEventBus.Register<ConfigSyncEventHandler>();
// Note: ConfigDeleteSyncEventHandler removed - using "replace all" strategy, no need to handle deletes
}
}
}
7 changes: 6 additions & 1 deletion src/AgileConfig.Server.Service/ServiceCollectionExt.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#nullable enable
using AgileConfig.Server.IService;
using AgileConfig.Server.Service.EventRegisterService;
using AgileConfig.Server.SyncPlugin;
using Microsoft.Extensions.DependencyInjection;

namespace AgileConfig.Server.Service;
Expand Down Expand Up @@ -33,5 +34,9 @@ public static void AddBusinessServices(this IServiceCollection sc)
sc.AddScoped<ConfigStatusUpdateEventHandlersRegister>();
sc.AddScoped<ServiceInfoStatusUpdateEventHandlersRegister>();
sc.AddScoped<SystemEventHandlersRegister>();

// SyncPlugin services
sc.AddSyncPlugin();
sc.AddHostedService<AgileConfig.Server.SyncPlugin.BackgroundServices.SyncPluginInitializer>();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

</Project>
Loading
Loading