diff --git a/.dockerignore b/.dockerignore index e52404d..2d15e48 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,20 +1,20 @@ -**/.dockerignore -**/.env -**/.git -**/.gitignore -**/.vs -**/.vscode -**/*.*proj.user -**/azds.yaml -**/charts -**/bin -**/obj -**/Dockerfile -**/Dockerfile.develop -**/docker-compose.yml -**/docker-compose.*.yml -**/*.dbmdl -**/*.jfm -**/secrets.dev.yaml -**/values.dev.yaml +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.vs +**/.vscode +**/*.*proj.user +**/azds.yaml +**/charts +**/bin +**/obj +**/Dockerfile +**/Dockerfile.develop +**/docker-compose.yml +**/docker-compose.*.yml +**/*.dbmdl +**/*.jfm +**/secrets.dev.yaml +**/values.dev.yaml **/.toolstarget \ No newline at end of file diff --git a/README.md b/README.md index 1faa50b..87c5b86 100644 --- a/README.md +++ b/README.md @@ -1,206 +1,206 @@ -# .NET Configuration in Kubernetes config maps with auto reload - -![Log level configuration in config map](media/article-preview.png) - -Kubernetes config maps allows the injection of configuration into an application. The contents of a config map can be injected as environment variables or mounted files. - -For instance, imagine you want to configure the log level in a separated file that will be mounted into your application. - -The following config map limits the verbosity to errors: - -```yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: demo-config -data: - appsettings.json: |- - { - "Logging": { - "LogLevel": { - "Default": "Error", - "System": "Error", - "Microsoft": "Error" - } - } - } -``` - -The file below deploys an application, mounting the contents of the config map into the /app/config folder. - -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: demo-deployment - labels: - app: config-demo-app -spec: - replicas: 1 - selector: - matchLabels: - app: config-demo-app - template: - metadata: - labels: - app: config-demo-app - spec: - containers: - - name: configmapfileprovidersample - image: fbeltrao/configmapfileprovidersample:1.0 - ports: - - containerPort: 80 - volumeMounts: - - name: config-volume - mountPath: /app/config - volumes: - - name: config-volume - configMap: - name: demo-config -``` - -In order to read configurations from the provided path (`config/appsettings.json`) the following code changes are required: - -```c# -public static IWebHostBuilder CreateWebHostBuilder(string[] args) => - WebHost.CreateDefaultBuilder(args) - .ConfigureAppConfiguration(c => - { - c.AddJsonFile("config/appsettings.json", optional: true, reloadOnChange: true); - }) - .UseStartup(); -``` - - -Deploy the application: -```bash -kubectl apply -f configmap.yaml -kubectl apply -f deployment.yaml -``` - -We can peek into the running pod in Kubernetes, looking at the files stored in the container: - -```bash -kubectl exec -it -- bash -root@demo-deployment-844f6c6546-x786b:/app# cd config/ -root@demo-deployment-844f6c6546-x786b:/app/config# ls -la - -rwxrwxrwx 3 root root 4096 Sep 14 09:01 . -drwxr-xr-x 1 root root 4096 Sep 14 08:47 .. -drwxr-xr-x 2 root root 4096 Sep 14 09:01 ..2019_09_14_09_01_16.386067924 -lrwxrwxrwx 1 root root 31 Sep 14 09:01 ..data -> ..2019_09_14_09_01_16.386067924 -lrwxrwxrwx 1 root root 53 Sep 14 08:47 appsettings.json -> ..data/appsettings.json -``` - -As you can see, the config map content is mounted using a [symlink](https://en.wikipedia.org/wiki/Symbolic_link). - -Let's change the log verbosity to `debug`, making the following changes to the config map: - -```yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: demo-config -data: - appsettings.json: |- - { - "Logging": { - "LogLevel": { - "Default": "Debug", - "System": "Error", - "Microsoft": "Error" - } - } - } -``` -and redeploying it - -```bash -kubectl apply -f configmap.yaml -``` - -Eventually the changes will be applied to the mounted file inside the container, as you can see below: - -```bash -root@demo-deployment-844f6c6546-gzc6j:/app/config# ls -la -total 12 -drwxrwxrwx 3 root root 4096 Sep 14 09:05 . -drwxr-xr-x 1 root root 4096 Sep 14 08:47 .. -drwxr-xr-x 2 root root 4096 Sep 14 09:05 ..2019_09_14_09_05_02.797339427 -lrwxrwxrwx 1 root root 31 Sep 14 09:05 ..data -> ..2019_09_14_09_05_02.797339427 -lrwxrwxrwx 1 root root 53 Sep 14 08:47 appsettings.json -> ..data/appsettings.json -``` - -Notice that the appsettings.json last modified date does not change, only the referenced file actually gets updated. - -Unfortunately, the build-in reload on changes in .NET core file provider does not work. The config map does not trigger the configuration reload as one would expect. - -Based on my investigation, it seems that the .NET core change discovery relies on the file last modified date. Since the file we are monitoring did not change (the symlink reference did), no changes are detected. - -## Working on a solution - -This problem is tracked [here](https://github.com/aspnet/Extensions/issues/1175). Until a fix is available we can take advantage of the extensible configuration system in .NET Core and implement a file based configuration provider that detect changes based on file contents. - -The setup looks like this: -```c# -public static IWebHostBuilder CreateWebHostBuilder(string[] args) => - WebHost.CreateDefaultBuilder(args) - .ConfigureAppConfiguration(c => - { - c.AddJsonFile(ConfigMapFileProvider.FromRelativePath("config"), - "appsettings.json", - optional: true, - reloadOnChange: true); - }) - .UseStartup(); -``` - -The provided implementation detect changes based on the hash of the content. Check the sample project files for more details. - -Disclaimer: this is a quick implementation, not tested in different environments/configurations. Use at your own risk. - -### Testing the sample application - -Clone this repository then deploy the application: -```bash -kubectl apply -f configmap.yaml -kubectl apply -f deployment.yaml -``` - -In a separated console window stream the container log: -```bash -kubectl logs -l app=config-demo-app -f -``` - -Open a tunnel to the application with kubectl port-forward -```bash - kubectl port-forward 60000:80 -``` - -Verify that the log is in error level, by opening a browser and navigating to `http://localhost:60000/api/values`. Look at the pod logs. You should see the following lines: -```log -fail: ConfigMapFileProviderSample.Controllers.ValuesController[0] - ERR log -crit: ConfigMapFileProviderSample.Controllers.ValuesController[0] - CRI log -``` - -Change the config map: -Replace `"Default": "Error"` to `"Default": "Debug"` in the configmap.yaml file, then redeploy the config map. -```bash -kubectl apply -f configmap.yaml -``` - -Verify that the log level changes to Debug (it can take a couple of minutes until the file change is detected) by issuing new requests to `http://localhost:60000/api/values`. The logs will change to this: -```log -dbug: ConfigMapFileProviderSample.Controllers.ValuesController[0] - DBG log -info: ConfigMapFileProviderSample.Controllers.ValuesController[0] - INF log -warn: ConfigMapFileProviderSample.Controllers.ValuesController[0] - WRN log -fail: ConfigMapFileProviderSample.Controllers.ValuesController[0] - ERR log -crit: ConfigMapFileProviderSample.Controllers.ValuesController[0] - CRI log +# .NET Configuration in Kubernetes config maps with auto reload + +![Log level configuration in config map](media/article-preview.png) + +Kubernetes config maps allows the injection of configuration into an application. The contents of a config map can be injected as environment variables or mounted files. + +For instance, imagine you want to configure the log level in a separated file that will be mounted into your application. + +The following config map limits the verbosity to errors: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: demo-config +data: + appsettings.json: |- + { + "Logging": { + "LogLevel": { + "Default": "Error", + "System": "Error", + "Microsoft": "Error" + } + } + } +``` + +The file below deploys an application, mounting the contents of the config map into the /app/config folder. + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: demo-deployment + labels: + app: config-demo-app +spec: + replicas: 1 + selector: + matchLabels: + app: config-demo-app + template: + metadata: + labels: + app: config-demo-app + spec: + containers: + - name: configmapfileprovidersample + image: fbeltrao/configmapfileprovidersample:1.0 + ports: + - containerPort: 80 + volumeMounts: + - name: config-volume + mountPath: /app/config + volumes: + - name: config-volume + configMap: + name: demo-config +``` + +In order to read configurations from the provided path (`config/appsettings.json`) the following code changes are required: + +```c# +public static IWebHostBuilder CreateWebHostBuilder(string[] args) => + WebHost.CreateDefaultBuilder(args) + .ConfigureAppConfiguration(c => + { + c.AddJsonFile("config/appsettings.json", optional: true, reloadOnChange: true); + }) + .UseStartup(); +``` + + +Deploy the application: +```bash +kubectl apply -f configmap.yaml +kubectl apply -f deployment.yaml +``` + +We can peek into the running pod in Kubernetes, looking at the files stored in the container: + +```bash +kubectl exec -it -- bash +root@demo-deployment-844f6c6546-x786b:/app# cd config/ +root@demo-deployment-844f6c6546-x786b:/app/config# ls -la + +rwxrwxrwx 3 root root 4096 Sep 14 09:01 . +drwxr-xr-x 1 root root 4096 Sep 14 08:47 .. +drwxr-xr-x 2 root root 4096 Sep 14 09:01 ..2019_09_14_09_01_16.386067924 +lrwxrwxrwx 1 root root 31 Sep 14 09:01 ..data -> ..2019_09_14_09_01_16.386067924 +lrwxrwxrwx 1 root root 53 Sep 14 08:47 appsettings.json -> ..data/appsettings.json +``` + +As you can see, the config map content is mounted using a [symlink](https://en.wikipedia.org/wiki/Symbolic_link). + +Let's change the log verbosity to `debug`, making the following changes to the config map: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: demo-config +data: + appsettings.json: |- + { + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Error", + "Microsoft": "Error" + } + } + } +``` +and redeploying it + +```bash +kubectl apply -f configmap.yaml +``` + +Eventually the changes will be applied to the mounted file inside the container, as you can see below: + +```bash +root@demo-deployment-844f6c6546-gzc6j:/app/config# ls -la +total 12 +drwxrwxrwx 3 root root 4096 Sep 14 09:05 . +drwxr-xr-x 1 root root 4096 Sep 14 08:47 .. +drwxr-xr-x 2 root root 4096 Sep 14 09:05 ..2019_09_14_09_05_02.797339427 +lrwxrwxrwx 1 root root 31 Sep 14 09:05 ..data -> ..2019_09_14_09_05_02.797339427 +lrwxrwxrwx 1 root root 53 Sep 14 08:47 appsettings.json -> ..data/appsettings.json +``` + +Notice that the appsettings.json last modified date does not change, only the referenced file actually gets updated. + +Unfortunately, the build-in reload on changes in .NET core file provider does not work. The config map does not trigger the configuration reload as one would expect. + +Based on my investigation, it seems that the .NET core change discovery relies on the file last modified date. Since the file we are monitoring did not change (the symlink reference did), no changes are detected. + +## Working on a solution + +This problem is tracked [here](https://github.com/aspnet/Extensions/issues/1175). Until a fix is available we can take advantage of the extensible configuration system in .NET Core and implement a file based configuration provider that detect changes based on file contents. + +The setup looks like this: +```c# +public static IWebHostBuilder CreateWebHostBuilder(string[] args) => + WebHost.CreateDefaultBuilder(args) + .ConfigureAppConfiguration(c => + { + c.AddJsonFile(ConfigMapFileProvider.FromRelativePath("config"), + "appsettings.json", + optional: true, + reloadOnChange: true); + }) + .UseStartup(); +``` + +The provided implementation detect changes based on the hash of the content. Check the sample project files for more details. + +Disclaimer: this is a quick implementation, not tested in different environments/configurations. Use at your own risk. + +### Testing the sample application + +Clone this repository then deploy the application: +```bash +kubectl apply -f configmap.yaml +kubectl apply -f deployment.yaml +``` + +In a separated console window stream the container log: +```bash +kubectl logs -l app=config-demo-app -f +``` + +Open a tunnel to the application with kubectl port-forward +```bash + kubectl port-forward 60000:80 +``` + +Verify that the log is in error level, by opening a browser and navigating to `http://localhost:60000/api/values`. Look at the pod logs. You should see the following lines: +```log +fail: ConfigMapFileProviderSample.Controllers.ValuesController[0] + ERR log +crit: ConfigMapFileProviderSample.Controllers.ValuesController[0] + CRI log +``` + +Change the config map: +Replace `"Default": "Error"` to `"Default": "Debug"` in the configmap.yaml file, then redeploy the config map. +```bash +kubectl apply -f configmap.yaml +``` + +Verify that the log level changes to Debug (it can take a couple of minutes until the file change is detected) by issuing new requests to `http://localhost:60000/api/values`. The logs will change to this: +```log +dbug: ConfigMapFileProviderSample.Controllers.ValuesController[0] + DBG log +info: ConfigMapFileProviderSample.Controllers.ValuesController[0] + INF log +warn: ConfigMapFileProviderSample.Controllers.ValuesController[0] + WRN log +fail: ConfigMapFileProviderSample.Controllers.ValuesController[0] + ERR log +crit: ConfigMapFileProviderSample.Controllers.ValuesController[0] + CRI log ``` \ No newline at end of file diff --git a/ConfigMapFileProviderSample.sln b/demo/ConfigMapFileProviderSample.sln similarity index 97% rename from ConfigMapFileProviderSample.sln rename to demo/ConfigMapFileProviderSample.sln index 5b6ca43..ad5867e 100644 --- a/ConfigMapFileProviderSample.sln +++ b/demo/ConfigMapFileProviderSample.sln @@ -1,25 +1,25 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29215.179 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConfigMapFileProviderSample", "src\ConfigMapFileProviderSample\ConfigMapFileProviderSample.csproj", "{1343A1BF-33E0-49D5-AADB-A323867697AE}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {1343A1BF-33E0-49D5-AADB-A323867697AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1343A1BF-33E0-49D5-AADB-A323867697AE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1343A1BF-33E0-49D5-AADB-A323867697AE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1343A1BF-33E0-49D5-AADB-A323867697AE}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {AB82D515-15E8-4AAE-A7B7-0CDC78DB7234} - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29215.179 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConfigMapFileProviderSample", "src\ConfigMapFileProviderSample\ConfigMapFileProviderSample.csproj", "{1343A1BF-33E0-49D5-AADB-A323867697AE}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1343A1BF-33E0-49D5-AADB-A323867697AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1343A1BF-33E0-49D5-AADB-A323867697AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1343A1BF-33E0-49D5-AADB-A323867697AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1343A1BF-33E0-49D5-AADB-A323867697AE}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {AB82D515-15E8-4AAE-A7B7-0CDC78DB7234} + EndGlobalSection +EndGlobal diff --git a/media/article-preview.png b/demo/media/article-preview.png similarity index 100% rename from media/article-preview.png rename to demo/media/article-preview.png diff --git a/demo/src/ConfigMapFileProviderSample/ConfigMapFileProviderSample.csproj b/demo/src/ConfigMapFileProviderSample/ConfigMapFileProviderSample.csproj new file mode 100644 index 0000000..b3f8749 --- /dev/null +++ b/demo/src/ConfigMapFileProviderSample/ConfigMapFileProviderSample.csproj @@ -0,0 +1,14 @@ + + + + net5 + InProcess + Linux + ..\.. + + + + + + + diff --git a/src/ConfigMapFileProviderSample/Controllers/ValuesController.cs b/demo/src/ConfigMapFileProviderSample/Controllers/ValuesController.cs similarity index 96% rename from src/ConfigMapFileProviderSample/Controllers/ValuesController.cs rename to demo/src/ConfigMapFileProviderSample/Controllers/ValuesController.cs index 22f344c..270dd87 100644 --- a/src/ConfigMapFileProviderSample/Controllers/ValuesController.cs +++ b/demo/src/ConfigMapFileProviderSample/Controllers/ValuesController.cs @@ -1,58 +1,58 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace ConfigMapFileProviderSample.Controllers -{ - [Route("api/[controller]")] - [ApiController] - public class ValuesController : ControllerBase - { - private readonly ILogger logger; - - public ValuesController(ILogger logger) - { - this.logger = logger; - } - - // GET api/values - [HttpGet] - public ActionResult> Get() - { - logger.LogDebug("DBG log"); - logger.LogInformation("INF log"); - logger.LogWarning("WRN log"); - logger.LogError("ERR log"); - logger.LogCritical("CRI log"); - return new string[] { "value1", "value2" }; - } - - // GET api/values/5 - [HttpGet("{id}")] - public ActionResult Get(int id) - { - return "value"; - } - - // POST api/values - [HttpPost] - public void Post([FromBody] string value) - { - } - - // PUT api/values/5 - [HttpPut("{id}")] - public void Put(int id, [FromBody] string value) - { - } - - // DELETE api/values/5 - [HttpDelete("{id}")] - public void Delete(int id) - { - } - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace ConfigMapFileProviderSample.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class ValuesController : ControllerBase + { + private readonly ILogger logger; + + public ValuesController(ILogger logger) + { + this.logger = logger; + } + + // GET api/values + [HttpGet] + public ActionResult> Get() + { + logger.LogDebug("DBG log"); + logger.LogInformation("INF log"); + logger.LogWarning("WRN log"); + logger.LogError("ERR log"); + logger.LogCritical("CRI log"); + return new string[] { "value1", "value2" }; + } + + // GET api/values/5 + [HttpGet("{id}")] + public ActionResult Get(int id) + { + return "value"; + } + + // POST api/values + [HttpPost] + public void Post([FromBody] string value) + { + } + + // PUT api/values/5 + [HttpPut("{id}")] + public void Put(int id, [FromBody] string value) + { + } + + // DELETE api/values/5 + [HttpDelete("{id}")] + public void Delete(int id) + { + } + } +} diff --git a/src/ConfigMapFileProviderSample/Dockerfile b/demo/src/ConfigMapFileProviderSample/Dockerfile similarity index 97% rename from src/ConfigMapFileProviderSample/Dockerfile rename to demo/src/ConfigMapFileProviderSample/Dockerfile index b816488..7cf4344 100644 --- a/src/ConfigMapFileProviderSample/Dockerfile +++ b/demo/src/ConfigMapFileProviderSample/Dockerfile @@ -1,19 +1,19 @@ -FROM mcr.microsoft.com/dotnet/core/aspnet:2.2-stretch-slim AS base -WORKDIR /app -EXPOSE 80 - -FROM mcr.microsoft.com/dotnet/core/sdk:2.2-stretch AS build -WORKDIR /src -COPY ["src/ConfigMapFileProviderSample/ConfigMapFileProviderSample.csproj", "src/ConfigMapFileProviderSample/"] -RUN dotnet restore "src/ConfigMapFileProviderSample/ConfigMapFileProviderSample.csproj" -COPY . . -WORKDIR "/src/src/ConfigMapFileProviderSample" -RUN dotnet build "ConfigMapFileProviderSample.csproj" -c Release -o /app - -FROM build AS publish -RUN dotnet publish "ConfigMapFileProviderSample.csproj" -c Release -o /app - -FROM base AS final -WORKDIR /app -COPY --from=publish /app . +FROM mcr.microsoft.com/dotnet/core/aspnet:2.2-stretch-slim AS base +WORKDIR /app +EXPOSE 80 + +FROM mcr.microsoft.com/dotnet/core/sdk:2.2-stretch AS build +WORKDIR /src +COPY ["src/ConfigMapFileProviderSample/ConfigMapFileProviderSample.csproj", "src/ConfigMapFileProviderSample/"] +RUN dotnet restore "src/ConfigMapFileProviderSample/ConfigMapFileProviderSample.csproj" +COPY . . +WORKDIR "/src/src/ConfigMapFileProviderSample" +RUN dotnet build "ConfigMapFileProviderSample.csproj" -c Release -o /app + +FROM build AS publish +RUN dotnet publish "ConfigMapFileProviderSample.csproj" -c Release -o /app + +FROM base AS final +WORKDIR /app +COPY --from=publish /app . ENTRYPOINT ["dotnet", "ConfigMapFileProviderSample.dll"] \ No newline at end of file diff --git a/src/ConfigMapFileProviderSample/Program.cs b/demo/src/ConfigMapFileProviderSample/Program.cs similarity index 96% rename from src/ConfigMapFileProviderSample/Program.cs rename to demo/src/ConfigMapFileProviderSample/Program.cs index bbe09ff..c711b7d 100644 --- a/src/ConfigMapFileProviderSample/Program.cs +++ b/demo/src/ConfigMapFileProviderSample/Program.cs @@ -1,33 +1,33 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Threading.Tasks; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.Logging; - -namespace ConfigMapFileProviderSample -{ - public class Program - { - public static void Main(string[] args) - { - CreateWebHostBuilder(args).Build().Run(); - } - - public static IWebHostBuilder CreateWebHostBuilder(string[] args) => - WebHost.CreateDefaultBuilder(args) - .ConfigureAppConfiguration(c => - { - c.AddJsonFile(ConfigMapFileProvider.FromRelativePath("config"), - "appsettings.json", - optional: true, - reloadOnChange: true); - }) - .UseStartup(); - } -} +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging; + +namespace ConfigMapFileProviderSample +{ + public class Program + { + public static void Main(string[] args) + { + CreateWebHostBuilder(args).Build().Run(); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) => + WebHost.CreateDefaultBuilder(args) + .ConfigureAppConfiguration(c => + { + c.AddJsonFile(ConfigMapFileProvider.FromRelativePath("config"), + "appsettings.json", + optional: true, + reloadOnChange: true); + }) + .UseStartup(); + } +} diff --git a/src/ConfigMapFileProviderSample/Startup.cs b/demo/src/ConfigMapFileProviderSample/Startup.cs similarity index 96% rename from src/ConfigMapFileProviderSample/Startup.cs rename to demo/src/ConfigMapFileProviderSample/Startup.cs index a1782ae..550638b 100644 --- a/src/ConfigMapFileProviderSample/Startup.cs +++ b/demo/src/ConfigMapFileProviderSample/Startup.cs @@ -1,42 +1,42 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace ConfigMapFileProviderSample -{ - - public class Startup - { - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } - - public IConfiguration Configuration { get; } - - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IHostingEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - - app.UseMvc(); - } - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ConfigMapFileProviderSample +{ + + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseMvc(); + } + } +} diff --git a/src/ConfigMapFileProviderSample/appsettings.Development.json b/demo/src/ConfigMapFileProviderSample/appsettings.Development.json similarity index 93% rename from src/ConfigMapFileProviderSample/appsettings.Development.json rename to demo/src/ConfigMapFileProviderSample/appsettings.Development.json index a2880cb..e203e94 100644 --- a/src/ConfigMapFileProviderSample/appsettings.Development.json +++ b/demo/src/ConfigMapFileProviderSample/appsettings.Development.json @@ -1,9 +1,9 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" - } - } -} +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/src/ConfigMapFileProviderSample/appsettings.json b/demo/src/ConfigMapFileProviderSample/appsettings.json similarity index 92% rename from src/ConfigMapFileProviderSample/appsettings.json rename to demo/src/ConfigMapFileProviderSample/appsettings.json index 7376aad..def9159 100644 --- a/src/ConfigMapFileProviderSample/appsettings.json +++ b/demo/src/ConfigMapFileProviderSample/appsettings.json @@ -1,8 +1,8 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Warning" - } - }, - "AllowedHosts": "*" -} +{ + "Logging": { + "LogLevel": { + "Default": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/ConfigMapFileProviderSample/configmap.yaml b/demo/src/ConfigMapFileProviderSample/configmap.yaml similarity index 94% rename from src/ConfigMapFileProviderSample/configmap.yaml rename to demo/src/ConfigMapFileProviderSample/configmap.yaml index b8a5980..ec587ae 100644 --- a/src/ConfigMapFileProviderSample/configmap.yaml +++ b/demo/src/ConfigMapFileProviderSample/configmap.yaml @@ -1,15 +1,15 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: demo-config -data: - appsettings.json: |- - { - "Logging": { - "LogLevel": { - "Default": "Error", - "System": "Error", - "Microsoft": "Error" - } - } +apiVersion: v1 +kind: ConfigMap +metadata: + name: demo-config +data: + appsettings.json: |- + { + "Logging": { + "LogLevel": { + "Default": "Error", + "System": "Error", + "Microsoft": "Error" + } + } } \ No newline at end of file diff --git a/src/ConfigMapFileProviderSample/deployment.yaml b/demo/src/ConfigMapFileProviderSample/deployment.yaml similarity index 95% rename from src/ConfigMapFileProviderSample/deployment.yaml rename to demo/src/ConfigMapFileProviderSample/deployment.yaml index 42a1475..02d9072 100644 --- a/src/ConfigMapFileProviderSample/deployment.yaml +++ b/demo/src/ConfigMapFileProviderSample/deployment.yaml @@ -1,29 +1,29 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: demo-deployment - labels: - app: config-demo-app -spec: - replicas: 1 - selector: - matchLabels: - app: config-demo-app - template: - metadata: - labels: - app: config-demo-app - spec: - containers: - - name: configmapfileprovidersample - imagePullPolicy: Always - image: fbeltrao/configmapfileprovidersample:1.0 - ports: - - containerPort: 80 - volumeMounts: - - name: config-volume - mountPath: /app/config - volumes: - - name: config-volume - configMap: +apiVersion: apps/v1 +kind: Deployment +metadata: + name: demo-deployment + labels: + app: config-demo-app +spec: + replicas: 1 + selector: + matchLabels: + app: config-demo-app + template: + metadata: + labels: + app: config-demo-app + spec: + containers: + - name: configmapfileprovidersample + imagePullPolicy: Always + image: fbeltrao/configmapfileprovidersample:1.0 + ports: + - containerPort: 80 + volumeMounts: + - name: config-volume + mountPath: /app/config + volumes: + - name: config-volume + configMap: name: demo-config \ No newline at end of file diff --git a/src/ConfigMapFileProviderSample/ConfigMapFileProvider.cs b/src/ConfigMapFileProvider/ConfigMapFileProvider.cs similarity index 89% rename from src/ConfigMapFileProviderSample/ConfigMapFileProvider.cs rename to src/ConfigMapFileProvider/ConfigMapFileProvider.cs index 52e5322..745b769 100644 --- a/src/ConfigMapFileProviderSample/ConfigMapFileProvider.cs +++ b/src/ConfigMapFileProvider/ConfigMapFileProvider.cs @@ -1,76 +1,79 @@ -using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.FileProviders.Internal; -using Microsoft.Extensions.FileProviders.Physical; -using Microsoft.Extensions.Primitives; -using System.Collections.Concurrent; -using System.IO; -using System.Reflection; -using System.Text; -using System.Threading.Tasks; - -namespace ConfigMapFileProviderSample -{ - /// - /// Simple implementation using config maps as source - /// Config maps volumes in Linux/Kubernetes are implemented as symlink files. - /// Once reloaded their Last modified date does not change. This implementation uses a check sum to verify - /// - public class ConfigMapFileProvider : IFileProvider - { - ConcurrentDictionary watchers; - - public static IFileProvider FromRelativePath(string subPath) - { - var executableLocation = Assembly.GetEntryAssembly().Location; - var executablePath = Path.GetDirectoryName(executableLocation); - var configPath = Path.Combine(executablePath, subPath); - if (Directory.Exists(configPath)) - { - return new ConfigMapFileProvider(configPath); - } - - return null; - } - - public ConfigMapFileProvider(string rootPath) - { - if (string.IsNullOrWhiteSpace(rootPath)) - { - throw new System.ArgumentException("Invalid root path", nameof(rootPath)); - } - - RootPath = rootPath; - watchers = new ConcurrentDictionary(); - } - - public string RootPath { get; } - - public IDirectoryContents GetDirectoryContents(string subpath) - { - return new PhysicalDirectoryContents(Path.Combine(RootPath, subpath)); - } - - public IFileInfo GetFileInfo(string subpath) - { - var fi = new FileInfo(Path.Combine(RootPath, subpath)); - return new PhysicalFileInfo(fi); - } - - public IChangeToken Watch(string filter) - { - var watcher = watchers.AddOrUpdate(filter, - addValueFactory: (f) => - { - return new ConfigMapFileProviderChangeToken(RootPath, filter); - }, - updateValueFactory: (f, e) => - { - e.Dispose(); - return new ConfigMapFileProviderChangeToken(RootPath, filter); - }); - - watcher.EnsureStarted(); - return watcher; - } - } -} +using Microsoft.Extensions.Primitives; +using System; +using System.Collections.Generic; +using System.Collections.Concurrent; +using System.IO; +using System.Reflection; +using System.Security.Cryptography; +using System.Threading; +using Timer = System.Threading.Timer; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.FileProviders.Physical; +using Microsoft.Extensions.FileProviders.Internal; + +namespace Microsoft.Extensions.Configuration +{ + /// + /// Simple implementation using config maps as source + /// Config maps volumes in Linux/Kubernetes are implemented as symlink files. + /// Once reloaded their Last modified date does not change. This implementation uses a check sum to verify + /// + public class ConfigMapFileProvider : IFileProvider + { + ConcurrentDictionary watchers; + + public static IFileProvider FromRelativePath(string subPath) + { + var executableLocation = Assembly.GetEntryAssembly().Location; + var executablePath = Path.GetDirectoryName(executableLocation); + var configPath = Path.Combine(executablePath, subPath); + if (Directory.Exists(configPath)) + { + return new ConfigMapFileProvider(configPath); + } + + return null; + } + + public ConfigMapFileProvider(string rootPath) + { + if (string.IsNullOrWhiteSpace(rootPath)) + { + throw new System.ArgumentException("Invalid root path", nameof(rootPath)); + } + + RootPath = rootPath; + watchers = new ConcurrentDictionary(); + } + + public string RootPath { get; } + + public IDirectoryContents GetDirectoryContents(string subpath) + { + return new PhysicalDirectoryContents(Path.Combine(RootPath, subpath)); + } + + public IFileInfo GetFileInfo(string subpath) + { + var fi = new FileInfo(Path.Combine(RootPath, subpath)); + return new PhysicalFileInfo(fi); + } + + public IChangeToken Watch(string filter) + { + var watcher = watchers.AddOrUpdate(filter, + addValueFactory: (f) => + { + return new ConfigMapFileProviderChangeToken(RootPath, filter); + }, + updateValueFactory: (f, e) => + { + e.Dispose(); + return new ConfigMapFileProviderChangeToken(RootPath, filter); + }); + + watcher.EnsureStarted(); + return watcher; + } + } +} diff --git a/src/ConfigMapFileProvider/ConfigMapFileProvider.csproj b/src/ConfigMapFileProvider/ConfigMapFileProvider.csproj new file mode 100644 index 0000000..0385793 --- /dev/null +++ b/src/ConfigMapFileProvider/ConfigMapFileProvider.csproj @@ -0,0 +1,33 @@ + + + + netstandard2.0;netcoreapp3.1;net5 + + Dynamic reload config from file for Kubernetes with Config Map. + Usage: + static IHostBuilder CreateHostBuilder(string[] args) + { + return Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder(args) + .ConfigureAppConfiguration((c,b)=>{ + b.SetBasePath(Path.Join(AppContext.BaseDirectory,"config")) + .AddJsonFile(ConfigMapFileProvider.FromRelativePath("config"), "appsettings.json"); + }) + .UseConsoleLifetime() + ; + } + + 2.0.1 + Jaine.ch + Jaine.ch + Config Map File Provider + MIT + Disable the output in console about "Checking for ..." + + + + + + + + + diff --git a/src/ConfigMapFileProviderSample/ConfigMapFileProviderChangeToken.cs b/src/ConfigMapFileProvider/ConfigMapFileProviderChangeToken.cs similarity index 94% rename from src/ConfigMapFileProviderSample/ConfigMapFileProviderChangeToken.cs rename to src/ConfigMapFileProvider/ConfigMapFileProviderChangeToken.cs index 3bd70c6..58f7f20 100644 --- a/src/ConfigMapFileProviderSample/ConfigMapFileProviderChangeToken.cs +++ b/src/ConfigMapFileProvider/ConfigMapFileProviderChangeToken.cs @@ -1,164 +1,164 @@ -using Microsoft.Extensions.Primitives; -using System; -using System.Collections.Generic; -using System.IO; -using System.Security.Cryptography; -using System.Threading; -using Timer = System.Threading.Timer; - -namespace ConfigMapFileProviderSample -{ - public sealed class ConfigMapFileProviderChangeToken : IChangeToken, IDisposable - { - class CallbackRegistration : IDisposable - { - Action callback; - object state; - Action unregister; - - - public CallbackRegistration(Action callback, object state, Action unregister) - { - this.callback = callback; - this.state = state; - this.unregister = unregister; - } - - public void Notify() - { - var localState = this.state; - var localCallback = this.callback; - if (localCallback != null) - { - localCallback.Invoke(localState); - } - } - - - public void Dispose() - { - var localUnregister = Interlocked.Exchange(ref unregister, null); - if (localUnregister != null) - { - localUnregister(this); - this.callback = null; - this.state = null; - } - } - } - - List registeredCallbacks; - private readonly string rootPath; - private string filter; - private readonly int detectChangeIntervalMs; - private Timer timer; - private bool hasChanged; - private string lastChecksum; - object timerLock = new object(); - - public ConfigMapFileProviderChangeToken(string rootPath, string filter, int detectChangeIntervalMs = 30_000) - { - Console.WriteLine($"new {nameof(ConfigMapFileProviderChangeToken)} for {filter}"); - registeredCallbacks = new List(); - this.rootPath = rootPath; - this.filter = filter; - this.detectChangeIntervalMs = detectChangeIntervalMs; - } - - internal void EnsureStarted() - { - lock (timerLock) - { - if (timer == null) - { - var fullPath = Path.Combine(rootPath, filter); - if (File.Exists(fullPath)) - { - this.timer = new Timer(CheckForChanges); - this.timer.Change(0, detectChangeIntervalMs); - } - } - } - } - - private void CheckForChanges(object state) - { - var fullPath = Path.Combine(rootPath, filter); - - Console.WriteLine($"Checking for changes in {fullPath}"); - - var newCheckSum = GetFileChecksum(fullPath); - var newHasChangesValue = false; - if (this.lastChecksum != null && this.lastChecksum != newCheckSum) - { - Console.WriteLine($"File {fullPath} was modified!"); - - // changed - NotifyChanges(); - - newHasChangesValue = true; - } - - this.hasChanged = newHasChangesValue; - - this.lastChecksum = newCheckSum; - - } - - private void NotifyChanges() - { - var localRegisteredCallbacks = registeredCallbacks; - if (localRegisteredCallbacks != null) - { - var count = localRegisteredCallbacks.Count; - for (int i = 0; i < count; i++) - { - localRegisteredCallbacks[i].Notify(); - } - } - } - - string GetFileChecksum(string filename) - { - using (var md5 = MD5.Create()) - { - using (var stream = File.OpenRead(filename)) - { - return BitConverter.ToString(md5.ComputeHash(stream)); - } - } - } - - public bool HasChanged => this.hasChanged; - - public bool ActiveChangeCallbacks => true; - - public IDisposable RegisterChangeCallback(Action callback, object state) - { - var localRegisteredCallbacks = registeredCallbacks; - if (localRegisteredCallbacks == null) - throw new ObjectDisposedException(nameof(registeredCallbacks)); - - var cbRegistration = new CallbackRegistration(callback, state, (cb) => localRegisteredCallbacks.Remove(cb)); - localRegisteredCallbacks.Add(cbRegistration); - - return cbRegistration; - } - - public void Dispose() - { - Interlocked.Exchange(ref registeredCallbacks, null); - - Timer localTimer = null; - lock (timerLock) - { - localTimer = Interlocked.Exchange(ref timer, null); - } - - if (localTimer != null) - { - localTimer.Dispose(); - } - } - } -} +using Microsoft.Extensions.Primitives; +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using Timer = System.Threading.Timer; + +namespace Microsoft.Extensions.Configuration +{ + public sealed class ConfigMapFileProviderChangeToken : IChangeToken, IDisposable + { + class CallbackRegistration : IDisposable + { + Action callback; + object state; + Action unregister; + + + public CallbackRegistration(Action callback, object state, Action unregister) + { + this.callback = callback; + this.state = state; + this.unregister = unregister; + } + + public void Notify() + { + var localState = this.state; + var localCallback = this.callback; + if (localCallback != null) + { + localCallback.Invoke(localState); + } + } + + + public void Dispose() + { + var localUnregister = Interlocked.Exchange(ref unregister, null); + if (localUnregister != null) + { + localUnregister(this); + this.callback = null; + this.state = null; + } + } + } + + List registeredCallbacks; + private readonly string rootPath; + private string filter; + private readonly int detectChangeIntervalMs; + private Timer timer; + private bool hasChanged; + private string lastChecksum; + object timerLock = new object(); + + public ConfigMapFileProviderChangeToken(string rootPath, string filter, int detectChangeIntervalMs = 30_000) + { + Console.WriteLine($"new {nameof(ConfigMapFileProviderChangeToken)} for {filter}"); + registeredCallbacks = new List(); + this.rootPath = rootPath; + this.filter = filter; + this.detectChangeIntervalMs = detectChangeIntervalMs; + } + + internal void EnsureStarted() + { + lock (timerLock) + { + if (timer == null) + { + var fullPath = Path.Combine(rootPath, filter); + if (File.Exists(fullPath)) + { + this.timer = new Timer(CheckForChanges); + this.timer.Change(0, detectChangeIntervalMs); + } + } + } + } + + private void CheckForChanges(object state) + { + var fullPath = Path.Combine(rootPath, filter); + + //Console.WriteLine($"Checking for changes in {fullPath}"); + + var newCheckSum = GetFileChecksum(fullPath); + var newHasChangesValue = false; + if (this.lastChecksum != null && this.lastChecksum != newCheckSum) + { + Console.WriteLine($"File {fullPath} was modified!"); + + // changed + NotifyChanges(); + + newHasChangesValue = true; + } + + this.hasChanged = newHasChangesValue; + + this.lastChecksum = newCheckSum; + + } + + private void NotifyChanges() + { + var localRegisteredCallbacks = registeredCallbacks; + if (localRegisteredCallbacks != null) + { + var count = localRegisteredCallbacks.Count; + for (int i = 0; i < count; i++) + { + localRegisteredCallbacks[i].Notify(); + } + } + } + + string GetFileChecksum(string filename) + { + using (var md5 = MD5.Create()) + { + using (var stream = File.OpenRead(filename)) + { + return BitConverter.ToString(md5.ComputeHash(stream)); + } + } + } + + public bool HasChanged => this.hasChanged; + + public bool ActiveChangeCallbacks => true; + + public IDisposable RegisterChangeCallback(Action callback, object state) + { + var localRegisteredCallbacks = registeredCallbacks; + if (localRegisteredCallbacks == null) + throw new ObjectDisposedException(nameof(registeredCallbacks)); + + var cbRegistration = new CallbackRegistration(callback, state, (cb) => localRegisteredCallbacks.Remove(cb)); + localRegisteredCallbacks.Add(cbRegistration); + + return cbRegistration; + } + + public void Dispose() + { + Interlocked.Exchange(ref registeredCallbacks, null); + + Timer localTimer = null; + lock (timerLock) + { + localTimer = Interlocked.Exchange(ref timer, null); + } + + if (localTimer != null) + { + localTimer.Dispose(); + } + } + } +} \ No newline at end of file diff --git a/src/ConfigMapFileProviderSample/ConfigMapFileProviderSample.csproj b/src/ConfigMapFileProviderSample/ConfigMapFileProviderSample.csproj deleted file mode 100644 index 65a1622..0000000 --- a/src/ConfigMapFileProviderSample/ConfigMapFileProviderSample.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - netcoreapp2.2 - InProcess - Linux - ..\.. - - - - - - - - -