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 operations; + int deletedCount = 0; + int addedCount = 0; + int updatedCount = 0; + + if (_syncStrategy.Equals("Incremental", StringComparison.OrdinalIgnoreCase)) + { + // Incremental sync: compare existing configs with new ones + var existingKvs = await GetRangeKvsAsync(prefix); + var existingDict = existingKvs.ToDictionary( + kv => System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(kv.key)), + kv => System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(kv.value)) + ); + + var newDict = contexts.ToDictionary( + c => BuildKey(c), + c => c.Value + ); + + operations = new List(); + + // Find keys to delete (exist in etcd but not in new configs) + foreach (var kv in existingDict) + { + if (!newDict.ContainsKey(kv.Key)) + { + operations.Add(new + { + request_delete_range = new + { + key = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(kv.Key)) + } + }); + deletedCount++; + } + } + + // Find keys to add/update + foreach (var kv in newDict) + { + if (!existingDict.TryGetValue(kv.Key, out var existingValue) || existingValue != kv.Value) + { + operations.Add(new + { + request_put = new + { + key = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(kv.Key)), + value = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(kv.Value)) + } + }); + if (!existingDict.ContainsKey(kv.Key)) + addedCount++; + else + updatedCount++; + } + } + + _logger.LogInformation("Incremental sync: {Deleted} to delete, {Added} to add, {Updated} to update", + deletedCount, addedCount, updatedCount); + } + else + { + // Full replace sync: delete all then insert all + operations = new List + { + // Add single delete_range operation to delete all existing keys with prefix + new + { + request_delete_range = new + { + key = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(prefix)), + range_end = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(GetRangeEnd(prefix))) + } + } + }; + existingKeys ??= await GetRangeKeysAsync(prefix); + deletedCount = existingKeys.Count; + + // Add put operations for all new configs + foreach (var context in contexts) + { + var key = BuildKey(context); + operations.Add(new + { + request_put = new + { + key = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(key)), + value = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(context.Value)) + } + }); + addedCount++; + } + + _logger.LogInformation("Full replace sync: {Deleted} old keys to delete, {Added} new keys to add", + deletedCount, addedCount); + } + + // Split operations into batches and execute + var batchSize = _maxTxnOperations; + var totalBatches = (int)Math.Ceiling((double)operations.Count / batchSize); + var processed = 0; + + for (var i = 0; i < totalBatches; i++) + { + var batch = operations.Skip(i * batchSize).Take(batchSize).ToList(); + try + { + var batchTxn = new + { + success = batch + }; + + var batchResponse = await _httpClient.PostAsJsonAsync("/v3/kv/txn", batchTxn); + batchResponse.EnsureSuccessStatusCode(); + processed += batch.Count; + _logger.LogDebug("Processed batch {Current}/{Total}, {Processed} operations completed", i + 1, totalBatches, processed); + } + catch (HttpRequestException ex) when (ex.Message.Contains("request too large") && batchSize > 10) + { + // Auto reduce batch size on request too large error + batchSize = Math.Max(10, batchSize / 2); + _logger.LogWarning("Request too large, reducing batch size to {BatchSize} and retrying batch", batchSize); + i--; // Retry current batch + } + } + + _logger.LogInformation("Successfully synced configs to etcd for app {AppId} env {Env}: {Deleted} deleted, {Added} added, {Updated} updated in {Batches} batches", + appId, env, deletedCount, addedCount, updatedCount, totalBatches); + + return new SyncPluginResult + { + Success = true, + Message = $"Synced: {deletedCount} deleted, {addedCount} added, {updatedCount} updated in {totalBatches} batches" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to sync configs to etcd (transaction rolled back, no changes applied)"); + return new SyncPluginResult { Success = false, Message = ex.Message, Exception = ex }; + } + } + + /// + /// Get all keys with given prefix + /// + private async Task> GetRangeKeysAsync(string prefix) + { + var keys = new List(); + var rangeRequest = new + { + key = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(prefix)), + range_end = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(GetRangeEnd(prefix))), + keys_only = true + }; + + var rangeResponse = await _httpClient.PostAsJsonAsync("/v3/kv/range", rangeRequest); + rangeResponse.EnsureSuccessStatusCode(); + + var rangeResult = await rangeResponse.Content.ReadFromJsonAsync(); + + if (rangeResult?.kvs != null) + { + keys.AddRange(rangeResult.kvs.Select(kv => kv.key)); + } + + return keys; + } + + /// + /// Get all key-value pairs with given prefix + /// + private async Task> GetRangeKvsAsync(string prefix) + { + var rangeRequest = new + { + key = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(prefix)), + range_end = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(GetRangeEnd(prefix))) + }; + + var rangeResponse = await _httpClient.PostAsJsonAsync("/v3/kv/range", rangeRequest); + rangeResponse.EnsureSuccessStatusCode(); + + var rangeResult = await rangeResponse.Content.ReadFromJsonAsync(); + + return rangeResult?.kvs ?? new List(); + } + + /// + /// Get the range end for prefix deletion + /// + private string GetRangeEnd(string prefix) + { + // Increment the last character to create a range end + var bytes = System.Text.Encoding.UTF8.GetBytes(prefix); + bytes[bytes.Length - 1]++; + return System.Text.Encoding.UTF8.GetString(bytes); + } + + public async Task HealthCheckAsync() + { + try + { + // Try a simple range request to check connectivity + var request = new + { + key = "YWJj", // "abc" in base64 + limit = 1 + }; + + var response = await _httpClient.PostAsJsonAsync("/v3/kv/range", request); + + return new SyncPluginHealthResult + { + Healthy = response.IsSuccessStatusCode, + Message = response.IsSuccessStatusCode ? "Etcd connection OK" : "Etcd connection failed" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Etcd health check failed"); + return new SyncPluginHealthResult + { + Healthy = false, + Message = ex.Message + }; + } + } + + public Task ShutdownAsync() + { + _logger.LogInformation("Etcd plugin shutdown"); + _httpClient?.Dispose(); + return Task.CompletedTask; + } + + private string BuildKey(SyncContext context) + { + var group = string.IsNullOrEmpty(context.Group) ? "default" : context.Group; + return $"{_keyPrefix}/{context.AppId}/{context.Env}/{group}/{context.Key}"; + } +} + +/// +/// Response model for etcd range query +/// +internal class EtcdRangeResponse +{ + public int count { get; set; } + public List? kvs { get; set; } +} + +internal class EtcdKv +{ + public string key { get; set; } = ""; + public string value { get; set; } = ""; +} diff --git a/src/AgileConfig.Server.SyncPlugin/AgileConfig.Server.SyncPlugin.csproj b/src/AgileConfig.Server.SyncPlugin/AgileConfig.Server.SyncPlugin.csproj new file mode 100644 index 00000000..0739cd9c --- /dev/null +++ b/src/AgileConfig.Server.SyncPlugin/AgileConfig.Server.SyncPlugin.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/src/AgileConfig.Server.SyncPlugin/BackgroundServices/SyncPluginInitializer.cs b/src/AgileConfig.Server.SyncPlugin/BackgroundServices/SyncPluginInitializer.cs new file mode 100644 index 00000000..32d61580 --- /dev/null +++ b/src/AgileConfig.Server.SyncPlugin/BackgroundServices/SyncPluginInitializer.cs @@ -0,0 +1,109 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Configuration; +using AgileConfig.Server.SyncPlugin.Plugins.Etcd; +using AgileConfig.Server.SyncPlugin.Contracts; + +namespace AgileConfig.Server.SyncPlugin.BackgroundServices; + +/// +/// Configuration model for SyncPlugin section in appsettings.json +/// +public class SyncPluginConfiguration +{ + public bool Enabled { get; set; } = true; + public Dictionary Plugins { get; set; } = new(); +} + +public class PluginConfig +{ + public string Enabled { get; set; } = "false"; + public Dictionary Settings { get; set; } = new(); +} + +/// +/// Initializes SyncEngine and registers plugins on application startup +/// +public class SyncPluginInitializer : IHostedService +{ + private readonly IServiceProvider _serviceProvider; + private readonly SyncEngine _syncEngine; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public SyncPluginInitializer( + IServiceProvider serviceProvider, + SyncEngine syncEngine, + ILogger logger, + IConfiguration configuration) + { + _serviceProvider = serviceProvider; + _syncEngine = syncEngine; + _logger = logger; + _configuration = configuration; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Initializing SyncPlugin..."); + + try + { + var loggerFactory = _serviceProvider.GetRequiredService(); + + // Read configuration from appsettings.json + var syncPluginConfig = _configuration.GetSection("SyncPlugin").Get(); + + if (syncPluginConfig == null || !syncPluginConfig.Enabled) + { + _logger.LogInformation("SyncPlugin is disabled in configuration"); + return; + } + + // Register built-in plugins from configuration + RegisterBuiltInPlugins(_syncEngine, loggerFactory, syncPluginConfig); + + // Initialize all registered plugins + await _syncEngine.InitializeAsync(); + + _logger.LogInformation("SyncPlugin initialized successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize SyncPlugin"); + } + } + + private void RegisterBuiltInPlugins(SyncEngine syncEngine, ILoggerFactory loggerFactory, SyncPluginConfiguration config) + { + // Register Etcd plugin from configuration + if (config.Plugins.TryGetValue("etcd", out var etcdConfig)) + { + try + { + var etcdLogger = loggerFactory.CreateLogger(); + var etcdPlugin = new EtcdSyncPlugin(etcdLogger); + + syncEngine.RegisterPlugin(etcdPlugin, new SyncPluginConfig + { + PluginName = "etcd", + Enabled = etcdConfig.Enabled, + Settings = etcdConfig.Settings + }); + + _logger.LogInformation("Registered Etcd sync plugin with endpoints: {Endpoints}", + etcdConfig.Settings.GetValueOrDefault("endpoints", "")); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to register Etcd plugin"); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return _syncEngine.ShutdownAsync(); + } +} diff --git a/src/AgileConfig.Server.SyncPlugin/BackgroundServices/SyncRetryBackgroundService.cs b/src/AgileConfig.Server.SyncPlugin/BackgroundServices/SyncRetryBackgroundService.cs new file mode 100644 index 00000000..dabbefa6 --- /dev/null +++ b/src/AgileConfig.Server.SyncPlugin/BackgroundServices/SyncRetryBackgroundService.cs @@ -0,0 +1,44 @@ +using AgileConfig.Server.SyncPlugin.Retry; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace AgileConfig.Server.SyncPlugin.BackgroundServices; + +/// +/// Background service that periodically processes failed sync records +/// +public class SyncRetryBackgroundService : BackgroundService +{ + private readonly SyncRetryService _retryService; + private readonly ILogger _logger; + private readonly TimeSpan _interval = TimeSpan.FromSeconds(30); + + public SyncRetryBackgroundService( + SyncRetryService retryService, + ILogger logger) + { + _retryService = retryService; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("SyncRetryBackgroundService started"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await _retryService.ProcessFailedRecordsAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing failed sync records"); + } + + await Task.Delay(_interval, stoppingToken); + } + + _logger.LogInformation("SyncRetryBackgroundService stopped"); + } +} diff --git a/src/AgileConfig.Server.SyncPlugin/ConfigSyncService.cs b/src/AgileConfig.Server.SyncPlugin/ConfigSyncService.cs new file mode 100644 index 00000000..140ab99e --- /dev/null +++ b/src/AgileConfig.Server.SyncPlugin/ConfigSyncService.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.Logging; +using AgileConfig.Server.Data.Entity; +using AgileConfig.Server.SyncPlugin.Contracts; + +namespace AgileConfig.Server.SyncPlugin; + +/// +/// Service that handles config sync operations +/// Now simplified - sync is handled by event handlers, this service is for manual sync if needed +/// +public class ConfigSyncService +{ + private readonly SyncEngine _syncEngine; + private readonly ILogger _logger; + + public ConfigSyncService(SyncEngine syncEngine, ILogger logger) + { + _syncEngine = syncEngine; + _logger = logger; + } + + /// + /// Full sync all configs for an app+env + /// Uses "replace all" strategy + /// + public async Task SyncAllAsync(Config[] configs, string env) + { + if (configs == null || !configs.Any()) + { + _logger.LogInformation("No configs to sync"); + return true; + } + + var contexts = configs.Select(c => new SyncContext + { + AppId = c.AppId, + AppName = c.AppId, + Env = env, + Key = c.Key, + Value = c.Value ?? "", + Group = c.Group, + OperationType = SyncOperationType.Add, + Timestamp = DateTimeOffset.UtcNow + }).ToArray(); + + try + { + var result = await _syncEngine.SyncAllAsync(contexts); + return result.Success; + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception during full sync"); + return false; + } + } + + /// + /// Health check all sync plugins + /// + public async Task> HealthCheckAsync() + { + return await _syncEngine.HealthCheckAsync(); + } +} diff --git a/src/AgileConfig.Server.SyncPlugin/Models/SyncContext.cs b/src/AgileConfig.Server.SyncPlugin/Models/SyncContext.cs new file mode 100644 index 00000000..a90b7211 --- /dev/null +++ b/src/AgileConfig.Server.SyncPlugin/Models/SyncContext.cs @@ -0,0 +1,83 @@ +namespace AgileConfig.Server.SyncPlugin.Models; + +/// +/// 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/Retry/FailedSyncRecord.cs b/src/AgileConfig.Server.SyncPlugin/Retry/FailedSyncRecord.cs new file mode 100644 index 00000000..e0961b87 --- /dev/null +++ b/src/AgileConfig.Server.SyncPlugin/Retry/FailedSyncRecord.cs @@ -0,0 +1,54 @@ +namespace AgileConfig.Server.SyncPlugin.Retry; + +/// +/// Record of failed sync attempts +/// Only stores (appId, env) - not the actual config values +/// When retrying, we always fetch the latest configs from database +/// +public class FailedSyncRecord +{ + /// + /// Unique identifier + /// + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Application ID + /// + public string AppId { get; set; } = string.Empty; + + /// + /// Environment (e.g., PROD, DEV) + /// + public string Env { get; set; } = string.Empty; + + /// + /// When the sync first failed + /// + public DateTimeOffset FailedTime { get; set; } = DateTimeOffset.UtcNow; + + /// + /// Number of retry attempts + /// + public int RetryCount { get; set; } = 0; + + /// + /// Last retry time + /// + public DateTimeOffset? LastRetryTime { get; set; } + + /// + /// Last error message + /// + public string? LastError { get; set; } + + /// + /// Next retry time + /// + public DateTimeOffset? NextRetryTime { get; set; } + + /// + /// Whether the record is in circuit breaker state + /// + public bool IsCircuitBroken { get; set; } +} diff --git a/src/AgileConfig.Server.SyncPlugin/Retry/SyncRetryService.cs b/src/AgileConfig.Server.SyncPlugin/Retry/SyncRetryService.cs new file mode 100644 index 00000000..774d9d47 --- /dev/null +++ b/src/AgileConfig.Server.SyncPlugin/Retry/SyncRetryService.cs @@ -0,0 +1,239 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using AgileConfig.Server.Data.Entity; +using AgileConfig.Server.IService; +using AgileConfig.Server.SyncPlugin.Contracts; + +namespace AgileConfig.Server.SyncPlugin.Retry; + +/// +/// Service that handles sync retry logic +/// Uses "replace all" strategy - always fetches latest configs from DB on retry +/// +public class SyncRetryService +{ + private readonly SyncEngine _syncEngine; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly List _failedRecords = new(); + private readonly object _lock = new(); + + // Configuration + private const int MaxRetryCount = 10; + private const int CircuitBreakDurationMinutes = 60; + private const int MinRetryDelaySeconds = 1; + private const int MaxRetryDelaySeconds = 60; + + public SyncRetryService( + SyncEngine syncEngine, + IServiceProvider serviceProvider, + ILogger logger) + { + _syncEngine = syncEngine; + _serviceProvider = serviceProvider; + _logger = logger; + } + + /// + /// Record a failed sync attempt + /// + public void RecordFailed(string appId, string env, string? errorMessage = null) + { + lock (_lock) + { + // Check if already exists + var existing = _failedRecords.FirstOrDefault(x => x.AppId == appId && x.Env == env); + + if (existing != null) + { + existing.RetryCount++; + existing.LastRetryTime = DateTimeOffset.UtcNow; + existing.LastError = errorMessage; + + if (existing.RetryCount >= MaxRetryCount) + { + // Circuit break: stop retrying for CircuitBreakDurationMinutes + existing.IsCircuitBroken = true; + existing.NextRetryTime = DateTimeOffset.UtcNow.AddMinutes(CircuitBreakDurationMinutes); + _logger.LogError("Sync failed for app {AppId} env {Env} for {Count} times, circuit breaker activated, next retry after {NextRetry}", + appId, env, existing.RetryCount, existing.NextRetryTime); + } + else + { + // Exponential backoff: 2^retryCount seconds, capped at MaxRetryDelaySeconds + var delaySeconds = Math.Min(MaxRetryDelaySeconds, Math.Pow(2, existing.RetryCount)); + existing.NextRetryTime = DateTimeOffset.UtcNow.AddSeconds(delaySeconds); + _logger.LogWarning("Sync failed for app {AppId} env {Env}, retry count: {Count}, next retry after {NextRetry} ({Delay}s)", + appId, env, existing.RetryCount, existing.NextRetryTime, delaySeconds); + } + } + else + { + var newRecord = new FailedSyncRecord + { + AppId = appId, + Env = env, + FailedTime = DateTimeOffset.UtcNow, + RetryCount = 1, + LastError = errorMessage, + NextRetryTime = DateTimeOffset.UtcNow.AddSeconds(MinRetryDelaySeconds) + }; + _failedRecords.Add(newRecord); + _logger.LogWarning("Recorded failed sync for app {AppId} env {Env}, first retry after {NextRetry} ({Delay}s)", + appId, env, newRecord.NextRetryTime, MinRetryDelaySeconds); + } + } + } + + /// + /// Process all failed records - retry sync + /// This should be called periodically by a background service + /// + public async Task ProcessFailedRecordsAsync() + { + List recordsToProcess; + + lock (_lock) + { + var now = DateTimeOffset.UtcNow; + // Get records that are ready to retry + recordsToProcess = _failedRecords + .Where(x => x.NextRetryTime <= now) + .ToList(); + + // Reset circuit breaker for records where next retry time has passed + foreach (var record in recordsToProcess.Where(x => x.IsCircuitBroken)) + { + record.IsCircuitBroken = false; + record.RetryCount = 0; // Reset retry count after circuit break + _logger.LogInformation("Circuit breaker reset for app {AppId} env {Env}, resuming retries", record.AppId, record.Env); + } + } + + if (!recordsToProcess.Any()) + { + _logger.LogDebug("No failed sync records to process"); + return; + } + + _logger.LogInformation("Processing {Count} failed sync records", recordsToProcess.Count); + + foreach (var record in recordsToProcess) + { + await RetrySyncAsync(record); + } + } + + /// + /// Retry sync for a specific record + /// Uses "replace all" strategy - fetches latest configs from DB + /// + private async Task RetrySyncAsync(FailedSyncRecord record) + { + // Create a scope to resolve scoped services + using var scope = _serviceProvider.CreateScope(); + var configService = scope.ServiceProvider.GetRequiredService(); + + try + { + _logger.LogInformation("Retrying sync for app {AppId} env {Env}, attempt {Attempt}", + record.AppId, record.Env, record.RetryCount); + + // Get latest configs from database + var configs = await configService.GetPublishedConfigsAsync(record.AppId, record.Env); + + if (configs == null || !configs.Any()) + { + _logger.LogInformation("No configs found for app {AppId} env {Env}, removing failed record", + record.AppId, record.Env); + + lock (_lock) + { + _failedRecords.RemoveAll(x => x.AppId == record.AppId && x.Env == record.Env); + } + return; + } + + // Convert to sync contexts + var contexts = configs.Select(c => new SyncContext + { + AppId = c.AppId, + AppName = c.AppId, // Use AppId as AppName + Env = c.Env, + Key = c.Key, + Value = c.Value ?? "", + Group = c.Group, + OperationType = SyncOperationType.Add, + Timestamp = DateTimeOffset.UtcNow + }).ToArray(); + + // Full sync + var result = await _syncEngine.SyncAllAsync(contexts); + + if (result.Success) + { + _logger.LogInformation("Retry successful for app {AppId} env {Env}", record.AppId, record.Env); + + lock (_lock) + { + _failedRecords.RemoveAll(x => x.AppId == record.AppId && x.Env == record.Env); + } + } + else + { + _logger.LogWarning("Retry failed for app {AppId} env {Env}: {Error}", + record.AppId, record.Env, result.Message); + + lock (_lock) + { + var rec = _failedRecords.FirstOrDefault(x => x.AppId == record.AppId && x.Env == record.Env); + if (rec != null) + { + rec.LastError = result.Message; + } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception during retry for app {AppId} env {Env}", record.AppId, record.Env); + } + } + + /// + /// Get all failed records + /// + public List GetFailedRecords() + { + lock (_lock) + { + return _failedRecords.ToList(); + } + } + + /// + /// Clear all failed records (for testing) + /// + public void ClearFailedRecords() + { + lock (_lock) + { + _failedRecords.Clear(); + } + } + + /// + /// Clear failed records for specific appId and env + /// + public void ClearFailedRecord(string appId, string env) + { + lock (_lock) + { + var removed = _failedRecords.RemoveAll(x => x.AppId == appId && x.Env == env); + if (removed > 0) + { + _logger.LogDebug("Cleared {Count} failed records for app {AppId} env {Env}", removed, appId, env); + } + } + } +} diff --git a/src/AgileConfig.Server.SyncPlugin/SyncEngine.cs b/src/AgileConfig.Server.SyncPlugin/SyncEngine.cs new file mode 100644 index 00000000..1be8e7ac --- /dev/null +++ b/src/AgileConfig.Server.SyncPlugin/SyncEngine.cs @@ -0,0 +1,220 @@ +using Microsoft.Extensions.Logging; +using AgileConfig.Server.SyncPlugin.Contracts; + +namespace AgileConfig.Server.SyncPlugin; + +/// +/// Sync engine that manages all plugins and handles config synchronization +/// Uses "replace all" strategy: delete all + insert all for each sync +/// +public class SyncEngine : IDisposable +{ + private readonly ILogger _logger; + private readonly Dictionary _plugins = new(); + private readonly Dictionary _pluginConfigs = new(); + private bool _initialized = false; + private readonly object _lock = new(); + + public SyncEngine(ILogger logger) + { + _logger = logger; + } + + /// + /// Register a sync plugin + /// + public void RegisterPlugin(ISyncPlugin plugin, SyncPluginConfig config) + { + lock (_lock) + { + if (_plugins.ContainsKey(plugin.Name)) + { + _logger.LogWarning("Plugin {PluginName} already registered, skipping", plugin.Name); + return; + } + + _plugins[plugin.Name] = plugin; + _pluginConfigs[plugin.Name] = config; + _logger.LogInformation("Registered sync plugin: {PluginName}", plugin.Name); + } + } + + /// + /// Initialize all registered plugins + /// + public async Task InitializeAsync() + { + if (_initialized) return; + + lock (_lock) + { + if (_initialized) return; + _initialized = true; + } + + foreach (var kvp in _plugins) + { + var pluginName = kvp.Key; + var plugin = kvp.Value; + var config = _pluginConfigs[pluginName]; + + try + { + var result = await plugin.InitializeAsync(config); + if (result.Success) + { + _logger.LogInformation("Initialized sync plugin: {PluginName}", pluginName); + } + else + { + _logger.LogError("Failed to initialize plugin {PluginName}: {Message}", pluginName, result.Message); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception initializing plugin {PluginName}", pluginName); + } + } + } + + /// + /// Full sync to all enabled plugins using "replace all" strategy + /// + public async Task SyncAllAsync(SyncContext[] contexts) + { + var enabledPlugins = _plugins.Values + .Where(p => IsPluginEnabled(p.Name)) + .ToList(); + + if (!enabledPlugins.Any()) + { + _logger.LogDebug("No enabled plugins, skipping sync"); + return new SyncPluginResult { Success = true, Message = "No enabled plugins" }; + } + + var tasks = enabledPlugins + .Select(p => SafeExecuteAsync(() => p.SyncAllAsync(contexts))); + + var results = await Task.WhenAll(tasks); + + var failed = results.Where(r => !r.Success).ToList(); + if (failed.Any()) + { + var failedPlugins = string.Join(", ", failed.Select(f => f.Message)); + _logger.LogWarning("Sync failed for {Count} plugins: {FailedPlugins}", failed.Count, failedPlugins); + + return new SyncPluginResult + { + Success = false, + Message = $"Sync failed for {failed.Count} plugins: {failedPlugins}" + }; + } + + _logger.LogInformation("Successfully synced {Count} configs to {PluginCount} plugins", + contexts.Length, enabledPlugins.Count); + + return new SyncPluginResult { Success = true, Message = $"Synced to {enabledPlugins.Count} plugins" }; + } + + /// + /// Check health of all plugins + /// + public async Task> HealthCheckAsync() + { + var tasks = _plugins.Values + .Where(p => IsPluginEnabled(p.Name)) + .Select(async p => + { + try + { + var result = await p.HealthCheckAsync(); + return (Name: p.Name, Result: result); + } + catch (Exception ex) + { + return (Name: p.Name, Result: new SyncPluginHealthResult + { + Healthy = false, + Message = ex.Message + }); + } + }); + + var results = await Task.WhenAll(tasks); + return results.ToDictionary(r => r.Name, r => r.Result); + } + + /// + /// Get all registered plugins + /// + public IReadOnlyDictionary GetPlugins() => _plugins; + + /// + /// Get plugin by name + /// + public ISyncPlugin? GetPlugin(string name) => _plugins.GetValueOrDefault(name); + + /// + /// Get list of enabled plugin names + /// + public List GetEnabledPluginNames() + { + return _plugins.Keys.Where(IsPluginEnabled).ToList(); + } + + private bool IsPluginEnabled(string pluginName) + { + if (!_pluginConfigs.TryGetValue(pluginName, out var config)) + return false; + + var raw = config.Enabled?.Trim(); + if (string.IsNullOrEmpty(raw)) + return false; + + if (bool.TryParse(raw, out var parsed)) + return parsed; + + return raw.Equals("1", StringComparison.OrdinalIgnoreCase) + || raw.Equals("yes", StringComparison.OrdinalIgnoreCase) + || raw.Equals("on", StringComparison.OrdinalIgnoreCase); + } + + private async Task SafeExecuteAsync(Func> action) + { + try + { + return await action(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception during sync operation"); + return new SyncPluginResult + { + Success = false, + Message = ex.Message, + Exception = ex + }; + } + } + + public async Task ShutdownAsync() + { + foreach (var plugin in _plugins.Values) + { + try + { + await plugin.ShutdownAsync(); + _logger.LogInformation("Shutdown plugin: {PluginName}", plugin.Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error shutting down plugin: {PluginName}", plugin.Name); + } + } + } + + public void Dispose() + { + ShutdownAsync().GetAwaiter().GetResult(); + } +} diff --git a/src/AgileConfig.Server.SyncPlugin/SyncPluginExtensions.cs b/src/AgileConfig.Server.SyncPlugin/SyncPluginExtensions.cs new file mode 100644 index 00000000..2d352ccc --- /dev/null +++ b/src/AgileConfig.Server.SyncPlugin/SyncPluginExtensions.cs @@ -0,0 +1,36 @@ +using AgileConfig.Server.SyncPlugin; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace AgileConfig.Server.SyncPlugin; + +/// +/// Extension methods for registering SyncPlugin services and plugins +/// +public static class SyncPluginExtensions +{ + /// + /// Add SyncPlugin services to the service collection + /// + public static IServiceCollection AddSyncPlugin(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); + services.AddHostedService(); + + return services; + } + + /// + /// Register built-in sync plugins + /// Should be called after AddSyncPlugin + /// + public static IServiceCollection AddSyncPluginBuiltIn(this IServiceCollection services, ILoggerFactory loggerFactory) + { + // This will be called during service provider building + // The plugins will be registered in SyncPluginInitializer + + return services; + } +} diff --git a/src/AgileConfig.Server.UI/react-ui-antd/config/proxy.ts b/src/AgileConfig.Server.UI/react-ui-antd/config/proxy.ts index 3fa70dde..96c38565 100644 --- a/src/AgileConfig.Server.UI/react-ui-antd/config/proxy.ts +++ b/src/AgileConfig.Server.UI/react-ui-antd/config/proxy.ts @@ -8,7 +8,7 @@ export default { dev: { '/api/': { - target: 'https://preview.pro.ant.design', + target: 'http://localhost:5000', changeOrigin: true, pathRewrite: { '^': '' }, }, diff --git a/src/AgileConfig.Server.UI/react-ui-antd/package-lock.json b/src/AgileConfig.Server.UI/react-ui-antd/package-lock.json index 6d0b1ab6..bd6171ec 100644 --- a/src/AgileConfig.Server.UI/react-ui-antd/package-lock.json +++ b/src/AgileConfig.Server.UI/react-ui-antd/package-lock.json @@ -11282,6 +11282,20 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.1.tgz", @@ -31980,6 +31994,12 @@ "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "optional": true + }, "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.1.tgz", diff --git a/test/ApiSiteTests/ApiSiteTests.csproj b/test/ApiSiteTests/ApiSiteTests.csproj index 3feaea61..3294b6b8 100644 --- a/test/ApiSiteTests/ApiSiteTests.csproj +++ b/test/ApiSiteTests/ApiSiteTests.csproj @@ -20,6 +20,9 @@ + + + diff --git a/test/ApiSiteTests/ConfigSyncEventHandlerTests.cs b/test/ApiSiteTests/ConfigSyncEventHandlerTests.cs new file mode 100644 index 00000000..022d168a --- /dev/null +++ b/test/ApiSiteTests/ConfigSyncEventHandlerTests.cs @@ -0,0 +1,286 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AgileConfig.Server.Data.Entity; +using AgileConfig.Server.Event; +using AgileConfig.Server.EventHandler; +using AgileConfig.Server.IService; +using AgileConfig.Server.SyncPlugin; +using AgileConfig.Server.SyncPlugin.Contracts; +using AgileConfig.Server.SyncPlugin.Retry; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace AgileConfig.Server.ApiSiteTests; + +[TestClass] +public class ConfigSyncEventHandlerTests +{ + private Mock _configServiceMock; + private Mock> _syncEngineLoggerMock; + private Mock> _retryServiceLoggerMock; + private Mock _serviceProviderMock; + private Mock _pluginMock; + private SyncEngine _syncEngine; + private SyncRetryService _retryService; + private Mock> _handlerLoggerMock; + private ConfigSyncEventHandler _handler; + + [TestInitialize] + public void Setup() + { + _configServiceMock = new Mock(); + + _syncEngineLoggerMock = new Mock>(); + _syncEngine = new SyncEngine(_syncEngineLoggerMock.Object); + + _serviceProviderMock = new Mock(); + _retryServiceLoggerMock = new Mock>(); + _retryService = new SyncRetryService( + _syncEngine, + _serviceProviderMock.Object, + _retryServiceLoggerMock.Object); + + _handlerLoggerMock = new Mock>(); + + _handler = new ConfigSyncEventHandler( + _configServiceMock.Object, + _syncEngine, + _retryService, + _handlerLoggerMock.Object); + } + + [TestCleanup] + public void Cleanup() + { + _syncEngine?.Dispose(); + } + + private void RegisterMockPlugin(bool returnSuccess = true) + { + _pluginMock = new Mock(); + _pluginMock.Setup(p => p.Name).Returns("mock_plugin"); + _pluginMock.Setup(p => p.DisplayName).Returns("Mock Plugin"); + _pluginMock.Setup(p => p.Description).Returns("Mock sync plugin for testing"); + + _pluginMock.Setup(p => p.InitializeAsync(It.IsAny())) + .ReturnsAsync(new SyncPluginResult { Success = true }); + + _pluginMock.Setup(p => p.SyncAllAsync(It.IsAny())) + .ReturnsAsync(new SyncPluginResult + { + Success = returnSuccess, + Message = returnSuccess ? "Success" : "Mock failure" + }); + + _pluginMock.Setup(p => p.HealthCheckAsync()) + .ReturnsAsync(new SyncPluginHealthResult { Healthy = true }); + + _pluginMock.Setup(p => p.ShutdownAsync()) + .Returns(Task.CompletedTask); + + var config = new SyncPluginConfig + { + PluginName = "mock_plugin", + Enabled = "true", + Settings = new Dictionary() + }; + + _syncEngine.RegisterPlugin(_pluginMock.Object, config); + } + + [TestMethod] + public async Task Handle_WithValidTimeline_ShouldSyncConfigs() + { + // Arrange + RegisterMockPlugin(returnSuccess: true); + + var timeline = new PublishTimeline + { + Id = "timeline-1", + AppId = "app-1", + Env = "PROD", + PublishTime = DateTime.Now + }; + + var evt = new PublishConfigSuccessful(timeline, "testuser"); + + var publishedConfigs = new List + { + new ConfigPublished + { + Id = "config-1", + AppId = "app-1", + Env = "PROD", + Key = "db.connection", + Value = "server=localhost", + Group = "db" + }, + new ConfigPublished + { + Id = "config-2", + AppId = "app-1", + Env = "PROD", + Key = "cache.enabled", + Value = "true", + Group = "cache" + } + }; + + _configServiceMock + .Setup(x => x.GetPublishedConfigsAsync("app-1", "PROD")) + .ReturnsAsync(publishedConfigs); + + // Act + await _handler.Handle(evt); + + // Assert + _configServiceMock.Verify( + x => x.GetPublishedConfigsAsync("app-1", "PROD"), + Times.Once); + + _pluginMock.Verify( + p => p.SyncAllAsync(It.Is(ctx => + ctx.Length == 2 && + ctx.Any(c => c.Key == "db.connection") && + ctx.Any(c => c.Key == "cache.enabled"))), + Times.Once); + } + + [TestMethod] + public async Task Handle_WithNoPublishedConfigs_ShouldNotSync() + { + // Arrange + RegisterMockPlugin(returnSuccess: true); + + var timeline = new PublishTimeline + { + Id = "timeline-1", + AppId = "app-1", + Env = "PROD", + PublishTime = DateTime.Now + }; + + var evt = new PublishConfigSuccessful(timeline, "testuser"); + + _configServiceMock + .Setup(x => x.GetPublishedConfigsAsync("app-1", "PROD")) + .ReturnsAsync(new List()); + + // Act + await _handler.Handle(evt); + + // Assert + _configServiceMock.Verify( + x => x.GetPublishedConfigsAsync("app-1", "PROD"), + Times.Once); + + // Should NOT call sync when no configs + _pluginMock.Verify( + p => p.SyncAllAsync(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task Handle_WithNullTimeline_ShouldReturnEarly() + { + // Arrange + RegisterMockPlugin(returnSuccess: true); + + var evt = new PublishConfigSuccessful(null!, "testuser"); + + // Act + await _handler.Handle(evt); + + // Assert + _configServiceMock.Verify( + x => x.GetPublishedConfigsAsync(It.IsAny(), It.IsAny()), + Times.Never); + + _pluginMock.Verify( + p => p.SyncAllAsync(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task Handle_WhenSyncFails_ShouldRecordFailed() + { + // Arrange + RegisterMockPlugin(returnSuccess: false); + + var timeline = new PublishTimeline + { + Id = "timeline-1", + AppId = "app-1", + Env = "PROD", + PublishTime = DateTime.Now + }; + + var evt = new PublishConfigSuccessful(timeline, "testuser"); + + var publishedConfigs = new List + { + new ConfigPublished + { + Id = "config-1", + AppId = "app-1", + Env = "PROD", + Key = "db.connection", + Value = "server=localhost", + Group = "db" + } + }; + + _configServiceMock + .Setup(x => x.GetPublishedConfigsAsync("app-1", "PROD")) + .ReturnsAsync(publishedConfigs); + + // Clear any initial failed records + _retryService.ClearFailedRecords(); + + // Act + await _handler.Handle(evt); + + // Assert - verify failed record was created + var failedRecords = _retryService.GetFailedRecords(); + Assert.AreEqual(1, failedRecords.Count); + Assert.AreEqual("app-1", failedRecords[0].AppId); + Assert.AreEqual("PROD", failedRecords[0].Env); + } + + [TestMethod] + public async Task Handle_WhenExceptionThrown_ShouldRecordFailed() + { + // Arrange + RegisterMockPlugin(returnSuccess: true); + + var timeline = new PublishTimeline + { + Id = "timeline-1", + AppId = "app-1", + Env = "PROD", + PublishTime = DateTime.Now + }; + + var evt = new PublishConfigSuccessful(timeline, "testuser"); + + _configServiceMock + .Setup(x => x.GetPublishedConfigsAsync("app-1", "PROD")) + .ThrowsAsync(new Exception("Database connection error")); + + // Clear any initial failed records + _retryService.ClearFailedRecords(); + + // Act + await _handler.Handle(evt); + + // Assert - verify failed record was created + var failedRecords = _retryService.GetFailedRecords(); + Assert.AreEqual(1, failedRecords.Count); + Assert.AreEqual("app-1", failedRecords[0].AppId); + Assert.AreEqual("PROD", failedRecords[0].Env); + Assert.AreEqual("Database connection error", failedRecords[0].LastError); + } +}