├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── develop.yml │ └── release.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── images ├── configuration-service-fs.png ├── configuration-service-git.png ├── configuration-service-vault.png └── configuration-service.gif └── src ├── ConfigurationService.Client ├── ConfigurationOptions.cs ├── ConfigurationService.Client.csproj ├── IConfigurationParser.cs ├── ISubscriber.cs ├── Logger.cs ├── Parsers │ ├── IniConfigurationFileParser.cs │ ├── JsonConfigurationFileParser.cs │ ├── XmlConfigurationFileParser.cs │ └── YamlConfigurationFileParser.cs ├── RemoteConfigurationBuilder.cs ├── RemoteConfigurationException.cs ├── RemoteConfigurationExtensions.cs ├── RemoteConfigurationOptions.cs ├── RemoteConfigurationProvider.cs ├── RemoteConfigurationSource.cs └── Subscribers │ ├── Nats │ └── NatsSubscriber.cs │ ├── RabbitMq │ ├── RabbitMqOptions.cs │ └── RabbitMqSubscriber.cs │ └── Redis │ └── RedisSubscriber.cs ├── ConfigurationService.Hosting ├── ConfigurationEndpointRouteBuilderExtensions.cs ├── ConfigurationService.Hosting.csproj ├── ConfigurationService.cs ├── ConfigurationServiceBuilder.cs ├── ConfigurationServiceBuilderExtensions.cs ├── Extensions │ └── StringExtensions.cs ├── Hasher.cs ├── HostedConfigurationService.cs ├── IConfigurationService.cs ├── IConfigurationServiceBuilder.cs ├── ProviderOptionNullException.cs ├── Providers │ ├── FileSystem │ │ ├── FileSystemProvider.cs │ │ └── FileSystemProviderOptions.cs │ ├── Git │ │ ├── GitProvider.cs │ │ └── GitProviderOptions.cs │ ├── IProvider.cs │ └── Vault │ │ ├── VaultProvider.cs │ │ └── VaultProviderOptions.cs └── Publishers │ ├── IPublisher.cs │ ├── Nats │ └── NatsPublisher.cs │ ├── RabbitMq │ ├── RabbitMqOptions.cs │ └── RabbitMqPublisher.cs │ └── Redis │ └── RedisPublisher.cs ├── ConfigurationService.Test ├── ConfigurationService.Test.csproj ├── Files │ ├── test.ini │ ├── test.json │ ├── test.xml │ └── test.yaml ├── ParserTests.cs └── PublishTests.cs ├── ConfigurationService.sln ├── ConfigurationService.sln.DotSettings └── samples ├── ConfigurationService.Samples.Client ├── ConfigWriter.cs ├── ConfigurationService.Samples.Client.csproj ├── Program.cs ├── TestConfig.cs └── appsettings.json └── ConfigurationService.Samples.Host ├── ConfigurationService.Samples.Host.csproj ├── Program.cs ├── Properties └── launchSettings.json ├── appsettings.Development.json └── appsettings.json /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/develop.yml: -------------------------------------------------------------------------------- 1 | name: develop 2 | 3 | on: 4 | push: 5 | branches-ignore: [ main ] 6 | pull_request: 7 | branches-ignore: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Setup .NET 17 | uses: actions/setup-dotnet@v1 18 | with: 19 | dotnet-version: 6.0.x 20 | - name: Install dependencies 21 | run: dotnet restore src/ConfigurationService.sln 22 | - name: Build 23 | run: dotnet build src/ConfigurationService.sln --configuration Release --no-restore 24 | - name: Test 25 | run: dotnet test src/ConfigurationService.sln --no-restore --logger "console;verbosity=normal" 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Setup .NET 17 | uses: actions/setup-dotnet@v1 18 | with: 19 | dotnet-version: 6.0.x 20 | 21 | - name: Install dependencies 22 | run: dotnet restore src/ConfigurationService.sln 23 | 24 | - name: Build 25 | run: dotnet build src/ConfigurationService.sln --configuration Release --no-restore 26 | 27 | - name: Test 28 | run: dotnet test src/ConfigurationService.sln --no-restore --logger "console;verbosity=normal" 29 | 30 | - name: Publish ConfigurationService.Hosting 31 | uses: brandedoutcast/publish-nuget@v2.5.2 32 | with: 33 | PROJECT_FILE_PATH: src/ConfigurationService.Hosting/ConfigurationService.Hosting.csproj 34 | NUGET_KEY: ${{secrets.NUGET_API_KEY}} 35 | TAG_COMMIT: false 36 | 37 | - name: Publish ConfigurationService.Client 38 | uses: brandedoutcast/publish-nuget@v2.5.2 39 | with: 40 | PROJECT_FILE_PATH: src/ConfigurationService.Client/ConfigurationService.Client.csproj 41 | NUGET_KEY: ${{secrets.NUGET_API_KEY}} 42 | TAG_COMMIT: false 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Build results 17 | [Dd]ebug/ 18 | [Dd]ebugPublic/ 19 | [Rr]elease/ 20 | [Rr]eleases/ 21 | x64/ 22 | x86/ 23 | [Aa][Rr][Mm]/ 24 | [Aa][Rr][Mm]64/ 25 | bld/ 26 | [Bb]in/ 27 | [Oo]bj/ 28 | [Ll]og/ 29 | 30 | # Visual Studio 2015/2017 cache/options directory 31 | .vs/ 32 | # Uncomment if you have tasks that create the project's static files in wwwroot 33 | #wwwroot/ 34 | 35 | # Visual Studio 2017 auto generated files 36 | Generated\ Files/ 37 | 38 | # MSTest test Results 39 | [Tt]est[Rr]esult*/ 40 | [Bb]uild[Ll]og.* 41 | 42 | # NUNIT 43 | *.VisualState.xml 44 | TestResult.xml 45 | 46 | # Build Results of an ATL Project 47 | [Dd]ebugPS/ 48 | [Rr]eleasePS/ 49 | dlldata.c 50 | 51 | # Benchmark Results 52 | BenchmarkDotNet.Artifacts/ 53 | 54 | # .NET Core 55 | project.lock.json 56 | project.fragment.lock.json 57 | artifacts/ 58 | 59 | # StyleCop 60 | StyleCopReport.xml 61 | 62 | # Files built by Visual Studio 63 | *_i.c 64 | *_p.c 65 | *_h.h 66 | *.ilk 67 | *.meta 68 | *.obj 69 | *.iobj 70 | *.pch 71 | *.pdb 72 | *.ipdb 73 | *.pgc 74 | *.pgd 75 | *.rsp 76 | *.sbr 77 | *.tlb 78 | *.tli 79 | *.tlh 80 | *.tmp 81 | *.tmp_proj 82 | *_wpftmp.csproj 83 | *.log 84 | *.vspscc 85 | *.vssscc 86 | .builds 87 | *.pidb 88 | *.svclog 89 | *.scc 90 | 91 | # Chutzpah Test files 92 | _Chutzpah* 93 | 94 | # Visual C++ cache files 95 | ipch/ 96 | *.aps 97 | *.ncb 98 | *.opendb 99 | *.opensdf 100 | *.sdf 101 | *.cachefile 102 | *.VC.db 103 | *.VC.VC.opendb 104 | 105 | # Visual Studio profiler 106 | *.psess 107 | *.vsp 108 | *.vspx 109 | *.sap 110 | 111 | # Visual Studio Trace Files 112 | *.e2e 113 | 114 | # TFS 2012 Local Workspace 115 | $tf/ 116 | 117 | # Guidance Automation Toolkit 118 | *.gpState 119 | 120 | # ReSharper is a .NET coding add-in 121 | _ReSharper*/ 122 | *.[Rr]e[Ss]harper 123 | *.DotSettings.user 124 | 125 | # JustCode is a .NET coding add-in 126 | .JustCode 127 | 128 | # TeamCity is a build add-in 129 | _TeamCity* 130 | 131 | # DotCover is a Code Coverage Tool 132 | *.dotCover 133 | 134 | # AxoCover is a Code Coverage Tool 135 | .axoCover/* 136 | !.axoCover/settings.json 137 | 138 | # Visual Studio code coverage results 139 | *.coverage 140 | *.coveragexml 141 | 142 | # NCrunch 143 | _NCrunch_* 144 | .*crunch*.local.xml 145 | nCrunchTemp_* 146 | 147 | # MightyMoose 148 | *.mm.* 149 | AutoTest.Net/ 150 | 151 | # Web workbench (sass) 152 | .sass-cache/ 153 | 154 | # Installshield output folder 155 | [Ee]xpress/ 156 | 157 | # DocProject is a documentation generator add-in 158 | DocProject/buildhelp/ 159 | DocProject/Help/*.HxT 160 | DocProject/Help/*.HxC 161 | DocProject/Help/*.hhc 162 | DocProject/Help/*.hhk 163 | DocProject/Help/*.hhp 164 | DocProject/Help/Html2 165 | DocProject/Help/html 166 | 167 | # Click-Once directory 168 | publish/ 169 | 170 | # Publish Web Output 171 | *.[Pp]ublish.xml 172 | *.azurePubxml 173 | # Note: Comment the next line if you want to checkin your web deploy settings, 174 | # but database connection strings (with potential passwords) will be unencrypted 175 | *.pubxml 176 | *.publishproj 177 | 178 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 179 | # checkin your Azure Web App publish settings, but sensitive information contained 180 | # in these scripts will be unencrypted 181 | PublishScripts/ 182 | 183 | # NuGet Packages 184 | *.nupkg 185 | # The packages folder can be ignored because of Package Restore 186 | **/[Pp]ackages/* 187 | # except build/, which is used as an MSBuild target. 188 | !**/[Pp]ackages/build/ 189 | # Uncomment if necessary however generally it will be regenerated when needed 190 | #!**/[Pp]ackages/repositories.config 191 | # NuGet v3's project.json files produces more ignorable files 192 | *.nuget.props 193 | *.nuget.targets 194 | 195 | # Microsoft Azure Build Output 196 | csx/ 197 | *.build.csdef 198 | 199 | # Microsoft Azure Emulator 200 | ecf/ 201 | rcf/ 202 | 203 | # Windows Store app package directories and files 204 | AppPackages/ 205 | BundleArtifacts/ 206 | Package.StoreAssociation.xml 207 | _pkginfo.txt 208 | *.appx 209 | 210 | # Visual Studio cache files 211 | # files ending in .cache can be ignored 212 | *.[Cc]ache 213 | # but keep track of directories ending in .cache 214 | !?*.[Cc]ache/ 215 | 216 | # Others 217 | ClientBin/ 218 | ~$* 219 | *~ 220 | *.dbmdl 221 | *.dbproj.schemaview 222 | *.jfm 223 | *.pfx 224 | *.publishsettings 225 | orleans.codegen.cs 226 | 227 | # Including strong name files can present a security risk 228 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 229 | #*.snk 230 | 231 | # Since there are multiple workflows, uncomment next line to ignore bower_components 232 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 233 | #bower_components/ 234 | 235 | # RIA/Silverlight projects 236 | Generated_Code/ 237 | 238 | # Backup & report files from converting an old project file 239 | # to a newer Visual Studio version. Backup files are not needed, 240 | # because we have git ;-) 241 | _UpgradeReport_Files/ 242 | Backup*/ 243 | UpgradeLog*.XML 244 | UpgradeLog*.htm 245 | ServiceFabricBackup/ 246 | *.rptproj.bak 247 | 248 | # SQL Server files 249 | *.mdf 250 | *.ldf 251 | *.ndf 252 | 253 | # Business Intelligence projects 254 | *.rdl.data 255 | *.bim.layout 256 | *.bim_*.settings 257 | *.rptproj.rsuser 258 | *- Backup*.rdl 259 | 260 | # Microsoft Fakes 261 | FakesAssemblies/ 262 | 263 | # GhostDoc plugin setting file 264 | *.GhostDoc.xml 265 | 266 | # Node.js Tools for Visual Studio 267 | .ntvs_analysis.dat 268 | node_modules/ 269 | 270 | # Visual Studio 6 build log 271 | *.plg 272 | 273 | # Visual Studio 6 workspace options file 274 | *.opt 275 | 276 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 277 | *.vbw 278 | 279 | # Visual Studio LightSwitch build output 280 | **/*.HTMLClient/GeneratedArtifacts 281 | **/*.DesktopClient/GeneratedArtifacts 282 | **/*.DesktopClient/ModelManifest.xml 283 | **/*.Server/GeneratedArtifacts 284 | **/*.Server/ModelManifest.xml 285 | _Pvt_Extensions 286 | 287 | # Paket dependency manager 288 | .paket/paket.exe 289 | paket-files/ 290 | 291 | # FAKE - F# Make 292 | .fake/ 293 | 294 | # JetBrains Rider 295 | .idea/ 296 | *.sln.iml 297 | 298 | # CodeRush personal settings 299 | .cr/personal 300 | 301 | # Python Tools for Visual Studio (PTVS) 302 | __pycache__/ 303 | *.pyc 304 | 305 | # Cake - Uncomment if you are using it 306 | # tools/** 307 | # !tools/packages.config 308 | 309 | # Tabs Studio 310 | *.tss 311 | 312 | # Telerik's JustMock configuration file 313 | *.jmconfig 314 | 315 | # BizTalk build output 316 | *.btp.cs 317 | *.btm.cs 318 | *.odx.cs 319 | *.xsd.cs 320 | 321 | # OpenCover UI analysis results 322 | OpenCover/ 323 | 324 | # Azure Stream Analytics local run output 325 | ASALocalRun/ 326 | 327 | # MSBuild Binary and Structured Log 328 | *.binlog 329 | 330 | # NVidia Nsight GPU debugger configuration file 331 | *.nvuser 332 | 333 | # MFractors (Xamarin productivity tool) working folder 334 | .mfractor/ 335 | 336 | # Local History for Visual Studio 337 | .localhistory/ 338 | 339 | # BeatPulse healthcheck temp database 340 | healthchecksdb -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Configuration Service 2 | We want to make contributing to this project as easy and transparent as possible, whether it's: 3 | 4 | - Reporting a bug 5 | - Discussing the current state of the code 6 | - Submitting a fix 7 | - Proposing new features 8 | - Becoming a maintainer 9 | 10 | ## We Develop with Github 11 | We use github to host code, to track issues and feature requests, as well as accept pull requests. 12 | 13 | 1. Fork the repo and create your branch from `main`. 14 | 2. If you've added code that should be tested, add tests. 15 | 3. If you've changed APIs, update the documentation. 16 | 4. Ensure the test suite passes. 17 | 5. Make sure your code lints. 18 | 6. Issue that pull request! 19 | 20 | ## Any contributions you make will be under the MIT Software License 21 | When you submit code changes, your submissions are understood to be under the same [MIT License](https://github.com/jamespratt/configuration-service/blob/main/LICENSE) that covers the project. 22 | Feel free to contact the maintainers if that's a concern. 23 | 24 | ## Report bugs using Github's [issues](https://github.com/jamespratt/configuration-service/issues) 25 | We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/jamespratt/configuration-service/issues); it's that easy! 26 | 27 | **Great Bug Reports** tend to have: 28 | 29 | - A quick summary and/or background 30 | - Steps to reproduce 31 | - Be specific! 32 | - Give sample code if you can. 33 | - What you expected would happen 34 | - What actually happens 35 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 36 | 37 | ## License 38 | By contributing, you agree that your contributions will be licensed under its MIT License. 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Configuration Service 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Configuration Service 2 | 3 | [![Build](https://github.com/jamespratt/configuration-service/workflows/release/badge.svg)](https://github.com/jamespratt/configuration-service/actions?query=workflow%3Arelease) 4 | 5 | 6 | | Package |Latest Release| 7 | |:----------|:------------:| 8 | |**ConfigurationService.Hosting**|[![NuGet Badge ConfigurationService.Hosting](https://buildstats.info/nuget/ConfigurationService.Hosting)](https://www.nuget.org/packages/ConfigurationService.Hosting) 9 | |**ConfigurationService.Client**|[![NuGet Badge ConfigurationService.Client](https://buildstats.info/nuget/ConfigurationService.Client)](https://www.nuget.org/packages/ConfigurationService.Client) 10 | 11 | 12 | 13 | ## About Configuration Service 14 | 15 | Configuration Service is a distributed configuration service for .NET. Configuration for fleets of applications, services, and containerized micro-services can be updated immediately without the need to redeploy or restart. Configuration Service uses a client/server pub/sub architecture to notify subscribed clients of configuration changes as they happen. Configuration can be injected using the standard options pattern with `IOptions`, `IOptionsMonitor` or `IOptionsSnapshot`. 16 | 17 | Configuration Service currently supports hosting configuration with git, file system or Vault backends and supports publishing changes with Redis, NATS or RabbitMQ publish/subscribe. File types supported are .json, .yaml, .xml and .ini. 18 | 19 | [![Configuration Service Diagram](https://raw.githubusercontent.com/jamespratt/configuration-service/main/images/configuration-service.gif)](#about-configuration-service) 20 | 21 | ## Features 22 | * RESTful HTTP based API for external configuration. 23 | * Server easily integrates into an ASP.NET application. 24 | * Client easily integrates into any .NET Standard 2.0 application using the standard `ConfigurationBuilder` pattern. 25 | * Client encapsulates real-time configuration updates. 26 | * Support for git, file system and Vault backend storage. 27 | * Support for pub/sub with Redis, NATS and RabbitMQ. 28 | * Support for .json, .yaml, .xml and .ini configuration files. 29 | * Inject configuration with `IOptionsMonitor` or `IOptionsSnapshot` to access configuration changes. 30 | 31 | ## Installing with NuGet 32 | 33 | The easiest way to install Configuration Service is with [NuGet](https://www.nuget.org/packages/ConfigurationService.Hosting/). 34 | 35 | In Visual Studio's [Package Manager Console](http://docs.nuget.org/docs/start-here/using-the-package-manager-console), 36 | enter the following command: 37 | 38 | Server: 39 | 40 | Install-Package ConfigurationService.Hosting 41 | 42 | Client: 43 | 44 | Install-Package ConfigurationService.Client 45 | 46 | ## Adding the Configuration Service Host 47 | The Configuration Service host middleware can be added to the service collection of an existing ASP.NET application. The following example configures a git storage provider with a Redis publisher. 48 | 49 | ```csharp 50 | builder.Services.AddConfigurationService() 51 | .AddGitProvider(c => 52 | { 53 | c.RepositoryUrl = "https://github.com/jamespratt/configuration-test.git"; 54 | c.LocalPath = "C:/local-repo"; 55 | }) 56 | .AddRedisPublisher("localhost:6379"); 57 | ``` 58 | 59 | In Startup.Configure, call `MapConfigurationService` on the endpoint builder. The default pattern is "/configuration". 60 | 61 | ```csharp 62 | app.UseEndpoints(endpoints => 63 | { 64 | endpoints.MapConfigurationService(); 65 | }); 66 | ``` 67 | 68 | The configured host will expose two API endpoints: 69 | * `configuration/` - Lists all files at the configured provider. 70 | * `configuration/{filename}` - Retrieves the contents of the specified file. 71 | 72 | #### Git Provider Options 73 | | Property | Description | 74 | |:-----------|:------------| 75 | |RepositoryUrl|URI for the remote repository.| 76 | |Username|Username for authentication.| 77 | |Password|Password for authentication.| 78 | |Branch|The name of the branch to checkout. When unspecified the remote's default branch will be used instead.| 79 | |LocalPath|Local path to clone into.| 80 | |SearchPattern|The search string to use as a filter against the names of files. Defaults to no filter (\*).| 81 | |PollingInterval|The interval to check for remote changes. Defaults to 60 seconds.| 82 | 83 | ```csharp 84 | services.AddConfigurationService() 85 | .AddGitProvider(c => 86 | { 87 | c.RepositoryUrl = "https://example.com/my-repo/my-repo.git"; 88 | c.Username = "username"; 89 | c.Password = "password"; 90 | c.Branch = "main"; 91 | c.LocalPath = "C:/config"; 92 | c.SearchPattern = ".*json"; 93 | c.PollingInterval = TimeSpan.FromSeconds(60); 94 | } 95 | ... 96 | ``` 97 | 98 | #### File System Provider Options 99 | | Property | Description | 100 | |:-----------|:------------| 101 | |Path|Path to the configuration files.| 102 | |SearchPattern|The search string to use as a filter against the names of files. Defaults to no filter (\*).| 103 | |IncludeSubdirectories|Includes the current directory and all its subdirectories. Defaults to `false`.| 104 | |Username|Username for authentication.| 105 | |Password|Password for authentication.| 106 | |Domain|Domain for authentication.| 107 | 108 | ```csharp 109 | services.AddConfigurationService() 110 | .AddFileSystemProvider(c => 111 | { 112 | c.Path = "C:/config"; 113 | c.SearchPattern = "*.json"; 114 | c.IncludeSubdirectories = true; 115 | }) 116 | ... 117 | ``` 118 | 119 | #### Vault Provider Options 120 | | Property | Description | 121 | |:-----------|:------------| 122 | |ServerUri|The Vault Server Uri with port.| 123 | |Path|The path where the kv secrets engine is enabled.| 124 | |AuthMethodInfo|The auth method to be used to acquire a Vault token.| 125 | |PollingInterval|The interval to check for for remote changes. Defaults to 60 seconds.| 126 | 127 | ```csharp 128 | services.AddConfigurationService() 129 | .AddVaultProvider(c => 130 | { 131 | c.ServerUri = "http://localhost:8200/"; 132 | c.Path = "secret/"; 133 | c.AuthMethodInfo = new TokenAuthMethodInfo("myToken"); 134 | }) 135 | ... 136 | ``` 137 | 138 | #### Custom Storage Providers and Publishers 139 | Custom implementations of storage providers and publishers can be added by implementing the `IProvider` and `IPublisher` interfaces and calling the appropriate extension methods on AddConfigurationService: 140 | 141 | ```csharp 142 | services.AddConfigurationService() 143 | .AddProvider(new CustomStorageProvider()) 144 | .AddPublisher(new CustomPublisher()); 145 | ``` 146 | 147 | ## Adding the Configuration Service Client 148 | The Configuration Service client can be configured by adding `AddRemoteConfiguration` to the standard configuration builder. In the following example, remote json configuration is added and a Redis endpoint is specified for configuration change subscription. Local configuration can be read for settings for the remote source by using multiple instances of configuration builder. 149 | 150 | ```csharp 151 | var loggerFactory = LoggerFactory.Create(builder => 152 | { 153 | builder.AddConsole(); 154 | }); 155 | 156 | IConfiguration configuration = new ConfigurationBuilder() 157 | .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) 158 | .Build(); 159 | 160 | configuration = new ConfigurationBuilder() 161 | .AddConfiguration(configuration) 162 | .AddRemoteConfiguration(o => 163 | { 164 | o.ServiceUri = "http://localhost:5000/configuration/"; 165 | o.AddConfiguration(c => 166 | { 167 | c.ConfigurationName = "test.json"; 168 | c.ReloadOnChange = true; 169 | c.Optional = false; 170 | }); 171 | o.AddConfiguration(c => 172 | { 173 | c.ConfigurationName = "test.yaml"; 174 | c.ReloadOnChange = true; 175 | c.Optional = false; 176 | c.Parser = new YamlConfigurationFileParser(); 177 | }); 178 | o.AddRedisSubscriber("localhost:6379"); 179 | o.AddLoggerFactory(loggerFactory); 180 | }) 181 | .Build(); 182 | ``` 183 | 184 | #### Configuration Soruce Options 185 | | Property | Description | 186 | |:-----------|:------------| 187 | |ServiceUri|Configuration service endpoint.| 188 | |HttpMessageHandler|The optional `HttpMessageHandler` for the `HttpClient`.| 189 | |RequestTimeout|The timeout for the `HttpClient` request to the configuration server. Defaults to 60 seconds.| 190 | |LoggerFactory|The type used to configure the logging system and create instances of `ILogger`. Defaults to `NullLoggerFactory`.| 191 | |**AddConfiguration**|Adds an individual configuration file.| 192 | |ConfigurationName|Path or name of the configuration file relative to the configuration provider. This value should match the value specified in the list returned by the `configuration/` endpoint.| 193 | |Optional|Determines if loading the file is optional.| 194 | |ReloadOnChange|Determines whether the source will be loaded if the underlying file changes.| 195 | |Parser|The type used to parse the remote configuration file. The client will attempt to resolve this from the file extension of `ConfigurationName` if not specified.

