diff --git a/AgileConfig.sln b/AgileConfig.sln
index 9951ede3..70a65986 100644
--- a/AgileConfig.sln
+++ b/AgileConfig.sln
@@ -56,92 +56,300 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgileConfig.Server.Event",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgileConfig.Server.EventHandler", "src\AgileConfig.Server.EventHandler\AgileConfig.Server.EventHandler.csproj", "{899F5AAE-2C6F-4F0A-9358-6F5C7D0412DC}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AgileConfig.Server.SyncPlugin", "src\AgileConfig.Server.SyncPlugin\AgileConfig.Server.SyncPlugin.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AgileConfig.Server.SyncPlugin.Plugins.Etcd", "src\AgileConfig.Server.SyncPlugin.Plugins.Etcd\AgileConfig.Server.SyncPlugin.Plugins.Etcd.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgileConfig.Server.SyncPlugin.Contracts", "src\AgileConfig.Server.SyncPlugin.Contracts\AgileConfig.Server.SyncPlugin.Contracts.csproj", "{85F46824-BD62-4C28-B34E-CBFB4B27FB5D}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{55EE475A-6F4E-44AF-85C6-9CF6037BA6EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{55EE475A-6F4E-44AF-85C6-9CF6037BA6EE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {55EE475A-6F4E-44AF-85C6-9CF6037BA6EE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {55EE475A-6F4E-44AF-85C6-9CF6037BA6EE}.Debug|x64.Build.0 = Debug|Any CPU
+ {55EE475A-6F4E-44AF-85C6-9CF6037BA6EE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {55EE475A-6F4E-44AF-85C6-9CF6037BA6EE}.Debug|x86.Build.0 = Debug|Any CPU
{55EE475A-6F4E-44AF-85C6-9CF6037BA6EE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{55EE475A-6F4E-44AF-85C6-9CF6037BA6EE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {55EE475A-6F4E-44AF-85C6-9CF6037BA6EE}.Release|x64.ActiveCfg = Release|Any CPU
+ {55EE475A-6F4E-44AF-85C6-9CF6037BA6EE}.Release|x64.Build.0 = Release|Any CPU
+ {55EE475A-6F4E-44AF-85C6-9CF6037BA6EE}.Release|x86.ActiveCfg = Release|Any CPU
+ {55EE475A-6F4E-44AF-85C6-9CF6037BA6EE}.Release|x86.Build.0 = Release|Any CPU
{5BE6420C-5798-4E06-8119-A0E9889B32BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5BE6420C-5798-4E06-8119-A0E9889B32BA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5BE6420C-5798-4E06-8119-A0E9889B32BA}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5BE6420C-5798-4E06-8119-A0E9889B32BA}.Debug|x64.Build.0 = Debug|Any CPU
+ {5BE6420C-5798-4E06-8119-A0E9889B32BA}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5BE6420C-5798-4E06-8119-A0E9889B32BA}.Debug|x86.Build.0 = Debug|Any CPU
{5BE6420C-5798-4E06-8119-A0E9889B32BA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5BE6420C-5798-4E06-8119-A0E9889B32BA}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5BE6420C-5798-4E06-8119-A0E9889B32BA}.Release|x64.ActiveCfg = Release|Any CPU
+ {5BE6420C-5798-4E06-8119-A0E9889B32BA}.Release|x64.Build.0 = Release|Any CPU
+ {5BE6420C-5798-4E06-8119-A0E9889B32BA}.Release|x86.ActiveCfg = Release|Any CPU
+ {5BE6420C-5798-4E06-8119-A0E9889B32BA}.Release|x86.Build.0 = Release|Any CPU
{10FE686F-AE1A-4FE6-9CC1-7AE2097FD165}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10FE686F-AE1A-4FE6-9CC1-7AE2097FD165}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {10FE686F-AE1A-4FE6-9CC1-7AE2097FD165}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {10FE686F-AE1A-4FE6-9CC1-7AE2097FD165}.Debug|x64.Build.0 = Debug|Any CPU
+ {10FE686F-AE1A-4FE6-9CC1-7AE2097FD165}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {10FE686F-AE1A-4FE6-9CC1-7AE2097FD165}.Debug|x86.Build.0 = Debug|Any CPU
{10FE686F-AE1A-4FE6-9CC1-7AE2097FD165}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10FE686F-AE1A-4FE6-9CC1-7AE2097FD165}.Release|Any CPU.Build.0 = Release|Any CPU
+ {10FE686F-AE1A-4FE6-9CC1-7AE2097FD165}.Release|x64.ActiveCfg = Release|Any CPU
+ {10FE686F-AE1A-4FE6-9CC1-7AE2097FD165}.Release|x64.Build.0 = Release|Any CPU
+ {10FE686F-AE1A-4FE6-9CC1-7AE2097FD165}.Release|x86.ActiveCfg = Release|Any CPU
+ {10FE686F-AE1A-4FE6-9CC1-7AE2097FD165}.Release|x86.Build.0 = Release|Any CPU
{DAEA55BA-B79B-44CE-BCB4-6A69B83D55CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DAEA55BA-B79B-44CE-BCB4-6A69B83D55CB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DAEA55BA-B79B-44CE-BCB4-6A69B83D55CB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {DAEA55BA-B79B-44CE-BCB4-6A69B83D55CB}.Debug|x64.Build.0 = Debug|Any CPU
+ {DAEA55BA-B79B-44CE-BCB4-6A69B83D55CB}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {DAEA55BA-B79B-44CE-BCB4-6A69B83D55CB}.Debug|x86.Build.0 = Debug|Any CPU
{DAEA55BA-B79B-44CE-BCB4-6A69B83D55CB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DAEA55BA-B79B-44CE-BCB4-6A69B83D55CB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {DAEA55BA-B79B-44CE-BCB4-6A69B83D55CB}.Release|x64.ActiveCfg = Release|Any CPU
+ {DAEA55BA-B79B-44CE-BCB4-6A69B83D55CB}.Release|x64.Build.0 = Release|Any CPU
+ {DAEA55BA-B79B-44CE-BCB4-6A69B83D55CB}.Release|x86.ActiveCfg = Release|Any CPU
+ {DAEA55BA-B79B-44CE-BCB4-6A69B83D55CB}.Release|x86.Build.0 = Release|Any CPU
{52BFD5C6-B473-433C-A698-A2D3B6823B12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{52BFD5C6-B473-433C-A698-A2D3B6823B12}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {52BFD5C6-B473-433C-A698-A2D3B6823B12}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {52BFD5C6-B473-433C-A698-A2D3B6823B12}.Debug|x64.Build.0 = Debug|Any CPU
+ {52BFD5C6-B473-433C-A698-A2D3B6823B12}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {52BFD5C6-B473-433C-A698-A2D3B6823B12}.Debug|x86.Build.0 = Debug|Any CPU
{52BFD5C6-B473-433C-A698-A2D3B6823B12}.Release|Any CPU.ActiveCfg = Release|Any CPU
{52BFD5C6-B473-433C-A698-A2D3B6823B12}.Release|Any CPU.Build.0 = Release|Any CPU
+ {52BFD5C6-B473-433C-A698-A2D3B6823B12}.Release|x64.ActiveCfg = Release|Any CPU
+ {52BFD5C6-B473-433C-A698-A2D3B6823B12}.Release|x64.Build.0 = Release|Any CPU
+ {52BFD5C6-B473-433C-A698-A2D3B6823B12}.Release|x86.ActiveCfg = Release|Any CPU
+ {52BFD5C6-B473-433C-A698-A2D3B6823B12}.Release|x86.Build.0 = Release|Any CPU
{1B01F2FF-0A07-45AD-BB8D-6DCAEF9381E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1B01F2FF-0A07-45AD-BB8D-6DCAEF9381E1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1B01F2FF-0A07-45AD-BB8D-6DCAEF9381E1}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1B01F2FF-0A07-45AD-BB8D-6DCAEF9381E1}.Debug|x64.Build.0 = Debug|Any CPU
+ {1B01F2FF-0A07-45AD-BB8D-6DCAEF9381E1}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1B01F2FF-0A07-45AD-BB8D-6DCAEF9381E1}.Debug|x86.Build.0 = Debug|Any CPU
{1B01F2FF-0A07-45AD-BB8D-6DCAEF9381E1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1B01F2FF-0A07-45AD-BB8D-6DCAEF9381E1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1B01F2FF-0A07-45AD-BB8D-6DCAEF9381E1}.Release|x64.ActiveCfg = Release|Any CPU
+ {1B01F2FF-0A07-45AD-BB8D-6DCAEF9381E1}.Release|x64.Build.0 = Release|Any CPU
+ {1B01F2FF-0A07-45AD-BB8D-6DCAEF9381E1}.Release|x86.ActiveCfg = Release|Any CPU
+ {1B01F2FF-0A07-45AD-BB8D-6DCAEF9381E1}.Release|x86.Build.0 = Release|Any CPU
{EA462A1A-03CB-4FF0-AA1E-0988021E8711}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EA462A1A-03CB-4FF0-AA1E-0988021E8711}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EA462A1A-03CB-4FF0-AA1E-0988021E8711}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {EA462A1A-03CB-4FF0-AA1E-0988021E8711}.Debug|x64.Build.0 = Debug|Any CPU
+ {EA462A1A-03CB-4FF0-AA1E-0988021E8711}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {EA462A1A-03CB-4FF0-AA1E-0988021E8711}.Debug|x86.Build.0 = Debug|Any CPU
{EA462A1A-03CB-4FF0-AA1E-0988021E8711}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EA462A1A-03CB-4FF0-AA1E-0988021E8711}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EA462A1A-03CB-4FF0-AA1E-0988021E8711}.Release|x64.ActiveCfg = Release|Any CPU
+ {EA462A1A-03CB-4FF0-AA1E-0988021E8711}.Release|x64.Build.0 = Release|Any CPU
+ {EA462A1A-03CB-4FF0-AA1E-0988021E8711}.Release|x86.ActiveCfg = Release|Any CPU
+ {EA462A1A-03CB-4FF0-AA1E-0988021E8711}.Release|x86.Build.0 = Release|Any CPU
{1EBE3129-A926-497A-801E-FDC29F998272}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1EBE3129-A926-497A-801E-FDC29F998272}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1EBE3129-A926-497A-801E-FDC29F998272}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1EBE3129-A926-497A-801E-FDC29F998272}.Debug|x64.Build.0 = Debug|Any CPU
+ {1EBE3129-A926-497A-801E-FDC29F998272}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1EBE3129-A926-497A-801E-FDC29F998272}.Debug|x86.Build.0 = Debug|Any CPU
{1EBE3129-A926-497A-801E-FDC29F998272}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1EBE3129-A926-497A-801E-FDC29F998272}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1EBE3129-A926-497A-801E-FDC29F998272}.Release|x64.ActiveCfg = Release|Any CPU
+ {1EBE3129-A926-497A-801E-FDC29F998272}.Release|x64.Build.0 = Release|Any CPU
+ {1EBE3129-A926-497A-801E-FDC29F998272}.Release|x86.ActiveCfg = Release|Any CPU
+ {1EBE3129-A926-497A-801E-FDC29F998272}.Release|x86.Build.0 = Release|Any CPU
{8CE2DB53-3A35-4991-A419-0EC12B001F59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8CE2DB53-3A35-4991-A419-0EC12B001F59}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8CE2DB53-3A35-4991-A419-0EC12B001F59}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {8CE2DB53-3A35-4991-A419-0EC12B001F59}.Debug|x64.Build.0 = Debug|Any CPU
+ {8CE2DB53-3A35-4991-A419-0EC12B001F59}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {8CE2DB53-3A35-4991-A419-0EC12B001F59}.Debug|x86.Build.0 = Debug|Any CPU
{8CE2DB53-3A35-4991-A419-0EC12B001F59}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8CE2DB53-3A35-4991-A419-0EC12B001F59}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8CE2DB53-3A35-4991-A419-0EC12B001F59}.Release|x64.ActiveCfg = Release|Any CPU
+ {8CE2DB53-3A35-4991-A419-0EC12B001F59}.Release|x64.Build.0 = Release|Any CPU
+ {8CE2DB53-3A35-4991-A419-0EC12B001F59}.Release|x86.ActiveCfg = Release|Any CPU
+ {8CE2DB53-3A35-4991-A419-0EC12B001F59}.Release|x86.Build.0 = Release|Any CPU
{70724B0E-7D81-412C-BDA7-747F4845E990}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{70724B0E-7D81-412C-BDA7-747F4845E990}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {70724B0E-7D81-412C-BDA7-747F4845E990}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {70724B0E-7D81-412C-BDA7-747F4845E990}.Debug|x64.Build.0 = Debug|Any CPU
+ {70724B0E-7D81-412C-BDA7-747F4845E990}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {70724B0E-7D81-412C-BDA7-747F4845E990}.Debug|x86.Build.0 = Debug|Any CPU
{70724B0E-7D81-412C-BDA7-747F4845E990}.Release|Any CPU.ActiveCfg = Release|Any CPU
{70724B0E-7D81-412C-BDA7-747F4845E990}.Release|Any CPU.Build.0 = Release|Any CPU
+ {70724B0E-7D81-412C-BDA7-747F4845E990}.Release|x64.ActiveCfg = Release|Any CPU
+ {70724B0E-7D81-412C-BDA7-747F4845E990}.Release|x64.Build.0 = Release|Any CPU
+ {70724B0E-7D81-412C-BDA7-747F4845E990}.Release|x86.ActiveCfg = Release|Any CPU
+ {70724B0E-7D81-412C-BDA7-747F4845E990}.Release|x86.Build.0 = Release|Any CPU
{E49A2006-6D07-4434-AEC1-27E356A767AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E49A2006-6D07-4434-AEC1-27E356A767AE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E49A2006-6D07-4434-AEC1-27E356A767AE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E49A2006-6D07-4434-AEC1-27E356A767AE}.Debug|x64.Build.0 = Debug|Any CPU
+ {E49A2006-6D07-4434-AEC1-27E356A767AE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E49A2006-6D07-4434-AEC1-27E356A767AE}.Debug|x86.Build.0 = Debug|Any CPU
{E49A2006-6D07-4434-AEC1-27E356A767AE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E49A2006-6D07-4434-AEC1-27E356A767AE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E49A2006-6D07-4434-AEC1-27E356A767AE}.Release|x64.ActiveCfg = Release|Any CPU
+ {E49A2006-6D07-4434-AEC1-27E356A767AE}.Release|x64.Build.0 = Release|Any CPU
+ {E49A2006-6D07-4434-AEC1-27E356A767AE}.Release|x86.ActiveCfg = Release|Any CPU
+ {E49A2006-6D07-4434-AEC1-27E356A767AE}.Release|x86.Build.0 = Release|Any CPU
{4803646E-8327-4F69-8BA5-2244CB58BBA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4803646E-8327-4F69-8BA5-2244CB58BBA2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4803646E-8327-4F69-8BA5-2244CB58BBA2}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {4803646E-8327-4F69-8BA5-2244CB58BBA2}.Debug|x64.Build.0 = Debug|Any CPU
+ {4803646E-8327-4F69-8BA5-2244CB58BBA2}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {4803646E-8327-4F69-8BA5-2244CB58BBA2}.Debug|x86.Build.0 = Debug|Any CPU
{4803646E-8327-4F69-8BA5-2244CB58BBA2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4803646E-8327-4F69-8BA5-2244CB58BBA2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4803646E-8327-4F69-8BA5-2244CB58BBA2}.Release|x64.ActiveCfg = Release|Any CPU
+ {4803646E-8327-4F69-8BA5-2244CB58BBA2}.Release|x64.Build.0 = Release|Any CPU
+ {4803646E-8327-4F69-8BA5-2244CB58BBA2}.Release|x86.ActiveCfg = Release|Any CPU
+ {4803646E-8327-4F69-8BA5-2244CB58BBA2}.Release|x86.Build.0 = Release|Any CPU
{E8101403-72C9-40FB-BCEE-16ED5C23A495}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E8101403-72C9-40FB-BCEE-16ED5C23A495}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E8101403-72C9-40FB-BCEE-16ED5C23A495}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E8101403-72C9-40FB-BCEE-16ED5C23A495}.Debug|x64.Build.0 = Debug|Any CPU
+ {E8101403-72C9-40FB-BCEE-16ED5C23A495}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E8101403-72C9-40FB-BCEE-16ED5C23A495}.Debug|x86.Build.0 = Debug|Any CPU
{E8101403-72C9-40FB-BCEE-16ED5C23A495}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E8101403-72C9-40FB-BCEE-16ED5C23A495}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E8101403-72C9-40FB-BCEE-16ED5C23A495}.Release|x64.ActiveCfg = Release|Any CPU
+ {E8101403-72C9-40FB-BCEE-16ED5C23A495}.Release|x64.Build.0 = Release|Any CPU
+ {E8101403-72C9-40FB-BCEE-16ED5C23A495}.Release|x86.ActiveCfg = Release|Any CPU
+ {E8101403-72C9-40FB-BCEE-16ED5C23A495}.Release|x86.Build.0 = Release|Any CPU
{955F64CC-9EAC-4563-804D-6E2CF9547357}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{955F64CC-9EAC-4563-804D-6E2CF9547357}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {955F64CC-9EAC-4563-804D-6E2CF9547357}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {955F64CC-9EAC-4563-804D-6E2CF9547357}.Debug|x64.Build.0 = Debug|Any CPU
+ {955F64CC-9EAC-4563-804D-6E2CF9547357}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {955F64CC-9EAC-4563-804D-6E2CF9547357}.Debug|x86.Build.0 = Debug|Any CPU
{955F64CC-9EAC-4563-804D-6E2CF9547357}.Release|Any CPU.ActiveCfg = Release|Any CPU
{955F64CC-9EAC-4563-804D-6E2CF9547357}.Release|Any CPU.Build.0 = Release|Any CPU
+ {955F64CC-9EAC-4563-804D-6E2CF9547357}.Release|x64.ActiveCfg = Release|Any CPU
+ {955F64CC-9EAC-4563-804D-6E2CF9547357}.Release|x64.Build.0 = Release|Any CPU
+ {955F64CC-9EAC-4563-804D-6E2CF9547357}.Release|x86.ActiveCfg = Release|Any CPU
+ {955F64CC-9EAC-4563-804D-6E2CF9547357}.Release|x86.Build.0 = Release|Any CPU
{C6B7A5A6-7287-4A20-9336-EBE82A5256F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C6B7A5A6-7287-4A20-9336-EBE82A5256F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C6B7A5A6-7287-4A20-9336-EBE82A5256F1}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C6B7A5A6-7287-4A20-9336-EBE82A5256F1}.Debug|x64.Build.0 = Debug|Any CPU
+ {C6B7A5A6-7287-4A20-9336-EBE82A5256F1}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C6B7A5A6-7287-4A20-9336-EBE82A5256F1}.Debug|x86.Build.0 = Debug|Any CPU
{C6B7A5A6-7287-4A20-9336-EBE82A5256F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C6B7A5A6-7287-4A20-9336-EBE82A5256F1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C6B7A5A6-7287-4A20-9336-EBE82A5256F1}.Release|x64.ActiveCfg = Release|Any CPU
+ {C6B7A5A6-7287-4A20-9336-EBE82A5256F1}.Release|x64.Build.0 = Release|Any CPU
+ {C6B7A5A6-7287-4A20-9336-EBE82A5256F1}.Release|x86.ActiveCfg = Release|Any CPU
+ {C6B7A5A6-7287-4A20-9336-EBE82A5256F1}.Release|x86.Build.0 = Release|Any CPU
{15089E5A-12E4-4953-BA35-0CBB2B1189C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{15089E5A-12E4-4953-BA35-0CBB2B1189C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {15089E5A-12E4-4953-BA35-0CBB2B1189C0}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {15089E5A-12E4-4953-BA35-0CBB2B1189C0}.Debug|x64.Build.0 = Debug|Any CPU
+ {15089E5A-12E4-4953-BA35-0CBB2B1189C0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {15089E5A-12E4-4953-BA35-0CBB2B1189C0}.Debug|x86.Build.0 = Debug|Any CPU
{15089E5A-12E4-4953-BA35-0CBB2B1189C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{15089E5A-12E4-4953-BA35-0CBB2B1189C0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {15089E5A-12E4-4953-BA35-0CBB2B1189C0}.Release|x64.ActiveCfg = Release|Any CPU
+ {15089E5A-12E4-4953-BA35-0CBB2B1189C0}.Release|x64.Build.0 = Release|Any CPU
+ {15089E5A-12E4-4953-BA35-0CBB2B1189C0}.Release|x86.ActiveCfg = Release|Any CPU
+ {15089E5A-12E4-4953-BA35-0CBB2B1189C0}.Release|x86.Build.0 = Release|Any CPU
{964F5F7A-3BBD-47B3-8C28-EC16B1038A5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{964F5F7A-3BBD-47B3-8C28-EC16B1038A5A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {964F5F7A-3BBD-47B3-8C28-EC16B1038A5A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {964F5F7A-3BBD-47B3-8C28-EC16B1038A5A}.Debug|x64.Build.0 = Debug|Any CPU
+ {964F5F7A-3BBD-47B3-8C28-EC16B1038A5A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {964F5F7A-3BBD-47B3-8C28-EC16B1038A5A}.Debug|x86.Build.0 = Debug|Any CPU
{964F5F7A-3BBD-47B3-8C28-EC16B1038A5A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{964F5F7A-3BBD-47B3-8C28-EC16B1038A5A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {964F5F7A-3BBD-47B3-8C28-EC16B1038A5A}.Release|x64.ActiveCfg = Release|Any CPU
+ {964F5F7A-3BBD-47B3-8C28-EC16B1038A5A}.Release|x64.Build.0 = Release|Any CPU
+ {964F5F7A-3BBD-47B3-8C28-EC16B1038A5A}.Release|x86.ActiveCfg = Release|Any CPU
+ {964F5F7A-3BBD-47B3-8C28-EC16B1038A5A}.Release|x86.Build.0 = Release|Any CPU
{AC7E4D24-D5E8-4E30-BFB0-5DDC581CB0C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AC7E4D24-D5E8-4E30-BFB0-5DDC581CB0C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AC7E4D24-D5E8-4E30-BFB0-5DDC581CB0C2}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {AC7E4D24-D5E8-4E30-BFB0-5DDC581CB0C2}.Debug|x64.Build.0 = Debug|Any CPU
+ {AC7E4D24-D5E8-4E30-BFB0-5DDC581CB0C2}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {AC7E4D24-D5E8-4E30-BFB0-5DDC581CB0C2}.Debug|x86.Build.0 = Debug|Any CPU
{AC7E4D24-D5E8-4E30-BFB0-5DDC581CB0C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AC7E4D24-D5E8-4E30-BFB0-5DDC581CB0C2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AC7E4D24-D5E8-4E30-BFB0-5DDC581CB0C2}.Release|x64.ActiveCfg = Release|Any CPU
+ {AC7E4D24-D5E8-4E30-BFB0-5DDC581CB0C2}.Release|x64.Build.0 = Release|Any CPU
+ {AC7E4D24-D5E8-4E30-BFB0-5DDC581CB0C2}.Release|x86.ActiveCfg = Release|Any CPU
+ {AC7E4D24-D5E8-4E30-BFB0-5DDC581CB0C2}.Release|x86.Build.0 = Release|Any CPU
{C1138EE0-0C28-4BDE-82CD-CCE621C21BD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C1138EE0-0C28-4BDE-82CD-CCE621C21BD0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C1138EE0-0C28-4BDE-82CD-CCE621C21BD0}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C1138EE0-0C28-4BDE-82CD-CCE621C21BD0}.Debug|x64.Build.0 = Debug|Any CPU
+ {C1138EE0-0C28-4BDE-82CD-CCE621C21BD0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C1138EE0-0C28-4BDE-82CD-CCE621C21BD0}.Debug|x86.Build.0 = Debug|Any CPU
{C1138EE0-0C28-4BDE-82CD-CCE621C21BD0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C1138EE0-0C28-4BDE-82CD-CCE621C21BD0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C1138EE0-0C28-4BDE-82CD-CCE621C21BD0}.Release|x64.ActiveCfg = Release|Any CPU
+ {C1138EE0-0C28-4BDE-82CD-CCE621C21BD0}.Release|x64.Build.0 = Release|Any CPU
+ {C1138EE0-0C28-4BDE-82CD-CCE621C21BD0}.Release|x86.ActiveCfg = Release|Any CPU
+ {C1138EE0-0C28-4BDE-82CD-CCE621C21BD0}.Release|x86.Build.0 = Release|Any CPU
{899F5AAE-2C6F-4F0A-9358-6F5C7D0412DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{899F5AAE-2C6F-4F0A-9358-6F5C7D0412DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {899F5AAE-2C6F-4F0A-9358-6F5C7D0412DC}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {899F5AAE-2C6F-4F0A-9358-6F5C7D0412DC}.Debug|x64.Build.0 = Debug|Any CPU
+ {899F5AAE-2C6F-4F0A-9358-6F5C7D0412DC}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {899F5AAE-2C6F-4F0A-9358-6F5C7D0412DC}.Debug|x86.Build.0 = Debug|Any CPU
{899F5AAE-2C6F-4F0A-9358-6F5C7D0412DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{899F5AAE-2C6F-4F0A-9358-6F5C7D0412DC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {899F5AAE-2C6F-4F0A-9358-6F5C7D0412DC}.Release|x64.ActiveCfg = Release|Any CPU
+ {899F5AAE-2C6F-4F0A-9358-6F5C7D0412DC}.Release|x64.Build.0 = Release|Any CPU
+ {899F5AAE-2C6F-4F0A-9358-6F5C7D0412DC}.Release|x86.ActiveCfg = Release|Any CPU
+ {899F5AAE-2C6F-4F0A-9358-6F5C7D0412DC}.Release|x86.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.Build.0 = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.Build.0 = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.ActiveCfg = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.Build.0 = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.ActiveCfg = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.Build.0 = Release|Any CPU
+ {85F46824-BD62-4C28-B34E-CBFB4B27FB5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {85F46824-BD62-4C28-B34E-CBFB4B27FB5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {85F46824-BD62-4C28-B34E-CBFB4B27FB5D}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {85F46824-BD62-4C28-B34E-CBFB4B27FB5D}.Debug|x64.Build.0 = Debug|Any CPU
+ {85F46824-BD62-4C28-B34E-CBFB4B27FB5D}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {85F46824-BD62-4C28-B34E-CBFB4B27FB5D}.Debug|x86.Build.0 = Debug|Any CPU
+ {85F46824-BD62-4C28-B34E-CBFB4B27FB5D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {85F46824-BD62-4C28-B34E-CBFB4B27FB5D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {85F46824-BD62-4C28-B34E-CBFB4B27FB5D}.Release|x64.ActiveCfg = Release|Any CPU
+ {85F46824-BD62-4C28-B34E-CBFB4B27FB5D}.Release|x64.Build.0 = Release|Any CPU
+ {85F46824-BD62-4C28-B34E-CBFB4B27FB5D}.Release|x86.ActiveCfg = Release|Any CPU
+ {85F46824-BD62-4C28-B34E-CBFB4B27FB5D}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -167,6 +375,9 @@ Global
{AC7E4D24-D5E8-4E30-BFB0-5DDC581CB0C2} = {F277EC27-8C0E-4490-9645-F5F3244F9246}
{C1138EE0-0C28-4BDE-82CD-CCE621C21BD0} = {1D2FD643-CB85-40F9-BC8A-CE4A39E9F43E}
{899F5AAE-2C6F-4F0A-9358-6F5C7D0412DC} = {1D2FD643-CB85-40F9-BC8A-CE4A39E9F43E}
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {1D2FD643-CB85-40F9-BC8A-CE4A39E9F43E}
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901} = {1D2FD643-CB85-40F9-BC8A-CE4A39E9F43E}
+ {85F46824-BD62-4C28-B34E-CBFB4B27FB5D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {7F10DB58-5B6F-4EAC-994F-14E8046B306F}
diff --git a/Dockerfile b/Dockerfile
index a1403e19..0b0b6698 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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/. .
diff --git a/src/AgileConfig.Server.Apisite/AgileConfig.Server.Apisite.csproj b/src/AgileConfig.Server.Apisite/AgileConfig.Server.Apisite.csproj
index 76ecc231..fef2a1b7 100644
--- a/src/AgileConfig.Server.Apisite/AgileConfig.Server.Apisite.csproj
+++ b/src/AgileConfig.Server.Apisite/AgileConfig.Server.Apisite.csproj
@@ -60,6 +60,8 @@
+
+
diff --git a/src/AgileConfig.Server.Apisite/Filters/PermissionCheckByBasicAttribute.cs b/src/AgileConfig.Server.Apisite/Filters/PermissionCheckByBasicAttribute.cs
index 5b62ff81..38466f49 100644
--- a/src/AgileConfig.Server.Apisite/Filters/PermissionCheckByBasicAttribute.cs
+++ b/src/AgileConfig.Server.Apisite/Filters/PermissionCheckByBasicAttribute.cs
@@ -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;
@@ -11,25 +12,21 @@ namespace AgileConfig.Server.Apisite.Filters;
///
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 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();
+ var userService = services.GetRequiredService();
+
+ var userName = basicAuthService.GetUserNamePassword(context.HttpContext.Request).Item1;
+ var user = (await userService.GetUsersByNameAsync(userName)).FirstOrDefault(x =>
x.Status == UserStatus.Normal);
return user?.Id;
diff --git a/src/AgileConfig.Server.Apisite/Startup.cs b/src/AgileConfig.Server.Apisite/Startup.cs
index b8c03a93..292fa204 100644
--- a/src/AgileConfig.Server.Apisite/Startup.cs
+++ b/src/AgileConfig.Server.Apisite/Startup.cs
@@ -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;
@@ -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))
diff --git a/src/AgileConfig.Server.Apisite/StartupExtension.cs b/src/AgileConfig.Server.Apisite/StartupExtension.cs
index 17e89915..086f374c 100644
--- a/src/AgileConfig.Server.Apisite/StartupExtension.cs
+++ b/src/AgileConfig.Server.Apisite/StartupExtension.cs
@@ -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();
diff --git a/src/AgileConfig.Server.Apisite/appsettings.json b/src/AgileConfig.Server.Apisite/appsettings.json
index 20b11cdd..403e2e9a 100644
--- a/src/AgileConfig.Server.Apisite/appsettings.json
+++ b/src/AgileConfig.Server.Apisite/appsettings.json
@@ -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"
+ }
+ }
+ }
}
}
diff --git a/src/AgileConfig.Server.Common/EventBus/ServiceCollectionExt.cs b/src/AgileConfig.Server.Common/EventBus/ServiceCollectionExt.cs
index 91f2d3d6..950702e0 100644
--- a/src/AgileConfig.Server.Common/EventBus/ServiceCollectionExt.cs
+++ b/src/AgileConfig.Server.Common/EventBus/ServiceCollectionExt.cs
@@ -6,9 +6,7 @@ public static class ServiceCollectionExt
{
public static IServiceCollection AddTinyEventBus(this IServiceCollection sc)
{
- sc.AddSingleton(sp =>
- new TinyEventBus(sc));
-
+ sc.AddSingleton();
return sc;
}
}
\ No newline at end of file
diff --git a/src/AgileConfig.Server.Common/EventBus/TinyEventBus.cs b/src/AgileConfig.Server.Common/EventBus/TinyEventBus.cs
index 4e248d07..02a38701 100644
--- a/src/AgileConfig.Server.Common/EventBus/TinyEventBus.cs
+++ b/src/AgileConfig.Server.Common/EventBus/TinyEventBus.cs
@@ -11,14 +11,13 @@ namespace AgileConfig.Server.Common.EventBus;
public class TinyEventBus : ITinyEventBus
{
private static readonly ConcurrentDictionary> EventHandlerMap = new();
- private readonly ILogger _logger;
- private readonly IServiceCollection _serviceCollection;
- private IServiceProvider _localServiceProvider;
+ private readonly ILogger _logger;
+ private readonly IServiceProvider _serviceProvider;
- public TinyEventBus(IServiceCollection serviceCollection)
+ public TinyEventBus(IServiceProvider serviceProvider, ILogger logger)
{
- _serviceCollection = serviceCollection;
- _logger = _serviceCollection.BuildServiceProvider().GetService().CreateLogger();
+ _serviceProvider = serviceProvider;
+ _logger = logger;
}
public void Register() where T : class, IEventHandler
@@ -30,7 +29,6 @@ public void Register() where T : class, IEventHandler
handlerTypes.Add(handlerType);
else
EventHandlerMap.TryAdd(eventType, [handlerType]);
- _serviceCollection.AddScoped();
}
///
@@ -40,24 +38,22 @@ public void Register() where T : class, IEventHandler
/// Event payload instance to dispatch to handlers.
public void Fire(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
{
@@ -65,8 +61,7 @@ public void Fire(TEvent evt) where TEvent : IEvent
}
catch (Exception ex)
{
- _logger
- .LogError(ex, "try run {handler} occur error.", handlerType);
+ _logger.LogError(ex, "try run {handler} occur error.", handlerType);
}
});
}
diff --git a/src/AgileConfig.Server.EventHandler/AgileConfig.Server.EventHandler.csproj b/src/AgileConfig.Server.EventHandler/AgileConfig.Server.EventHandler.csproj
index 923ee151..8410a463 100644
--- a/src/AgileConfig.Server.EventHandler/AgileConfig.Server.EventHandler.csproj
+++ b/src/AgileConfig.Server.EventHandler/AgileConfig.Server.EventHandler.csproj
@@ -11,6 +11,7 @@
+
diff --git a/src/AgileConfig.Server.EventHandler/SyncEventHandlers.cs b/src/AgileConfig.Server.EventHandler/SyncEventHandlers.cs
new file mode 100644
index 00000000..8c26bdc9
--- /dev/null
+++ b/src/AgileConfig.Server.EventHandler/SyncEventHandlers.cs
@@ -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;
+
+///
+/// Event handler that syncs published configs to external systems via SyncPlugin
+/// Uses "replace all" strategy - always fetches latest configs and replaces all
+///
+public class ConfigSyncEventHandler : IEventHandler
+{
+ private readonly IConfigService _configService;
+ private readonly SyncEngine _syncEngine;
+ private readonly SyncRetryService _retryService;
+ private readonly Microsoft.Extensions.Logging.ILogger _logger;
+
+ public ConfigSyncEventHandler(
+ IConfigService configService,
+ SyncEngine syncEngine,
+ SyncRetryService retryService,
+ Microsoft.Extensions.Logging.ILogger 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);
+ }
+ }
+}
diff --git a/src/AgileConfig.Server.Service/AgileConfig.Server.Service.csproj b/src/AgileConfig.Server.Service/AgileConfig.Server.Service.csproj
index 21bcd015..8e85cbac 100644
--- a/src/AgileConfig.Server.Service/AgileConfig.Server.Service.csproj
+++ b/src/AgileConfig.Server.Service/AgileConfig.Server.Service.csproj
@@ -16,6 +16,7 @@
+
diff --git a/src/AgileConfig.Server.Service/EventRegisterService/SystemEventHandlersRegister.cs b/src/AgileConfig.Server.Service/EventRegisterService/SystemEventHandlersRegister.cs
index 1a2805ff..f725138c 100644
--- a/src/AgileConfig.Server.Service/EventRegisterService/SystemEventHandlersRegister.cs
+++ b/src/AgileConfig.Server.Service/EventRegisterService/SystemEventHandlersRegister.cs
@@ -1,4 +1,4 @@
-using AgileConfig.Server.EventHandler;
+using AgileConfig.Server.EventHandler;
using AgileConfig.Server.IService;
using ITinyEventBus = AgileConfig.Server.Common.EventBus.ITinyEventBus;
@@ -30,5 +30,9 @@ public void Register()
tinyEventBus.Register();
tinyEventBus.Register();
tinyEventBus.Register();
+
+ // SyncPlugin event handlers
+ tinyEventBus.Register();
+ // Note: ConfigDeleteSyncEventHandler removed - using "replace all" strategy, no need to handle deletes
}
-}
\ No newline at end of file
+}
diff --git a/src/AgileConfig.Server.Service/ServiceCollectionExt.cs b/src/AgileConfig.Server.Service/ServiceCollectionExt.cs
index 4c9b3145..35c78d08 100644
--- a/src/AgileConfig.Server.Service/ServiceCollectionExt.cs
+++ b/src/AgileConfig.Server.Service/ServiceCollectionExt.cs
@@ -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;
@@ -33,5 +34,9 @@ public static void AddBusinessServices(this IServiceCollection sc)
sc.AddScoped();
sc.AddScoped();
sc.AddScoped();
+
+ // SyncPlugin services
+ sc.AddSyncPlugin();
+ sc.AddHostedService();
}
-}
\ No newline at end of file
+}
diff --git a/src/AgileConfig.Server.SyncPlugin.Contracts/AgileConfig.Server.SyncPlugin.Contracts.csproj b/src/AgileConfig.Server.SyncPlugin.Contracts/AgileConfig.Server.SyncPlugin.Contracts.csproj
new file mode 100644
index 00000000..9ed914b5
--- /dev/null
+++ b/src/AgileConfig.Server.SyncPlugin.Contracts/AgileConfig.Server.SyncPlugin.Contracts.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/src/AgileConfig.Server.SyncPlugin.Contracts/Contracts.cs b/src/AgileConfig.Server.SyncPlugin.Contracts/Contracts.cs
new file mode 100644
index 00000000..cff92a0a
--- /dev/null
+++ b/src/AgileConfig.Server.SyncPlugin.Contracts/Contracts.cs
@@ -0,0 +1,127 @@
+namespace AgileConfig.Server.SyncPlugin.Contracts;
+
+///
+/// Interface for sync plugins
+/// All sync operations use "replace all" strategy: delete all + insert all
+///
+public interface ISyncPlugin
+{
+ ///
+ /// Unique name of the plugin
+ ///
+ string Name { get; }
+
+ ///
+ /// Display name for UI
+ ///
+ string DisplayName { get; }
+
+ ///
+ /// Description of the plugin
+ ///
+ string Description { get; }
+
+ ///
+ /// Initialize the plugin with configuration
+ ///
+ Task InitializeAsync(SyncPluginConfig config);
+
+ ///
+ /// Full sync: delete all + insert all for the given app+env
+ /// This is the ONLY sync method - no need to handle add/update/delete separately
+ ///
+ /// All current published configs for the app+env
+ Task SyncAllAsync(SyncContext[] contexts);
+
+ ///
+ /// Health check for the plugin
+ ///
+ Task HealthCheckAsync();
+
+ ///
+ /// Shutdown the plugin
+ ///
+ Task ShutdownAsync();
+}
+
+///
+/// Result of sync plugin operation
+///
+public class SyncPluginResult
+{
+ public bool Success { get; set; }
+ public string? Message { get; set; }
+ public Exception? Exception { get; set; }
+}
+
+///
+/// Health check result of sync plugin
+///
+public class SyncPluginHealthResult
+{
+ public bool Healthy { get; set; }
+ public string? Message { get; set; }
+}
+
+///
+/// Configuration for sync plugin
+///
+public class SyncPluginConfig
+{
+ public string? PluginName { get; set; }
+ public string? Enabled { get; set; }
+ public Dictionary Settings { get; set; } = new();
+}
+
+///
+/// Context for sync operation
+///
+public class SyncContext
+{
+ ///
+ /// Application Id
+ ///
+ public string AppId { get; set; } = string.Empty;
+
+ ///
+ /// Application Name
+ ///
+ public string AppName { get; set; } = string.Empty;
+
+ ///
+ /// Environment (e.g., PROD, DEV)
+ ///
+ public string Env { get; set; } = string.Empty;
+
+ ///
+ /// Config key
+ ///
+ public string Key { get; set; } = string.Empty;
+
+ ///
+ /// Config value
+ ///
+ public string Value { get; set; } = string.Empty;
+
+ ///
+ /// Config group
+ ///
+ public string? Group { get; set; }
+
+ ///
+ /// Operation type: Add, Update, Delete
+ ///
+ public SyncOperationType OperationType { get; set; }
+
+ ///
+ /// Timestamp of the change
+ ///
+ public DateTimeOffset Timestamp { get; set; }
+}
+
+public enum SyncOperationType
+{
+ Add,
+ Update,
+ Delete
+}
diff --git a/src/AgileConfig.Server.SyncPlugin.Plugins.Etcd/AgileConfig.Server.SyncPlugin.Plugins.Etcd.csproj b/src/AgileConfig.Server.SyncPlugin.Plugins.Etcd/AgileConfig.Server.SyncPlugin.Plugins.Etcd.csproj
new file mode 100644
index 00000000..7bca9656
--- /dev/null
+++ b/src/AgileConfig.Server.SyncPlugin.Plugins.Etcd/AgileConfig.Server.SyncPlugin.Plugins.Etcd.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AgileConfig.Server.SyncPlugin.Plugins.Etcd/EtcdSyncPlugin.cs b/src/AgileConfig.Server.SyncPlugin.Plugins.Etcd/EtcdSyncPlugin.cs
new file mode 100644
index 00000000..303d1b1a
--- /dev/null
+++ b/src/AgileConfig.Server.SyncPlugin.Plugins.Etcd/EtcdSyncPlugin.cs
@@ -0,0 +1,359 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using AgileConfig.Server.SyncPlugin.Contracts;
+
+namespace AgileConfig.Server.SyncPlugin.Plugins.Etcd;
+
+///
+/// Etcd sync plugin implementation using HTTP API
+/// Uses "replace all" strategy: delete all keys for app+env, then insert all
+///
+public class EtcdSyncPlugin : ISyncPlugin
+{
+ private readonly ILogger _logger;
+ private SyncPluginConfig? _config;
+ private HttpClient? _httpClient;
+ private string _keyPrefix = "/agileconfig";
+ private bool _allowOverwriteOtherData = false;
+ private int _maxTxnOperations = 500;
+ private string _syncStrategy = "FullReplace";
+
+ public string Name => "etcd";
+ public string DisplayName => "Etcd";
+ public string Description => "Sync configs to etcd using replace-all strategy (HTTP API)";
+
+ public EtcdSyncPlugin(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public Task InitializeAsync(SyncPluginConfig config)
+ {
+ try
+ {
+ _config = config;
+
+ var endpoints = config.Settings.GetValueOrDefault("endpoints", "http://localhost:2379");
+ _keyPrefix = config.Settings.GetValueOrDefault("keyPrefix", "/agileconfig").TrimEnd('/');
+ _allowOverwriteOtherData = bool.TryParse(config.Settings.GetValueOrDefault("allowOverwriteOtherData", "false"), out var a) && a;
+ _maxTxnOperations = int.TryParse(config.Settings.GetValueOrDefault("maxTxnOperations", "500"), out var m) && m > 0 ? m : 500;
+ _syncStrategy = config.Settings.GetValueOrDefault("syncStrategy", "FullReplace");
+
+ // Validate key prefix
+ if (_keyPrefix == "/" || _keyPrefix.Length < 5 || !_keyPrefix.StartsWith('/'))
+ {
+ var error = $"Invalid key prefix '{_keyPrefix}'. Prefix must start with '/' and be at least 5 characters long, cannot be root path '/'.";
+ _logger.LogError(error);
+ return Task.FromResult(new SyncPluginResult { Success = false, Message = error });
+ }
+
+ _logger.LogInformation("Initializing Etcd plugin with endpoints: {Endpoints}, keyPrefix: {KeyPrefix}, allowOverwriteOtherData: {AllowOverwrite}, maxTxnOperations: {MaxTxn}, syncStrategy: {SyncStrategy}",
+ endpoints, _keyPrefix, _allowOverwriteOtherData, _maxTxnOperations, _syncStrategy);
+
+ _httpClient = new HttpClient
+ {
+ BaseAddress = new Uri(endpoints.TrimEnd('/'))
+ };
+
+ return Task.FromResult(new SyncPluginResult { Success = true, Message = "Initialized" });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to initialize Etcd plugin");
+ return Task.FromResult(new SyncPluginResult { Success = false, Message = ex.Message, Exception = ex });
+ }
+ }
+
+ ///
+ /// Full sync: use etcd transaction to atomically replace all configs
+ ///
+ public async Task SyncAllAsync(SyncContext[] contexts)
+ {
+ if (contexts == null || contexts.Length == 0)
+ {
+ _logger.LogInformation("No configs to sync");
+ return new SyncPluginResult { Success = true, Message = "No configs to sync" };
+ }
+
+ try
+ {
+ var appId = contexts[0].AppId;
+ var env = contexts[0].Env;
+ var prefix = $"{_keyPrefix}/{appId}/{env}/";
+
+ List? existingKeys = null;
+ // Safety check: verify existing keys are valid AgileConfig keys if overwrite is not allowed
+ if (!_allowOverwriteOtherData)
+ {
+ existingKeys = await GetRangeKeysAsync(prefix);
+ foreach (var base64Key in existingKeys)
+ {
+ var key = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(base64Key));
+ // Valid key format: {prefix}/{appId}/{env}/{group}/{key}
+ var relativePath = key.Substring(prefix.Length);
+ if (string.IsNullOrEmpty(relativePath) || !relativePath.Contains('/') || relativePath.StartsWith('/') || relativePath.EndsWith('/'))
+ {
+ var error = $"Found invalid key '{key}' under prefix '{prefix}' that does not match AgileConfig format. To overwrite anyway, set 'allowOverwriteOtherData' to true.";
+ _logger.LogError(error);
+ return new SyncPluginResult { Success = false, Message = error };
+ }
+ }
+ }
+
+ List