Supported Types: | 196 | |**AddRedisSubscriber**|Adds Redis as the configuration subscriber.| 197 | |**AddNatsSubscriber**|Adds NATS as the configuration subscriber.| 198 | |**AddRabbitMqSubscriber**|Adds RabbitMQ as the configuration subscriber.| 199 | |**AddSubscriber**|Adds a custom configuration subscriber the implements `ISubscriber`.| 200 | |**AddLoggerFactory**|Adds the type used to configure the logging system and create instances of `ILogger`.| 201 | 202 | ## Samples 203 | Samples of both host and client implementations can be viewed at [Samples](https://github.com/jamespratt/configuration-service/tree/main/src/samples). 204 | 205 | [![Build history](https://buildstats.info/github/chart/jamespratt/configuration-service)](https://github.com/jamespratt/configuration-service/actions?query=workflow%3Arelease) 206 | -------------------------------------------------------------------------------- /images/configuration-service-fs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamespratt/configuration-service/1011f3bbf2af857a000d2e379ba960625024ffa5/images/configuration-service-fs.png -------------------------------------------------------------------------------- /images/configuration-service-git.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamespratt/configuration-service/1011f3bbf2af857a000d2e379ba960625024ffa5/images/configuration-service-git.png -------------------------------------------------------------------------------- /images/configuration-service-vault.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamespratt/configuration-service/1011f3bbf2af857a000d2e379ba960625024ffa5/images/configuration-service-vault.png -------------------------------------------------------------------------------- /images/configuration-service.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamespratt/configuration-service/1011f3bbf2af857a000d2e379ba960625024ffa5/images/configuration-service.gif -------------------------------------------------------------------------------- /src/ConfigurationService.Client/ConfigurationOptions.cs: -------------------------------------------------------------------------------- 1 | namespace ConfigurationService.Client 2 | { 3 | public class ConfigurationOptions 4 | { 5 | /// 6 | /// Name or path of the configuration file relative to the configuration provider path. 7 | /// 8 | public string ConfigurationName { get; set; } 9 | 10 | /// 11 | /// Determines if loading the file is optional. Defaults to false>. 12 | /// 13 | public bool Optional { get; set; } 14 | 15 | /// 16 | /// Determines whether the source will be loaded if the underlying file changes. Defaults to false. 17 | /// 18 | public bool ReloadOnChange { get; set; } 19 | 20 | /// 21 | /// The type of used to parse the remote configuration file. 22 | /// 23 | public IConfigurationParser Parser { get; set; } 24 | } 25 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Client/ConfigurationService.Client.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Contains the client configuration provider, file parsers and subscriber components for Configuration Service. 6 | Configuration Service is a distributed configuration service for .NET. 7 | 8 | netstandard2.0 9 | 1.0.2 10 | James Pratt 11 | ConfigurationService.Client 12 | ConfigurationService.Client 13 | 14 | 15 | 16 | true 17 | $(NoWarn);1591 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/ConfigurationService.Client/IConfigurationParser.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.IO; 3 | 4 | namespace ConfigurationService.Client 5 | { 6 | public interface IConfigurationParser 7 | { 8 | IDictionary Parse(Stream input); 9 | } 10 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Client/ISubscriber.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace ConfigurationService.Client 4 | { 5 | public interface ISubscriber 6 | { 7 | string Name { get; } 8 | 9 | void Initialize(); 10 | 11 | void Subscribe(string topic, Action handler); 12 | } 13 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Client/Logger.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using Microsoft.Extensions.Logging.Abstractions; 3 | 4 | namespace ConfigurationService.Client 5 | { 6 | public static class Logger 7 | { 8 | private static ILoggerFactory _factory; 9 | 10 | public static ILoggerFactory LoggerFactory 11 | { 12 | get => _factory = _factory ?? new NullLoggerFactory(); 13 | set => _factory = value; 14 | } 15 | 16 | public static ILogger CreateLogger() => LoggerFactory.CreateLogger(); 17 | } 18 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Client/Parsers/IniConfigurationFileParser.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using Microsoft.Extensions.Configuration; 5 | 6 | namespace ConfigurationService.Client.Parsers 7 | { 8 | public class IniConfigurationFileParser : IConfigurationParser 9 | { 10 | private readonly IDictionary _data = new SortedDictionary(StringComparer.OrdinalIgnoreCase); 11 | 12 | public IDictionary Parse(Stream input) => ParseStream(input); 13 | 14 | private IDictionary ParseStream(Stream input) 15 | { 16 | _data.Clear(); 17 | 18 | using (var reader = new StreamReader(input)) 19 | { 20 | var sectionPrefix = string.Empty; 21 | 22 | while (reader.Peek() != -1) 23 | { 24 | var rawLine = reader.ReadLine(); 25 | if (rawLine == null) 26 | { 27 | continue; 28 | } 29 | 30 | var line = rawLine.Trim(); 31 | 32 | if (string.IsNullOrWhiteSpace(line)) 33 | { 34 | continue; 35 | } 36 | 37 | if (line[0] == ';' || line[0] == '#' || line[0] == '/') 38 | { 39 | continue; 40 | } 41 | 42 | if (line[0] == '[' && line[line.Length - 1] == ']') 43 | { 44 | sectionPrefix = line.Substring(1, line.Length - 2) + ConfigurationPath.KeyDelimiter; 45 | continue; 46 | } 47 | 48 | int separator = line.IndexOf('='); 49 | if (separator < 0) 50 | { 51 | throw new FormatException($"Unrecognized line format '{rawLine}'."); 52 | } 53 | 54 | string key = sectionPrefix + line.Substring(0, separator).Trim(); 55 | string value = line.Substring(separator + 1).Trim(); 56 | 57 | if (value.Length > 1 && value[0] == '"' && value[value.Length - 1] == '"') 58 | { 59 | value = value.Substring(1, value.Length - 2); 60 | } 61 | 62 | if (_data.ContainsKey(key)) 63 | { 64 | throw new FormatException($"A duplicate key '{key}' was found."); 65 | } 66 | 67 | _data[key] = value; 68 | } 69 | } 70 | return _data; 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Client/Parsers/JsonConfigurationFileParser.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Text.Json; 6 | using Microsoft.Extensions.Configuration; 7 | 8 | namespace ConfigurationService.Client.Parsers 9 | { 10 | public class JsonConfigurationFileParser : IConfigurationParser 11 | { 12 | private readonly IDictionary _data = new SortedDictionary(StringComparer.OrdinalIgnoreCase); 13 | private readonly Stack _context = new Stack(); 14 | private string _currentPath; 15 | 16 | public IDictionary Parse(Stream input) => ParseStream(input); 17 | 18 | private IDictionary ParseStream(Stream input) 19 | { 20 | _data.Clear(); 21 | 22 | var jsonDocumentOptions = new JsonDocumentOptions 23 | { 24 | CommentHandling = JsonCommentHandling.Skip, 25 | AllowTrailingCommas = true, 26 | }; 27 | 28 | using (var reader = new StreamReader(input)) 29 | using (JsonDocument doc = JsonDocument.Parse(reader.ReadToEnd(), jsonDocumentOptions)) 30 | { 31 | if (doc.RootElement.ValueKind != JsonValueKind.Object) 32 | { 33 | throw new FormatException($"Unsupported JSON token '{doc.RootElement.ValueKind}' was found."); 34 | } 35 | VisitElement(doc.RootElement); 36 | } 37 | 38 | return _data; 39 | } 40 | 41 | private void VisitElement(JsonElement element) 42 | { 43 | foreach (var property in element.EnumerateObject()) 44 | { 45 | EnterContext(property.Name); 46 | VisitValue(property.Value); 47 | ExitContext(); 48 | } 49 | } 50 | 51 | private void VisitValue(JsonElement value) 52 | { 53 | switch (value.ValueKind) 54 | { 55 | case JsonValueKind.Object: 56 | VisitElement(value); 57 | break; 58 | 59 | case JsonValueKind.Array: 60 | var index = 0; 61 | foreach (var arrayElement in value.EnumerateArray()) 62 | { 63 | EnterContext(index.ToString()); 64 | VisitValue(arrayElement); 65 | ExitContext(); 66 | index++; 67 | } 68 | break; 69 | 70 | case JsonValueKind.Number: 71 | case JsonValueKind.String: 72 | case JsonValueKind.True: 73 | case JsonValueKind.False: 74 | case JsonValueKind.Null: 75 | var key = _currentPath; 76 | if (_data.ContainsKey(key)) 77 | { 78 | throw new FormatException($"A duplicate key '{key}' was found."); 79 | } 80 | _data[key] = value.ToString(); 81 | break; 82 | 83 | default: 84 | throw new FormatException($"Unsupported JSON token '{value.ValueKind}' was found."); 85 | } 86 | } 87 | 88 | private void EnterContext(string context) 89 | { 90 | _context.Push(context); 91 | _currentPath = ConfigurationPath.Combine(_context.Reverse()); 92 | } 93 | 94 | private void ExitContext() 95 | { 96 | _context.Pop(); 97 | _currentPath = ConfigurationPath.Combine(_context.Reverse()); 98 | } 99 | } 100 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Client/Parsers/XmlConfigurationFileParser.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Xml; 6 | using Microsoft.Extensions.Configuration; 7 | 8 | namespace ConfigurationService.Client.Parsers 9 | { 10 | public class XmlConfigurationFileParser : IConfigurationParser 11 | { 12 | private const string NameAttributeKey = "Name"; 13 | 14 | private readonly IDictionary _data = new SortedDictionary(StringComparer.OrdinalIgnoreCase); 15 | 16 | public IDictionary Parse(Stream input) => ParseStream(input); 17 | 18 | private IDictionary ParseStream(Stream input) 19 | { 20 | _data.Clear(); 21 | 22 | var readerSettings = new XmlReaderSettings() 23 | { 24 | CloseInput = false, 25 | DtdProcessing = DtdProcessing.Prohibit, 26 | IgnoreComments = true, 27 | IgnoreWhitespace = true 28 | }; 29 | 30 | using (var reader = CreateXmlReader(input, readerSettings)) 31 | { 32 | var prefixStack = new Stack(); 33 | 34 | SkipUntilRootElement(reader); 35 | 36 | ProcessAttributes(reader, prefixStack, _data, AddNamePrefix); 37 | ProcessAttributes(reader, prefixStack, _data, AddAttributePair); 38 | 39 | var preNodeType = reader.NodeType; 40 | while (reader.Read()) 41 | { 42 | switch (reader.NodeType) 43 | { 44 | case XmlNodeType.Element: 45 | prefixStack.Push(reader.LocalName); 46 | ProcessAttributes(reader, prefixStack, _data, AddNamePrefix); 47 | ProcessAttributes(reader, prefixStack, _data, AddAttributePair); 48 | 49 | if (reader.IsEmptyElement) 50 | { 51 | prefixStack.Pop(); 52 | } 53 | break; 54 | 55 | case XmlNodeType.EndElement: 56 | if (prefixStack.Any()) 57 | { 58 | if (preNodeType == XmlNodeType.Element) 59 | { 60 | var key = ConfigurationPath.Combine(prefixStack.Reverse()); 61 | _data[key] = string.Empty; 62 | } 63 | 64 | prefixStack.Pop(); 65 | } 66 | break; 67 | 68 | case XmlNodeType.CDATA: 69 | case XmlNodeType.Text: 70 | { 71 | var key = ConfigurationPath.Combine(prefixStack.Reverse()); 72 | if (_data.ContainsKey(key)) 73 | { 74 | throw new FormatException($"A duplicate key '{key}' was found."); 75 | } 76 | 77 | _data[key] = reader.Value; 78 | break; 79 | } 80 | case XmlNodeType.XmlDeclaration: 81 | case XmlNodeType.ProcessingInstruction: 82 | case XmlNodeType.Comment: 83 | case XmlNodeType.Whitespace: 84 | break; 85 | 86 | default: 87 | throw new FormatException($"Unsupported node type '{reader.NodeType}' was found."); 88 | } 89 | preNodeType = reader.NodeType; 90 | 91 | if (preNodeType == XmlNodeType.Element && 92 | reader.IsEmptyElement) 93 | { 94 | preNodeType = XmlNodeType.EndElement; 95 | } 96 | } 97 | } 98 | 99 | return _data; 100 | } 101 | 102 | private XmlReader CreateXmlReader(Stream input, XmlReaderSettings settings) 103 | { 104 | var memStream = new MemoryStream(); 105 | input.CopyTo(memStream); 106 | memStream.Position = 0; 107 | 108 | var document = new XmlDocument(); 109 | using (var reader = XmlReader.Create(memStream, settings)) 110 | { 111 | document.Load(reader); 112 | } 113 | memStream.Position = 0; 114 | 115 | return XmlReader.Create(memStream, settings); 116 | } 117 | 118 | private void SkipUntilRootElement(XmlReader reader) 119 | { 120 | while (reader.Read()) 121 | { 122 | if (reader.NodeType != XmlNodeType.XmlDeclaration && 123 | reader.NodeType != XmlNodeType.ProcessingInstruction) 124 | { 125 | break; 126 | } 127 | } 128 | } 129 | 130 | private void ProcessAttributes(XmlReader reader, Stack prefixStack, IDictionary data, 131 | Action, IDictionary, XmlWriter> act, XmlWriter writer = null) 132 | { 133 | for (int i = 0; i < reader.AttributeCount; i++) 134 | { 135 | reader.MoveToAttribute(i); 136 | 137 | if (!string.IsNullOrEmpty(reader.NamespaceURI)) 138 | { 139 | throw new FormatException("Namespace is not supported."); 140 | } 141 | 142 | act(reader, prefixStack, data, writer); 143 | } 144 | 145 | reader.MoveToElement(); 146 | } 147 | 148 | private static void AddNamePrefix(XmlReader reader, Stack prefixStack, 149 | IDictionary data, XmlWriter writer) 150 | { 151 | if (!string.Equals(reader.LocalName, NameAttributeKey, StringComparison.OrdinalIgnoreCase)) 152 | { 153 | return; 154 | } 155 | 156 | if (prefixStack.Any()) 157 | { 158 | var lastPrefix = prefixStack.Pop(); 159 | prefixStack.Push(ConfigurationPath.Combine(lastPrefix, reader.Value)); 160 | } 161 | else 162 | { 163 | prefixStack.Push(reader.Value); 164 | } 165 | } 166 | 167 | private static void AddAttributePair(XmlReader reader, Stack prefixStack, 168 | IDictionary data, XmlWriter writer) 169 | { 170 | prefixStack.Push(reader.LocalName); 171 | var key = ConfigurationPath.Combine(prefixStack.Reverse()); 172 | if (data.ContainsKey(key)) 173 | { 174 | throw new FormatException($"A duplicate key '{key}' was found."); 175 | } 176 | 177 | data[key] = reader.Value; 178 | prefixStack.Pop(); 179 | } 180 | } 181 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Client/Parsers/YamlConfigurationFileParser.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using Microsoft.Extensions.Configuration; 6 | using YamlDotNet.RepresentationModel; 7 | 8 | namespace ConfigurationService.Client.Parsers 9 | { 10 | public class YamlConfigurationFileParser : IConfigurationParser 11 | { 12 | private readonly IDictionary _data = new SortedDictionary(StringComparer.OrdinalIgnoreCase); 13 | private readonly Stack _context = new Stack(); 14 | private string _currentPath; 15 | 16 | public IDictionary Parse(Stream input) 17 | { 18 | _data.Clear(); 19 | _context.Clear(); 20 | 21 | var yaml = new YamlStream(); 22 | yaml.Load(new StreamReader(input, detectEncodingFromByteOrderMarks: true)); 23 | 24 | if (yaml.Documents.Any()) 25 | { 26 | var mapping = (YamlMappingNode)yaml.Documents[0].RootNode; 27 | 28 | VisitYamlMappingNode(mapping); 29 | } 30 | 31 | return _data; 32 | } 33 | 34 | private void VisitYamlNodePair(KeyValuePair yamlNodePair) 35 | { 36 | var context = ((YamlScalarNode)yamlNodePair.Key).Value; 37 | VisitYamlNode(context, yamlNodePair.Value); 38 | } 39 | 40 | private void VisitYamlNode(string context, YamlNode node) 41 | { 42 | if (node is YamlScalarNode scalarNode) 43 | { 44 | VisitYamlScalarNode(context, scalarNode); 45 | } 46 | if (node is YamlMappingNode mappingNode) 47 | { 48 | VisitYamlMappingNode(context, mappingNode); 49 | } 50 | if (node is YamlSequenceNode sequenceNode) 51 | { 52 | VisitYamlSequenceNode(context, sequenceNode); 53 | } 54 | } 55 | 56 | private void VisitYamlScalarNode(string context, YamlScalarNode yamlValue) 57 | { 58 | EnterContext(context); 59 | var key = _currentPath; 60 | 61 | if (_data.ContainsKey(key)) 62 | { 63 | throw new FormatException($"A duplicate key '{key}' was found."); 64 | } 65 | 66 | _data[key] = IsNullValue(yamlValue) ? null : yamlValue.Value; 67 | ExitContext(); 68 | } 69 | 70 | private void VisitYamlMappingNode(YamlMappingNode node) 71 | { 72 | foreach (var yamlNodePair in node.Children) 73 | { 74 | VisitYamlNodePair(yamlNodePair); 75 | } 76 | } 77 | 78 | private void VisitYamlMappingNode(string context, YamlMappingNode yamlValue) 79 | { 80 | EnterContext(context); 81 | 82 | VisitYamlMappingNode(yamlValue); 83 | 84 | ExitContext(); 85 | } 86 | 87 | private void VisitYamlSequenceNode(string context, YamlSequenceNode yamlValue) 88 | { 89 | EnterContext(context); 90 | 91 | VisitYamlSequenceNode(yamlValue); 92 | 93 | ExitContext(); 94 | } 95 | 96 | private void VisitYamlSequenceNode(YamlSequenceNode node) 97 | { 98 | for (int i = 0; i < node.Children.Count; i++) 99 | { 100 | VisitYamlNode(i.ToString(), node.Children[i]); 101 | } 102 | } 103 | 104 | private void EnterContext(string context) 105 | { 106 | _context.Push(context); 107 | _currentPath = ConfigurationPath.Combine(_context.Reverse()); 108 | } 109 | 110 | private void ExitContext() 111 | { 112 | _context.Pop(); 113 | _currentPath = ConfigurationPath.Combine(_context.Reverse()); 114 | } 115 | 116 | private bool IsNullValue(YamlScalarNode yamlValue) 117 | { 118 | return yamlValue.Style == YamlDotNet.Core.ScalarStyle.Plain 119 | && ( 120 | yamlValue.Value == "~" 121 | || yamlValue.Value == "null" 122 | || yamlValue.Value == "Null" 123 | || yamlValue.Value == "NULL" 124 | ); 125 | } 126 | } 127 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Client/RemoteConfigurationBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Microsoft.Extensions.Configuration; 4 | 5 | namespace ConfigurationService.Client 6 | { 7 | internal class RemoteConfigurationBuilder : IConfigurationBuilder 8 | { 9 | private readonly IConfigurationBuilder _configurationBuilder; 10 | 11 | private readonly RemoteConfigurationOptions _remoteConfigurationOptions; 12 | 13 | public IDictionary Properties => _configurationBuilder.Properties; 14 | 15 | public IList Sources => _configurationBuilder.Sources; 16 | 17 | public RemoteConfigurationBuilder(IConfigurationBuilder configurationBuilder, RemoteConfigurationOptions remoteConfigurationOptions) 18 | { 19 | _configurationBuilder = configurationBuilder ?? throw new ArgumentNullException(nameof(configurationBuilder)); 20 | _remoteConfigurationOptions = remoteConfigurationOptions ?? throw new ArgumentNullException(nameof(remoteConfigurationOptions)); 21 | } 22 | 23 | public IConfigurationBuilder Add(IConfigurationSource source) 24 | { 25 | return _configurationBuilder.Add(source); 26 | } 27 | 28 | public IConfigurationRoot Build() 29 | { 30 | foreach (var configuration in _remoteConfigurationOptions.Configurations) 31 | { 32 | var source = new RemoteConfigurationSource 33 | { 34 | ConfigurationServiceUri = _remoteConfigurationOptions.ServiceUri, 35 | HttpMessageHandler = _remoteConfigurationOptions.HttpMessageHandler, 36 | RequestTimeout = _remoteConfigurationOptions.RequestTimeout, 37 | LoggerFactory = _remoteConfigurationOptions.LoggerFactory, 38 | 39 | ConfigurationName = configuration.ConfigurationName, 40 | Optional = configuration.Optional, 41 | ReloadOnChange = configuration.ReloadOnChange, 42 | Parser = configuration.Parser, 43 | 44 | CreateSubscriber = _remoteConfigurationOptions.CreateSubscriber 45 | }; 46 | 47 | Add(source); 48 | } 49 | 50 | return _configurationBuilder.Build(); 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Client/RemoteConfigurationException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.Serialization; 3 | 4 | namespace ConfigurationService.Client 5 | { 6 | [Serializable] 7 | public class RemoteConfigurationException : Exception 8 | { 9 | public RemoteConfigurationException() 10 | { 11 | } 12 | 13 | public RemoteConfigurationException(string name) 14 | : base($"{name} cannot be NULL or empty.") 15 | { 16 | } 17 | 18 | public RemoteConfigurationException(string message, Exception inner) 19 | : base(message, inner) 20 | { 21 | } 22 | 23 | protected RemoteConfigurationException(SerializationInfo info, StreamingContext context) 24 | : base(info, context) 25 | { 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Client/RemoteConfigurationExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Extensions.Configuration; 3 | 4 | namespace ConfigurationService.Client 5 | { 6 | public static class RemoteConfigurationExtensions 7 | { 8 | /// 9 | /// Adds a remote configuration source. 10 | /// 11 | /// The to add to. 12 | /// Configures the source. 13 | /// 14 | public static IConfigurationBuilder AddRemoteConfiguration(this IConfigurationBuilder builder, Action configure) 15 | { 16 | if (builder == null) 17 | { 18 | throw new ArgumentNullException(nameof(builder)); 19 | } 20 | 21 | if (configure == null) 22 | { 23 | throw new ArgumentNullException(nameof(configure)); 24 | } 25 | 26 | var options = new RemoteConfigurationOptions(); 27 | configure(options); 28 | 29 | var remoteBuilder = new RemoteConfigurationBuilder(builder, options); 30 | return remoteBuilder; 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Client/RemoteConfigurationOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net.Http; 4 | using ConfigurationService.Client.Subscribers.Nats; 5 | using ConfigurationService.Client.Subscribers.RabbitMq; 6 | using ConfigurationService.Client.Subscribers.Redis; 7 | using Microsoft.Extensions.Logging; 8 | using NATS.Client; 9 | using RedisOptions = StackExchange.Redis.ConfigurationOptions; 10 | using NatsOptions = NATS.Client.Options; 11 | 12 | namespace ConfigurationService.Client 13 | { 14 | public class RemoteConfigurationOptions 15 | { 16 | internal IList Configurations { get; } = new List(); 17 | 18 | internal Func CreateSubscriber { get; set; } 19 | 20 | /// 21 | /// Configuration service endpoint. 22 | /// 23 | public string ServiceUri { get; set; } 24 | 25 | /// 26 | /// The for the . 27 | /// 28 | public HttpMessageHandler HttpMessageHandler { get; set; } 29 | 30 | /// 31 | /// The timeout for the request to the configuration server. 32 | /// 33 | public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(60); 34 | 35 | /// 36 | /// The type used to configure the logging system and create instances of 37 | /// 38 | public ILoggerFactory LoggerFactory { get; set; } 39 | 40 | /// 41 | /// Adds an individual configuration file. 42 | /// 43 | /// Configures the options for the configuration file. 44 | public void AddConfiguration(Action configure) 45 | { 46 | var configurationOptions = new ConfigurationOptions(); 47 | configure(configurationOptions); 48 | 49 | Configurations.Add(configurationOptions); 50 | } 51 | 52 | /// 53 | /// Adds the type used to configure the logging system and create instances of 54 | /// 55 | /// 56 | public void AddLoggerFactory(ILoggerFactory loggerFactory) 57 | { 58 | LoggerFactory = loggerFactory; 59 | } 60 | 61 | /// 62 | /// Adds a custom subscriber. 63 | /// 64 | /// The delegate used to create the custom implementation of . 65 | public void AddSubscriber(Func subscriberFactory) 66 | { 67 | if (CreateSubscriber != null) 68 | { 69 | throw new InvalidOperationException("A subscriber has already been configured."); 70 | } 71 | 72 | CreateSubscriber = subscriberFactory ?? throw new ArgumentNullException(nameof(subscriberFactory)); 73 | } 74 | 75 | /// 76 | /// Adds RabbitMQ as the configuration subscriber. 77 | /// 78 | /// Configure options for the RabbitMQ subscriber. 79 | public void AddRabbitMqSubscriber(Action configure) 80 | { 81 | if (configure == null) 82 | { 83 | throw new ArgumentNullException(nameof(configure)); 84 | } 85 | 86 | if (CreateSubscriber != null) 87 | { 88 | throw new InvalidOperationException("A subscriber has already been configured."); 89 | } 90 | 91 | var options = new RabbitMqOptions(); 92 | configure(options); 93 | 94 | CreateSubscriber = () => new RabbitMqSubscriber(options); 95 | } 96 | 97 | /// 98 | /// Adds Redis as the configuration subscriber. 99 | /// 100 | /// Configure options for the Redis multiplexer. 101 | public void AddRedisSubscriber(Action configure) 102 | { 103 | if (configure == null) 104 | { 105 | throw new ArgumentNullException(nameof(configure)); 106 | } 107 | 108 | if (CreateSubscriber != null) 109 | { 110 | throw new InvalidOperationException("A subscriber has already been configured."); 111 | } 112 | 113 | var options = new RedisOptions(); 114 | configure(options); 115 | 116 | CreateSubscriber = () => new RedisSubscriber(options); 117 | } 118 | 119 | /// 120 | /// Adds Redis as the configuration subscriber. 121 | /// 122 | /// The string configuration for the Redis multiplexer. 123 | public void AddRedisSubscriber(string configuration) 124 | { 125 | if (configuration == null) 126 | { 127 | throw new ArgumentNullException(nameof(configuration)); 128 | } 129 | 130 | if (CreateSubscriber != null) 131 | { 132 | throw new InvalidOperationException("A subscriber has already been configured."); 133 | } 134 | 135 | var options = RedisOptions.Parse(configuration); 136 | 137 | CreateSubscriber = () => new RedisSubscriber(options); 138 | } 139 | 140 | /// 141 | /// Adds NATS as the configuration subscriber. 142 | /// 143 | /// Configure options for the NATS connection. 144 | public void AddNatsSubscriber(Action configure) 145 | { 146 | if (configure == null) 147 | { 148 | throw new ArgumentNullException(nameof(configure)); 149 | } 150 | 151 | if (CreateSubscriber != null) 152 | { 153 | throw new InvalidOperationException("A subscriber has already been configured."); 154 | } 155 | 156 | var options = ConnectionFactory.GetDefaultOptions(); 157 | configure(options); 158 | 159 | CreateSubscriber = () => new NatsSubscriber(options); 160 | } 161 | } 162 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Client/RemoteConfigurationProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Net; 5 | using System.Net.Http; 6 | using System.Security.Cryptography; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | using ConfigurationService.Client.Parsers; 10 | using Microsoft.Extensions.Configuration; 11 | using Microsoft.Extensions.Logging; 12 | using Microsoft.Extensions.Logging.Abstractions; 13 | 14 | namespace ConfigurationService.Client 15 | { 16 | internal class RemoteConfigurationProvider : ConfigurationProvider, IDisposable 17 | { 18 | private readonly ILogger _logger; 19 | 20 | private readonly RemoteConfigurationSource _source; 21 | private readonly Lazy _httpClient; 22 | private readonly IConfigurationParser _parser; 23 | private bool _disposed; 24 | 25 | private string Hash { get; set; } 26 | 27 | private HttpClient HttpClient => _httpClient.Value; 28 | 29 | public RemoteConfigurationProvider(RemoteConfigurationSource source) 30 | { 31 | _source = source ?? throw new ArgumentNullException(nameof(source)); 32 | 33 | if (string.IsNullOrWhiteSpace(source.ConfigurationName)) 34 | { 35 | throw new RemoteConfigurationException(nameof(source.ConfigurationName)); 36 | } 37 | 38 | if (string.IsNullOrWhiteSpace(source.ConfigurationServiceUri)) 39 | { 40 | throw new RemoteConfigurationException(nameof(source.ConfigurationServiceUri)); 41 | } 42 | 43 | Logger.LoggerFactory = source.LoggerFactory ?? new NullLoggerFactory(); 44 | 45 | _logger = Logger.CreateLogger(); 46 | 47 | _logger.LogInformation("Initializing remote configuration source for configuration '{ConfigurationName}'", source.ConfigurationName); 48 | 49 | _httpClient = new Lazy(CreateHttpClient); 50 | 51 | _parser = source.Parser; 52 | 53 | if (_parser == null) 54 | { 55 | var extension = Path.GetExtension(source.ConfigurationName).ToLower(); 56 | 57 | _logger.LogInformation("A file parser was not specified. Attempting to resolve parser from file extension '{Extension}'", extension); 58 | 59 | switch (extension) 60 | { 61 | case ".ini": 62 | _parser = new IniConfigurationFileParser(); 63 | break; 64 | case ".xml": 65 | _parser = new XmlConfigurationFileParser(); 66 | break; 67 | case ".yaml": 68 | _parser = new YamlConfigurationFileParser(); 69 | break; 70 | default: 71 | _parser = new JsonConfigurationFileParser(); 72 | break; 73 | } 74 | } 75 | 76 | _logger.LogInformation("Using parser {Name}", _parser.GetType().Name); 77 | 78 | if (source.ReloadOnChange) 79 | { 80 | if (source.CreateSubscriber == null) 81 | { 82 | _logger.LogWarning("ReloadOnChange is enabled but a subscriber has not been configured"); 83 | return; 84 | } 85 | 86 | var subscriber = source.CreateSubscriber(); 87 | 88 | _logger.LogInformation("Initializing remote configuration {Name} subscriber for configuration '{ConfigurationName}'", 89 | subscriber.Name, source.ConfigurationName); 90 | 91 | subscriber.Initialize(); 92 | 93 | subscriber.Subscribe(source.ConfigurationName, message => 94 | { 95 | _logger.LogInformation("Received remote configuration change subscription for configuration '{ConfigurationName}' with hash {Message}", 96 | source.ConfigurationName, message); 97 | 98 | _logger.LogInformation("Current hash is {Hash}", Hash); 99 | 100 | if (message != null && message.Equals(Hash, StringComparison.OrdinalIgnoreCase)) 101 | { 102 | _logger.LogInformation("Configuration '{ConfigurationName}' current hash {Hash} matches new hash", 103 | source.ConfigurationName, Hash); 104 | 105 | _logger.LogInformation("Configuration will not be updated"); 106 | 107 | return; 108 | } 109 | 110 | Load(); 111 | OnReload(); 112 | }); 113 | } 114 | } 115 | 116 | public override void Load() => LoadAsync().ContinueWith(task => 117 | { 118 | if (task.IsFaulted && task.Exception != null) 119 | { 120 | var ex = task.Exception.Flatten(); 121 | _logger.LogError(ex, "Failed to load remote configuration provider"); 122 | throw ex; 123 | } 124 | }).GetAwaiter().GetResult(); 125 | 126 | public void Dispose() 127 | { 128 | Dispose(true); 129 | GC.SuppressFinalize(this); 130 | } 131 | 132 | protected virtual void Dispose(bool disposing) 133 | { 134 | if (_disposed) 135 | { 136 | return; 137 | } 138 | 139 | if (disposing && _httpClient?.IsValueCreated == true) 140 | { 141 | _httpClient.Value.Dispose(); 142 | } 143 | 144 | _disposed = true; 145 | } 146 | 147 | private HttpClient CreateHttpClient() 148 | { 149 | var handler = _source.HttpMessageHandler ?? new HttpClientHandler(); 150 | var client = new HttpClient(handler, true) 151 | { 152 | BaseAddress = new Uri(_source.ConfigurationServiceUri), 153 | Timeout = _source.RequestTimeout 154 | }; 155 | 156 | return client; 157 | } 158 | 159 | private async Task LoadAsync() 160 | { 161 | Data = await RequestConfigurationAsync().ConfigureAwait(false); 162 | } 163 | 164 | private async Task> RequestConfigurationAsync() 165 | { 166 | var encodedConfigurationName = WebUtility.UrlEncode(_source.ConfigurationName); 167 | 168 | _logger.LogInformation("Requesting remote configuration {ConfigurationName} from {BaseAddress}", 169 | _source.ConfigurationName, HttpClient.BaseAddress); 170 | 171 | try 172 | { 173 | using (var response = await HttpClient.GetAsync(encodedConfigurationName).ConfigureAwait(false)) 174 | { 175 | _logger.LogInformation("Received response status code {StatusCode} from endpoint for configuration '{ConfigurationName}'", 176 | response.StatusCode, _source.ConfigurationName); 177 | 178 | if (response.IsSuccessStatusCode) 179 | { 180 | using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) 181 | { 182 | _logger.LogInformation("Parsing remote configuration response stream " + 183 | "({Length:N0} bytes) for configuration '{ConfigurationName}'", 184 | stream.Length, _source.ConfigurationName); 185 | 186 | Hash = ComputeHash(stream); 187 | _logger.LogInformation("Computed hash for Configuration '{ConfigurationName}' is {Hash}", 188 | _source.ConfigurationName, Hash); 189 | 190 | stream.Position = 0; 191 | var data = _parser.Parse(stream); 192 | 193 | _logger.LogInformation("Configuration updated for '{ConfigurationName}'", _source.ConfigurationName); 194 | 195 | return data; 196 | } 197 | } 198 | 199 | if (!_source.Optional) 200 | { 201 | throw new HttpRequestException($"Error calling remote configuration endpoint: {response.StatusCode} - {response.ReasonPhrase}"); 202 | } 203 | } 204 | } 205 | catch (Exception) 206 | { 207 | if (!_source.Optional) 208 | { 209 | throw; 210 | } 211 | } 212 | 213 | return new Dictionary(StringComparer.OrdinalIgnoreCase); 214 | } 215 | 216 | private string ComputeHash(Stream stream) 217 | { 218 | using (var hash = SHA1.Create()) 219 | { 220 | var hashBytes = hash.ComputeHash(stream); 221 | 222 | var sb = new StringBuilder(); 223 | foreach (var b in hashBytes) 224 | { 225 | sb.Append(b.ToString("X2")); 226 | } 227 | return sb.ToString(); 228 | } 229 | } 230 | } 231 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Client/RemoteConfigurationSource.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using Microsoft.Extensions.Configuration; 4 | using Microsoft.Extensions.Logging; 5 | 6 | namespace ConfigurationService.Client 7 | { 8 | /// 9 | /// Represents a remote file as an . 10 | /// 11 | internal class RemoteConfigurationSource : IConfigurationSource 12 | { 13 | /// 14 | /// Configuration service endpoint. 15 | /// 16 | public string ConfigurationServiceUri { get; set; } 17 | 18 | /// 19 | /// Name or path of the configuration file relative to the configuration provider path. 20 | /// 21 | public string ConfigurationName { get; set; } 22 | 23 | /// 24 | /// Determines if loading the file is optional. Defaults to false>. 25 | /// 26 | public bool Optional { get; set; } 27 | 28 | /// 29 | /// Determines whether the source will be loaded if the underlying file changes. Defaults to false. 30 | /// 31 | public bool ReloadOnChange { get; set; } 32 | 33 | /// 34 | /// The for the . 35 | /// 36 | public HttpMessageHandler HttpMessageHandler { get; set; } 37 | 38 | /// 39 | /// The timeout for the request to the configuration server. 40 | /// 41 | public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(60); 42 | 43 | /// 44 | /// The type of used to parse the remote configuration file. 45 | /// 46 | public IConfigurationParser Parser { get; set; } 47 | 48 | /// 49 | /// Delegate to create the type of used to subscribe to published configuration messages. 50 | /// 51 | public Func CreateSubscriber { get; set; } 52 | 53 | /// 54 | /// The type used to configure the logging system and create instances of 55 | /// 56 | public ILoggerFactory LoggerFactory { get; set; } 57 | 58 | public IConfigurationProvider Build(IConfigurationBuilder builder) 59 | { 60 | return new RemoteConfigurationProvider(this); 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Client/Subscribers/Nats/NatsSubscriber.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Extensions.Logging; 3 | using NATS.Client; 4 | 5 | namespace ConfigurationService.Client.Subscribers.Nats 6 | { 7 | public class NatsSubscriber : ISubscriber 8 | { 9 | private readonly ILogger _logger; 10 | 11 | private readonly Options _options; 12 | 13 | private static IConnection _connection; 14 | 15 | public string Name => "NATS"; 16 | 17 | public NatsSubscriber(Options options) 18 | { 19 | _logger = Logger.CreateLogger(); 20 | 21 | _options = options ?? throw new ArgumentNullException(nameof(options)); 22 | } 23 | 24 | public void Initialize() 25 | { 26 | _options.AsyncErrorEventHandler += (sender, args) => { _logger.LogError("NATS replied with an error message: {Message}", args.Error); }; 27 | 28 | _options.ClosedEventHandler += (sender, args) => { _logger.LogError(args.Error, "NATS connection was closed"); }; 29 | 30 | _options.DisconnectedEventHandler += (sender, args) => { _logger.LogError(args.Error, "NATS connection was disconnected"); }; 31 | 32 | _options.ReconnectedEventHandler += (sender, args) => { _logger.LogInformation("NATS connection was restored"); }; 33 | 34 | var connectionFactory = new ConnectionFactory(); 35 | _connection = connectionFactory.CreateConnection(_options); 36 | } 37 | 38 | public void Subscribe(string topic, Action handler) 39 | { 40 | _logger.LogInformation("Subscribing to NATS subject '{Subject}'", topic); 41 | 42 | _connection.SubscribeAsync(topic, (sender, args) => 43 | { 44 | _logger.LogInformation("Received subscription on NATS subject '{Subject}'", topic); 45 | 46 | var message = args.Message.ToString(); 47 | 48 | handler(message); 49 | }); 50 | 51 | _logger.LogInformation("Subscribed to NATS subject '{Subject}'", topic); 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Client/Subscribers/RabbitMq/RabbitMqOptions.cs: -------------------------------------------------------------------------------- 1 | namespace ConfigurationService.Client.Subscribers.RabbitMq 2 | { 3 | public class RabbitMqOptions 4 | { 5 | /// The host to connect to. 6 | /// Defaults to "localhost". 7 | public string HostName { get; set; } = "localhost"; 8 | 9 | /// 10 | /// Virtual host to access during this connection. 11 | /// Defaults to "/". 12 | /// 13 | public string VirtualHost { get; set; } = "/"; 14 | 15 | /// 16 | /// Username to use when authenticating to the server. 17 | /// Defaults to "guest". 18 | /// 19 | public string UserName { get; set; } = "guest"; 20 | 21 | /// 22 | /// Password to use when authenticating to the server. 23 | /// Defaults to "guest". 24 | /// 25 | public string Password { get; set; } = "guest"; 26 | 27 | /// 28 | /// Name of the fanout exchange. 29 | /// Defaults to "configuration-service" 30 | /// 31 | public string ExchangeName { get; set; } = "configuration-service"; 32 | } 33 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Client/Subscribers/RabbitMq/RabbitMqSubscriber.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using Microsoft.Extensions.Logging; 4 | using RabbitMQ.Client; 5 | using RabbitMQ.Client.Events; 6 | 7 | namespace ConfigurationService.Client.Subscribers.RabbitMq 8 | { 9 | public class RabbitMqSubscriber : ISubscriber 10 | { 11 | private readonly ILogger _logger; 12 | 13 | private readonly RabbitMqOptions _options; 14 | private string _exchangeName; 15 | private static IModel _channel; 16 | 17 | public string Name => "RabbitMQ"; 18 | 19 | public RabbitMqSubscriber(RabbitMqOptions options) 20 | { 21 | _logger = Logger.CreateLogger(); 22 | 23 | _options = options ?? throw new ArgumentNullException(nameof(options)); 24 | } 25 | 26 | public void Initialize() 27 | { 28 | var factory = new ConnectionFactory 29 | { 30 | HostName = _options.HostName, 31 | VirtualHost = _options.VirtualHost, 32 | UserName = _options.UserName, 33 | Password = _options.Password 34 | }; 35 | 36 | _exchangeName = _options.ExchangeName; 37 | 38 | var connection = factory.CreateConnection(); 39 | _channel = connection.CreateModel(); 40 | 41 | connection.CallbackException += (sender, args) => { _logger.LogError(args.Exception, "RabbitMQ callback exception"); }; 42 | 43 | connection.ConnectionBlocked += (sender, args) => { _logger.LogError("RabbitMQ connection is blocked. Reason: {Reason}", args.Reason); }; 44 | 45 | connection.ConnectionShutdown += (sender, args) => { _logger.LogError("RabbitMQ connection was shut down. Reason: {ReplyText}", args.ReplyText); }; 46 | 47 | connection.ConnectionUnblocked += (sender, args) => { _logger.LogInformation("RabbitMQ connection was unblocked"); }; 48 | 49 | _channel.ExchangeDeclare(_exchangeName, ExchangeType.Fanout); 50 | } 51 | 52 | public void Subscribe(string topic, Action handler) 53 | { 54 | _logger.LogInformation("Binding to RabbitMQ queue with routing key '{RoutingKey}'", topic); 55 | 56 | var queueName = _channel.QueueDeclare().QueueName; 57 | _channel.QueueBind(queueName, _exchangeName, topic); 58 | 59 | var consumer = new EventingBasicConsumer(_channel); 60 | 61 | consumer.Received += (model, args) => 62 | { 63 | _logger.LogInformation("Received message with routing key '{RoutingKey}'", args.RoutingKey); 64 | 65 | var body = args.Body.ToArray(); 66 | var message = Encoding.UTF8.GetString(body); 67 | 68 | handler(message); 69 | }; 70 | 71 | var consumerTag = _channel.BasicConsume(queueName, true, consumer); 72 | 73 | _logger.LogInformation("Consuming RabbitMQ queue {QueueName} for consumer '{ConsumerTag}'", queueName, consumerTag); 74 | } 75 | } 76 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Client/Subscribers/Redis/RedisSubscriber.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using Microsoft.Extensions.Logging; 4 | using StackExchange.Redis; 5 | using RedisOptions = StackExchange.Redis.ConfigurationOptions; 6 | 7 | namespace ConfigurationService.Client.Subscribers.Redis 8 | { 9 | public class RedisSubscriber : ISubscriber 10 | { 11 | private readonly ILogger _logger; 12 | 13 | private readonly RedisOptions _options; 14 | private static ConnectionMultiplexer _connection; 15 | 16 | public string Name => "Redis"; 17 | 18 | public RedisSubscriber(string configuration) 19 | { 20 | _logger = Logger.CreateLogger(); 21 | 22 | if (configuration == null) 23 | { 24 | throw new ArgumentNullException(nameof(configuration)); 25 | } 26 | 27 | _options = RedisOptions.Parse(configuration); 28 | 29 | } 30 | 31 | public RedisSubscriber(RedisOptions configurationOptions) 32 | { 33 | _logger = Logger.CreateLogger(); 34 | 35 | _options = configurationOptions ?? throw new ArgumentNullException(nameof(configurationOptions)); 36 | } 37 | 38 | public void Initialize() 39 | { 40 | using (var writer = new StringWriter()) 41 | { 42 | _connection = ConnectionMultiplexer.Connect(_options, writer); 43 | 44 | _logger.LogTrace("Redis subscriber connected with log:\r\n{Log}", writer); 45 | } 46 | 47 | _connection.ErrorMessage += (sender, args) => { _logger.LogError("Redis replied with an error message: {Message}", args.Message); }; 48 | 49 | _connection.ConnectionFailed += (sender, args) => { _logger.LogError(args.Exception, "Redis connection failed"); }; 50 | 51 | _connection.ConnectionRestored += (sender, args) => { _logger.LogInformation("Redis connection restored"); }; 52 | } 53 | 54 | public void Subscribe(string topic, Action handler) 55 | { 56 | _logger.LogInformation("Subscribing to Redis channel '{Channel}'", topic); 57 | 58 | var subscriber = _connection.GetSubscriber(); 59 | 60 | subscriber.Subscribe(topic, (redisChannel, value) => 61 | { 62 | _logger.LogInformation("Received subscription on Redis channel '{Channel}'", topic); 63 | 64 | handler(value); 65 | }); 66 | 67 | var endpoint = subscriber.SubscribedEndpoint(topic); 68 | _logger.LogInformation("Subscribed to Redis endpoint {Endpoint} for channel '{Channel}'", endpoint, topic); 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/ConfigurationEndpointRouteBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net; 4 | using System.Text; 5 | using System.Text.Json; 6 | using ConfigurationService.Hosting.Providers; 7 | using Microsoft.AspNetCore.Builder; 8 | using Microsoft.AspNetCore.Http; 9 | using Microsoft.AspNetCore.Routing; 10 | using Microsoft.Extensions.DependencyInjection; 11 | 12 | namespace ConfigurationService.Hosting; 13 | 14 | public static class ConfigurationEndpointRouteBuilderExtensions 15 | { 16 | public static IEndpointConventionBuilder MapConfigurationService(this IEndpointRouteBuilder endpoints, string pattern = "/configuration") 17 | { 18 | if (endpoints == null) 19 | { 20 | throw new ArgumentNullException(nameof(endpoints)); 21 | } 22 | 23 | if (pattern == null) 24 | { 25 | throw new ArgumentNullException(nameof(pattern)); 26 | } 27 | 28 | var conventionBuilders = new List(); 29 | 30 | var listConfigurationBuilder = endpoints.RegisterListRoute(pattern); 31 | conventionBuilders.Add(listConfigurationBuilder); 32 | 33 | var fileConfigurationBuilder = endpoints.RegisterFileRoute(pattern); 34 | conventionBuilders.Add(fileConfigurationBuilder); 35 | 36 | return new CompositeEndpointConventionBuilder(conventionBuilders); 37 | } 38 | 39 | private static IEndpointConventionBuilder RegisterListRoute(this IEndpointRouteBuilder endpointRouteBuilder, string pattern) 40 | { 41 | var provider = endpointRouteBuilder.ServiceProvider.GetService(); 42 | 43 | return endpointRouteBuilder.MapGet(pattern, async context => 44 | { 45 | var files = await provider.ListPaths(); 46 | 47 | context.Response.OnStarting(async () => 48 | { 49 | await JsonSerializer.SerializeAsync(context.Response.Body, files); 50 | }); 51 | 52 | context.Response.ContentType = "application/json; charset=UTF-8"; 53 | await context.Response.Body.FlushAsync(); 54 | }); 55 | } 56 | 57 | private static IEndpointConventionBuilder RegisterFileRoute(this IEndpointRouteBuilder endpointRouteBuilder, string pattern) 58 | { 59 | var provider = endpointRouteBuilder.ServiceProvider.GetService(); 60 | 61 | return endpointRouteBuilder.MapGet(pattern + "/{name}", async context => 62 | { 63 | var name = context.GetRouteValue("name")?.ToString(); 64 | name = WebUtility.UrlDecode(name); 65 | 66 | var bytes = await provider.GetConfiguration(name); 67 | 68 | if (bytes == null) 69 | { 70 | context.Response.StatusCode = 404; 71 | return; 72 | } 73 | 74 | var fileContent = Encoding.UTF8.GetString(bytes); 75 | 76 | await context.Response.WriteAsync(fileContent); 77 | await context.Response.Body.FlushAsync(); 78 | }); 79 | } 80 | 81 | private sealed class CompositeEndpointConventionBuilder : IEndpointConventionBuilder 82 | { 83 | private readonly List _endpointConventionBuilders; 84 | 85 | public CompositeEndpointConventionBuilder(List endpointConventionBuilders) 86 | { 87 | _endpointConventionBuilders = endpointConventionBuilders; 88 | } 89 | 90 | public void Add(Action convention) 91 | { 92 | foreach (var endpointConventionBuilder in _endpointConventionBuilders) 93 | { 94 | endpointConventionBuilder.Add(convention); 95 | } 96 | } 97 | } 98 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/ConfigurationService.Hosting.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Contains the hosted service, storage providers and publisher components for Configuration Service. 6 | Configuration Service is a distributed configuration service for .NET. 7 | 8 | net6.0 9 | 1.0.2 10 | James Pratt 11 | ConfigurationService.Hosting 12 | ConfigurationService.Hosting 13 | 14 | 15 | 16 | true 17 | $(NoWarn);1591 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/ConfigurationService.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using ConfigurationService.Hosting.Providers; 6 | using ConfigurationService.Hosting.Publishers; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace ConfigurationService.Hosting; 10 | 11 | public class ConfigurationService : IConfigurationService 12 | { 13 | private readonly ILogger _logger; 14 | 15 | private readonly IProvider _provider; 16 | private readonly IPublisher _publisher; 17 | 18 | public ConfigurationService(ILogger logger, IProvider provider, IPublisher publisher = null) 19 | { 20 | _logger = logger; 21 | _provider = provider; 22 | _publisher = publisher; 23 | 24 | if (_publisher == null) 25 | { 26 | _logger.LogInformation("A publisher has not been configured"); 27 | } 28 | } 29 | 30 | public async Task Initialize(CancellationToken cancellationToken = default) 31 | { 32 | _logger.LogInformation("Initializing {Name} configuration provider...", _provider.Name); 33 | 34 | _provider.Initialize(); 35 | 36 | if (_publisher != null) 37 | { 38 | _logger.LogInformation("Initializing publisher..."); 39 | _publisher.Initialize(); 40 | } 41 | 42 | var paths = await _provider.ListPaths(); 43 | 44 | await PublishChanges(paths); 45 | 46 | await _provider.Watch(OnChange, cancellationToken); 47 | 48 | _logger.LogInformation("{Name} configuration watching for changes", _provider.Name); 49 | } 50 | 51 | public async Task OnChange(IEnumerable paths) 52 | { 53 | _logger.LogInformation("Changes were detected on the remote {Name} configuration provider", _provider.Name); 54 | 55 | paths = paths.ToList(); 56 | 57 | if (paths.Any()) 58 | { 59 | await PublishChanges(paths); 60 | } 61 | } 62 | 63 | public async Task PublishChanges(IEnumerable paths) 64 | { 65 | if (_publisher == null) 66 | { 67 | return; 68 | } 69 | 70 | _logger.LogInformation("Publishing changes..."); 71 | 72 | foreach (var path in paths) 73 | { 74 | var hash = await _provider.GetHash(path); 75 | await _publisher.Publish(path, hash); 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/ConfigurationServiceBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Extensions.DependencyInjection; 3 | 4 | namespace ConfigurationService.Hosting; 5 | 6 | public class ConfigurationServiceBuilder : IConfigurationServiceBuilder 7 | { 8 | public IServiceCollection Services { get; } 9 | 10 | public ConfigurationServiceBuilder(IServiceCollection services) 11 | { 12 | Services = services ?? throw new ArgumentNullException(nameof(services)); 13 | } 14 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/ConfigurationServiceBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using ConfigurationService.Hosting.Providers; 3 | using ConfigurationService.Hosting.Providers.FileSystem; 4 | using ConfigurationService.Hosting.Providers.Git; 5 | using ConfigurationService.Hosting.Providers.Vault; 6 | using ConfigurationService.Hosting.Publishers; 7 | using ConfigurationService.Hosting.Publishers.Nats; 8 | using ConfigurationService.Hosting.Publishers.RabbitMq; 9 | using ConfigurationService.Hosting.Publishers.Redis; 10 | using Microsoft.Extensions.DependencyInjection; 11 | using NATS.Client; 12 | using StackExchange.Redis; 13 | 14 | namespace ConfigurationService.Hosting; 15 | 16 | public static class ConfigurationServiceBuilderExtensions 17 | { 18 | /// 19 | /// Adds services for configuration hosting to the specified . 20 | /// 21 | /// The to add services to. 22 | /// An that can be used to further configure the 23 | /// ConfigurationService services. 24 | public static IConfigurationServiceBuilder AddConfigurationService(this IServiceCollection services) 25 | { 26 | if (services == null) 27 | { 28 | throw new ArgumentNullException(nameof(services)); 29 | } 30 | 31 | services.AddHostedService(); 32 | services.AddSingleton(); 33 | 34 | return new ConfigurationServiceBuilder(services); 35 | } 36 | 37 | /// 38 | /// Add Git as the storage provider backend. 39 | /// 40 | /// The to add services to. 41 | /// Configure git provider options. 42 | /// An that can be used to further configure the 43 | /// ConfigurationService services. 44 | public static IConfigurationServiceBuilder AddGitProvider(this IConfigurationServiceBuilder builder, Action configure) 45 | { 46 | if (builder == null) 47 | { 48 | throw new ArgumentNullException(nameof(builder)); 49 | } 50 | 51 | if (configure == null) 52 | { 53 | throw new ArgumentNullException(nameof(configure)); 54 | } 55 | 56 | var options = new GitProviderOptions(); 57 | configure(options); 58 | 59 | builder.Services.AddSingleton(options); 60 | builder.Services.AddSingleton(); 61 | 62 | return builder; 63 | } 64 | 65 | /// 66 | /// Add file system as the storage provider backend. 67 | /// 68 | /// The to add services to. 69 | /// Configure file system provider options. 70 | /// An that can be used to further configure the 71 | /// ConfigurationService services. 72 | public static IConfigurationServiceBuilder AddFileSystemProvider(this IConfigurationServiceBuilder builder, Action configure) 73 | { 74 | if (builder == null) 75 | { 76 | throw new ArgumentNullException(nameof(builder)); 77 | } 78 | 79 | if (configure == null) 80 | { 81 | throw new ArgumentNullException(nameof(configure)); 82 | } 83 | 84 | var options = new FileSystemProviderOptions(); 85 | configure(options); 86 | 87 | builder.Services.AddSingleton(options); 88 | builder.Services.AddSingleton(); 89 | 90 | return builder; 91 | } 92 | 93 | /// 94 | /// Add Vault as the storage provider backend. 95 | /// 96 | /// The to add services to. 97 | /// Configure Vault provider options. 98 | /// An that can be used to further configure the 99 | /// ConfigurationService services. 100 | public static IConfigurationServiceBuilder AddVaultProvider(this IConfigurationServiceBuilder builder, Action configure) 101 | { 102 | if (builder == null) 103 | { 104 | throw new ArgumentNullException(nameof(builder)); 105 | } 106 | 107 | if (configure == null) 108 | { 109 | throw new ArgumentNullException(nameof(configure)); 110 | } 111 | 112 | var options = new VaultProviderOptions(); 113 | configure(options); 114 | 115 | builder.Services.AddSingleton(options); 116 | builder.Services.AddSingleton(); 117 | 118 | return builder; 119 | } 120 | 121 | /// 122 | /// Adds a custom storage provider backend. 123 | /// 124 | /// The to add services to. 125 | /// The custom implementation of . 126 | /// An that can be used to further configure the 127 | /// ConfigurationService services. 128 | public static IConfigurationServiceBuilder AddProvider(this IConfigurationServiceBuilder builder, IProvider provider) 129 | { 130 | if (builder == null) 131 | { 132 | throw new ArgumentNullException(nameof(builder)); 133 | } 134 | 135 | if (provider == null) 136 | { 137 | throw new ArgumentNullException(nameof(provider)); 138 | } 139 | 140 | builder.Services.AddSingleton(provider); 141 | 142 | return builder; 143 | } 144 | 145 | /// 146 | /// Adds RabbitMQ as the configuration publisher. 147 | /// 148 | /// The to add services to. 149 | /// Configure options for the RabbitMQ publisher. 150 | /// An that can be used to further configure the 151 | /// ConfigurationService services. 152 | public static IConfigurationServiceBuilder AddRabbitMqPublisher(this IConfigurationServiceBuilder builder, Action configure) 153 | { 154 | if (builder == null) 155 | { 156 | throw new ArgumentNullException(nameof(builder)); 157 | } 158 | 159 | if (configure == null) 160 | { 161 | throw new ArgumentNullException(nameof(configure)); 162 | } 163 | 164 | var options = new RabbitMqOptions(); 165 | configure(options); 166 | 167 | builder.Services.AddSingleton(options); 168 | builder.Services.AddSingleton(); 169 | 170 | return builder; 171 | } 172 | 173 | /// 174 | /// Adds Redis as the configuration publisher. 175 | /// 176 | /// The to add services to. 177 | /// Configure options for the Redis multiplexer. 178 | /// An that can be used to further configure the 179 | /// ConfigurationService services. 180 | public static IConfigurationServiceBuilder AddRedisPublisher(this IConfigurationServiceBuilder builder, Action configure) 181 | { 182 | if (builder == null) 183 | { 184 | throw new ArgumentNullException(nameof(builder)); 185 | } 186 | 187 | if (configure == null) 188 | { 189 | throw new ArgumentNullException(nameof(configure)); 190 | } 191 | 192 | var options = new ConfigurationOptions(); 193 | configure(options); 194 | 195 | builder.Services.AddSingleton(options); 196 | builder.Services.AddSingleton(); 197 | 198 | return builder; 199 | } 200 | 201 | /// 202 | /// Adds Redis as the configuration publisher. 203 | /// 204 | /// The to add services to. 205 | /// The string configuration for the Redis multiplexer. 206 | /// An that can be used to further configure the 207 | /// ConfigurationService services. 208 | public static IConfigurationServiceBuilder AddRedisPublisher(this IConfigurationServiceBuilder builder, string configuration) 209 | { 210 | if (builder == null) 211 | { 212 | throw new ArgumentNullException(nameof(builder)); 213 | } 214 | 215 | if (configuration == null) 216 | { 217 | throw new ArgumentNullException(nameof(configuration)); 218 | } 219 | 220 | var options = ConfigurationOptions.Parse(configuration); 221 | 222 | builder.Services.AddSingleton(options); 223 | builder.Services.AddSingleton(); 224 | 225 | return builder; 226 | } 227 | 228 | /// 229 | /// Adds NATS as the configuration publisher. 230 | /// 231 | /// The to add services to. 232 | /// Configure options for the NATS connection. 233 | /// An that can be used to further configure the 234 | /// ConfigurationService services. 235 | public static IConfigurationServiceBuilder AddNatsPublisher(this IConfigurationServiceBuilder builder, Action configure) 236 | { 237 | if (builder == null) 238 | { 239 | throw new ArgumentNullException(nameof(builder)); 240 | } 241 | 242 | if (configure == null) 243 | { 244 | throw new ArgumentNullException(nameof(configure)); 245 | } 246 | 247 | var options = ConnectionFactory.GetDefaultOptions(); 248 | configure(options); 249 | 250 | builder.Services.AddSingleton(options); 251 | builder.Services.AddSingleton(); 252 | 253 | return builder; 254 | } 255 | 256 | /// 257 | /// Adds a custom configuration publisher. 258 | /// 259 | /// The to add services to. 260 | /// The custom implementation of . 261 | /// An that can be used to further configure the 262 | /// ConfigurationService services. 263 | public static IConfigurationServiceBuilder AddPublisher(this IConfigurationServiceBuilder builder, IPublisher publisher) 264 | { 265 | if (builder == null) 266 | { 267 | throw new ArgumentNullException(nameof(builder)); 268 | } 269 | 270 | if (publisher == null) 271 | { 272 | throw new ArgumentNullException(nameof(publisher)); 273 | } 274 | 275 | builder.Services.AddSingleton(publisher); 276 | 277 | return builder; 278 | } 279 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | 3 | namespace ConfigurationService.Hosting.Extensions; 4 | 5 | public static class StringExtensions 6 | { 7 | 8 | public static string NormalizePathSeparators(this string path) 9 | { 10 | return path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); 11 | } 12 | 13 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/Hasher.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography; 2 | using System.Text; 3 | 4 | namespace ConfigurationService.Hosting; 5 | 6 | public static class Hasher 7 | { 8 | public static string CreateHash(byte[] bytes) 9 | { 10 | using var hash = SHA1.Create(); 11 | var hashBytes = hash.ComputeHash(bytes); 12 | 13 | var sb = new StringBuilder(); 14 | foreach (var b in hashBytes) 15 | { 16 | sb.Append(b.ToString("X2")); 17 | } 18 | return sb.ToString(); 19 | } 20 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/HostedConfigurationService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Microsoft.Extensions.Hosting; 5 | using Microsoft.Extensions.Logging; 6 | 7 | namespace ConfigurationService.Hosting; 8 | 9 | public class HostedConfigurationService : IHostedService, IDisposable 10 | { 11 | private readonly ILogger _logger; 12 | 13 | private readonly IHostApplicationLifetime _applicationLifetime; 14 | private readonly IConfigurationService _configurationService; 15 | 16 | private Task _executingTask; 17 | private readonly CancellationTokenSource _stoppingCts = new CancellationTokenSource(); 18 | private bool _disposed; 19 | 20 | public HostedConfigurationService(ILogger logger, IHostApplicationLifetime applicationLifetime, IConfigurationService configurationService) 21 | { 22 | _logger = logger; 23 | _applicationLifetime = applicationLifetime; 24 | _configurationService = configurationService; 25 | } 26 | 27 | public Task StartAsync(CancellationToken cancellationToken) 28 | { 29 | _logger.LogInformation("Starting Configuration Service"); 30 | 31 | _applicationLifetime.ApplicationStarted.Register(OnStarted); 32 | _applicationLifetime.ApplicationStopping.Register(OnStopping); 33 | _applicationLifetime.ApplicationStopped.Register(OnStopped); 34 | 35 | _executingTask = ExecuteAsync(_stoppingCts.Token); 36 | 37 | if (_executingTask.IsCompleted) 38 | { 39 | return _executingTask; 40 | } 41 | 42 | return Task.CompletedTask; 43 | } 44 | 45 | public async Task StopAsync(CancellationToken cancellationToken) 46 | { 47 | if (_executingTask == null) 48 | { 49 | return; 50 | } 51 | 52 | try 53 | { 54 | // Signal cancellation to the executing method 55 | _stoppingCts.Cancel(); 56 | } 57 | finally 58 | { 59 | // Wait until the task completes or the stop token triggers 60 | await Task.WhenAny(_executingTask, Task.Delay(Timeout.Infinite, cancellationToken)); 61 | } 62 | } 63 | 64 | public async Task ExecuteAsync(CancellationToken stoppingToken) 65 | { 66 | try 67 | { 68 | await _configurationService.Initialize(stoppingToken); 69 | } 70 | catch (Exception ex) 71 | { 72 | _logger.LogError(ex, "An unhandled exception occurred while attempting to initialize the configuration provider"); 73 | 74 | _logger.LogInformation("The application will be terminated"); 75 | 76 | await StopAsync(stoppingToken); 77 | _applicationLifetime.StopApplication(); 78 | } 79 | } 80 | 81 | public void Dispose() 82 | { 83 | Dispose(true); 84 | GC.SuppressFinalize(this); 85 | } 86 | 87 | protected virtual void Dispose(bool disposing) 88 | { 89 | if (_disposed) 90 | { 91 | return; 92 | } 93 | 94 | if (disposing) 95 | { 96 | _stoppingCts.Cancel(); 97 | } 98 | 99 | _disposed = true; 100 | } 101 | 102 | private void OnStarted() 103 | { 104 | _logger.LogInformation("Configuration Service started"); 105 | } 106 | 107 | private void OnStopping() 108 | { 109 | _logger.LogInformation("Configuration Service is stopping..."); 110 | } 111 | 112 | private void OnStopped() 113 | { 114 | _logger.LogInformation("Configuration Service stopped"); 115 | } 116 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/IConfigurationService.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | namespace ConfigurationService.Hosting; 6 | 7 | public interface IConfigurationService 8 | { 9 | Task Initialize(CancellationToken cancellationToken = default); 10 | 11 | Task OnChange(IEnumerable paths); 12 | 13 | Task PublishChanges(IEnumerable paths); 14 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/IConfigurationServiceBuilder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | 3 | namespace ConfigurationService.Hosting; 4 | 5 | public interface IConfigurationServiceBuilder 6 | { 7 | IServiceCollection Services { get; } 8 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/ProviderOptionNullException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.Serialization; 3 | 4 | namespace ConfigurationService.Hosting; 5 | 6 | [Serializable] 7 | public class ProviderOptionNullException : Exception 8 | { 9 | public ProviderOptionNullException() 10 | { 11 | } 12 | 13 | public ProviderOptionNullException(string name) 14 | : base($"{name} cannot be NULL or empty.") 15 | { 16 | } 17 | 18 | public ProviderOptionNullException(string message, Exception inner) 19 | : base(message, inner) 20 | { 21 | } 22 | 23 | protected ProviderOptionNullException(SerializationInfo info, StreamingContext context) 24 | : base(info, context) 25 | { 26 | } 27 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/Providers/FileSystem/FileSystemProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Net; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using Microsoft.Extensions.Logging; 9 | 10 | namespace ConfigurationService.Hosting.Providers.FileSystem; 11 | 12 | public class FileSystemProvider : IProvider 13 | { 14 | private readonly ILogger _logger; 15 | 16 | private readonly FileSystemProviderOptions _providerOptions; 17 | private FileSystemWatcher _fileSystemWatcher; 18 | private Func, Task> _onChange; 19 | 20 | public string Name => "File System"; 21 | 22 | public FileSystemProvider(ILogger logger, FileSystemProviderOptions providerOptions) 23 | { 24 | _logger = logger; 25 | _providerOptions = providerOptions; 26 | 27 | if (string.IsNullOrWhiteSpace(_providerOptions.Path)) 28 | { 29 | throw new ProviderOptionNullException(nameof(_providerOptions.Path)); 30 | } 31 | } 32 | 33 | public Task Watch(Func, Task> onChange, CancellationToken cancellationToken = default) 34 | { 35 | _onChange = onChange; 36 | _fileSystemWatcher.EnableRaisingEvents = true; 37 | return Task.CompletedTask; 38 | } 39 | 40 | public void Initialize() 41 | { 42 | _logger.LogInformation("Initializing {Name} provider with options {@Options}", Name, new 43 | { 44 | _providerOptions.Path, 45 | _providerOptions.SearchPattern, 46 | _providerOptions.IncludeSubdirectories 47 | }); 48 | 49 | if (_providerOptions.Username != null && _providerOptions.Password != null) 50 | { 51 | var credentials = new NetworkCredential(_providerOptions.Username, _providerOptions.Password, _providerOptions.Domain); 52 | var uri = new Uri(_providerOptions.Path); 53 | _ = new CredentialCache 54 | { 55 | {new Uri($"{uri.Scheme}://{uri.Host}"), "Basic", credentials} 56 | }; 57 | } 58 | 59 | _fileSystemWatcher = new FileSystemWatcher 60 | { 61 | Path = _providerOptions.Path, 62 | Filter = _providerOptions.SearchPattern, 63 | IncludeSubdirectories = _providerOptions.IncludeSubdirectories, 64 | NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName 65 | }; 66 | 67 | _fileSystemWatcher.Created += FileSystemWatcher_Changed; 68 | _fileSystemWatcher.Changed += FileSystemWatcher_Changed; 69 | } 70 | 71 | public async Task GetConfiguration(string name) 72 | { 73 | string path = Path.Combine(_providerOptions.Path, name); 74 | 75 | if (!File.Exists(path)) 76 | { 77 | _logger.LogInformation("File does not exist at {Path}", path); 78 | return null; 79 | } 80 | 81 | return await File.ReadAllBytesAsync(path); 82 | } 83 | 84 | public async Task GetHash(string name) 85 | { 86 | var bytes = await GetConfiguration(name); 87 | 88 | return Hasher.CreateHash(bytes); 89 | } 90 | 91 | public Task> ListPaths() 92 | { 93 | _logger.LogInformation("Listing files at {Path}", _providerOptions.Path); 94 | 95 | var searchOption = _providerOptions.IncludeSubdirectories ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; 96 | var files = Directory.EnumerateFiles(_providerOptions.Path, _providerOptions.SearchPattern ?? "*", searchOption).ToList(); 97 | files = files.Select(GetRelativePath).ToList(); 98 | 99 | _logger.LogInformation("{Count} files found", files.Count); 100 | 101 | return Task.FromResult>(files); 102 | } 103 | 104 | private void FileSystemWatcher_Changed(object sender, FileSystemEventArgs e) 105 | { 106 | _logger.LogInformation("Detected file change at {FullPath}", e.FullPath); 107 | 108 | var filename = GetRelativePath(e.FullPath); 109 | _onChange(new[] { filename }); 110 | } 111 | 112 | private string GetRelativePath(string fullPath) 113 | { 114 | return Path.GetRelativePath(_providerOptions.Path, fullPath); 115 | } 116 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/Providers/FileSystem/FileSystemProviderOptions.cs: -------------------------------------------------------------------------------- 1 | namespace ConfigurationService.Hosting.Providers.FileSystem; 2 | 3 | /// 4 | /// Options for . 5 | /// 6 | public class FileSystemProviderOptions 7 | { 8 | /// 9 | /// Path to the configuration files. 10 | /// 11 | public string Path { get; set; } 12 | 13 | /// 14 | /// The search string to use as a filter against the names of files. Defaults to all files ('*'). 15 | /// 16 | public string SearchPattern { get; set; } 17 | 18 | /// 19 | /// Includes the current directory and all its subdirectories. 20 | /// 21 | public bool IncludeSubdirectories { get; set; } 22 | 23 | /// 24 | /// Username for authentication. 25 | /// 26 | public string Username { get; set; } 27 | 28 | /// 29 | /// Password for authentication. 30 | /// 31 | public string Password { get; set; } 32 | 33 | /// 34 | /// Domain for authentication. 35 | /// 36 | public string Domain { get; set; } 37 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/Providers/Git/GitProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using ConfigurationService.Hosting.Extensions; 8 | 9 | using LibGit2Sharp; 10 | using LibGit2Sharp.Handlers; 11 | using Microsoft.Extensions.Logging; 12 | 13 | namespace ConfigurationService.Hosting.Providers.Git; 14 | 15 | public class GitProvider : IProvider 16 | { 17 | private readonly ILogger _logger; 18 | 19 | private readonly GitProviderOptions _providerOptions; 20 | private CredentialsHandler _credentialsHandler; 21 | 22 | public string Name => "Git"; 23 | 24 | public GitProvider(ILogger logger, GitProviderOptions providerOptions) 25 | { 26 | _logger = logger; 27 | _providerOptions = providerOptions; 28 | 29 | if (string.IsNullOrWhiteSpace(_providerOptions.LocalPath)) 30 | { 31 | throw new ProviderOptionNullException(nameof(_providerOptions.LocalPath)); 32 | } 33 | 34 | if (string.IsNullOrWhiteSpace(_providerOptions.RepositoryUrl)) 35 | { 36 | throw new ProviderOptionNullException(nameof(_providerOptions.RepositoryUrl)); 37 | } 38 | } 39 | 40 | public async Task Watch(Func, Task> onChange, CancellationToken cancellationToken = default) 41 | { 42 | while (!cancellationToken.IsCancellationRequested) 43 | { 44 | try 45 | { 46 | List files; 47 | 48 | var task = Task.Run(ListChangedFiles, cancellationToken); 49 | // The git fetch operation can sometimes hang. Force to complete after a minute. 50 | if (task.Wait(TimeSpan.FromSeconds(60))) 51 | { 52 | files = task.Result.ToList(); 53 | } 54 | else 55 | { 56 | throw new TimeoutException("Attempting to list changed files timed out after 60 seconds."); 57 | } 58 | 59 | if (files.Count > 0) 60 | { 61 | await onChange(files); 62 | } 63 | } 64 | catch (Exception ex) 65 | { 66 | _logger.LogError(ex, "An unhandled exception occurred while attempting to poll for changes"); 67 | } 68 | 69 | var delayDate = DateTime.UtcNow.Add(_providerOptions.PollingInterval); 70 | 71 | _logger.LogInformation("Next polling period will begin in {PollingInterval:c} at {DelayDate}", 72 | _providerOptions.PollingInterval, delayDate); 73 | 74 | await Task.Delay(_providerOptions.PollingInterval, cancellationToken); 75 | } 76 | } 77 | 78 | public void Initialize() 79 | { 80 | _logger.LogInformation("Initializing {Name} provider with options {@Options}", Name, new 81 | { 82 | _providerOptions.RepositoryUrl, 83 | _providerOptions.LocalPath, 84 | _providerOptions.Branch, 85 | _providerOptions.PollingInterval, 86 | _providerOptions.SearchPattern 87 | }); 88 | 89 | if (Directory.Exists(_providerOptions.LocalPath)) 90 | { 91 | _logger.LogInformation("A local repository already exists at {LocalPath}", _providerOptions.LocalPath); 92 | 93 | _logger.LogInformation("Deleting directory {LocalPath}", _providerOptions.LocalPath); 94 | 95 | DeleteDirectory(_providerOptions.LocalPath); 96 | } 97 | 98 | if (!Directory.Exists(_providerOptions.LocalPath)) 99 | { 100 | _logger.LogInformation("Creating directory {LocalPath}", _providerOptions.LocalPath); 101 | 102 | Directory.CreateDirectory(_providerOptions.LocalPath); 103 | } 104 | 105 | if (_providerOptions.Username != null && _providerOptions.Password != null) 106 | { 107 | _credentialsHandler = (url, user, cred) => new UsernamePasswordCredentials 108 | { 109 | Username = _providerOptions.Username, 110 | Password = _providerOptions.Password 111 | }; 112 | } 113 | 114 | var cloneOptions = new CloneOptions 115 | { 116 | CredentialsProvider = _credentialsHandler, 117 | BranchName = _providerOptions.Branch 118 | }; 119 | 120 | _logger.LogInformation("Cloning git repository {RepositoryUrl} to {LocalPath}", _providerOptions.RepositoryUrl, _providerOptions.LocalPath); 121 | 122 | var path = Repository.Clone(_providerOptions.RepositoryUrl, _providerOptions.LocalPath, cloneOptions); 123 | 124 | _logger.LogInformation("Repository cloned to {Path}", path); 125 | 126 | using var repo = new Repository(_providerOptions.LocalPath); 127 | var hash = repo.Head.Tip.Sha.Substring(0, 6); 128 | 129 | _logger.LogInformation("Current HEAD is [{Hash}] '{MessageShort}'", hash, repo.Head.Tip.MessageShort); 130 | } 131 | 132 | public async Task GetConfiguration(string name) 133 | { 134 | string path = Path.Combine(_providerOptions.LocalPath, name); 135 | 136 | if (!File.Exists(path)) 137 | { 138 | _logger.LogInformation("File does not exist at {Path}", path); 139 | return null; 140 | } 141 | 142 | return await File.ReadAllBytesAsync(path); 143 | } 144 | 145 | public async Task GetHash(string name) 146 | { 147 | var bytes = await GetConfiguration(name); 148 | 149 | return Hasher.CreateHash(bytes); 150 | } 151 | 152 | public Task> ListPaths() 153 | { 154 | _logger.LogInformation("Listing files at {LocalPath}", _providerOptions.LocalPath); 155 | 156 | IList files = new List(); 157 | 158 | using (var repo = new Repository(_providerOptions.LocalPath)) 159 | { 160 | _logger.LogInformation("Listing files in repository at {LocalPath}", _providerOptions.LocalPath); 161 | 162 | foreach (var entry in repo.Index) 163 | { 164 | files.Add(entry.Path.NormalizePathSeparators()); 165 | } 166 | } 167 | 168 | var localFiles = Directory.EnumerateFiles(_providerOptions.LocalPath, _providerOptions.SearchPattern ?? "*", SearchOption.AllDirectories).ToList(); 169 | localFiles = localFiles.Select(GetRelativePath).ToList(); 170 | 171 | files = localFiles.Intersect(files).ToList(); 172 | 173 | _logger.LogInformation("{Count} files found", files.Count); 174 | 175 | return Task.FromResult>(files); 176 | } 177 | 178 | private async Task> ListChangedFiles() 179 | { 180 | Fetch(); 181 | 182 | IList changedFiles = new List(); 183 | 184 | using (var repo = new Repository(_providerOptions.LocalPath)) 185 | { 186 | _logger.LogInformation("Checking for remote changes on {RemoteName}", repo.Head.TrackedBranch.RemoteName); 187 | 188 | foreach (TreeEntryChanges entry in repo.Diff.Compare(repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree)) 189 | { 190 | if (entry.Exists) 191 | { 192 | _logger.LogInformation("File {Path} changed", entry.Path); 193 | changedFiles.Add(entry.Path.NormalizePathSeparators()); 194 | } 195 | else 196 | { 197 | _logger.LogInformation("File {Path} no longer exists", entry.Path); 198 | } 199 | } 200 | } 201 | 202 | if (changedFiles.Count == 0) 203 | { 204 | _logger.LogInformation("No tree entry changes were detected"); 205 | 206 | return changedFiles; 207 | } 208 | 209 | UpdateLocal(); 210 | 211 | var filteredFiles = await ListPaths(); 212 | changedFiles = filteredFiles.Intersect(changedFiles).ToList(); 213 | 214 | _logger.LogInformation("{Count} files changed", changedFiles.Count); 215 | 216 | return changedFiles; 217 | } 218 | 219 | private void UpdateLocal() 220 | { 221 | using var repo = new Repository(_providerOptions.LocalPath); 222 | var options = new PullOptions 223 | { 224 | FetchOptions = new FetchOptions 225 | { 226 | CredentialsProvider = _credentialsHandler 227 | } 228 | }; 229 | 230 | var signature = new Signature(new Identity("Configuration Service", "Configuration Service"), DateTimeOffset.Now); 231 | 232 | _logger.LogInformation("Pulling changes to local repository"); 233 | 234 | var currentHash = repo.Head.Tip.Sha.Substring(0, 6); 235 | 236 | _logger.LogInformation("Current HEAD is [{CurrentHash}] '{MessageShort}'", currentHash, repo.Head.Tip.MessageShort); 237 | 238 | var result = Commands.Pull(repo, signature, options); 239 | 240 | _logger.LogInformation("Merge completed with status {Status}", result.Status); 241 | 242 | var newHash = result.Commit.Sha.Substring(0, 6); 243 | 244 | _logger.LogInformation("New HEAD is [{NewHash}] '{MessageShort}'", newHash, result.Commit.MessageShort); 245 | } 246 | 247 | private static void DeleteDirectory(string path) 248 | { 249 | foreach (var directory in Directory.EnumerateDirectories(path)) 250 | { 251 | DeleteDirectory(directory); 252 | } 253 | 254 | foreach (var fileName in Directory.EnumerateFiles(path)) 255 | { 256 | var fileInfo = new FileInfo(fileName) 257 | { 258 | Attributes = FileAttributes.Normal 259 | }; 260 | 261 | fileInfo.Delete(); 262 | } 263 | 264 | Directory.Delete(path); 265 | } 266 | 267 | private void Fetch() 268 | { 269 | using var repo = new Repository(_providerOptions.LocalPath); 270 | FetchOptions options = new FetchOptions 271 | { 272 | CredentialsProvider = _credentialsHandler 273 | }; 274 | 275 | foreach (var remote in repo.Network.Remotes) 276 | { 277 | var refSpecs = remote.FetchRefSpecs.Select(x => x.Specification); 278 | 279 | _logger.LogInformation("Fetching from remote {Name} at {Url}", remote.Name, remote.Url); 280 | 281 | Commands.Fetch(repo, remote.Name, refSpecs, options, string.Empty); 282 | } 283 | } 284 | 285 | private string GetRelativePath(string fullPath) 286 | { 287 | return Path.GetRelativePath(_providerOptions.LocalPath, fullPath).NormalizePathSeparators(); 288 | } 289 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/Providers/Git/GitProviderOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace ConfigurationService.Hosting.Providers.Git; 4 | 5 | /// 6 | /// Options for . 7 | /// 8 | public class GitProviderOptions 9 | { 10 | /// 11 | /// URI for the remote repository. 12 | /// 13 | public string RepositoryUrl { get; set; } 14 | 15 | /// 16 | /// Username for authentication. 17 | /// 18 | public string Username { get; set; } 19 | 20 | /// 21 | /// Password for authentication. 22 | /// 23 | public string Password { get; set; } 24 | 25 | /// 26 | /// The name of the branch to checkout. When unspecified the remote's default branch will be used instead. 27 | /// 28 | public string Branch { get; set; } 29 | 30 | /// 31 | /// Local path to clone into. 32 | /// 33 | public string LocalPath { get; set; } 34 | 35 | /// 36 | /// The search string to use as a filter against the names of files. Defaults to all files ('*'). 37 | /// 38 | public string SearchPattern { get; set; } 39 | 40 | /// 41 | /// The interval to check for for remote changes. Defaults to 60 seconds. 42 | /// 43 | public TimeSpan PollingInterval { get; set; } = TimeSpan.FromSeconds(60); 44 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/Providers/IProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | namespace ConfigurationService.Hosting.Providers; 7 | 8 | public interface IProvider 9 | { 10 | string Name { get; } 11 | 12 | Task Watch(Func, Task> onChange, CancellationToken cancellationToken = default); 13 | 14 | void Initialize(); 15 | 16 | Task GetConfiguration(string name); 17 | 18 | Task GetHash(string name); 19 | 20 | Task> ListPaths(); 21 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/Providers/Vault/VaultProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Text.Json; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using Microsoft.Extensions.Logging; 9 | using VaultSharp; 10 | 11 | namespace ConfigurationService.Hosting.Providers.Vault; 12 | 13 | public class VaultProvider : IProvider 14 | { 15 | private readonly ILogger _logger; 16 | 17 | private readonly VaultProviderOptions _providerOptions; 18 | 19 | private IVaultClient _vaultClient; 20 | private readonly IDictionary _secretVersions = new Dictionary(); 21 | 22 | public string Name => "Vault"; 23 | 24 | public VaultProvider(ILogger logger, VaultProviderOptions providerOptions) 25 | { 26 | _logger = logger; 27 | _providerOptions = providerOptions; 28 | 29 | if (string.IsNullOrWhiteSpace(_providerOptions.ServerUri)) 30 | { 31 | throw new ProviderOptionNullException(nameof(_providerOptions.ServerUri)); 32 | } 33 | 34 | if (string.IsNullOrWhiteSpace(_providerOptions.Path)) 35 | { 36 | throw new ProviderOptionNullException(nameof(_providerOptions.Path)); 37 | } 38 | 39 | if (_providerOptions.AuthMethodInfo == null) 40 | { 41 | throw new ProviderOptionNullException(nameof(_providerOptions.AuthMethodInfo)); 42 | } 43 | } 44 | 45 | public async Task Watch(Func, Task> onChange, CancellationToken cancellationToken = default) 46 | { 47 | while (!cancellationToken.IsCancellationRequested) 48 | { 49 | try 50 | { 51 | var changes = new List(); 52 | 53 | var paths = await ListPaths(); 54 | 55 | foreach (var path in paths) 56 | { 57 | var metadata = await _vaultClient.V1.Secrets.KeyValue.V2.ReadSecretMetadataAsync(path, _providerOptions.Path); 58 | 59 | _secretVersions.TryGetValue(path, out int version); 60 | 61 | if (version != metadata.Data.CurrentVersion) 62 | { 63 | changes.Add(path); 64 | 65 | _secretVersions[path] = metadata.Data.CurrentVersion; 66 | } 67 | } 68 | 69 | if (changes.Count > 0) 70 | { 71 | await onChange(changes); 72 | } 73 | } 74 | catch (Exception ex) 75 | { 76 | _logger.LogError(ex, "An unhandled exception occurred while attempting to poll for changes"); 77 | } 78 | 79 | var delayDate = DateTime.UtcNow.Add(_providerOptions.PollingInterval); 80 | 81 | _logger.LogInformation("Next polling period will begin in {PollingInterval:c} at {DelayDate}", 82 | _providerOptions.PollingInterval, delayDate); 83 | 84 | await Task.Delay(_providerOptions.PollingInterval, cancellationToken); 85 | } 86 | } 87 | 88 | public void Initialize() 89 | { 90 | _logger.LogInformation("Initializing {Name} provider with options {@Options}", Name, new 91 | { 92 | _providerOptions.ServerUri, 93 | _providerOptions.Path 94 | }); 95 | 96 | var vaultClientSettings = new VaultClientSettings(_providerOptions.ServerUri, _providerOptions.AuthMethodInfo); 97 | 98 | _vaultClient = new VaultClient(vaultClientSettings); 99 | } 100 | 101 | public async Task GetConfiguration(string name) 102 | { 103 | var secret = await _vaultClient.V1.Secrets.KeyValue.V2.ReadSecretAsync(name, null, _providerOptions.Path); 104 | 105 | if (secret == null) 106 | { 107 | _logger.LogInformation("Secret does not exist at {Name}", name); 108 | return null; 109 | } 110 | 111 | await using var stream = new MemoryStream(); 112 | await JsonSerializer.SerializeAsync(stream, secret.Data.Data); 113 | return stream.ToArray(); 114 | } 115 | 116 | public async Task GetHash(string name) 117 | { 118 | var bytes = await GetConfiguration(name); 119 | 120 | return Hasher.CreateHash(bytes); 121 | } 122 | 123 | public async Task> ListPaths() 124 | { 125 | _logger.LogInformation("Listing paths at {Path}", _providerOptions.Path); 126 | 127 | var secret = await _vaultClient.V1.Secrets.KeyValue.V2.ReadSecretPathsAsync(null, _providerOptions.Path); 128 | var paths = secret.Data.Keys.ToList(); 129 | 130 | _logger.LogInformation("{Count} paths found", paths.Count); 131 | 132 | return paths; 133 | } 134 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/Providers/Vault/VaultProviderOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using VaultSharp.V1.AuthMethods; 3 | 4 | namespace ConfigurationService.Hosting.Providers.Vault; 5 | 6 | /// 7 | /// Options for . 8 | /// 9 | public class VaultProviderOptions 10 | { 11 | /// 12 | /// The Vault Server Uri with port. 13 | /// 14 | public string ServerUri { get; set; } 15 | 16 | /// 17 | /// The path where the kv secrets engine is enabled. 18 | /// 19 | public string Path { get; set; } 20 | 21 | /// 22 | /// The auth method to be used to acquire a vault token. 23 | /// 24 | public IAuthMethodInfo AuthMethodInfo { get; set; } 25 | 26 | /// 27 | /// The interval to check for for remote changes. Defaults to 60 seconds. 28 | /// 29 | public TimeSpan PollingInterval { get; set; } = TimeSpan.FromSeconds(60); 30 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/Publishers/IPublisher.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace ConfigurationService.Hosting.Publishers; 4 | 5 | public interface IPublisher 6 | { 7 | void Initialize(); 8 | 9 | Task Publish(string topic, string message); 10 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/Publishers/Nats/NatsPublisher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using System.Threading.Tasks; 4 | using Microsoft.Extensions.Logging; 5 | using NATS.Client; 6 | 7 | namespace ConfigurationService.Hosting.Publishers.Nats; 8 | 9 | public class NatsPublisher : IPublisher 10 | { 11 | private readonly ILogger _logger; 12 | 13 | private readonly Options _options; 14 | private static IConnection _connection; 15 | 16 | public NatsPublisher(ILogger logger, Options options) 17 | { 18 | _logger = logger; 19 | 20 | _options = options ?? throw new ArgumentNullException(nameof(options)); 21 | } 22 | 23 | public void Initialize() 24 | { 25 | _options.AsyncErrorEventHandler += (sender, args) => { _logger.LogError("NATS replied with an error message: {Message}", args.Error); }; 26 | 27 | _options.ClosedEventHandler += (sender, args) => { _logger.LogError(args.Error, "NATS connection was closed"); }; 28 | 29 | _options.DisconnectedEventHandler += (sender, args) => { _logger.LogError(args.Error, "NATS connection was disconnected"); }; 30 | 31 | _options.ReconnectedEventHandler += (sender, args) => { _logger.LogInformation("NATS connection was restored"); }; 32 | 33 | var connectionFactory = new ConnectionFactory(); 34 | _connection = connectionFactory.CreateConnection(_options); 35 | 36 | _logger.LogInformation("NATS publisher initialized"); 37 | } 38 | 39 | public Task Publish(string topic, string message) 40 | { 41 | _logger.LogInformation("Publishing message to NATS with subject {Subject}", topic); 42 | 43 | var data = Encoding.UTF8.GetBytes(message); 44 | 45 | _connection.Publish(topic, data); 46 | 47 | return Task.CompletedTask; 48 | } 49 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/Publishers/RabbitMq/RabbitMqOptions.cs: -------------------------------------------------------------------------------- 1 | namespace ConfigurationService.Hosting.Publishers.RabbitMq; 2 | 3 | public class RabbitMqOptions 4 | { 5 | /// The host to connect to. 6 | /// Defaults to "localhost". 7 | public string HostName { get; set; } = "localhost"; 8 | 9 | /// 10 | /// Virtual host to access during this connection. 11 | /// Defaults to "/". 12 | /// 13 | public string VirtualHost { get; set; } = "/"; 14 | 15 | /// 16 | /// Username to use when authenticating to the server. 17 | /// Defaults to "guest". 18 | /// 19 | public string UserName { get; set; } = "guest"; 20 | 21 | /// 22 | /// Password to use when authenticating to the server. 23 | /// Defaults to "guest". 24 | /// 25 | public string Password { get; set; } = "guest"; 26 | 27 | /// 28 | /// Name of the fanout exchange. 29 | /// Defaults to "configuration-service" 30 | /// 31 | public string ExchangeName { get; set; } = "configuration-service"; 32 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/Publishers/RabbitMq/RabbitMqPublisher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using System.Threading.Tasks; 4 | using Microsoft.Extensions.Logging; 5 | using RabbitMQ.Client; 6 | 7 | namespace ConfigurationService.Hosting.Publishers.RabbitMq; 8 | 9 | public class RabbitMqPublisher : IPublisher 10 | { 11 | private readonly ILogger _logger; 12 | 13 | private readonly RabbitMqOptions _options; 14 | private string _exchangeName; 15 | private static IModel _channel; 16 | 17 | public RabbitMqPublisher(ILogger logger, RabbitMqOptions options) 18 | { 19 | _logger = logger; 20 | 21 | _options = options ?? throw new ArgumentNullException(nameof(options)); 22 | } 23 | 24 | public void Initialize() 25 | { 26 | var factory = new ConnectionFactory 27 | { 28 | HostName = _options.HostName, 29 | VirtualHost = _options.VirtualHost, 30 | UserName = _options.UserName, 31 | Password = _options.Password 32 | }; 33 | 34 | _exchangeName = _options.ExchangeName; 35 | 36 | var connection = factory.CreateConnection(); 37 | _channel = connection.CreateModel(); 38 | 39 | connection.CallbackException += (sender, args) => { _logger.LogError(args.Exception, "RabbitMQ callback exception"); }; 40 | 41 | connection.ConnectionBlocked += (sender, args) => { _logger.LogError("RabbitMQ connection is blocked. Reason: {Reason}", args.Reason); }; 42 | 43 | connection.ConnectionShutdown += (sender, args) => { _logger.LogError("RabbitMQ connection was shut down. Reason: {ReplyText}", args.ReplyText); }; 44 | 45 | connection.ConnectionUnblocked += (sender, args) => { _logger.LogInformation("RabbitMQ connection was unblocked"); }; 46 | 47 | _channel.ExchangeDeclare(_exchangeName, ExchangeType.Fanout); 48 | 49 | _logger.LogInformation("RabbitMQ publisher initialized"); 50 | } 51 | 52 | public Task Publish(string topic, string message) 53 | { 54 | _logger.LogInformation("Publishing message with routing key {RoutingKey}", topic); 55 | 56 | var body = Encoding.UTF8.GetBytes(message); 57 | _channel.BasicPublish(_exchangeName, topic, null, body); 58 | 59 | return Task.CompletedTask; 60 | } 61 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Hosting/Publishers/Redis/RedisPublisher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Threading.Tasks; 4 | using Microsoft.Extensions.Logging; 5 | using StackExchange.Redis; 6 | 7 | namespace ConfigurationService.Hosting.Publishers.Redis; 8 | 9 | public class RedisPublisher : IPublisher 10 | { 11 | private readonly ILogger _logger; 12 | 13 | private readonly ConfigurationOptions _options; 14 | private static IConnectionMultiplexer _connection; 15 | 16 | public RedisPublisher(ILogger logger, ConfigurationOptions configuration) 17 | { 18 | _logger = logger; 19 | 20 | _options = configuration ?? throw new ArgumentNullException(nameof(configuration)); 21 | } 22 | 23 | public void Initialize() 24 | { 25 | 26 | using (var writer = new StringWriter()) 27 | { 28 | _connection = ConnectionMultiplexer.Connect(_options, writer); 29 | 30 | _logger.LogTrace("Redis publisher connected with log:\r\n{Log}", writer); 31 | } 32 | 33 | _connection.ErrorMessage += (sender, args) => { _logger.LogError("Redis replied with an error message: {Message}", args.Message); }; 34 | 35 | _connection.ConnectionFailed += (sender, args) => { _logger.LogError(args.Exception, "Redis connection failed"); }; 36 | 37 | _connection.ConnectionRestored += (sender, args) => { _logger.LogInformation("Redis connection restored"); }; 38 | 39 | _logger.LogInformation("Redis publisher initialized"); 40 | } 41 | 42 | public async Task Publish(string topic, string message) 43 | { 44 | _logger.LogInformation("Publishing message to channel {Channel}", topic); 45 | 46 | var publisher = _connection.GetSubscriber(); 47 | 48 | var clientCount = await publisher.PublishAsync(topic, message); 49 | 50 | _logger.LogInformation("Message to channel {Channel} was received by {ClientCount} clients", topic, clientCount); 51 | } 52 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Test/ConfigurationService.Test.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | all 13 | runtime; build; native; contentfiles; analyzers; buildtransitive 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | PreserveNewest 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/ConfigurationService.Test/Files/test.ini: -------------------------------------------------------------------------------- 1 | [config] 2 | value = test -------------------------------------------------------------------------------- /src/ConfigurationService.Test/Files/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": 3 | { 4 | "value": "test" 5 | } 6 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Test/Files/test.xml: -------------------------------------------------------------------------------- 1 | 2 | test 3 | -------------------------------------------------------------------------------- /src/ConfigurationService.Test/Files/test.yaml: -------------------------------------------------------------------------------- 1 | config: 2 | value: test -------------------------------------------------------------------------------- /src/ConfigurationService.Test/ParserTests.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading.Tasks; 3 | using ConfigurationService.Client.Parsers; 4 | using Xunit; 5 | 6 | namespace ConfigurationService.Test; 7 | 8 | public class ParserTests 9 | { 10 | [Theory] 11 | [InlineData("Files/test.json")] 12 | public async Task Parses_Valid_Json(string path) 13 | { 14 | await using var stream = new FileStream(path, FileMode.Open); 15 | var parser = new JsonConfigurationFileParser(); 16 | var output = parser.Parse(stream); 17 | 18 | Assert.NotEmpty(output); 19 | } 20 | 21 | [Theory] 22 | [InlineData("Files/test.xml")] 23 | public async Task Parses_Valid_Xml(string path) 24 | { 25 | await using var stream = new FileStream(path, FileMode.Open); 26 | var parser = new XmlConfigurationFileParser(); 27 | var output = parser.Parse(stream); 28 | 29 | Assert.NotEmpty(output); 30 | } 31 | 32 | [Theory] 33 | [InlineData("Files/test.yaml")] 34 | public async Task Parses_Valid_Yaml(string path) 35 | { 36 | await using var stream = new FileStream(path, FileMode.Open); 37 | var parser = new YamlConfigurationFileParser(); 38 | var output = parser.Parse(stream); 39 | 40 | Assert.NotEmpty(output); 41 | } 42 | 43 | [Theory] 44 | [InlineData("Files/test.ini")] 45 | public async Task Parses_Valid_Ini(string path) 46 | { 47 | await using var stream = new FileStream(path, FileMode.Open); 48 | var parser = new IniConfigurationFileParser(); 49 | var output = parser.Parse(stream); 50 | 51 | Assert.NotEmpty(output); 52 | } 53 | } -------------------------------------------------------------------------------- /src/ConfigurationService.Test/PublishTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Threading.Tasks; 5 | using ConfigurationService.Hosting; 6 | using ConfigurationService.Hosting.Providers; 7 | using ConfigurationService.Hosting.Publishers; 8 | using Microsoft.Extensions.Logging; 9 | using NSubstitute; 10 | using Xunit; 11 | 12 | namespace ConfigurationService.Test; 13 | 14 | public class PublishTests 15 | { 16 | private readonly ILogger _logger; 17 | private readonly IProvider _provider; 18 | private readonly IPublisher _publisher; 19 | private readonly IConfigurationService _configurationService; 20 | 21 | public PublishTests() 22 | { 23 | _logger = Substitute.For>(); 24 | _publisher = Substitute.For(); 25 | _provider = SetupStorageProvider(); 26 | _configurationService = new Hosting.ConfigurationService(_logger, _provider, _publisher); 27 | } 28 | 29 | [Fact] 30 | public async Task Publish_Invoked_on_Initialization() 31 | { 32 | await _configurationService.Initialize(); 33 | 34 | await _publisher.Received().Publish(Arg.Any(), Arg.Any()); 35 | } 36 | 37 | [Fact] 38 | public async Task Publish_Invoked_on_Change() 39 | { 40 | await _configurationService.OnChange(ListRandomFiles(1)); 41 | 42 | await _publisher.Received(1).Publish(Arg.Any(), Arg.Any()); 43 | } 44 | 45 | [Fact] 46 | public async Task Publish_Invoked_on_PublishChanges() 47 | { 48 | await _configurationService.PublishChanges(ListRandomFiles(1)); 49 | 50 | await _publisher.Received(1).Publish(Arg.Any(), Arg.Any()); 51 | } 52 | 53 | [Fact] 54 | public async Task Publish_Invoked_on_Change_for_Each_File() 55 | { 56 | var fileCount = 5; 57 | await _configurationService.OnChange(ListRandomFiles(fileCount)); 58 | 59 | await _publisher.Received(fileCount).Publish(Arg.Any(), Arg.Any()); 60 | } 61 | 62 | [Fact] 63 | public async Task Publish_Is_Not_Invoked_when_No_Change() 64 | { 65 | await _configurationService.OnChange(new List()); 66 | 67 | await _publisher.DidNotReceive().Publish(Arg.Any(), Arg.Any()); 68 | } 69 | 70 | [Fact] 71 | public async Task Publish_Does_Not_Fail_when_No_Publisher_Registered() 72 | { 73 | var configurationService = new Hosting.ConfigurationService(_logger, _provider); 74 | await configurationService.PublishChanges(ListRandomFiles(1)); 75 | 76 | await _publisher.DidNotReceive().Publish(Arg.Any(), Arg.Any()); 77 | } 78 | 79 | [Fact] 80 | public async Task Publish_Is_Not_Invoked_when_No_Publisher_Registered() 81 | { 82 | var configurationService = new Hosting.ConfigurationService(_logger, _provider); 83 | await configurationService.OnChange(ListRandomFiles(1)); 84 | 85 | await _publisher.DidNotReceive().Publish(Arg.Any(), Arg.Any()); 86 | } 87 | 88 | private IProvider SetupStorageProvider() 89 | { 90 | var storageProvider = Substitute.For(); 91 | storageProvider.ListPaths().Returns(ListRandomFiles(1)); 92 | storageProvider.GetConfiguration(Arg.Any()).Returns(name => Encoding.UTF8.GetBytes($"{{ \"name\": \"{name}\" }}")); 93 | return storageProvider; 94 | } 95 | 96 | private static IEnumerable ListRandomFiles(int count) 97 | { 98 | var list = new List(); 99 | 100 | for (int i = 0; i < count; i++) 101 | { 102 | list.Add($"{Guid.NewGuid()}.json"); 103 | } 104 | 105 | return list; 106 | } 107 | } -------------------------------------------------------------------------------- /src/ConfigurationService.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30225.117 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConfigurationService.Hosting", "ConfigurationService.Hosting\ConfigurationService.Hosting.csproj", "{791DC3A5-9493-41AE-A605-E45142C5246D}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConfigurationService.Client", "ConfigurationService.Client\ConfigurationService.Client.csproj", "{7A654435-A733-4BCD-8477-77065F75C438}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConfigurationService.Test", "ConfigurationService.Test\ConfigurationService.Test.csproj", "{D76FFB0A-603C-47B0-B5F9-B85092E091D7}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConfigurationService.Samples.Client", "samples\ConfigurationService.Samples.Client\ConfigurationService.Samples.Client.csproj", "{6E272D96-7293-4FB8-BAEC-4481223385D9}" 13 | EndProject 14 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConfigurationService.Samples.Host", "samples\ConfigurationService.Samples.Host\ConfigurationService.Samples.Host.csproj", "{042D1AF2-C723-4D39-AE9D-73E75D24F93F}" 15 | EndProject 16 | Global 17 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 18 | Debug|Any CPU = Debug|Any CPU 19 | Release|Any CPU = Release|Any CPU 20 | EndGlobalSection 21 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 22 | {791DC3A5-9493-41AE-A605-E45142C5246D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {791DC3A5-9493-41AE-A605-E45142C5246D}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {791DC3A5-9493-41AE-A605-E45142C5246D}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {791DC3A5-9493-41AE-A605-E45142C5246D}.Release|Any CPU.Build.0 = Release|Any CPU 26 | {7A654435-A733-4BCD-8477-77065F75C438}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {7A654435-A733-4BCD-8477-77065F75C438}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {7A654435-A733-4BCD-8477-77065F75C438}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {7A654435-A733-4BCD-8477-77065F75C438}.Release|Any CPU.Build.0 = Release|Any CPU 30 | {D76FFB0A-603C-47B0-B5F9-B85092E091D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 31 | {D76FFB0A-603C-47B0-B5F9-B85092E091D7}.Debug|Any CPU.Build.0 = Debug|Any CPU 32 | {D76FFB0A-603C-47B0-B5F9-B85092E091D7}.Release|Any CPU.ActiveCfg = Release|Any CPU 33 | {D76FFB0A-603C-47B0-B5F9-B85092E091D7}.Release|Any CPU.Build.0 = Release|Any CPU 34 | {6E272D96-7293-4FB8-BAEC-4481223385D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 35 | {6E272D96-7293-4FB8-BAEC-4481223385D9}.Debug|Any CPU.Build.0 = Debug|Any CPU 36 | {6E272D96-7293-4FB8-BAEC-4481223385D9}.Release|Any CPU.ActiveCfg = Release|Any CPU 37 | {6E272D96-7293-4FB8-BAEC-4481223385D9}.Release|Any CPU.Build.0 = Release|Any CPU 38 | {042D1AF2-C723-4D39-AE9D-73E75D24F93F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 39 | {042D1AF2-C723-4D39-AE9D-73E75D24F93F}.Debug|Any CPU.Build.0 = Debug|Any CPU 40 | {042D1AF2-C723-4D39-AE9D-73E75D24F93F}.Release|Any CPU.ActiveCfg = Release|Any CPU 41 | {042D1AF2-C723-4D39-AE9D-73E75D24F93F}.Release|Any CPU.Build.0 = Release|Any CPU 42 | EndGlobalSection 43 | GlobalSection(SolutionProperties) = preSolution 44 | HideSolutionNode = FALSE 45 | EndGlobalSection 46 | GlobalSection(ExtensibilityGlobals) = postSolution 47 | SolutionGuid = {FC20561E-ACA1-4E8E-819D-AAA8A39A2721} 48 | EndGlobalSection 49 | EndGlobal 50 | -------------------------------------------------------------------------------- /src/ConfigurationService.sln.DotSettings: -------------------------------------------------------------------------------- 1 |  2 | True 3 | True -------------------------------------------------------------------------------- /src/samples/ConfigurationService.Samples.Client/ConfigWriter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Microsoft.Extensions.Options; 5 | 6 | namespace ConfigurationService.Samples.Client; 7 | 8 | public class ConfigWriter 9 | { 10 | private readonly IOptionsMonitor _testConfig; 11 | 12 | public ConfigWriter(IOptionsMonitor testConfig) 13 | { 14 | _testConfig = testConfig; 15 | } 16 | 17 | public async Task Write(CancellationToken cancellationToken = default) 18 | { 19 | while (!cancellationToken.IsCancellationRequested) 20 | { 21 | var config = _testConfig.CurrentValue; 22 | Console.WriteLine(config.Text); 23 | 24 | await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/samples/ConfigurationService.Samples.Client/ConfigurationService.Samples.Client.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net6.0 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | PreserveNewest 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/samples/ConfigurationService.Samples.Client/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Microsoft.Extensions.Configuration; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Logging; 5 | using ConfigurationService.Client; 6 | using ConfigurationService.Client.Parsers; 7 | 8 | namespace ConfigurationService.Samples.Client; 9 | 10 | static class Program 11 | { 12 | static async Task Main() 13 | { 14 | var loggerFactory = LoggerFactory.Create(builder => 15 | { 16 | builder.AddConsole(); 17 | }); 18 | 19 | IConfiguration localConfiguration = new ConfigurationBuilder() 20 | .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) 21 | .Build(); 22 | 23 | var configuration = new ConfigurationBuilder() 24 | .AddConfiguration(localConfiguration) 25 | .AddRemoteConfiguration(o => 26 | { 27 | o.ServiceUri = "http://localhost:5000/configuration/"; 28 | o.AddConfiguration(c => 29 | { 30 | c.ConfigurationName = "test.json"; 31 | c.ReloadOnChange = true; 32 | c.Optional = false; 33 | }); 34 | o.AddConfiguration(c => 35 | { 36 | c.ConfigurationName = "test.yaml"; 37 | c.ReloadOnChange = true; 38 | c.Optional = false; 39 | c.Parser = new YamlConfigurationFileParser(); 40 | }); 41 | o.AddRedisSubscriber("localhost:6379"); 42 | o.AddLoggerFactory(loggerFactory); 43 | }) 44 | .Build(); 45 | 46 | var services = new ServiceCollection(); 47 | services.AddSingleton(); 48 | services.Configure(configuration.GetSection("Config")); 49 | 50 | var serviceProvider = services.BuildServiceProvider(); 51 | 52 | var configWriter = serviceProvider.GetService(); 53 | 54 | await configWriter.Write(); 55 | } 56 | } -------------------------------------------------------------------------------- /src/samples/ConfigurationService.Samples.Client/TestConfig.cs: -------------------------------------------------------------------------------- 1 | namespace ConfigurationService.Samples.Client; 2 | 3 | public class TestConfig 4 | { 5 | public string Text { get; set; } 6 | } -------------------------------------------------------------------------------- /src/samples/ConfigurationService.Samples.Client/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /src/samples/ConfigurationService.Samples.Host/ConfigurationService.Samples.Host.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/samples/ConfigurationService.Samples.Host/Program.cs: -------------------------------------------------------------------------------- 1 | using ConfigurationService.Hosting; 2 | 3 | var builder = WebApplication.CreateBuilder(args); 4 | 5 | builder.Services.AddConfigurationService() 6 | .AddGitProvider(c => 7 | { 8 | c.RepositoryUrl = "https://github.com/jamespratt/configuration-test.git"; 9 | c.LocalPath = "C:/local-repo"; 10 | }) 11 | .AddRedisPublisher("localhost:6379"); 12 | 13 | var app = builder.Build(); 14 | 15 | app.UseRouting(); 16 | 17 | app.UseEndpoints(endpoints => 18 | { 19 | endpoints.MapConfigurationService(); 20 | }); 21 | 22 | app.Run(); -------------------------------------------------------------------------------- /src/samples/ConfigurationService.Samples.Host/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "ConfigurationService.Samples.Host": { 5 | "commandName": "Project", 6 | "launchBrowser": true, 7 | "launchUrl": "configuration", 8 | "applicationUrl": "http://localhost:5000", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /src/samples/ConfigurationService.Samples.Host/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/samples/ConfigurationService.Samples.Host/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | --------------------------------------------------------------------------------