├── .gitignore
├── .gitlab-ci.yml
├── .vscode
├── launch.json
└── tasks.json
├── Configuration.TestHarness
├── ConsulRx.Configuration.TestHarness.csproj
├── Controllers
│ └── ConfigurationController.cs
├── Dockerfile-ConsulSeeder
├── Dockerfile-TestHarness
├── Program.cs
├── docker-compose.yml
├── register-service.sh
├── seed.sh
├── set-kv.sh
└── start.sh
├── Configuration.UnitTests
├── ConfigurationLoadingSpec.cs
├── ConsulRx.Configuration.UnitTests.csproj
├── KVMappingSpec.cs
├── ServiceMappingSpec.cs
└── TestSupport
│ ├── ConfigProviderLoadingExtensions.cs
│ └── InMemoryEmergencyCache.cs
├── Configuration
├── AutoUpdateOptions.cs
├── CommaDelimitedListConverter.cs
├── ConsulConfigurationProvider.cs
├── ConsulConfigurationSource.cs
├── ConsulRx.Configuration.csproj
├── ConsulRxConfigurationException.cs
├── EndpointFormatters.cs
├── FileSystemEmergencyCache.cs
├── IConfigTypeConverter.cs
├── IEmergencyCache.cs
├── KVItemConfigMapping.cs
├── KVTreeConfigMapping.cs
├── NodeSelectionException.cs
├── NodeSelectors.cs
├── NullEmergencyCache.cs
├── ObservableExtensions.cs
├── PassthruConfigTypeConverter.cs
├── Properties
│ └── AssemblyInfo.cs
└── ServiceConfigMapping.cs
├── ConsulRx.UnitTests
├── ConsulRx.UnitTests.csproj
├── ConsulStateSpec.cs
├── ObservableConsulAsyncSpec.cs
├── ObservableConsulStreamingSpec.cs
└── ObservableDependenciesSpec.cs
├── ConsulRx.sln
├── ConsulRx
├── ConsulDependencies.cs
├── ConsulErrorException.cs
├── ConsulRx.csproj
├── ConsulState.cs
├── Defaults.cs
├── IConsulObservation.cs
├── IObservableConsul.cs
├── IReadOnlyKeyValueStore.cs
├── KeyObservation.cs
├── KeyRecursiveObservation.cs
├── KeyValueNode.cs
├── KeyValueStore.cs
├── MonitoringExtensions.cs
├── ObservableConsul.cs
├── ObservableConsulConfiguration.cs
├── Service.cs
└── ServiceObservation.cs
├── Directory.Build.targets
├── LICENSE
├── NuGet.Config
├── README.md
├── Templating.CommandLine
├── ConsulRx.Templating.CommandLine.csproj
├── Program.cs
├── _build.razor
├── build.txt
├── builds.razor
├── builds.txt
└── example.yml.razor
├── Templating.UnitTests
├── ConsulRx.Templating.UnitTests.csproj
└── TemplateProcessorSpec.cs
├── Templating
├── ConsulRx.Templating.csproj
├── ConsulTemplateBase.cs
├── ITemplateRenderer.cs
├── PropertyBag.cs
├── RazorTemplateCompiler.cs
├── RazorTemplateRenderer.cs
├── TemplateCompilationException.cs
├── TemplateMetadata.cs
├── TemplateProcessor.cs
└── TemplateProcessorBuilder.cs
├── TestSupport
├── AsyncAutoResetEvent.cs
├── ConsulRx.TestSupport.csproj
├── FakeConsulClient.cs
├── FakeObservableConsul.cs
└── ObservationSink.cs
└── build.sh
/.gitignore:
--------------------------------------------------------------------------------
1 | [Bb]in/
2 | [Oo]bj/
3 | .idea/
4 | .vs/
5 | .vscode/
6 | development.yml
7 | ConsulTemplate/Properties/launchSettings.json
8 | *.user
9 | *.userprefs
--------------------------------------------------------------------------------
/.gitlab-ci.yml:
--------------------------------------------------------------------------------
1 | image: mcr.microsoft.com/dotnet/sdk:6.0
2 |
3 | stages:
4 | - build
5 | - publish
6 |
7 | variables:
8 | Configuration: Release
9 | NoSourceLink: 'true'
10 |
11 | build:
12 | stage: build
13 | script:
14 | - ./build.sh
15 | - dotnet publish -r linux-x64 /p:PublishSingleFile=true Templating.CommandLine/ConsulRx.Templating.CommandLine.csproj
16 | artifacts:
17 | name: ConsulRx-$CI_PIPELINE_ID
18 | when: on_success
19 | paths:
20 | - ConsulRx/bin/$Configuration/*.nupkg
21 | - Configuration/bin/$Configuration/*.nupkg
22 | - Templating.CommandLine/bin/$Configuration/net6.0/linux-x64/publish/**
23 |
24 | publish ConsulRx to nuget.org:
25 | stage: publish
26 | only:
27 | - master
28 | script:
29 | - dotnet nuget push $(ls ConsulRx/bin/$Configuration/*.nupkg) -k $NUGET_API_KEY -s https://api.nuget.org/v3/index.json
30 | environment:
31 | name: nuget.org
32 | url: https://nuget.org/packages/ConsulRx/
33 | when: manual
34 |
35 | publish Configuration to nuget.org:
36 | stage: publish
37 | only:
38 | - master
39 | script:
40 | - dotnet nuget push $(ls Configuration/bin/$Configuration/*.nupkg) -k $NUGET_API_KEY -s https://api.nuget.org/v3/index.json
41 | environment:
42 | name: nuget.org
43 | url: https://nuget.org/packages/ConsulRx.Configuration/
44 | when: manual
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to find out which attributes exist for C# debugging
3 | // Use hover for the description of the existing attributes
4 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": ".NET Core Launch (console)",
9 | "type": "coreclr",
10 | "request": "launch",
11 | "preLaunchTask": "build",
12 | // If you have changed target frameworks, make sure to update the program path.
13 | "program": "${workspaceRoot}/Templating.CommandLine/bin/Debug/netcoreapp1.1/ConsulRazor.CommandLine.dll",
14 | "args": [],
15 | "cwd": "${workspaceRoot}/Templating.CommandLine",
16 | // For more information about the 'console' field, see https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md#console-terminal-window
17 | "console": "internalConsole",
18 | "stopAtEntry": false,
19 | "internalConsoleOptions": "openOnSessionStart"
20 | },
21 | {
22 | "name": ".NET Core Attach",
23 | "type": "coreclr",
24 | "request": "attach",
25 | "processId": "${command:pickProcess}"
26 | }
27 | ]
28 | }
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | // See https://go.microsoft.com/fwlink/?LinkId=733558
3 | // for the documentation about the tasks.json format
4 | "version": "2.0.0",
5 | "tasks": [
6 | {
7 | "label": "build",
8 | "command": "dotnet build",
9 | "type": "shell",
10 | "group": {
11 | "kind": "build",
12 | "isDefault": true
13 | },
14 | "presentation": {
15 | "reveal": "silent"
16 | },
17 | "problemMatcher": "$msCompile"
18 | },
19 | {
20 | "label": "test",
21 | "command": "dotnet",
22 | "args": [
23 | "test",
24 | "Configuration.UnitTests"
25 | ],
26 | "type": "shell",
27 | "group": {
28 | "kind": "test",
29 | "isDefault": true
30 | },
31 | "presentation": {
32 | "reveal": "always",
33 | "focus": true
34 | },
35 | "problemMatcher": "$msCompile"
36 | }
37 | ]
38 | }
--------------------------------------------------------------------------------
/Configuration.TestHarness/ConsulRx.Configuration.TestHarness.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | net6.0
4 | Exe
5 |
6 |
7 |
8 |
9 |
10 |
11 | Always
12 |
13 |
14 |
--------------------------------------------------------------------------------
/Configuration.TestHarness/Controllers/ConfigurationController.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Linq;
3 | using Microsoft.AspNetCore.Mvc;
4 | using Microsoft.Extensions.Configuration;
5 |
6 | namespace ConsulRx.Configuration.TestHarness.Controllers
7 | {
8 | public class ConfigurationController : Controller
9 | {
10 | private readonly IConfiguration _config;
11 |
12 | public ConfigurationController(IConfiguration config)
13 | {
14 | _config = config;
15 | }
16 |
17 | [HttpGet("hello")]
18 | public IActionResult Hello()
19 | {
20 | return Ok("Hello");
21 | }
22 |
23 | [HttpGet("configuration")]
24 | public IActionResult Index()
25 | {
26 | var allSettings = _config.AsEnumerable()
27 | .Where(p => !string.IsNullOrWhiteSpace(p.Value))
28 | .OrderBy(p => p.Key)
29 | .ToDictionary(p => p.Key, p => p.Value);
30 |
31 | return Json(allSettings);
32 | }
33 | }
34 | }
--------------------------------------------------------------------------------
/Configuration.TestHarness/Dockerfile-ConsulSeeder:
--------------------------------------------------------------------------------
1 | FROM alpine
2 |
3 | RUN apk add --update bash curl && rm -rf /var/cache/apk/*
4 |
5 | COPY register-service.sh /
6 | COPY set-kv.sh /
7 | COPY seed.sh /
8 | CMD ["/seed.sh"]
--------------------------------------------------------------------------------
/Configuration.TestHarness/Dockerfile-TestHarness:
--------------------------------------------------------------------------------
1 | FROM microsoft/aspnetcore:2.0
2 | WORKDIR /app
3 | COPY . .
4 | CMD ["dotnet", "ConsulRx.Configuration.TestHarness.dll"]
--------------------------------------------------------------------------------
/Configuration.TestHarness/Program.cs:
--------------------------------------------------------------------------------
1 | using ConsulRx.Configuration;
2 | using Microsoft.AspNetCore.Builder;
3 |
4 | var builder = WebApplication.CreateBuilder(args);
5 | builder.Configuration.AddConsul(consul =>
6 | {
7 | consul
8 | .AutoUpdate()
9 | .MapHttpService("service1-http", "serviceEndpoints:service1")
10 | .MapHttpService("service2-http", "serviceEndpoints:service2")
11 | .MapKeyPrefix("apps/harness", "consul")
12 | .MapKey("shared/feature1", "features:feature1");
13 | });
14 | var app = builder.Build();
15 | app.UseEndpoints(e =>
16 | {
17 | e.MapControllers();
18 | });
19 |
20 | app.Run();
21 |
22 |
--------------------------------------------------------------------------------
/Configuration.TestHarness/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | consul:
4 | image: consul
5 | ports:
6 | - '8500:8500'
7 | command: ["agent", "-dev", "-client", "0.0.0.0"]
8 | seeder:
9 | build:
10 | context: .
11 | dockerfile: Dockerfile-ConsulSeeder
12 | environment:
13 | CONSUL_HTTP_ADDR: "consul:8500"
14 | depends_on:
15 | - consul
16 | web:
17 | build:
18 | context: bin/Debug/netcoreapp2.0/publish
19 | dockerfile: Dockerfile-TestHarness
20 | ports:
21 | - '80:80'
22 | environment:
23 | CONSUL_HTTP_ADDR: "consul:8500"
24 | depends_on:
25 | - consul
26 | - seeder
27 |
--------------------------------------------------------------------------------
/Configuration.TestHarness/register-service.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | CONSUL_HTTP_ADDR=${CONSUL_HTTP_ADDR:-"localhost:8500"}
4 | SERVICE_NAME=$1
5 | NODE_NAME=$2
6 | SERVICE_ADDRESS=${3:-10.0.0.1}
7 | SERVICE_PORT=${4:-80}
8 |
9 | echo "Registering service $SERVICE_NAME at address $SERVICE_ADDRESS"
10 | curl -X PUT --data "{\"Node\": \"$NODE_NAME\", \"Address\": \"$SERVICE_ADDRESS\", \"Service\": { \"Service\": \"$SERVICE_NAME\", \"Port\": $SERVICE_PORT } }" "http://$CONSUL_HTTP_ADDR/v1/catalog/register"
--------------------------------------------------------------------------------
/Configuration.TestHarness/seed.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | sleep 10
4 |
5 | ./register-service.sh service1-http node1 10.0.0.1
6 | ./register-service.sh service2-http node2 10.0.0.2
7 |
8 | ./set-kv.sh apps/harness/features/feature1 on
9 | ./set-kv.sh apps/harness/features/feature2 off
10 | ./set-kv.sh apps/harness/features/feature3 on
--------------------------------------------------------------------------------
/Configuration.TestHarness/set-kv.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | CONSUL_HTTP_ADDR=${CONSUL_HTTP_ADDR:-"localhost:8500"}
4 | KEY=$1
5 | VALUE=$2
6 |
7 |
8 | echo "Setting key $KEY to value $VALUE"
9 | curl -X PUT --data $VALUE http://$CONSUL_HTTP_ADDR/v1/kv/$KEY
--------------------------------------------------------------------------------
/Configuration.TestHarness/start.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | dotnet build
4 | dotnet publish
5 | docker-compose up --build -d
6 |
7 | trap ctrl_c INT
8 |
9 | function ctrl_c() {
10 | docker-compose down
11 | }
12 |
13 | docker-compose logs -f
14 |
15 |
--------------------------------------------------------------------------------
/Configuration.UnitTests/ConfigurationLoadingSpec.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Net;
5 | using System.Reactive.Subjects;
6 | using System.Text;
7 | using System.Threading;
8 | using System.Threading.Tasks;
9 | using Consul;
10 | using ConsulRx.TestSupport;
11 | using FluentAssertions;
12 | using NSubstitute;
13 | using NSubstitute.ExceptionExtensions;
14 | using Xunit;
15 |
16 | namespace ConsulRx.Configuration.UnitTests
17 | {
18 | public class ConfigurationLoadingSpec
19 | {
20 | private readonly InMemoryEmergencyCache _cache = new InMemoryEmergencyCache();
21 | private readonly ConsulConfigurationSource _configSource;
22 |
23 | public ConfigurationLoadingSpec()
24 | {
25 | _configSource = new ConsulConfigurationSource()
26 | .UseCache(_cache)
27 | .MapKeyPrefix("apps/myapp", "consul");
28 | }
29 |
30 | [Fact]
31 | public async Task SettingsSuccessfullyRetrievedFromConsulAreCachedInLocalCache()
32 | {
33 | var consul = new FakeObservableConsul();
34 |
35 | var configProvider = (ConsulConfigurationProvider) _configSource.Build(consul);
36 |
37 | consul.CurrentState = consul.CurrentState.UpdateKVNodes(new[]
38 | {
39 | new KeyValueNode("apps/myapp/folder1/item1", "value1"),
40 | new KeyValueNode("apps/myapp/folder1/item2", "value2"),
41 | new KeyValueNode("apps/myapp/folder2/item1", "value3")
42 | });
43 |
44 | await configProvider.LoadAsync();
45 |
46 | _cache.CachedSettings.Should().NotBeNull();
47 | _cache.CachedSettings.Should().NotBeEmpty();
48 | _cache.CachedSettings.Should().Contain("consul:folder1:item1", "value1");
49 | _cache.CachedSettings.Should().Contain("consul:folder1:item2", "value2");
50 | _cache.CachedSettings.Should().Contain("consul:folder2:item1", "value3");
51 | }
52 |
53 | [Fact]
54 | public async Task ExceptionLoadingFromConsulFallsBackToEmergencyCache()
55 | {
56 | _cache.CachedSettings = new Dictionary
57 | {
58 | {"consul:folder1:item1", "cachedvalue"}
59 | };
60 |
61 | var consul = Substitute.For();
62 | consul.GetDependenciesAsync(null)
63 | .ThrowsForAnyArgs(
64 | new ConsulErrorException(new QueryResult {StatusCode = HttpStatusCode.InternalServerError}));
65 |
66 | var configProvider = (ConsulConfigurationProvider) _configSource.Build(consul);
67 | await configProvider.LoadAsync();
68 |
69 | configProvider.TryGet("consul:folder1:item1", out var value).Should().BeTrue();
70 | value.Should().Be("cachedvalue");
71 | }
72 |
73 | [Fact]
74 | public async Task ObserveDependenciesIsRetriedAfterLoadingFromEmergencyCacheIfAutoUpdateIsOn()
75 | {
76 | _cache.CachedSettings = new Dictionary
77 | {
78 | {"consul:folder1:item1", "cachedvalue"}
79 | };
80 |
81 | var consul = Substitute.For();
82 | consul.Configuration.Returns(new ObservableConsulConfiguration());
83 | var dependencySubject = new Subject();
84 | consul.GetDependenciesAsync(null)
85 | .ThrowsForAnyArgs(
86 | new ConsulErrorException(new QueryResult {StatusCode = HttpStatusCode.InternalServerError}));
87 | consul.ObserveDependencies(null).ReturnsForAnyArgs(dependencySubject);
88 |
89 | var configProvider = (ConsulConfigurationProvider) _configSource.AutoUpdate().Build(consul);
90 | await configProvider.LoadAsync();
91 |
92 | dependencySubject.OnNext(new ConsulState().UpdateKVNode(new KeyValueNode("apps/myapp/folder1/item1", "value1")));
93 |
94 | //give time for values to update
95 | await Task.Delay(50);
96 |
97 | configProvider.TryGet("consul:folder1:item1", out var value).Should().BeTrue();
98 | value.Should().Be("value1");
99 | }
100 |
101 | [Fact]
102 | public async Task DependenciesAreNotObservedIfAutoUpdateIsDisabled()
103 | {
104 | var consul = Substitute.For();
105 | consul.GetDependenciesAsync(null)
106 | .ReturnsForAnyArgs(
107 | new ConsulState().UpdateKVNode(new KeyValueNode("apps/myapp/folder1/item1", "value1")));
108 | var configProvider = (ConsulConfigurationProvider) _configSource.Build(consul);
109 | await configProvider.LoadAsync();
110 |
111 | consul.DidNotReceiveWithAnyArgs().ObserveDependencies(null);
112 | }
113 | }
114 | }
--------------------------------------------------------------------------------
/Configuration.UnitTests/ConsulRx.Configuration.UnitTests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | net6.0
4 |
5 |
6 |
7 |
8 |
9 |
10 | all
11 | runtime; build; native; contentfiles; analyzers; buildtransitive
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Configuration.UnitTests/KVMappingSpec.cs:
--------------------------------------------------------------------------------
1 | using ConsulRx.TestSupport;
2 | using FluentAssertions;
3 | using Microsoft.Extensions.Configuration;
4 | using Xunit;
5 |
6 | namespace ConsulRx.Configuration.UnitTests
7 | {
8 | public class KVTreeMappingSpec
9 | {
10 | private readonly FakeObservableConsul _consul = new FakeObservableConsul();
11 |
12 | [Fact]
13 | public void TreeOfKeysBeRetrievedViaMappedConfigPrefix()
14 | {
15 | var source = new ConsulConfigurationSource()
16 | .UseCache(new InMemoryEmergencyCache())
17 | .MapKeyPrefix("apps/myapp", "consul");
18 |
19 | var consulState = new ConsulState();
20 | consulState = consulState.UpdateKVNodes(new[]
21 | {
22 | new KeyValueNode("apps/myapp/folder1/item1", "value1"),
23 | new KeyValueNode("apps/myapp/folder1/item2", "value2"),
24 | new KeyValueNode("apps/myapp/folder2/item1", "value3")
25 | });
26 |
27 | var configProvider = _consul.LoadConfigProvider(source, consulState);
28 |
29 | VerifyConfigKey(configProvider, "consul:folder1:item1", "value1");
30 | VerifyConfigKey(configProvider, "consul:folder1:item2", "value2");
31 | VerifyConfigKey(configProvider, "consul:folder2:item1", "value3");
32 | }
33 |
34 | [Fact]
35 | public void IndividualKeyBeRetrievedViaMappedConfigKey()
36 | {
37 | var source = new ConsulConfigurationSource()
38 | .UseCache(new InMemoryEmergencyCache())
39 | .MapKey("apps/myapp/myfeature", "consul:afeature");
40 |
41 | var consulState = new ConsulState();
42 | consulState = consulState.UpdateKVNode(new KeyValueNode("apps/myapp/myfeature", "myvalue"));
43 |
44 | var configProvider = _consul.LoadConfigProvider(source, consulState);
45 |
46 | VerifyConfigKey(configProvider, "consul:afeature", "myvalue");
47 | }
48 |
49 | [Fact]
50 | public void ConfigKeyReturnsNullForMappedConsulKeyThatDoesNotExist()
51 | {
52 | var source = new ConsulConfigurationSource()
53 | .UseCache(new InMemoryEmergencyCache())
54 | .MapKey("apps/myapp/myfeature", "consul:afeature");
55 |
56 | var configProvider = _consul.LoadConfigProvider(source, new ConsulState());
57 |
58 | configProvider.TryGet("consul:afeature", out _).Should().BeFalse();
59 | }
60 |
61 | [Fact]
62 | public void CommaDelimitedValueCanBeConvertedToConfigList()
63 | {
64 | var source = new ConsulConfigurationSource()
65 | .UseCache(new InMemoryEmergencyCache())
66 | .MapKey("apps/myapp/myfeatures", "consul:myfeatures");
67 |
68 | var consulState = new ConsulState();
69 | consulState = consulState.UpdateKVNode(new KeyValueNode("apps/myapp/myfeatures", "myvalue1, myvalue2, myvalue3"));
70 |
71 | var configProvider = _consul.LoadConfigProvider(source, consulState);
72 |
73 | VerifyConfigKey(configProvider, "consul:myfeatures:0", "myvalue1");
74 | VerifyConfigKey(configProvider, "consul:myfeatures:1", "myvalue2");
75 | VerifyConfigKey(configProvider, "consul:myfeatures:2", "myvalue3");
76 | }
77 |
78 | private void VerifyConfigKey(IConfigurationProvider configProvider, string key, string expectedValue)
79 | {
80 | configProvider.TryGet(key, out var actualValue).Should().BeTrue($"expected key {key} to exist in the config store");
81 | actualValue.Should().Be(expectedValue);
82 | }
83 | }
84 | }
--------------------------------------------------------------------------------
/Configuration.UnitTests/ServiceMappingSpec.cs:
--------------------------------------------------------------------------------
1 | using System.Linq;
2 | using ConsulRx.TestSupport;
3 | using FluentAssertions;
4 | using Microsoft.Extensions.Configuration;
5 | using Xunit;
6 |
7 | namespace ConsulRx.Configuration.UnitTests
8 | {
9 | public class ServiceMappingSpec
10 | {
11 | private readonly FakeObservableConsul _consul = new FakeObservableConsul();
12 |
13 | [Fact]
14 | public void ServiceEndpointCanBeRetrievedViaMappedConfigKey()
15 | {
16 | var source = new ConsulConfigurationSource()
17 | .UseCache(new InMemoryEmergencyCache())
18 | .MapService("myservice1", "serviceEndpoints:v1:myservice", EndpointFormatters.AddressAndPort, NodeSelectors.First);
19 |
20 | var consulState = new ConsulState();
21 | consulState = consulState.UpdateService(new Service
22 | {
23 | Name = "myservice1",
24 | Nodes = new[]
25 | {
26 | new ServiceNode
27 | {
28 | Address = "myaddress",
29 | Port = 80
30 | },
31 | }
32 | });
33 |
34 | var configProvider = _consul.LoadConfigProvider(source, consulState);
35 |
36 | string serviceEndpoint;
37 | configProvider.TryGet("serviceEndpoints:v1:myservice", out serviceEndpoint).Should().BeTrue();
38 | serviceEndpoint.Should().Be("myaddress:80");
39 | }
40 |
41 | [Fact]
42 | public void ServiceEndpointBuildingCanBeCustomized()
43 | {
44 | var source = new ConsulConfigurationSource()
45 | .UseCache(new InMemoryEmergencyCache())
46 | .MapService("myservice1", "serviceEndpoints:v1:myservice", EndpointFormatters.Http, NodeSelectors.First);
47 |
48 | var consulState = new ConsulState();
49 | consulState = consulState.UpdateService(new Service
50 | {
51 | Name = "myservice1",
52 | Nodes = new[]
53 | {
54 | new ServiceNode
55 | {
56 | Address = "myaddress",
57 | Port = 80
58 | },
59 | }
60 | });
61 |
62 | var configProvider = _consul.LoadConfigProvider(source, consulState);
63 |
64 | string serviceEndpoint;
65 | configProvider.TryGet("serviceEndpoints:v1:myservice", out serviceEndpoint).Should().BeTrue();
66 | serviceEndpoint.Should().Be("http://myaddress:80");
67 | }
68 |
69 | [Fact]
70 | public void NoRegisteredNodesThrowsWhenUsingFirstNodeSelector()
71 | {
72 | Assert.Throws(() =>
73 | {
74 | var source = new ConsulConfigurationSource()
75 | .UseCache(new InMemoryEmergencyCache())
76 | .MapService("missingservice", "serviceEndpoints:v1:myservice", EndpointFormatters.Http, NodeSelectors.First);
77 |
78 | var consulState = new ConsulState();
79 | consulState = consulState.UpdateService(new Service
80 | {
81 | Name = "missingservice",
82 | Nodes = new ServiceNode[0]
83 | });
84 |
85 | var configProvider = _consul.LoadConfigProvider(source, consulState);
86 | });
87 | }
88 |
89 | [Fact]
90 | public void ServiceEndpointsCanBeACollection()
91 | {
92 | var source = new ConsulConfigurationSource()
93 | .UseCache(new InMemoryEmergencyCache())
94 | .MapService("myservice1", "serviceEndpoints:v1:myservice", EndpointFormatters.AddressAndPort, NodeSelectors.All);
95 |
96 | var consulState = new ConsulState();
97 | consulState = consulState.UpdateService(new Service
98 | {
99 | Name = "myservice1",
100 | Nodes = new[]
101 | {
102 | new ServiceNode
103 | {
104 | Address = "myaddress",
105 | Port = 80
106 | },
107 | new ServiceNode
108 | {
109 | Address = "anotheraddress",
110 | Port = 8080
111 | }
112 | }
113 | });
114 |
115 | var configProvider = _consul.LoadConfigProvider(source, consulState);
116 |
117 | string serviceEndpoint;
118 | configProvider.TryGet("serviceEndpoints:v1:myservice:0", out serviceEndpoint).Should().BeTrue();
119 | serviceEndpoint.Should().Be("myaddress:80");
120 |
121 | configProvider.TryGet("serviceEndpoints:v1:myservice:1", out serviceEndpoint).Should().BeTrue();
122 | serviceEndpoint.Should().Be("anotheraddress:8080");
123 | }
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/Configuration.UnitTests/TestSupport/ConfigProviderLoadingExtensions.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Threading.Tasks;
3 | using ConsulRx.TestSupport;
4 | using Microsoft.Extensions.Configuration;
5 |
6 | namespace ConsulRx.Configuration.UnitTests
7 | {
8 | public static class ConfigProviderLoadingExtensions
9 | {
10 | public static IConfigurationProvider LoadConfigProvider(this FakeObservableConsul consul, ConsulConfigurationSource configSource, ConsulState consulState = null)
11 | {
12 | var configProvider = (ConsulConfigurationProvider)configSource.Build(consul);
13 |
14 | if (consulState != null)
15 | {
16 | consul.CurrentState = consulState;
17 | }
18 | Task.WhenAll(configProvider.LoadAsync(), Task.Run(() =>
19 | {
20 | consul.DependencyObservations.OnNext(consulState);
21 | })).GetAwaiter().GetResult();
22 |
23 | return configProvider;
24 | }
25 | }
26 | }
--------------------------------------------------------------------------------
/Configuration.UnitTests/TestSupport/InMemoryEmergencyCache.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 |
4 | namespace ConsulRx.Configuration.UnitTests
5 | {
6 | public class InMemoryEmergencyCache : IEmergencyCache
7 | {
8 | public IDictionary CachedSettings { get; set; }
9 |
10 | public void Save(IDictionary settings)
11 | {
12 | CachedSettings = settings;
13 | }
14 |
15 | public bool TryLoad(out IDictionary settings)
16 | {
17 | if (CachedSettings != null)
18 | {
19 | settings = CachedSettings;
20 | return true;
21 | }
22 |
23 | settings = null;
24 | return false;
25 | }
26 | }
27 | }
--------------------------------------------------------------------------------
/Configuration/AutoUpdateOptions.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace ConsulRx.Configuration
4 | {
5 | ///
6 | /// Options for controlling auto-update behavior.
7 | ///
8 | public class AutoUpdateOptions
9 | {
10 | ///
11 | /// The amount of time to wait before retrying after receiving an error.
12 | ///
13 | ///
14 | public TimeSpan ErrorRetryInterval { get; set; } = Defaults.ErrorRetryInterval;
15 |
16 | ///
17 | /// The maximum amount of time between updates.
18 | ///
19 | ///
20 | public TimeSpan UpdateMaxInterval { get; set; } = Defaults.UpdateMaxInterval;
21 | }
22 | }
--------------------------------------------------------------------------------
/Configuration/CommaDelimitedListConverter.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 |
5 | namespace ConsulRx.Configuration
6 | {
7 | public class CommaDelimitedListConverter : IConfigTypeConverter
8 | {
9 | public IEnumerable> GetConfigValues(string rawValue)
10 | {
11 | return rawValue.Split(new []{','}, StringSplitOptions.RemoveEmptyEntries)
12 | .Select((value, index) => new KeyValuePair(index.ToString(), value.Trim()));
13 | }
14 | }
15 | }
--------------------------------------------------------------------------------
/Configuration/ConsulConfigurationProvider.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Threading.Tasks;
4 | using Microsoft.Extensions.Configuration;
5 | using Spiffy.Monitoring;
6 |
7 | namespace ConsulRx.Configuration
8 | {
9 | public class ConsulConfigurationProvider : ConfigurationProvider
10 | {
11 | private readonly IObservableConsul _consulClient;
12 | private readonly IEmergencyCache _emergencyCache;
13 | private readonly ConsulDependencies _dependencies;
14 | private readonly ServiceConfigMappingCollection _serviceConfigMappings;
15 | private readonly KVTreeConfigMappingCollection _kvTreeConfigMappings;
16 | private readonly KVItemConfigMappingCollection _kvItemConfigMappings;
17 | private readonly bool _autoUpdate;
18 | private ConsulState _consulState;
19 |
20 | public ConsulConfigurationProvider(IObservableConsul consulClient,
21 | IEmergencyCache emergencyCache,
22 | ConsulDependencies dependencies,
23 | ServiceConfigMappingCollection serviceConfigMappings,
24 | KVTreeConfigMappingCollection kvTreeConfigMappings,
25 | KVItemConfigMappingCollection kvItemConfigMappings,
26 | bool autoUpdate)
27 | {
28 | _consulClient = consulClient;
29 | _emergencyCache = emergencyCache;
30 | _dependencies = dependencies;
31 | _serviceConfigMappings = serviceConfigMappings;
32 | _kvTreeConfigMappings = kvTreeConfigMappings;
33 | _kvItemConfigMappings = kvItemConfigMappings;
34 | _autoUpdate = autoUpdate;
35 | }
36 |
37 | public override void Load()
38 | {
39 | LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult();
40 | }
41 |
42 | public async Task LoadAsync()
43 | {
44 | var eventContext = new EventContext("ConsulRx.Configuration", "Load");
45 | try
46 | {
47 | _consulState = await _consulClient.GetDependenciesAsync(_dependencies).ConfigureAwait(false);
48 | UpdateData();
49 | eventContext["LoadedFrom"] = "Consul";
50 | }
51 | catch (Exception exception)
52 | {
53 | eventContext.IncludeException(exception);
54 | if (_emergencyCache.TryLoad(out var cachedData))
55 | {
56 | eventContext["LoadedFrom"] = "EmergencyCache";
57 | Data = cachedData;
58 | }
59 | else
60 | {
61 | eventContext["LoadedFrom"] = "UnableToLoad";
62 | throw new ConsulRxConfigurationException("Unable to load configuration from consul. It is likely down or the endpoint is misconfigured. Please check the InnerException for details.", exception);
63 | }
64 | }
65 | finally
66 | {
67 | eventContext.Dispose();
68 | }
69 |
70 | if (_autoUpdate)
71 | {
72 | _consulClient.ObserveDependencies(_dependencies).DelayedRetry(_consulClient.Configuration.RetryDelay ?? TimeSpan.Zero).Subscribe(updatedState =>
73 | {
74 | using (var reloadEventContext = new EventContext("ConsulRx.Configuration", "Reload"))
75 | {
76 | try
77 | {
78 | _consulState = updatedState;
79 | UpdateData();
80 | OnReload();
81 | }
82 | catch (Exception ex)
83 | {
84 | reloadEventContext.IncludeException(ex);
85 | }
86 | }
87 | });
88 | }
89 | }
90 |
91 | private void UpdateData()
92 | {
93 | var data = new Dictionary(StringComparer.OrdinalIgnoreCase);
94 | AddServiceData(data);
95 | AddKVTreeData(data);
96 | AddKVItemData(data);
97 |
98 | Data = data;
99 | _emergencyCache.Save(data);
100 | }
101 |
102 | private void AddKVItemData(Dictionary data)
103 | {
104 | foreach (var mapping in _kvItemConfigMappings)
105 | {
106 | var value = _consulState.KVStore.GetValue(mapping.ConsulKey);
107 | if (value != null)
108 | {
109 | foreach (var configPair in mapping.TypeConverter.GetConfigValues(value))
110 | {
111 | var configKey = CombineKeys(mapping.ConfigKey, configPair.Key);
112 | data[configKey] = configPair.Value;
113 | }
114 | }
115 | }
116 | }
117 |
118 | private void AddServiceData(Dictionary data)
119 | {
120 | foreach (var mapping in _serviceConfigMappings)
121 | {
122 | var service = _consulState.GetService(mapping.ServiceName);
123 | if(service != null)
124 | {
125 | mapping.BindToConfiguration(service, data);
126 | }
127 | }
128 | }
129 |
130 | private void AddKVTreeData(Dictionary data)
131 | {
132 | foreach (var mapping in _kvTreeConfigMappings)
133 | {
134 | foreach (var kv in _consulState.KVStore.GetTree(mapping.ConsulKeyPrefix))
135 | {
136 | var fullConfigKey = mapping.FullConfigKey(kv);
137 | data[fullConfigKey] = kv.Value;
138 | }
139 | }
140 | }
141 |
142 | private string CombineKeys(string parentConfigKey, string childConfigKey)
143 | {
144 | if (string.IsNullOrEmpty(childConfigKey))
145 | {
146 | return parentConfigKey;
147 | }
148 |
149 | if (!childConfigKey.StartsWith(":"))
150 | {
151 | childConfigKey = $":{childConfigKey}";
152 | }
153 |
154 | return $"{parentConfigKey}{childConfigKey}";
155 | }
156 | }
157 | }
--------------------------------------------------------------------------------
/Configuration/ConsulConfigurationSource.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Threading;
4 | using Consul;
5 | using Microsoft.Extensions.Configuration;
6 |
7 | namespace ConsulRx.Configuration
8 | {
9 | public class ConsulConfigurationSource : IConfigurationSource
10 | {
11 | private readonly ObservableConsulConfiguration _consulConfig = new ObservableConsulConfiguration();
12 | private readonly ConsulDependencies _consulDependencies = new ConsulDependencies();
13 | private readonly ServiceConfigMappingCollection _serviceConfigMappings = new ServiceConfigMappingCollection();
14 | private readonly KVTreeConfigMappingCollection _kvTreeConfigMappings = new KVTreeConfigMappingCollection();
15 | private readonly KVItemConfigMappingCollection _kvItemConfigMappings = new KVItemConfigMappingCollection();
16 | private IEmergencyCache _cache = NullEmergencyCache.Instance;
17 | private bool _autoUpdate = false;
18 |
19 | public ConsulConfigurationSource()
20 | {
21 | var autoUpdateEnv = Environment.GetEnvironmentVariable("CONSULRX_AUTO_UPDATE");
22 | if(autoUpdateEnv != null &&
23 | (autoUpdateEnv.Equals("1") || autoUpdateEnv.Equals("true", StringComparison.OrdinalIgnoreCase)))
24 | {
25 | AutoUpdate();
26 | }
27 | }
28 |
29 | public ConsulConfigurationSource Endpoint(string consulEndpoint)
30 | {
31 | _consulConfig.Endpoint = consulEndpoint;
32 |
33 | return this;
34 | }
35 |
36 | public ConsulConfigurationSource Endpoint(string consulEndpoint, string aclToken)
37 | {
38 | _consulConfig.Endpoint = consulEndpoint;
39 | _consulConfig.AclToken = aclToken;
40 |
41 | return this;
42 | }
43 |
44 | public ConsulConfigurationSource ConsistencyMode(ConsistencyMode consistencyMode)
45 | {
46 | _consulConfig.ConsistencyMode = consistencyMode;
47 |
48 | return this;
49 | }
50 |
51 | public ConsulConfigurationSource MapService(string consulServiceName, string configKey,
52 | Func endpointFormatter, Func nodeSelector)
53 | {
54 | _consulDependencies.Services.Add(consulServiceName);
55 | _serviceConfigMappings.Add(new SingleNodeServiceConfigMapping(configKey, consulServiceName, endpointFormatter, nodeSelector));
56 |
57 | return this;
58 | }
59 |
60 | public ConsulConfigurationSource MapService(string consulServiceName, string configKey,
61 | Func endpointFormatter, Func> nodeSelector)
62 | {
63 | _consulDependencies.Services.Add(consulServiceName);
64 | _serviceConfigMappings.Add(new MultipleNodeServiceConfigMapping(configKey, consulServiceName, endpointFormatter, nodeSelector));
65 |
66 | return this;
67 | }
68 |
69 | public ConsulConfigurationSource MapHttpService(string consulServiceName, string configKey)
70 | {
71 | return MapService(consulServiceName, configKey, EndpointFormatters.Http, NodeSelectors.First);
72 | }
73 |
74 | public ConsulConfigurationSource MapHttpsService(string consulServiceName, string configKey)
75 | {
76 | return MapService(consulServiceName, configKey, EndpointFormatters.Https, NodeSelectors.First);
77 | }
78 |
79 | public ConsulConfigurationSource MapKeyPrefix(string consulKeyPrefix, string configKey)
80 | {
81 | _consulDependencies.KeyPrefixes.Add(consulKeyPrefix);
82 | _kvTreeConfigMappings.Add(new KVTreeConfigMapping(configKey, consulKeyPrefix));
83 |
84 | return this;
85 | }
86 |
87 | public ConsulConfigurationSource MapKey(string consulKey, string configKey)
88 | {
89 | return MapKey(consulKey, configKey);
90 | }
91 |
92 | public ConsulConfigurationSource MapKey(string consulKey, string configKey) where TTypeConverter : IConfigTypeConverter, new()
93 | {
94 | _consulDependencies.Keys.Add(consulKey);
95 | _kvItemConfigMappings.Add(new KVItemConfigMapping(configKey, consulKey, new TTypeConverter()));
96 |
97 | return this;
98 | }
99 |
100 | ///
101 | /// Configures a periodic, automatic update based on
102 | /// .
103 | ///
104 | ///
105 | /// The settings for automatic updates.
106 | ///
107 | ///
108 | /// The same instance on which the method was called.
109 | ///
110 | public ConsulConfigurationSource AutoUpdate(AutoUpdateOptions options = null)
111 | {
112 | _autoUpdate = true;
113 | options = options ?? new AutoUpdateOptions();
114 |
115 | _consulConfig.RetryDelay = options.ErrorRetryInterval;
116 | _consulConfig.LongPollMaxWait = options.UpdateMaxInterval;
117 |
118 | return this;
119 | }
120 |
121 | IConfigurationProvider IConfigurationSource.Build(IConfigurationBuilder builder)
122 | {
123 | var consulClient = new ObservableConsul(_consulConfig);
124 |
125 | return Build(consulClient);
126 | }
127 |
128 | internal IConfigurationProvider Build(IObservableConsul consulClient)
129 | {
130 | return new ConsulConfigurationProvider(consulClient, _cache, _consulDependencies, _serviceConfigMappings, _kvTreeConfigMappings, _kvItemConfigMappings, _autoUpdate);
131 | }
132 |
133 | public ConsulConfigurationSource UseFilesystemCache()
134 | {
135 | return UseCache(new FileSystemEmergencyCache());
136 | }
137 |
138 | public ConsulConfigurationSource UseCache(IEmergencyCache cache)
139 | {
140 | _cache = cache;
141 |
142 | return this;
143 | }
144 | }
145 |
146 | public static class ConsulConfigurationExtensions
147 | {
148 | public static IConfigurationBuilder AddConsul(this IConfigurationBuilder builder, Action configureSource)
149 | {
150 | var source = new ConsulConfigurationSource();
151 | configureSource(source);
152 | builder.Add(source);
153 |
154 | return builder;
155 | }
156 | }
157 | }
--------------------------------------------------------------------------------
/Configuration/ConsulRx.Configuration.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | netstandard2.0
4 | 1.1.0
5 | Provides a configuration source that pulls values from Consul's Service Catalog and KV
6 | Store.
7 | 2017-2020
8 | Andy Alm
9 | consul;configuration;reactive;extensions
10 | https://github.com/andyalm/consul-rx
11 | http://opensource.org/licenses/MIT
12 | True
13 |
14 | - Removed file system caching by default as it does not play well in a serverless environment and is not really useful in a containerized one either. It can still be opted into.
15 | - Started targeting netstandard2.0.
16 | - Added a IConfigTypeConverter, which can be used to customize how a consul key is mapped to the config store.
17 |
18 | Latest
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/Configuration/ConsulRxConfigurationException.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace ConsulRx.Configuration
4 | {
5 | public class ConsulRxConfigurationException : Exception
6 | {
7 | public ConsulRxConfigurationException(string message, Exception innerException) : base(message, innerException)
8 | {
9 | }
10 | }
11 | }
--------------------------------------------------------------------------------
/Configuration/EndpointFormatters.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Consul;
3 |
4 | namespace ConsulRx.Configuration
5 | {
6 | public static class EndpointFormatters
7 | {
8 | public static Func AddressAndPort { get; } = node => $"{node.Address}:{node.Port}";
9 |
10 | public static Func Uri(string scheme = "http")
11 | {
12 | return node => $"{scheme}://{node.Address}:{node.Port}";
13 | }
14 |
15 | public static Func Http { get; } = Uri();
16 | public static Func Https { get; } = Uri("https");
17 | }
18 | }
--------------------------------------------------------------------------------
/Configuration/FileSystemEmergencyCache.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.IO;
4 | using Newtonsoft.Json;
5 |
6 | namespace ConsulRx.Configuration
7 | {
8 | public class FileSystemEmergencyCache : IEmergencyCache
9 | {
10 | private readonly Lazy _filePath;
11 | private readonly JsonSerializer _jsonSerializer;
12 |
13 | public FileSystemEmergencyCache()
14 | {
15 | _filePath = new Lazy(ResolveFilePath);
16 | _jsonSerializer = new JsonSerializer();
17 | }
18 |
19 | public void Save(IDictionary settings)
20 | {
21 | using (var stream = File.Open(_filePath.Value, FileMode.Create))
22 | {
23 | var writer = new JsonTextWriter(new StreamWriter(stream));
24 | _jsonSerializer.Serialize(writer, settings);
25 | writer.Flush();
26 | }
27 | }
28 |
29 | public bool TryLoad(out IDictionary settings)
30 | {
31 | if (!File.Exists(_filePath.Value))
32 | {
33 | settings = null;
34 | return false;
35 | }
36 | try
37 | {
38 | using (var stream = File.OpenRead(_filePath.Value))
39 | {
40 | var deserializedSettings = _jsonSerializer
41 | .Deserialize>(new JsonTextReader(new StreamReader(stream)));
42 | settings = new Dictionary(deserializedSettings, StringComparer.OrdinalIgnoreCase);
43 | return true;
44 | }
45 | }
46 | catch (Exception ex)
47 | {
48 | throw new ConsulRxConfigurationException(
49 | $"An error occurred while trying to read the ConsulRx emergency cache file. It might be corrupted (filename: {_filePath.Value})"
50 | , ex);
51 | }
52 | }
53 |
54 |
55 | private string ResolveFilePath()
56 | {
57 | var path = Environment.GetEnvironmentVariable("CONSULRX_EMERGENCY_CACHE_PATH");
58 | if (!string.IsNullOrWhiteSpace(path))
59 | return path;
60 |
61 | return Path.Combine(AppContext.BaseDirectory, "consulrx-emergency-cache.json");
62 | }
63 | }
64 | }
--------------------------------------------------------------------------------
/Configuration/IConfigTypeConverter.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 |
3 | namespace ConsulRx.Configuration
4 | {
5 | public interface IConfigTypeConverter
6 | {
7 | ///
8 | /// Returns the config key(s) and value(s) that should be created for the given raw value from consul.
9 | ///
10 | ///
11 | /// The config keys returned by this method should be "relative" to the parent config context. If you wish to return a single key,
12 | /// then you should use an empty string for the key.
13 | ///
14 | ///
15 | ///
16 | IEnumerable> GetConfigValues(string rawValue);
17 | }
18 | }
--------------------------------------------------------------------------------
/Configuration/IEmergencyCache.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 |
3 | namespace ConsulRx.Configuration
4 | {
5 | public interface IEmergencyCache
6 | {
7 | void Save(IDictionary settings);
8 | bool TryLoad(out IDictionary settings);
9 | }
10 | }
--------------------------------------------------------------------------------
/Configuration/KVItemConfigMapping.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.ObjectModel;
2 |
3 | namespace ConsulRx.Configuration
4 | {
5 | public class KVItemConfigMapping
6 | {
7 | public KVItemConfigMapping(string configKey, string consulKey, IConfigTypeConverter typeConverter)
8 | {
9 | ConfigKey = configKey;
10 | ConsulKey = consulKey;
11 | TypeConverter = typeConverter;
12 | }
13 |
14 | public KVItemConfigMapping(string configKey, string consulKey)
15 | {
16 | ConfigKey = configKey;
17 | ConsulKey = consulKey;
18 | TypeConverter = new PassthruConfigTypeConverter();
19 | }
20 |
21 | public string ConfigKey { get; }
22 | public string ConsulKey { get; }
23 |
24 | public IConfigTypeConverter TypeConverter { get; }
25 | }
26 |
27 | public class KVItemConfigMappingCollection : KeyedCollection
28 | {
29 | protected override string GetKeyForItem(KVItemConfigMapping item)
30 | {
31 | return item.ConfigKey;
32 | }
33 | }
34 | }
--------------------------------------------------------------------------------
/Configuration/KVTreeConfigMapping.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.ObjectModel;
2 |
3 | namespace ConsulRx.Configuration
4 | {
5 | public class KVTreeConfigMapping
6 | {
7 | public KVTreeConfigMapping(string configKey, string consulKeyPrefix)
8 | {
9 | ConfigKey = configKey;
10 | ConsulKeyPrefix = consulKeyPrefix;
11 | }
12 |
13 | public string ConfigKey { get; }
14 | public string ConsulKeyPrefix { get; }
15 |
16 | public string FullConfigKey(KeyValueNode node)
17 | {
18 | var relativeConsulKey = node.FullKey.Substring(ConsulKeyPrefix.Length + 1);
19 | return $"{ConfigKey}:{relativeConsulKey.Replace("/", ":")}";
20 | }
21 | }
22 |
23 | public class KVTreeConfigMappingCollection : KeyedCollection
24 | {
25 | protected override string GetKeyForItem(KVTreeConfigMapping item)
26 | {
27 | return item.ConfigKey;
28 | }
29 | }
30 | }
--------------------------------------------------------------------------------
/Configuration/NodeSelectionException.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace ConsulRx.Configuration
4 | {
5 | public class NodeSelectionException : Exception
6 | {
7 | public NodeSelectionException(string message) : base(message)
8 | {
9 |
10 | }
11 | }
12 | }
--------------------------------------------------------------------------------
/Configuration/NodeSelectors.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 |
5 | namespace ConsulRx.Configuration
6 | {
7 | public static class NodeSelectors
8 | {
9 | private static readonly Random _random = new Random();
10 |
11 | public static Func RandomOne { get; } = nodes =>
12 | {
13 | if (!nodes.Any())
14 | return null;
15 |
16 | var randomNodeIndex = _random.Next(nodes.Length - 1);
17 | return nodes[randomNodeIndex];
18 | };
19 |
20 | public static Func First { get; } = nodes =>
21 | {
22 | var firstNode = nodes.FirstOrDefault();
23 | if (firstNode == null)
24 | throw new NodeSelectionException("Could not find any registered nodes");
25 |
26 | return firstNode;
27 | };
28 |
29 | public static Func> Tag(string value)
30 | {
31 | return nodes => nodes.Where(n => n.Tags.Contains(value, StringComparer.OrdinalIgnoreCase));
32 | }
33 |
34 | public static Func> All { get; } = nodes => nodes;
35 | }
36 | }
--------------------------------------------------------------------------------
/Configuration/NullEmergencyCache.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 |
3 | namespace ConsulRx.Configuration
4 | {
5 | public class NullEmergencyCache : IEmergencyCache
6 | {
7 | public static IEmergencyCache Instance { get; } = new NullEmergencyCache();
8 |
9 | private NullEmergencyCache() {}
10 |
11 | public void Save(IDictionary settings)
12 | {
13 |
14 | }
15 |
16 | public bool TryLoad(out IDictionary settings)
17 | {
18 | settings = null;
19 | return false;
20 | }
21 | }
22 | }
--------------------------------------------------------------------------------
/Configuration/ObservableExtensions.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Reactive.Linq;
3 |
4 | namespace ConsulRx.Configuration
5 | {
6 | internal static class ObservableExtensions
7 | {
8 | public static IObservable DelayedRetry(this IObservable src, TimeSpan delay)
9 | {
10 | if (delay == TimeSpan.Zero) return src.Retry();
11 | return src.Catch(src.DelaySubscription(delay).Retry());
12 | }
13 | }
14 | }
--------------------------------------------------------------------------------
/Configuration/PassthruConfigTypeConverter.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 |
3 | namespace ConsulRx.Configuration
4 | {
5 | public class PassthruConfigTypeConverter : IConfigTypeConverter
6 | {
7 | public IEnumerable> GetConfigValues(string rawValue)
8 | {
9 | yield return new KeyValuePair(string.Empty, rawValue);
10 | }
11 | }
12 | }
--------------------------------------------------------------------------------
/Configuration/Properties/AssemblyInfo.cs:
--------------------------------------------------------------------------------
1 | using System.Runtime.CompilerServices;
2 |
3 | [assembly: InternalsVisibleTo("ConsulRx.Configuration.UnitTests")]
--------------------------------------------------------------------------------
/Configuration/ServiceConfigMapping.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Collections.ObjectModel;
4 | using System.Linq;
5 |
6 | namespace ConsulRx.Configuration
7 | {
8 | public abstract class ServiceConfigMapping
9 | {
10 | protected ServiceConfigMapping(string configKey, string serviceName)
11 | {
12 | ConfigKey = configKey;
13 | ServiceName = serviceName;
14 | }
15 |
16 | public string ConfigKey { get; }
17 | public string ServiceName { get; }
18 |
19 | public abstract void BindToConfiguration(Service service, Dictionary config);
20 | }
21 |
22 | public class SingleNodeServiceConfigMapping : ServiceConfigMapping
23 | {
24 | public SingleNodeServiceConfigMapping(string configKey, string serviceName, Func endpointFormatter, Func nodeSelector) : base(configKey, serviceName)
25 | {
26 | EndpointFormatter = endpointFormatter;
27 | NodeSelector = nodeSelector;
28 | }
29 |
30 | private Func EndpointFormatter { get; }
31 | private Func NodeSelector { get; }
32 |
33 | public override void BindToConfiguration(Service service, Dictionary config)
34 | {
35 | try
36 | {
37 | var selectedNode = NodeSelector(service.Nodes);
38 |
39 | config[ConfigKey] = EndpointFormatter(selectedNode);
40 | }
41 | catch (NodeSelectionException ex)
42 | {
43 | throw new ConsulRxConfigurationException($"An error occurred when selecting a node for the consul " +
44 | $"service {service.Name}: {ex.Message}", ex);
45 | }
46 | }
47 | }
48 |
49 | public class MultipleNodeServiceConfigMapping : ServiceConfigMapping
50 | {
51 | public MultipleNodeServiceConfigMapping(string configKey, string serviceName,
52 | Func endpointFormatter,
53 | Func> nodeSelector = null) : base(configKey, serviceName)
54 | {
55 | EndpointFormatter = endpointFormatter ?? throw new ArgumentNullException(nameof(endpointFormatter));
56 | NodeSelector = nodeSelector ?? (nodes => nodes);
57 | }
58 |
59 | private Func> NodeSelector { get; }
60 | private Func EndpointFormatter { get; }
61 |
62 | public override void BindToConfiguration(Service service, Dictionary config)
63 | {
64 | try
65 | {
66 | var selectedNodes = NodeSelector(service.Nodes).ToArray();
67 | for (int i = 0; i < selectedNodes.Length; i++)
68 | {
69 | config[$"{ConfigKey}:{i}"] = EndpointFormatter(selectedNodes[i]);
70 | }
71 | }
72 | catch (NodeSelectionException ex)
73 | {
74 | throw new ConsulRxConfigurationException($"An error occurred when selecting a node for the consul " +
75 | $"service {service.Name}: {ex.Message}", ex);
76 | }
77 | }
78 | }
79 |
80 | public class ServiceConfigMappingCollection : KeyedCollection
81 | {
82 | public ServiceConfigMappingCollection() : base(StringComparer.OrdinalIgnoreCase) {}
83 |
84 | protected override string GetKeyForItem(ServiceConfigMapping item)
85 | {
86 | return item.ConfigKey;
87 | }
88 | }
89 | }
--------------------------------------------------------------------------------
/ConsulRx.UnitTests/ConsulRx.UnitTests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | net6.0
4 | ClassLibrary
5 | false
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | all
15 | runtime; build; native; contentfiles; analyzers; buildtransitive
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/ConsulRx.UnitTests/ConsulStateSpec.cs:
--------------------------------------------------------------------------------
1 | using System.Linq;
2 | using FluentAssertions;
3 | using Xunit;
4 |
5 | namespace ConsulRx.UnitTests
6 | {
7 | public class ConsulStateSpec
8 | {
9 | private ConsulState _consulState = new ConsulState();
10 |
11 | [Fact]
12 | public void TryUpdateServiceWithSameValuesDoesNothing()
13 | {
14 | var consulState = _consulState.UpdateService(new Service
15 | {
16 | Id = "myid",
17 | Name = "myservice",
18 | Nodes = new[]
19 | {
20 | new ServiceNode
21 | {
22 | Name = "mynode1",
23 | Address = "10.0.0.1",
24 | Port = 80,
25 | Tags = new[]
26 | {
27 | "tag1",
28 | "tag2"
29 | }
30 | },
31 | }
32 | });
33 |
34 | consulState.TryUpdateService(new Service
35 | {
36 | Id = "myid",
37 | Name = "myservice",
38 | Nodes = new[]
39 | {
40 | new ServiceNode
41 | {
42 | Name = "mynode1",
43 | Address = "10.0.0.1",
44 | Port = 80,
45 | Tags = new[]
46 | {
47 | "tag1",
48 | "tag2"
49 | }
50 | },
51 | }
52 | }, out var dontCare).Should().BeFalse();
53 | }
54 |
55 | [Fact]
56 | public void TryUpdateServiceWithDifferentTagsOnANodeReturnsTrue()
57 | {
58 | var consulState = _consulState.UpdateService(new Service
59 | {
60 | Id = "myid",
61 | Name = "myservice",
62 | Nodes = new[]
63 | {
64 | new ServiceNode
65 | {
66 | Name = "mynode1",
67 | Address = "10.0.0.1",
68 | Port = 80,
69 | Tags = new[]
70 | {
71 | "tag1",
72 | "tag2"
73 | }
74 | },
75 | }
76 | });
77 |
78 | consulState.TryUpdateService(new Service
79 | {
80 | Id = "myid",
81 | Name = "myservice",
82 | Nodes = new[]
83 | {
84 | new ServiceNode
85 | {
86 | Name = "mynode1",
87 | Address = "10.0.0.1",
88 | Port = 80,
89 | Tags = new[]
90 | {
91 | "tag2",
92 | "tag3"
93 | }
94 | },
95 | }
96 | }, out var updatedState).Should().BeTrue();
97 | updatedState.Services.First().Nodes.First().Tags.Should().HaveCount(2);
98 | updatedState.Services.First().Nodes.First().Tags.Should().ContainInOrder("tag2", "tag3");
99 | }
100 |
101 | [Fact]
102 | public void TryUpdateKeyWithSameValueDoesNotUpdate()
103 | {
104 | var consulState = _consulState.UpdateKVNode(new KeyValueNode("apps/setting1", "val1"));
105 | consulState.TryUpdateKVNode(new KeyValueNode("apps/setting1", "val1"), out var dontCare).Should().BeFalse();
106 | }
107 |
108 | [Fact]
109 | public void TryUpdateKeyWithDifferentValueDoesUpdate()
110 | {
111 | var consulState = _consulState.UpdateKVNode(new KeyValueNode("apps/setting1", "val1"));
112 | consulState.TryUpdateKVNode(new KeyValueNode("apps/setting1", "val2"), out var updatedState).Should().BeTrue();
113 | updatedState.KVStore.GetValue("apps/setting1").Should().Be("val2");
114 | }
115 |
116 | [Fact]
117 | public void TryUpdateKeyPrefixWithSameChildKeysDoesNotUpdate()
118 | {
119 | var consulState = _consulState.UpdateKVNodes(new[]
120 | {
121 | new KeyValueNode("apps/myapp/setting1", "val1"),
122 | new KeyValueNode("apps/myapp/setting2", "val2"),
123 | });
124 | consulState.TryUpdateKVNodes(new[]
125 | {
126 | new KeyValueNode("apps/myapp/setting1", "val1"),
127 | new KeyValueNode("apps/myapp/setting2", "val2"),
128 | }, out var dontCare).Should().BeFalse();
129 | }
130 |
131 | [Fact]
132 | public void TryUpdateKeyPrefixWithAddedChildKeysDoesUpdate()
133 | {
134 | var consulState = _consulState.UpdateKVNodes(new[]
135 | {
136 | new KeyValueNode("apps/myapp/setting1", "val1"),
137 | new KeyValueNode("apps/myapp/setting2", "val2"),
138 | });
139 | consulState.TryUpdateKVNodes(new[]
140 | {
141 | new KeyValueNode("apps/myapp/setting1", "val1"),
142 | new KeyValueNode("apps/myapp/setting2", "val2"),
143 | new KeyValueNode("apps/myapp/setting3", "val3"),
144 | }, out var updatedState).Should().BeTrue();
145 | updatedState.KVStore.GetChildren("apps/myapp").Should().HaveCount(3);
146 | }
147 |
148 | [Fact]
149 | public void TryUpdateKeyPrefixWithUpdatedValueDoesUpdate()
150 | {
151 | var consulState = _consulState.UpdateKVNodes(new[]
152 | {
153 | new KeyValueNode("apps/myapp/setting1", "val1"),
154 | new KeyValueNode("apps/myapp/setting2", "val2"),
155 | });
156 | consulState.TryUpdateKVNodes(new[]
157 | {
158 | new KeyValueNode("apps/myapp/setting1", "val3"),
159 | new KeyValueNode("apps/myapp/setting2", "val2")
160 | }, out var updatedState).Should().BeTrue();
161 | updatedState.KVStore.GetValue("apps/myapp/setting1").Should().Be("val3");
162 | updatedState.KVStore.GetValue("apps/myapp/setting2").Should().Be("val2");
163 | }
164 |
165 | [Fact]
166 | public void MissingKeyPrefixIsUnmarkedAsMissingWhenOneIsAdded()
167 | {
168 | var consulState = _consulState.MarkKeyPrefixAsMissingOrEmpty("apps/myapp");
169 | consulState.TryUpdateKVNodes(new[]
170 | {
171 | new KeyValueNode("apps/myapp/setting1", "val1")
172 | }, out var updatedState).Should().BeTrue();
173 |
174 | updatedState.MissingKeyPrefixes.Should().NotContain("apps/myapp");
175 | updatedState.KVStore.GetValue("apps/myapp/setting1").Should().Be("val1");
176 | }
177 |
178 | [Fact]
179 | public void ExistingChildKeysAreRemovedWhenKeyPrefixIsMarkedAsMissing()
180 | {
181 | var consulState = _consulState.UpdateKVNodes(new[]
182 | {
183 | new KeyValueNode("apps/myapp/setting1", "val1"),
184 | new KeyValueNode("apps/myapp/setting2", "val2"),
185 | });
186 | consulState.TryMarkKeyPrefixAsMissingOrEmpty("apps/myapp", out var updatedState).Should().BeTrue();
187 | updatedState.MissingKeyPrefixes.Should().Contain("apps/myapp");
188 | updatedState.KVStore.GetChildren("apps/myapp").Should().BeEmpty();
189 | }
190 | }
191 | }
--------------------------------------------------------------------------------
/ConsulRx.UnitTests/ObservableConsulAsyncSpec.cs:
--------------------------------------------------------------------------------
1 | using System.Net;
2 | using System.Text;
3 | using System.Threading.Tasks;
4 | using Consul;
5 | using FluentAssertions;
6 | using NSubstitute;
7 | using Xunit;
8 |
9 | namespace ConsulRx.UnitTests
10 | {
11 | public class ObservableConsulAsyncSpec
12 | {
13 | private readonly ObservableConsul _observableConsul;
14 | private readonly IConsulClient _consulClient;
15 |
16 | public ObservableConsulAsyncSpec()
17 | {
18 | _consulClient = Substitute.For();
19 | _observableConsul = new ObservableConsul(_consulClient);
20 |
21 | _consulClient.Catalog.Service("MyService", Arg.Any(), Arg.Any())
22 | .Returns(new QueryResult
23 | {
24 | StatusCode = HttpStatusCode.OK,
25 | Response = new[]
26 | {
27 | new CatalogService
28 | {
29 | ServiceName = "MyService",
30 | Node = "Node1",
31 | ServiceAddress = "10.0.0.2"
32 | },
33 | new CatalogService
34 | {
35 | ServiceName = "MyService",
36 | Node = "Node2",
37 | ServiceAddress = "10.0.0.3"
38 | }
39 | }
40 | });
41 |
42 | _consulClient.Catalog.Service("MyService2", Arg.Any(), Arg.Any())
43 | .Returns(new QueryResult
44 | {
45 | StatusCode = HttpStatusCode.OK,
46 | Response = new[]
47 | {
48 | new CatalogService
49 | {
50 | ServiceName = "MyService2",
51 | Node = "Node1",
52 | ServiceAddress = "10.0.0.10"
53 | }
54 | }
55 | });
56 |
57 | _consulClient.Catalog.Service("MissingService", Arg.Any(), Arg.Any())
58 | .Returns(new QueryResult
59 | {
60 | StatusCode = HttpStatusCode.NotFound
61 | });
62 |
63 | _consulClient.KV.Get("shared/key1", Arg.Any()).Returns(new QueryResult
64 | {
65 | StatusCode = HttpStatusCode.OK,
66 | Response = new KVPair("shared/key1")
67 | {
68 | Value = Encoding.UTF8.GetBytes("value1")
69 | }
70 | });
71 |
72 | _consulClient.KV.Get("shared/missingkey", Arg.Any())
73 | .Returns(new QueryResult
74 | {
75 | StatusCode = HttpStatusCode.NotFound
76 | });
77 |
78 | _consulClient.KV.List("apps/myapp", Arg.Any())
79 | .Returns(new QueryResult
80 | {
81 | StatusCode = HttpStatusCode.OK,
82 | Response = new[]
83 | {
84 | new KVPair("apps/myapp/folder1/item1")
85 | {
86 | Value = Encoding.UTF8.GetBytes("value1")
87 | },
88 | new KVPair("apps/myapp/folder1/item2")
89 | {
90 | Value = Encoding.UTF8.GetBytes("value2")
91 | },
92 | new KVPair("apps/myapp/folder2/item1")
93 | {
94 | Value = Encoding.UTF8.GetBytes("value3")
95 | }
96 | }
97 | });
98 |
99 | _consulClient.KV.List("apps/missingapp", Arg.Any())
100 | .Returns(new QueryResult
101 | {
102 | StatusCode = HttpStatusCode.NotFound
103 | });
104 | }
105 |
106 | [Fact]
107 | public async Task GetServiceReturnsServiceWhenItExists()
108 | {
109 | var service = await _observableConsul.GetServiceAsync("MyService");
110 | service.Should().NotBeNull();
111 |
112 | service.Name.Should().Be("MyService");
113 | service.Nodes.Should().HaveCount(2);
114 | }
115 |
116 | [Fact]
117 | public async Task GetServiceReturnsNullWhenItDoesNotExist()
118 | {
119 | var service = await _observableConsul.GetServiceAsync("MissingService");
120 | service.Should().BeNull();
121 | }
122 |
123 | [Fact]
124 | public async Task GetServiceThrowsWhenConsulReturnsServerError()
125 | {
126 | _consulClient.Catalog.Service("MyService", Arg.Any(), Arg.Any())
127 | .Returns(new QueryResult
128 | {
129 | StatusCode = HttpStatusCode.InternalServerError
130 | });
131 |
132 | await Assert.ThrowsAsync(async () =>
133 | {
134 | await _observableConsul.GetServiceAsync("MyService");
135 | });
136 | }
137 |
138 | [Fact]
139 | public async Task GetKeyReturnsValueWhenItExists()
140 | {
141 | var node = await _observableConsul.GetKeyAsync("shared/key1");
142 | node.Should().NotBeNull();
143 |
144 | node.FullKey.Should().Be("shared/key1");
145 | node.Value.Should().Be("value1");
146 | }
147 |
148 | [Fact]
149 | public async Task GetKeyReturnsNullWhenItDoesNotExist()
150 | {
151 | var node = await _observableConsul.GetKeyAsync("shared/missingkey");
152 | node.Should().BeNull();
153 | }
154 |
155 | [Fact]
156 | public async Task GetKeyThrowsWhenConsulReturnsServerError()
157 | {
158 | _consulClient.KV.Get("shared/key1", Arg.Any())
159 | .Returns(new QueryResult
160 | {
161 | StatusCode = HttpStatusCode.InternalServerError
162 | });
163 |
164 | await Assert.ThrowsAsync(async () =>
165 | {
166 | await _observableConsul.GetKeyAsync("shared/key1");
167 | });
168 | }
169 |
170 | [Fact]
171 | public async Task GetKeyRecursiveReturnsAllChildKeys()
172 | {
173 | var nodes = await _observableConsul.GetKeyRecursiveAsync("apps/myapp");
174 |
175 | nodes.Should().HaveCount(3);
176 | nodes.Should().Contain(n => n.FullKey == "apps/myapp/folder1/item1" && n.Value == "value1");
177 | nodes.Should().Contain(n => n.FullKey == "apps/myapp/folder1/item2" && n.Value == "value2");
178 | nodes.Should().Contain(n => n.FullKey == "apps/myapp/folder2/item1" && n.Value == "value3");
179 | }
180 |
181 | [Fact]
182 | public async Task GetKeyRecursiveReturnsEmptyListWhenKeyDoesNotExist()
183 | {
184 | var nodes = await _observableConsul.GetKeyRecursiveAsync("apps/missingapp");
185 | nodes.Should().NotBeNull();
186 | nodes.Should().BeEmpty();
187 | }
188 |
189 | [Fact]
190 | public async Task GetKeyRecursiveThrowsWhenConsulReturnsServerError()
191 | {
192 | _consulClient.KV.List("apps/myapp", Arg.Any())
193 | .Returns(new QueryResult
194 | {
195 | StatusCode = HttpStatusCode.InternalServerError
196 | });
197 |
198 | await Assert.ThrowsAsync(async () =>
199 | {
200 | await _observableConsul.GetKeyRecursiveAsync("apps/myapp");
201 | });
202 | }
203 |
204 | [Fact]
205 | public async Task GetDependenciesReturnsEntireStateOfAllDependencies()
206 | {
207 | var dependencies = new ConsulDependencies
208 | {
209 | KeyPrefixes = { "apps/myapp" },
210 | Keys = { "shared/key1", "shared/missingkey" },
211 | Services = { "MyService", "MyService2", "MissingService" }
212 | };
213 |
214 | var consulState = await _observableConsul.GetDependenciesAsync(dependencies);
215 |
216 | consulState.Services.Should().HaveCount(2);
217 | consulState.Services.Should().Contain(s => s.Name == "MyService");
218 | consulState.Services.Should().Contain(s => s.Name == "MyService2");
219 |
220 | consulState.KVStore.ContainsKey("shared/key1").Should().BeTrue();
221 | consulState.KVStore.ContainsKey("shared/missingkey").Should().BeFalse();
222 | var childKeys = consulState.KVStore.GetTree("apps/myapp");
223 | childKeys.Should().HaveCount(3);
224 | }
225 | }
226 | }
--------------------------------------------------------------------------------
/ConsulRx.UnitTests/ObservableConsulStreamingSpec.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Net;
5 | using System.Text;
6 | using System.Threading;
7 | using System.Threading.Tasks;
8 | using Consul;
9 | using ConsulRx.TestSupport;
10 | using FluentAssertions;
11 | using FluentAssertions.Common;
12 | using Xunit;
13 |
14 | namespace ConsulRx.UnitTests
15 | {
16 | public class ObservableConsulStreamingSpec
17 | {
18 | private readonly ObservableConsul _observableConsul;
19 | private readonly FakeConsulClient _consulClient;
20 |
21 | public ObservableConsulStreamingSpec()
22 | {
23 | _consulClient = new FakeConsulClient();
24 | _observableConsul = new ObservableConsul(_consulClient);
25 | }
26 |
27 | [Fact]
28 | public async Task OkServiceResponseIsStreamed()
29 | {
30 | List observations = new List();
31 | _observableConsul.ObserveService("MyService").Subscribe(o => {
32 | observations.Add(o);
33 | });
34 | await _consulClient.CompleteServiceAsync("MyService", new QueryResult
35 | {
36 | StatusCode = HttpStatusCode.OK,
37 | Response = new CatalogService[] {
38 | new CatalogService {
39 | ServiceName = "MyService",
40 | ServiceAddress = "10.8.8.3"
41 | }
42 | }
43 | });
44 | await Task.Delay(50);
45 | observations.Should().HaveCount(1);
46 | observations[0].ServiceName.Should().Be("MyService");
47 | observations[0].Result.Response[0].ServiceAddress.Should().Be("10.8.8.3");
48 | }
49 |
50 | [Fact]
51 | public async Task ServerErrorStreamsExceptionIfEncounteredOnFirstRequest()
52 | {
53 | List observations = new List();
54 | Exception exception = null;
55 | _observableConsul.ObserveService("MyService").Subscribe(o => {
56 | observations.Add(o);
57 | }, ex =>
58 | {
59 | exception = ex;
60 | });
61 | await _consulClient.CompleteServiceAsync("MyService", new QueryResult
62 | {
63 | StatusCode = HttpStatusCode.InternalServerError
64 | });
65 | await Task.Delay(10);
66 | observations.Should().BeEmpty();
67 | exception.Should().NotBeNull();
68 | exception.Should().BeAssignableTo();
69 | ((ConsulErrorException)exception).Result.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
70 | }
71 |
72 | [Fact]
73 | public async Task ServerErrorIsRetriedIfEncounteredAfterSucceedingBefore()
74 | {
75 | List observations = new List();
76 | List errorObservations = new List();
77 | _observableConsul.ObserveService("MyService").Subscribe(
78 | o =>
79 | {
80 | observations.Add(o);
81 | },
82 | ex => errorObservations.Add(ex));
83 | await _consulClient.CompleteServiceAsync("MyService", new QueryResult
84 | {
85 | StatusCode = HttpStatusCode.OK,
86 | Response = new []
87 | {
88 | new CatalogService
89 | {
90 | ServiceName = "MyService",
91 | ServiceAddress = "10.8.8.3"
92 | }
93 | }
94 | });
95 | await _consulClient.CompleteServiceAsync("MyService", new QueryResult
96 | {
97 | StatusCode = HttpStatusCode.InternalServerError
98 | });
99 | await _consulClient.CompleteServiceAsync("MyService", new QueryResult
100 | {
101 | StatusCode = HttpStatusCode.OK,
102 | Response = new []
103 | {
104 | new CatalogService
105 | {
106 | ServiceName = "MyService",
107 | ServiceAddress = "10.8.8.3"
108 | }
109 | }
110 | });
111 | errorObservations.Should().BeEmpty();
112 | observations.Should().HaveCount(2);
113 | }
114 |
115 | [Fact]
116 | public async Task FolderKeysAreIgnoredWhenObservedViaKeysRecursive()
117 | {
118 | List observations = new List();
119 | _observableConsul.ObserveKeyRecursive("apps/myapp").Subscribe(o => {
120 | observations.Add(o);
121 | });
122 | await _consulClient.CompleteListAsync("apps/myapp", new QueryResult
123 | {
124 | StatusCode = HttpStatusCode.OK,
125 | Response = new KVPair[] {
126 | new KVPair("apps/myapp/folder") { Value = null},
127 | new KVPair("apps/myapp/folder/key1")
128 | {
129 | Value = Encoding.UTF8.GetBytes("val1")
130 | },
131 | }
132 | });
133 | await Task.Delay(10);
134 | observations.Should().HaveCount(1);
135 | var kvNodes = observations[0].ToKeyValueNodes();
136 | kvNodes.Should().HaveCount(1);
137 | kvNodes.Should().ContainSingle(n => n.FullKey == "apps/myapp/folder/key1").Which.Value.Should().Be("val1");
138 | }
139 | }
140 | }
--------------------------------------------------------------------------------
/ConsulRx.UnitTests/ObservableDependenciesSpec.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Linq;
3 | using System.Net;
4 | using System.Reactive.Concurrency;
5 | using System.Text;
6 | using System.Threading;
7 | using System.Threading.Tasks;
8 | using Consul;
9 | using ConsulRx.TestSupport;
10 | using FluentAssertions;
11 | using Xunit;
12 |
13 | namespace ConsulRx.UnitTests
14 | {
15 | public class ObservableDependenciesSpec
16 | {
17 | private readonly ConsulDependencies _consulDependencies = new ConsulDependencies();
18 | private readonly FakeConsulClient _consulClient = new FakeConsulClient();
19 | private readonly ObservableConsul _observableConsul;
20 | private readonly ObservationSink _consulStateObservations = new ObservationSink();
21 | private Exception _consulStateException = null;
22 |
23 | public ObservableDependenciesSpec()
24 | {
25 | _observableConsul = new ObservableConsul(_consulClient);
26 | }
27 |
28 | [Fact]
29 | public async Task ConsulStateIsNotStreamedUntilAResponseHasBeenRecievedForEveryDependency()
30 | {
31 | _consulDependencies.Services.Add("myservice1");
32 | _consulDependencies.Services.Add("myservice2");
33 | _consulDependencies.Keys.Add("mykey1");
34 | _consulDependencies.KeyPrefixes.Add("mykeyprefix1");
35 |
36 | StartObserving();
37 | _consulStateObservations.Should().BeEmpty();
38 |
39 | await CompleteGetAsync("mykey1", CreateKeyObservation("mykey1"));
40 | _consulStateObservations.Should().BeEmpty();
41 |
42 | await CompleteServiceAsync("myservice1", CreateServiceObservation("myservice1"));
43 | _consulStateObservations.Should().BeEmpty();
44 |
45 | await CompleteServiceAsync("myservice2", CreateServiceObservation("myservice2"));
46 | _consulStateObservations.Should().BeEmpty();
47 |
48 | await CompleteListAsync("mykeyprefix1", CreateKeyRecursiveObservation("mykeyprefix1"));
49 | _consulStateObservations.Should().NotBeEmpty();
50 | }
51 |
52 | [Fact]
53 | public async Task ConsulStateIsOnlyStreamedWhenServiceResponseComesWithNewValue()
54 | {
55 | _consulDependencies.Services.Add("myservice1");
56 | StartObserving();
57 | await CompleteServiceAsync("myservice1", new QueryResult
58 | {
59 | StatusCode = HttpStatusCode.OK,
60 | Response = new []
61 | {
62 | new CatalogService
63 | {
64 | ServiceName = "myservice1",
65 | Address = "10.0.0.1",
66 | Node = "mynode1"
67 | },
68 | }
69 | });
70 | _consulStateObservations.Should().HaveCount(1);
71 |
72 | await CompleteServiceAsync("myservice1", new QueryResult
73 | {
74 | StatusCode = HttpStatusCode.OK,
75 | Response = new[]
76 | {
77 | new CatalogService
78 | {
79 | ServiceName = "myservice1",
80 | Address = "10.0.0.1",
81 | ServiceAddress = "10.0.0.1",
82 | Node = "mynode1"
83 | },
84 | }
85 | });
86 | _consulStateObservations.Should().HaveCount(1);
87 |
88 | //await Task.Delay(6000); //wait for next request for the service to begin
89 | await CompleteServiceAsync("myservice1", new QueryResult
90 | {
91 | StatusCode = HttpStatusCode.OK,
92 | Response = new[]
93 | {
94 | new CatalogService
95 | {
96 | ServiceName = "myservice1",
97 | Address = "10.0.0.2",
98 | ServiceAddress = "10.0.0.2",
99 | Node = "mynode1"
100 | },
101 | }
102 | });
103 | _consulStateObservations.Should().HaveCount(2);
104 | }
105 |
106 | [Fact]
107 | public async Task ConsulStateIsOnlyStreamedWhenKeyResponseComesWithNewValue()
108 | {
109 | _consulDependencies.Keys.Add("mykey1");
110 | StartObserving();
111 | await CompleteGetAsync("mykey1", new QueryResult
112 | {
113 | StatusCode = HttpStatusCode.OK,
114 | Response = new KVPair("mykey1")
115 | {
116 | Value = Encoding.UTF8.GetBytes("myval1")
117 | }
118 | });
119 | _consulStateObservations.Should().HaveCount(1);
120 |
121 | await CompleteGetAsync("mykey1", new QueryResult
122 | {
123 | StatusCode = HttpStatusCode.OK,
124 | Response = new KVPair("mykey1")
125 | {
126 | Value = Encoding.UTF8.GetBytes("myval1")
127 | }
128 | });
129 | _consulStateObservations.Should().HaveCount(1);
130 |
131 | await CompleteGetAsync("mykey1", new QueryResult
132 | {
133 | StatusCode = HttpStatusCode.OK,
134 | Response = new KVPair("mykey1")
135 | {
136 | Value = Encoding.UTF8.GetBytes("myval2")
137 | }
138 | });
139 | _consulStateObservations.Should().HaveCount(2);
140 | }
141 |
142 | [Fact]
143 | public async Task ConsulStateIsOnlyStreamedWhenListResponseComesWithNewValue()
144 | {
145 | _consulDependencies.KeyPrefixes.Add("mykey1");
146 | StartObserving();
147 | await CompleteListAsync("mykey1", new QueryResult
148 | {
149 | StatusCode = HttpStatusCode.OK,
150 | Response = new []
151 | {
152 | new KVPair("mykey1/child1")
153 | {
154 | Value = Encoding.UTF8.GetBytes("myval1")
155 | },
156 | new KVPair("mykey1/child2")
157 | {
158 | Value = Encoding.UTF8.GetBytes("myval2")
159 | }
160 | }
161 | });
162 | _consulStateObservations.Should().HaveCount(1);
163 |
164 | await CompleteListAsync("mykey1", new QueryResult
165 | {
166 | StatusCode = HttpStatusCode.OK,
167 | Response = new[]
168 | {
169 | new KVPair("mykey1/child1")
170 | {
171 | Value = Encoding.UTF8.GetBytes("myval1")
172 | },
173 | new KVPair("mykey1/child2")
174 | {
175 | Value = Encoding.UTF8.GetBytes("myval2")
176 | }
177 | }
178 | });
179 | _consulStateObservations.Should().HaveCount(1);
180 |
181 | await CompleteListAsync("mykey1", new QueryResult
182 | {
183 | StatusCode = HttpStatusCode.OK,
184 | Response = new[]
185 | {
186 | new KVPair("mykey1/child1")
187 | {
188 | Value = Encoding.UTF8.GetBytes("myval1")
189 | },
190 | new KVPair("mykey1/child2")
191 | {
192 | Value = Encoding.UTF8.GetBytes("myval3")
193 | }
194 | }
195 | });
196 | _consulStateObservations.Should().HaveCount(2);
197 | }
198 |
199 | [Fact]
200 | public async Task NotFoundErrorRetrievingServiceWillResultInEmptyServiceRecord()
201 | {
202 | _consulDependencies.Services.Add("missingservice1");
203 | StartObserving();
204 |
205 | await CompleteServiceAsync("missingservice1", new QueryResult
206 | {
207 | StatusCode = HttpStatusCode.NotFound
208 | });
209 | _consulStateObservations.Should().NotBeEmpty();
210 | _consulStateObservations.Last().Services.Should().Contain(s => s.Name == "missingservice1");
211 | }
212 |
213 | [Fact]
214 | public async Task NotFoundErrorRetrievingKeyWillResultInEmptyKeyRecord()
215 | {
216 | _consulDependencies.Keys.Add("missingkey1");
217 | StartObserving();
218 | await CompleteGetAsync("missingkey1", new QueryResult
219 | {
220 | StatusCode = HttpStatusCode.NotFound
221 | });
222 | _consulStateObservations.Should().NotBeEmpty();
223 | _consulStateObservations.Last().KVStore.Should().Contain(n => n.FullKey == "missingkey1");
224 | }
225 |
226 | [Fact]
227 | public async Task NotFoundErrorRetrievingKeyPrefixWillStillStreamConsulState()
228 | {
229 | _consulDependencies.KeyPrefixes.Add("mykeyprefix1");
230 | StartObserving();
231 | await CompleteListAsync("mykeyprefix1", new QueryResult
232 | {
233 | StatusCode = HttpStatusCode.NotFound
234 | });
235 | _consulStateObservations.Should().NotBeEmpty();
236 | _consulStateObservations.Last().MissingKeyPrefixes.Should().Contain(p => p == "mykeyprefix1");
237 | }
238 |
239 | [Fact]
240 | public async Task ExceptionGettingKeyIsBubbledUp()
241 | {
242 | _consulDependencies.Keys.Add("key1");
243 | StartObserving();
244 | await CompleteGetAsync("key1", new QueryResult
245 | {
246 | StatusCode = HttpStatusCode.InternalServerError
247 | });
248 | _consulStateObservations.Should().BeEmpty();
249 | _consulStateException.Should().NotBeNull();
250 | _consulStateException.Should().BeAssignableTo();
251 | }
252 |
253 | private async Task CompleteServiceAsync(string serviceName, QueryResult result)
254 | {
255 | await Task.WhenAll(
256 | _consulStateObservations.WaitForAddAsync(),
257 | _consulClient.CompleteServiceAsync(serviceName, result)
258 | );
259 | }
260 |
261 | private async Task CompleteGetAsync(string key, QueryResult result)
262 | {
263 | await Task.WhenAll(
264 | _consulStateObservations.WaitForAddAsync(),
265 | _consulClient.CompleteGetAsync(key, result)
266 | );
267 | }
268 |
269 | private async Task CompleteListAsync(string keyPrefix, QueryResult result)
270 | {
271 | await Task.WhenAll(
272 | _consulStateObservations.WaitForAddAsync(),
273 | _consulClient.CompleteListAsync(keyPrefix, result)
274 | );
275 | }
276 |
277 | private void StartObserving()
278 | {
279 | _observableConsul.ObserveDependencies(_consulDependencies).Subscribe(s => _consulStateObservations.Add(s),
280 | ex =>
281 | {
282 | _consulStateException = ex;
283 | });
284 | }
285 |
286 | private QueryResult CreateKeyObservation(string key)
287 | {
288 | return new QueryResult
289 | {
290 | StatusCode = HttpStatusCode.OK,
291 | Response = new KVPair(key)
292 | {
293 | Value = new byte[0]
294 | }
295 | };
296 | }
297 |
298 | private QueryResult CreateKeyRecursiveObservation(string keyPrefix)
299 | {
300 | return new QueryResult
301 | {
302 | StatusCode = HttpStatusCode.OK,
303 | Response = new []
304 | {
305 | new KVPair($"{keyPrefix}/child1")
306 | {
307 | Value = new byte[0]
308 | },
309 | new KVPair($"{keyPrefix}/child2")
310 | {
311 | Value = new byte[0]
312 | }
313 | }
314 | };
315 | }
316 |
317 | private QueryResult CreateServiceObservation(string serviceName)
318 | {
319 | return new QueryResult
320 | {
321 | StatusCode = HttpStatusCode.OK,
322 | Response = new[]
323 | {
324 | new CatalogService
325 | {
326 | ServiceName = serviceName,
327 | Address = serviceName,
328 | Node = serviceName,
329 | ServiceAddress = serviceName,
330 | ServicePort = 80,
331 | ServiceTags = new string[0]
332 | }
333 | }
334 | };
335 | }
336 | }
337 | }
--------------------------------------------------------------------------------
/ConsulRx.sln:
--------------------------------------------------------------------------------
1 | Microsoft Visual Studio Solution File, Format Version 12.00
2 | # Visual Studio 15
3 | VisualStudioVersion = 15.0.26430.4
4 | MinimumVisualStudioVersion = 15.0.26124.0
5 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsulRx", "ConsulRx\ConsulRx.csproj", "{B850A7CD-BBC7-4E44-8ABC-20781AB7F6A0}"
6 | EndProject
7 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsulRx.Configuration", "Configuration\ConsulRx.Configuration.csproj", "{30D3FD01-96CD-41BA-8E49-D1B366C82D55}"
8 | EndProject
9 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsulRx.Configuration.UnitTests", "Configuration.UnitTests\ConsulRx.Configuration.UnitTests.csproj", "{61A546B9-256E-4114-A741-F15A658ACC2D}"
10 | EndProject
11 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsulRx.TestSupport", "TestSupport\ConsulRx.TestSupport.csproj", "{B3C4CC80-3D7C-487A-8067-3D0692E2C4E6}"
12 | EndProject
13 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsulRx.Templating", "Templating\ConsulRx.Templating.csproj", "{8550A7D9-566D-4A53-BF3C-DEC5B83F2B7F}"
14 | EndProject
15 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsulRx.Templating.UnitTests", "Templating.UnitTests\ConsulRx.Templating.UnitTests.csproj", "{042D9788-EA42-4E15-935E-AF5874018487}"
16 | EndProject
17 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsulRx.Templating.CommandLine", "Templating.CommandLine\ConsulRx.Templating.CommandLine.csproj", "{A7E9B010-6E37-4199-A0C9-841E3F3494D0}"
18 | EndProject
19 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{D73E706D-51F9-4771-913E-B5EF6F24AD27}"
20 | ProjectSection(SolutionItems) = preProject
21 | README.md = README.md
22 | Directory.Build.targets = Directory.Build.targets
23 | .gitlab-ci.yml = .gitlab-ci.yml
24 | build.sh = build.sh
25 | EndProjectSection
26 | EndProject
27 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsulRx.UnitTests", "ConsulRx.UnitTests\ConsulRx.UnitTests.csproj", "{A41D94C5-85D2-46ED-8BA9-FE0412D63130}"
28 | EndProject
29 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsulRx.Configuration.TestHarness", "Configuration.TestHarness\ConsulRx.Configuration.TestHarness.csproj", "{80879533-D512-4774-9700-E6412D838684}"
30 | EndProject
31 | Global
32 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
33 | Debug|Any CPU = Debug|Any CPU
34 | Debug|x64 = Debug|x64
35 | Debug|x86 = Debug|x86
36 | Release|Any CPU = Release|Any CPU
37 | Release|x64 = Release|x64
38 | Release|x86 = Release|x86
39 | EndGlobalSection
40 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
41 | {B850A7CD-BBC7-4E44-8ABC-20781AB7F6A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
42 | {B850A7CD-BBC7-4E44-8ABC-20781AB7F6A0}.Debug|Any CPU.Build.0 = Debug|Any CPU
43 | {B850A7CD-BBC7-4E44-8ABC-20781AB7F6A0}.Debug|x64.ActiveCfg = Debug|Any CPU
44 | {B850A7CD-BBC7-4E44-8ABC-20781AB7F6A0}.Debug|x64.Build.0 = Debug|Any CPU
45 | {B850A7CD-BBC7-4E44-8ABC-20781AB7F6A0}.Debug|x86.ActiveCfg = Debug|Any CPU
46 | {B850A7CD-BBC7-4E44-8ABC-20781AB7F6A0}.Debug|x86.Build.0 = Debug|Any CPU
47 | {B850A7CD-BBC7-4E44-8ABC-20781AB7F6A0}.Release|Any CPU.ActiveCfg = Release|Any CPU
48 | {B850A7CD-BBC7-4E44-8ABC-20781AB7F6A0}.Release|Any CPU.Build.0 = Release|Any CPU
49 | {B850A7CD-BBC7-4E44-8ABC-20781AB7F6A0}.Release|x64.ActiveCfg = Release|Any CPU
50 | {B850A7CD-BBC7-4E44-8ABC-20781AB7F6A0}.Release|x64.Build.0 = Release|Any CPU
51 | {B850A7CD-BBC7-4E44-8ABC-20781AB7F6A0}.Release|x86.ActiveCfg = Release|Any CPU
52 | {B850A7CD-BBC7-4E44-8ABC-20781AB7F6A0}.Release|x86.Build.0 = Release|Any CPU
53 | {30D3FD01-96CD-41BA-8E49-D1B366C82D55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
54 | {30D3FD01-96CD-41BA-8E49-D1B366C82D55}.Debug|Any CPU.Build.0 = Debug|Any CPU
55 | {30D3FD01-96CD-41BA-8E49-D1B366C82D55}.Debug|x64.ActiveCfg = Debug|Any CPU
56 | {30D3FD01-96CD-41BA-8E49-D1B366C82D55}.Debug|x64.Build.0 = Debug|Any CPU
57 | {30D3FD01-96CD-41BA-8E49-D1B366C82D55}.Debug|x86.ActiveCfg = Debug|Any CPU
58 | {30D3FD01-96CD-41BA-8E49-D1B366C82D55}.Debug|x86.Build.0 = Debug|Any CPU
59 | {30D3FD01-96CD-41BA-8E49-D1B366C82D55}.Release|Any CPU.ActiveCfg = Release|Any CPU
60 | {30D3FD01-96CD-41BA-8E49-D1B366C82D55}.Release|Any CPU.Build.0 = Release|Any CPU
61 | {30D3FD01-96CD-41BA-8E49-D1B366C82D55}.Release|x64.ActiveCfg = Release|Any CPU
62 | {30D3FD01-96CD-41BA-8E49-D1B366C82D55}.Release|x64.Build.0 = Release|Any CPU
63 | {30D3FD01-96CD-41BA-8E49-D1B366C82D55}.Release|x86.ActiveCfg = Release|Any CPU
64 | {30D3FD01-96CD-41BA-8E49-D1B366C82D55}.Release|x86.Build.0 = Release|Any CPU
65 | {61A546B9-256E-4114-A741-F15A658ACC2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
66 | {61A546B9-256E-4114-A741-F15A658ACC2D}.Debug|Any CPU.Build.0 = Debug|Any CPU
67 | {61A546B9-256E-4114-A741-F15A658ACC2D}.Debug|x64.ActiveCfg = Debug|Any CPU
68 | {61A546B9-256E-4114-A741-F15A658ACC2D}.Debug|x64.Build.0 = Debug|Any CPU
69 | {61A546B9-256E-4114-A741-F15A658ACC2D}.Debug|x86.ActiveCfg = Debug|Any CPU
70 | {61A546B9-256E-4114-A741-F15A658ACC2D}.Debug|x86.Build.0 = Debug|Any CPU
71 | {61A546B9-256E-4114-A741-F15A658ACC2D}.Release|Any CPU.ActiveCfg = Release|Any CPU
72 | {61A546B9-256E-4114-A741-F15A658ACC2D}.Release|Any CPU.Build.0 = Release|Any CPU
73 | {61A546B9-256E-4114-A741-F15A658ACC2D}.Release|x64.ActiveCfg = Release|Any CPU
74 | {61A546B9-256E-4114-A741-F15A658ACC2D}.Release|x64.Build.0 = Release|Any CPU
75 | {61A546B9-256E-4114-A741-F15A658ACC2D}.Release|x86.ActiveCfg = Release|Any CPU
76 | {61A546B9-256E-4114-A741-F15A658ACC2D}.Release|x86.Build.0 = Release|Any CPU
77 | {B3C4CC80-3D7C-487A-8067-3D0692E2C4E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
78 | {B3C4CC80-3D7C-487A-8067-3D0692E2C4E6}.Debug|Any CPU.Build.0 = Debug|Any CPU
79 | {B3C4CC80-3D7C-487A-8067-3D0692E2C4E6}.Debug|x64.ActiveCfg = Debug|Any CPU
80 | {B3C4CC80-3D7C-487A-8067-3D0692E2C4E6}.Debug|x64.Build.0 = Debug|Any CPU
81 | {B3C4CC80-3D7C-487A-8067-3D0692E2C4E6}.Debug|x86.ActiveCfg = Debug|Any CPU
82 | {B3C4CC80-3D7C-487A-8067-3D0692E2C4E6}.Debug|x86.Build.0 = Debug|Any CPU
83 | {B3C4CC80-3D7C-487A-8067-3D0692E2C4E6}.Release|Any CPU.ActiveCfg = Release|Any CPU
84 | {B3C4CC80-3D7C-487A-8067-3D0692E2C4E6}.Release|Any CPU.Build.0 = Release|Any CPU
85 | {B3C4CC80-3D7C-487A-8067-3D0692E2C4E6}.Release|x64.ActiveCfg = Release|Any CPU
86 | {B3C4CC80-3D7C-487A-8067-3D0692E2C4E6}.Release|x64.Build.0 = Release|Any CPU
87 | {B3C4CC80-3D7C-487A-8067-3D0692E2C4E6}.Release|x86.ActiveCfg = Release|Any CPU
88 | {B3C4CC80-3D7C-487A-8067-3D0692E2C4E6}.Release|x86.Build.0 = Release|Any CPU
89 | {8550A7D9-566D-4A53-BF3C-DEC5B83F2B7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
90 | {8550A7D9-566D-4A53-BF3C-DEC5B83F2B7F}.Debug|Any CPU.Build.0 = Debug|Any CPU
91 | {8550A7D9-566D-4A53-BF3C-DEC5B83F2B7F}.Debug|x64.ActiveCfg = Debug|Any CPU
92 | {8550A7D9-566D-4A53-BF3C-DEC5B83F2B7F}.Debug|x64.Build.0 = Debug|Any CPU
93 | {8550A7D9-566D-4A53-BF3C-DEC5B83F2B7F}.Debug|x86.ActiveCfg = Debug|Any CPU
94 | {8550A7D9-566D-4A53-BF3C-DEC5B83F2B7F}.Debug|x86.Build.0 = Debug|Any CPU
95 | {8550A7D9-566D-4A53-BF3C-DEC5B83F2B7F}.Release|Any CPU.ActiveCfg = Release|Any CPU
96 | {8550A7D9-566D-4A53-BF3C-DEC5B83F2B7F}.Release|Any CPU.Build.0 = Release|Any CPU
97 | {8550A7D9-566D-4A53-BF3C-DEC5B83F2B7F}.Release|x64.ActiveCfg = Release|Any CPU
98 | {8550A7D9-566D-4A53-BF3C-DEC5B83F2B7F}.Release|x64.Build.0 = Release|Any CPU
99 | {8550A7D9-566D-4A53-BF3C-DEC5B83F2B7F}.Release|x86.ActiveCfg = Release|Any CPU
100 | {8550A7D9-566D-4A53-BF3C-DEC5B83F2B7F}.Release|x86.Build.0 = Release|Any CPU
101 | {042D9788-EA42-4E15-935E-AF5874018487}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
102 | {042D9788-EA42-4E15-935E-AF5874018487}.Debug|Any CPU.Build.0 = Debug|Any CPU
103 | {042D9788-EA42-4E15-935E-AF5874018487}.Debug|x64.ActiveCfg = Debug|Any CPU
104 | {042D9788-EA42-4E15-935E-AF5874018487}.Debug|x64.Build.0 = Debug|Any CPU
105 | {042D9788-EA42-4E15-935E-AF5874018487}.Debug|x86.ActiveCfg = Debug|Any CPU
106 | {042D9788-EA42-4E15-935E-AF5874018487}.Debug|x86.Build.0 = Debug|Any CPU
107 | {042D9788-EA42-4E15-935E-AF5874018487}.Release|Any CPU.ActiveCfg = Release|Any CPU
108 | {042D9788-EA42-4E15-935E-AF5874018487}.Release|Any CPU.Build.0 = Release|Any CPU
109 | {042D9788-EA42-4E15-935E-AF5874018487}.Release|x64.ActiveCfg = Release|Any CPU
110 | {042D9788-EA42-4E15-935E-AF5874018487}.Release|x64.Build.0 = Release|Any CPU
111 | {042D9788-EA42-4E15-935E-AF5874018487}.Release|x86.ActiveCfg = Release|Any CPU
112 | {042D9788-EA42-4E15-935E-AF5874018487}.Release|x86.Build.0 = Release|Any CPU
113 | {A7E9B010-6E37-4199-A0C9-841E3F3494D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
114 | {A7E9B010-6E37-4199-A0C9-841E3F3494D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
115 | {A7E9B010-6E37-4199-A0C9-841E3F3494D0}.Debug|x64.ActiveCfg = Debug|Any CPU
116 | {A7E9B010-6E37-4199-A0C9-841E3F3494D0}.Debug|x64.Build.0 = Debug|Any CPU
117 | {A7E9B010-6E37-4199-A0C9-841E3F3494D0}.Debug|x86.ActiveCfg = Debug|Any CPU
118 | {A7E9B010-6E37-4199-A0C9-841E3F3494D0}.Debug|x86.Build.0 = Debug|Any CPU
119 | {A7E9B010-6E37-4199-A0C9-841E3F3494D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
120 | {A7E9B010-6E37-4199-A0C9-841E3F3494D0}.Release|Any CPU.Build.0 = Release|Any CPU
121 | {A7E9B010-6E37-4199-A0C9-841E3F3494D0}.Release|x64.ActiveCfg = Release|Any CPU
122 | {A7E9B010-6E37-4199-A0C9-841E3F3494D0}.Release|x64.Build.0 = Release|Any CPU
123 | {A7E9B010-6E37-4199-A0C9-841E3F3494D0}.Release|x86.ActiveCfg = Release|Any CPU
124 | {A7E9B010-6E37-4199-A0C9-841E3F3494D0}.Release|x86.Build.0 = Release|Any CPU
125 | {A41D94C5-85D2-46ED-8BA9-FE0412D63130}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
126 | {A41D94C5-85D2-46ED-8BA9-FE0412D63130}.Debug|Any CPU.Build.0 = Debug|Any CPU
127 | {A41D94C5-85D2-46ED-8BA9-FE0412D63130}.Debug|x64.ActiveCfg = Debug|Any CPU
128 | {A41D94C5-85D2-46ED-8BA9-FE0412D63130}.Debug|x64.Build.0 = Debug|Any CPU
129 | {A41D94C5-85D2-46ED-8BA9-FE0412D63130}.Debug|x86.ActiveCfg = Debug|Any CPU
130 | {A41D94C5-85D2-46ED-8BA9-FE0412D63130}.Debug|x86.Build.0 = Debug|Any CPU
131 | {A41D94C5-85D2-46ED-8BA9-FE0412D63130}.Release|Any CPU.ActiveCfg = Release|Any CPU
132 | {A41D94C5-85D2-46ED-8BA9-FE0412D63130}.Release|Any CPU.Build.0 = Release|Any CPU
133 | {A41D94C5-85D2-46ED-8BA9-FE0412D63130}.Release|x64.ActiveCfg = Release|Any CPU
134 | {A41D94C5-85D2-46ED-8BA9-FE0412D63130}.Release|x64.Build.0 = Release|Any CPU
135 | {A41D94C5-85D2-46ED-8BA9-FE0412D63130}.Release|x86.ActiveCfg = Release|Any CPU
136 | {A41D94C5-85D2-46ED-8BA9-FE0412D63130}.Release|x86.Build.0 = Release|Any CPU
137 | {80879533-D512-4774-9700-E6412D838684}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
138 | {80879533-D512-4774-9700-E6412D838684}.Debug|Any CPU.Build.0 = Debug|Any CPU
139 | {80879533-D512-4774-9700-E6412D838684}.Debug|x64.ActiveCfg = Debug|x64
140 | {80879533-D512-4774-9700-E6412D838684}.Debug|x64.Build.0 = Debug|x64
141 | {80879533-D512-4774-9700-E6412D838684}.Debug|x86.ActiveCfg = Debug|x86
142 | {80879533-D512-4774-9700-E6412D838684}.Debug|x86.Build.0 = Debug|x86
143 | {80879533-D512-4774-9700-E6412D838684}.Release|Any CPU.ActiveCfg = Release|Any CPU
144 | {80879533-D512-4774-9700-E6412D838684}.Release|Any CPU.Build.0 = Release|Any CPU
145 | {80879533-D512-4774-9700-E6412D838684}.Release|x64.ActiveCfg = Release|x64
146 | {80879533-D512-4774-9700-E6412D838684}.Release|x64.Build.0 = Release|x64
147 | {80879533-D512-4774-9700-E6412D838684}.Release|x86.ActiveCfg = Release|x86
148 | {80879533-D512-4774-9700-E6412D838684}.Release|x86.Build.0 = Release|x86
149 | EndGlobalSection
150 | GlobalSection(SolutionProperties) = preSolution
151 | HideSolutionNode = FALSE
152 | EndGlobalSection
153 | EndGlobal
154 |
--------------------------------------------------------------------------------
/ConsulRx/ConsulDependencies.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 |
3 | namespace ConsulRx
4 | {
5 | public class ConsulDependencies
6 | {
7 | public HashSet Keys { get; } = new HashSet();
8 | public HashSet KeyPrefixes { get; } = new HashSet();
9 | public HashSet Services { get; } = new HashSet();
10 |
11 | public void CopyTo(ConsulDependencies other)
12 | {
13 | other.Keys.UnionWith(Keys);
14 | other.KeyPrefixes.UnionWith(KeyPrefixes);
15 | other.Services.UnionWith(Services);
16 | }
17 | }
18 | }
--------------------------------------------------------------------------------
/ConsulRx/ConsulErrorException.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Net;
3 | using Consul;
4 |
5 | namespace ConsulRx
6 | {
7 | public class ConsulErrorException : Exception
8 | {
9 | public ConsulErrorException(QueryResult result)
10 | {
11 | Result = result;
12 | }
13 |
14 | public QueryResult Result { get; }
15 | }
16 | }
--------------------------------------------------------------------------------
/ConsulRx/ConsulRx.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | netstandard1.4
4 | 1.0.2
5 | A library for consuming consul values in a continuous stream using the Reactive
6 | Extensions
7 | 2017-2018
8 | Andy Alm
9 | consul;configuration;reactive;extensions
10 | https://github.com/andyalm/consul-rx
11 | http://opensource.org/licenses/MIT
12 | True
13 |
14 | Fixed an issue where a missing key would report as having an empty string value instead of missing/null.
15 |
16 | Latest
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/ConsulRx/ConsulState.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Collections.Immutable;
5 |
6 | namespace ConsulRx
7 | {
8 | public class ConsulState
9 | {
10 | private readonly ImmutableDictionary _services;
11 | public IEnumerable Services => _services.Values;
12 |
13 | private readonly KeyValueStore _kvStore;
14 | public IReadOnlyKeyValueStore KVStore => _kvStore;
15 |
16 | private readonly ImmutableHashSet _missingKeyPrefixes;
17 | public IEnumerable MissingKeyPrefixes => _missingKeyPrefixes;
18 |
19 | public ConsulState()
20 | {
21 | _services = ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase);
22 | _kvStore = KeyValueStore.Empty;
23 | _missingKeyPrefixes = ImmutableHashSet.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
24 | }
25 |
26 | public ConsulState(ImmutableDictionary services, KeyValueStore kvStore,
27 | ImmutableHashSet missingKeyPrefixes)
28 | {
29 | _services = services;
30 | _kvStore = kvStore;
31 | _missingKeyPrefixes = missingKeyPrefixes;
32 | }
33 |
34 | public ConsulState UpdateService(Service service)
35 | {
36 | if (TryUpdateService(service, out var updatedState))
37 | return updatedState;
38 |
39 | return this;
40 | }
41 |
42 | public bool TryUpdateService(Service service, out ConsulState updatedState)
43 | {
44 | if (_services.TryGetValue(service.Name, out var existingService) && existingService.Equals(service))
45 | {
46 | updatedState = null;
47 | return false;
48 | }
49 |
50 | updatedState = new ConsulState(_services.SetItem(service.Name, service), _kvStore, _missingKeyPrefixes);
51 | return true;
52 | }
53 |
54 | public ConsulState UpdateKVNode(KeyValueNode kvNode)
55 | {
56 | if (TryUpdateKVNode(kvNode, out var updatedState))
57 | return updatedState;
58 |
59 | return this;
60 | }
61 |
62 | public bool TryUpdateKVNode(KeyValueNode kvNode, out ConsulState updatedState)
63 | {
64 | if (_kvStore.TryUpdate(kvNode, out var updatedKvStore))
65 | {
66 | updatedState = new ConsulState(_services, updatedKvStore, _missingKeyPrefixes);
67 | return true;
68 | }
69 | updatedState = null;
70 | return false;
71 | }
72 |
73 | public ConsulState UpdateKVNodes(IEnumerable kvNodes)
74 | {
75 | if (TryUpdateKVNodes(kvNodes, out var updatedState))
76 | return updatedState;
77 |
78 | return this;
79 | }
80 |
81 | public bool TryUpdateKVNodes(IEnumerable kvNodes, out ConsulState updatedState)
82 | {
83 | var missingKeyPrefixes = _missingKeyPrefixes;
84 | if (kvNodes.Any())
85 | {
86 | var noLongerMissingKeyPrefix = _missingKeyPrefixes.FirstOrDefault(keyPrefix => kvNodes.First().FullKey.StartsWith(keyPrefix));
87 | if (noLongerMissingKeyPrefix != null)
88 | {
89 | missingKeyPrefixes = _missingKeyPrefixes.Remove(noLongerMissingKeyPrefix);
90 | }
91 | }
92 |
93 | if (_kvStore.TryUpdate(kvNodes, out var updatedKvStore))
94 | {
95 | updatedState = new ConsulState(_services, updatedKvStore, missingKeyPrefixes);
96 | return true;
97 | }
98 | updatedState = null;
99 | return false;
100 | }
101 |
102 | public ConsulState MarkKeyPrefixAsMissingOrEmpty(string keyPrefix)
103 | {
104 | if (TryMarkKeyPrefixAsMissingOrEmpty(keyPrefix, out var updatedState))
105 | return updatedState;
106 |
107 | return this;
108 | }
109 |
110 | public bool TryMarkKeyPrefixAsMissingOrEmpty(string keyPrefix, out ConsulState updatedState)
111 | {
112 | if (_missingKeyPrefixes.Contains(keyPrefix))
113 | {
114 | updatedState = null;
115 | return false;
116 | }
117 | var kvStore = _kvStore;
118 | if (_kvStore.TryRemoveKeysStartingWith(keyPrefix, out var updatedKvStore))
119 | {
120 | kvStore = updatedKvStore;
121 | }
122 | updatedState = new ConsulState(_services, kvStore, _missingKeyPrefixes.Add(keyPrefix));
123 | return true;
124 | }
125 |
126 | public bool SatisfiesAll(ConsulDependencies consulDependencies)
127 | {
128 | return consulDependencies.Services.IsSubsetOf(_services.Keys)
129 | && consulDependencies.Keys.IsSubsetOf(KVStore.Select(s => s.FullKey))
130 | && consulDependencies.KeyPrefixes.All(p => KVStore.Any(k => k.FullKey.StartsWith(p)) || _missingKeyPrefixes.Contains(p));
131 | }
132 |
133 | public bool ContainsKey(string key)
134 | {
135 | return KVStore.ContainsKey(key);
136 | }
137 |
138 | public bool ContainsService(string serviceName)
139 | {
140 | return _services.ContainsKey(serviceName);
141 | }
142 |
143 | public Service GetService(string serviceName)
144 | {
145 | if (_services.TryGetValue(serviceName, out var service))
146 | return service;
147 |
148 | return null;
149 | }
150 |
151 | public bool ContainsKeyStartingWith(string keyPrefix)
152 | {
153 | return KVStore.ContainsKeyStartingWith(keyPrefix);
154 | }
155 | }
156 | }
--------------------------------------------------------------------------------
/ConsulRx/Defaults.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace ConsulRx
4 | {
5 | public static class Defaults
6 | {
7 | public static TimeSpan ErrorRetryInterval => TimeSpan.FromSeconds(15);
8 |
9 | public static TimeSpan UpdateMaxInterval => TimeSpan.FromSeconds(15);
10 | }
11 | }
--------------------------------------------------------------------------------
/ConsulRx/IConsulObservation.cs:
--------------------------------------------------------------------------------
1 | using Consul;
2 |
3 | namespace ConsulRx
4 | {
5 | public interface IConsulObservation
6 | {
7 | QueryResult Result { get; }
8 | }
9 | }
--------------------------------------------------------------------------------
/ConsulRx/IObservableConsul.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Threading.Tasks;
4 |
5 | namespace ConsulRx
6 | {
7 | public interface IObservableConsul
8 | {
9 | IObservable ObserveService(string serviceName);
10 | Task GetServiceAsync(string serviceName);
11 |
12 | IObservable ObserveKey(string key);
13 | Task GetKeyAsync(string key);
14 |
15 | IObservable ObserveKeyRecursive(string prefix);
16 | Task> GetKeyRecursiveAsync(string prefix);
17 |
18 | IObservable ObserveDependencies(ConsulDependencies dependencies);
19 | Task GetDependenciesAsync(ConsulDependencies dependencies);
20 |
21 | ObservableConsulConfiguration Configuration { get; }
22 | }
23 | }
--------------------------------------------------------------------------------
/ConsulRx/IReadOnlyKeyValueStore.cs:
--------------------------------------------------------------------------------
1 | using System.Collections;
2 | using System.Collections.Generic;
3 |
4 | namespace ConsulRx
5 | {
6 | public interface IReadOnlyKeyValueStore : IEnumerable
7 | {
8 | bool ContainsKey(string fullKey);
9 | string GetValue(string fullKey);
10 | IEnumerable GetChildren(string keyPrefix);
11 | IEnumerable GetTree(string keyPrefix);
12 | bool ContainsKeyStartingWith(string keyPrefix);
13 | }
14 | }
--------------------------------------------------------------------------------
/ConsulRx/KeyObservation.cs:
--------------------------------------------------------------------------------
1 | using Consul;
2 |
3 | namespace ConsulRx
4 | {
5 | public class KeyObservation : IConsulObservation
6 | {
7 | public string Key { get; }
8 | public QueryResult Result { get; }
9 | QueryResult IConsulObservation.Result => Result;
10 |
11 | public KeyObservation(string key, QueryResult result)
12 | {
13 | Key = key;
14 | Result = result;
15 | }
16 |
17 | public KeyValueNode ToKeyValueNode()
18 | {
19 | return new KeyValueNode(Key, Result.Response?.Value);
20 | }
21 | }
22 | }
--------------------------------------------------------------------------------
/ConsulRx/KeyRecursiveObservation.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Linq;
3 | using Consul;
4 |
5 | namespace ConsulRx
6 | {
7 | public class KeyRecursiveObservation : IConsulObservation
8 | {
9 | public string KeyPrefix { get; }
10 | public QueryResult Result { get; }
11 | QueryResult IConsulObservation.Result => Result;
12 |
13 | public KeyRecursiveObservation(string keyPrefix, QueryResult result)
14 | {
15 | KeyPrefix = keyPrefix;
16 | Result = result;
17 | }
18 |
19 | public IEnumerable ToKeyValueNodes()
20 | {
21 | if (Result.Response == null)
22 | return Enumerable.Empty();
23 |
24 | return Result.Response
25 | .Where(p => p.Value != null) //no point in returning keys with null values. I believe these are just folder keys anyways.
26 | .Select(p => new KeyValueNode(p.Key, p.Value));
27 | }
28 | }
29 | }
--------------------------------------------------------------------------------
/ConsulRx/KeyValueNode.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Text;
3 |
4 | namespace ConsulRx
5 | {
6 | public class KeyValueNode
7 | {
8 | public string FullKey { get; }
9 | public string Value { get; }
10 |
11 | public KeyValueNode(string fullKey, byte[] value) : this(fullKey, value == null ? null : Encoding.UTF8.GetString(value)) { }
12 |
13 | public KeyValueNode(string fullKey, string value)
14 | {
15 | FullKey = fullKey;
16 | Value = value;
17 | }
18 |
19 | public string LeafKey
20 | {
21 | get
22 | {
23 | var lastSlashIndex = FullKey.LastIndexOf('/');
24 | if (lastSlashIndex < 0)
25 | return FullKey;
26 | else
27 | return FullKey.Substring(lastSlashIndex + 1);
28 | }
29 | }
30 |
31 | public string ParentKey
32 | {
33 | get
34 | {
35 | var lastSlashIndex = FullKey.LastIndexOf('/');
36 | if (lastSlashIndex < 0)
37 | return FullKey;
38 | else
39 | return FullKey.Remove(lastSlashIndex);
40 | }
41 | }
42 |
43 | public bool IsChildOf(string otherKey)
44 | {
45 | return otherKey.Equals(ParentKey);
46 | }
47 |
48 | public bool IsDescendentOf(string prefix)
49 | {
50 | return FullKey.StartsWith(prefix);
51 | }
52 |
53 | protected bool Equals(KeyValueNode other)
54 | {
55 | return string.Equals(FullKey, other.FullKey) && string.Equals(Value, other.Value);
56 | }
57 |
58 | public override bool Equals(object obj)
59 | {
60 | if (ReferenceEquals(null, obj)) return false;
61 | if (ReferenceEquals(this, obj)) return true;
62 | if (obj.GetType() != this.GetType()) return false;
63 | return Equals((KeyValueNode) obj);
64 | }
65 |
66 | public override int GetHashCode()
67 | {
68 | unchecked
69 | {
70 | return ((FullKey != null ? FullKey.GetHashCode() : 0) * 397) ^ (Value != null ? Value.GetHashCode() : 0);
71 | }
72 | }
73 |
74 | public KeyValuePair ToIndexedPair()
75 | {
76 | return new KeyValuePair(FullKey, this);
77 | }
78 | }
79 | }
--------------------------------------------------------------------------------
/ConsulRx/KeyValueStore.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections;
3 | using System.Collections.Generic;
4 | using System.Collections.Immutable;
5 | using System.Linq;
6 |
7 | namespace ConsulRx
8 | {
9 | public class KeyValueStore : IReadOnlyKeyValueStore
10 | {
11 | private readonly ImmutableDictionary _leaves;
12 | public static readonly KeyValueStore Empty = new KeyValueStore();
13 |
14 | private KeyValueStore()
15 | {
16 | _leaves = ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase);
17 | }
18 |
19 | public KeyValueStore(IEnumerable leaves)
20 | {
21 | _leaves = ImmutableDictionary.Create(StringComparer.OrdinalIgnoreCase).AddRange(leaves.Select(n => n.ToIndexedPair()));
22 | }
23 |
24 | public KeyValueStore(ImmutableDictionary leaves)
25 | {
26 | _leaves = leaves;
27 | }
28 |
29 | public bool TryUpdate(KeyValueNode kvNode, out KeyValueStore updatedStore)
30 | {
31 | if (_leaves.TryGetValue(kvNode.FullKey, out var existingNode) && existingNode.Equals(kvNode))
32 | {
33 | updatedStore = null;
34 | return false;
35 | }
36 |
37 | updatedStore = new KeyValueStore(_leaves.SetItem(kvNode.FullKey, kvNode));
38 | return true;
39 | }
40 |
41 | public bool TryUpdate(IEnumerable kvNodes, out KeyValueStore updatedStore)
42 | {
43 | bool atLeastOneUpdate = false;
44 | var leaves = _leaves;
45 | foreach (var kvNode in kvNodes)
46 | {
47 | if (leaves.TryGetValue(kvNode.FullKey, out var existingNode) && existingNode.Equals(kvNode))
48 | {
49 | continue;
50 | }
51 | atLeastOneUpdate = true;
52 | leaves = leaves.SetItem(kvNode.FullKey, kvNode);
53 | }
54 |
55 | if (atLeastOneUpdate)
56 | {
57 | updatedStore = new KeyValueStore(leaves);
58 | return true;
59 | }
60 |
61 | updatedStore = null;
62 | return false;
63 | }
64 |
65 | public bool TryRemoveKeysStartingWith(string keyPrefix, out KeyValueStore updatedStore)
66 | {
67 | var keysToRemove = _leaves.Keys.Where(key => key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase)).ToArray();
68 | if (keysToRemove.Any())
69 | {
70 | updatedStore = new KeyValueStore(_leaves.RemoveRange(keysToRemove));
71 | return true;
72 | }
73 |
74 | updatedStore = null;
75 | return false;
76 | }
77 |
78 | public bool ContainsKey(string fullKey)
79 | {
80 | return _leaves.ContainsKey(fullKey);
81 | }
82 |
83 | public string GetValue(string fullKey)
84 | {
85 | if (_leaves.TryGetValue(fullKey, out var node))
86 | return node.Value;
87 |
88 | return null;
89 | }
90 |
91 | public IEnumerator GetEnumerator()
92 | {
93 | return _leaves.Values.GetEnumerator();
94 | }
95 |
96 | IEnumerator IEnumerable.GetEnumerator()
97 | {
98 | return GetEnumerator();
99 | }
100 |
101 | public IEnumerable GetChildren(string keyPrefix)
102 | {
103 | return _leaves.Values.Where(node => node.IsChildOf(keyPrefix));
104 | }
105 |
106 | public IEnumerable GetTree(string keyPrefix)
107 | {
108 | return _leaves.Values.Where(node => node.IsDescendentOf(keyPrefix));
109 | }
110 |
111 | public bool ContainsKeyStartingWith(string keyPrefix)
112 | {
113 | return _leaves.Keys.Any(k => k.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase));
114 | }
115 | }
116 | }
--------------------------------------------------------------------------------
/ConsulRx/MonitoringExtensions.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using Consul;
3 | using Spiffy.Monitoring;
4 |
5 | namespace ConsulRx
6 | {
7 | internal static class MonitoringExtensions
8 | {
9 | public static void IncludeConsulResult(this EventContext eventContext, QueryResult result)
10 | {
11 | eventContext["HttpStatusCode"] = (int)result.StatusCode;
12 | eventContext["ResponseIndex"] = result.LastIndex;
13 | eventContext["KnownLeader"] = result.KnownLeader;
14 |
15 | var services = result.Response as CatalogService[];
16 | if (services != null)
17 | {
18 | eventContext.IncludeCatalogServices(services);
19 | }
20 | }
21 |
22 | private static void IncludeCatalogServices(this EventContext eventContext, CatalogService[] services)
23 | {
24 | eventContext["ServiceCount"] = services.Length;
25 | }
26 | }
27 | }
--------------------------------------------------------------------------------
/ConsulRx/ObservableConsul.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Collections.Immutable;
4 | using System.Linq;
5 | using System.Net;
6 | using System.Reactive.Disposables;
7 | using System.Reactive.Linq;
8 | using System.Threading.Tasks;
9 | using Consul;
10 | using Spiffy.Monitoring;
11 |
12 | namespace ConsulRx
13 | {
14 | public class ObservableConsul : IObservableConsul
15 | {
16 | private readonly IConsulClient _client;
17 | private readonly ObservableConsulConfiguration _configuration;
18 |
19 | public ObservableConsul(IConsulClient client, TimeSpan? longPollMaxWait = null, TimeSpan? retryDelay = null, string aclToken = null)
20 | {
21 | _client = client;
22 | _configuration = new ObservableConsulConfiguration
23 | {
24 | AclToken = aclToken,
25 | LongPollMaxWait = longPollMaxWait,
26 | RetryDelay = retryDelay
27 | };
28 | }
29 |
30 | public ObservableConsul(ObservableConsulConfiguration config)
31 | {
32 | if(config == null)
33 | throw new ArgumentNullException(nameof(config));
34 |
35 | _configuration = config;
36 |
37 | _client = new ConsulClient(c =>
38 | {
39 | if(!string.IsNullOrEmpty(config.Endpoint))
40 | c.Address = new Uri(config.Endpoint);
41 | if(!string.IsNullOrEmpty(config.Datacenter))
42 | c.Datacenter = config.Datacenter;
43 | });
44 | }
45 |
46 | public ObservableConsulConfiguration Configuration => _configuration;
47 |
48 | private TimeSpan? RetryDelay => Configuration.RetryDelay;
49 |
50 | public IObservable ObserveService(string serviceName)
51 | {
52 | return LongPoll(index => _client.Catalog.Service(serviceName, null,
53 | QueryOptions(index)), result => new ServiceObservation(serviceName, result),
54 | "GetService", new Dictionary
55 | {
56 | {"ServiceName",serviceName}
57 | });
58 | }
59 |
60 | public async Task GetServiceAsync(string serviceName)
61 | {
62 | var result = await CallConsulAsync(() => _client.Catalog.Service(serviceName, null, QueryOptions(0)),
63 | "GetService", new Dictionary
64 | {
65 | {"ServiceName",serviceName}
66 | }).ConfigureAwait(false);
67 |
68 | if (result.Response == null || result.Response.Length == 0)
69 | {
70 | return null;
71 | }
72 |
73 | return new ServiceObservation(serviceName, result).ToService();
74 | }
75 |
76 | public IObservable ObserveKey(string key)
77 | {
78 | return LongPoll(index => _client.KV.Get(key, QueryOptions(index)), result => new KeyObservation(key, result),
79 | "GetKey", new Dictionary
80 | {
81 | {"Key", key}
82 | });
83 | }
84 |
85 | public async Task GetKeyAsync(string key)
86 | {
87 | var result = await CallConsulAsync(() => _client.KV.Get(key, QueryOptions(0)),
88 | "GetKey", new Dictionary
89 | {
90 | {"Key", key}
91 | }).ConfigureAwait(false);
92 |
93 | if (result.Response == null)
94 | {
95 | return null;
96 | }
97 |
98 | return new KeyObservation(key, result).ToKeyValueNode();
99 | }
100 |
101 | public IObservable ObserveKeyRecursive(string prefix)
102 | {
103 | return LongPoll(index => _client.KV.List(prefix, QueryOptions(index)), result => new KeyRecursiveObservation(prefix, result),
104 | "GetKeys", new Dictionary
105 | {
106 | {"KeyPrefix", prefix}
107 | });
108 | }
109 |
110 | public async Task> GetKeyRecursiveAsync(string prefix)
111 | {
112 | var result = await CallConsulAsync(() => _client.KV.List(prefix, QueryOptions(0)),
113 | "GetKeys", new Dictionary
114 | {
115 | {"KeyPrefix", prefix}
116 | }).ConfigureAwait(false);
117 |
118 | return new KeyRecursiveObservation(prefix, result).ToKeyValueNodes();
119 | }
120 |
121 | public async Task GetDependenciesAsync(ConsulDependencies dependencies)
122 | {
123 | var serviceTasks = dependencies.Services.Select(GetServiceAsync).ToArray();
124 | var keyTasks = dependencies.Keys.Select(GetKeyAsync).ToArray();
125 | var keyRecursiveTasks = dependencies.KeyPrefixes.Select(GetKeyRecursiveAsync).ToArray();
126 |
127 | await Task.WhenAll(serviceTasks.Cast().Concat(keyTasks).Concat(keyRecursiveTasks)).ConfigureAwait(false);
128 |
129 | var services = serviceTasks.Select(t => t.Result)
130 | .Where(s => s != null)
131 | .ToImmutableDictionary(s => s.Name);
132 |
133 | var keys = new KeyValueStore(keyTasks
134 | .Select(t => t.Result)
135 | .Where(k => k != null)
136 | .Concat(keyRecursiveTasks.SelectMany(t => t.Result)));
137 |
138 | var missingKeyPrefixes = dependencies.KeyPrefixes
139 | .Where(prefix => !keys.ContainsKeyStartingWith(prefix))
140 | .ToImmutableHashSet();
141 |
142 | return new ConsulState(services, keys, missingKeyPrefixes);
143 | }
144 |
145 | public IObservable ObserveDependencies(ConsulDependencies dependencies)
146 | {
147 | var consulState = new ConsulState();
148 | var updateMutex = new object();
149 |
150 | void WrapUpdate(string operationName, Action tryUpdate)
151 | {
152 | var eventContext = new EventContext("ConsulRx.ConsulState", operationName);
153 | try
154 | {
155 | lock (updateMutex)
156 | {
157 | tryUpdate(eventContext);
158 | }
159 | }
160 | catch (Exception ex)
161 | {
162 | eventContext.IncludeException(ex);
163 | throw;
164 | }
165 | finally
166 | {
167 | eventContext.Dispose();
168 | }
169 | }
170 |
171 | var consulStateObservable = Observable.Create(o =>
172 | {
173 | var compositeDisposable = new CompositeDisposable
174 | {
175 | this.ObserveServices(dependencies.Services)
176 | .Select(services => services.ToService())
177 | .Subscribe(service =>
178 | {
179 | WrapUpdate("UpdateService", eventContext =>
180 | {
181 | eventContext["ServiceName"] = service.Name;
182 | bool alreadyExisted = consulState.ContainsService(service.Name);
183 | if (consulState.TryUpdateService(service, out var updatedState))
184 | {
185 | eventContext["UpdateType"] = alreadyExisted ? "Update" : "Add";
186 | consulState = updatedState;
187 | o.OnNext(consulState);
188 | }
189 | else
190 | {
191 | eventContext.Suppress();
192 | }
193 | });
194 | }, o.OnError),
195 | this.ObserveKeys(dependencies.Keys)
196 | .Select(kv => kv.ToKeyValueNode())
197 | .Subscribe(kvNode =>
198 | {
199 | WrapUpdate("UpdateKey", eventContext =>
200 | {
201 | eventContext["Key"] = kvNode.FullKey;
202 | eventContext["Value"] = kvNode.Value;
203 | bool alreadyExisted = consulState.ContainsKey(kvNode.FullKey);
204 | if (consulState.TryUpdateKVNode(kvNode, out var updatedState))
205 | {
206 | eventContext["UpdateType"] = alreadyExisted ? "Update" : "Add";
207 | consulState = updatedState;
208 | o.OnNext(consulState);
209 | }
210 | else
211 | {
212 | eventContext.Suppress();
213 | }
214 | });
215 | }, o.OnError),
216 | this.ObserveKeysRecursive(dependencies.KeyPrefixes)
217 | .Subscribe(kv =>
218 | {
219 | WrapUpdate("UpdateKeys", eventContext =>
220 | {
221 | eventContext["KeyPrefix"] = kv.KeyPrefix;
222 | eventContext["ChildKeyCount"] = kv.Result.Response?.Length ?? 0;
223 | if (kv.Result.Response == null || !kv.Result.Response.Any())
224 | {
225 | if (consulState.TryMarkKeyPrefixAsMissingOrEmpty(kv.KeyPrefix, out var updatedState)
226 | )
227 | {
228 | eventContext["UpdateType"] = "MarkAsMissing";
229 | consulState = updatedState;
230 | o.OnNext(consulState);
231 | }
232 | else
233 | {
234 | eventContext.Suppress();
235 | }
236 | }
237 | else
238 | {
239 | var kvNodes = kv.ToKeyValueNodes();
240 | bool alreadyExisted = consulState.ContainsKeyStartingWith(kv.KeyPrefix);
241 | if (consulState.TryUpdateKVNodes(kvNodes, out var updatedState))
242 | {
243 | eventContext["UpdateType"] = alreadyExisted ? "Update" : "Add";
244 | consulState = updatedState;
245 | o.OnNext(consulState);
246 | }
247 | else
248 | {
249 | eventContext.Suppress();
250 | }
251 | }
252 | });
253 | }, o.OnError)
254 | };
255 |
256 | return compositeDisposable;
257 | });
258 |
259 |
260 | return consulStateObservable.Where(s => s.SatisfiesAll(dependencies));
261 | }
262 |
263 | private QueryOptions QueryOptions(ulong index)
264 | {
265 | return new QueryOptions
266 | {
267 | Token = _configuration.AclToken ?? "anonymous",
268 | WaitIndex = index,
269 | WaitTime = _configuration.LongPollMaxWait,
270 | Consistency = _configuration.ConsistencyMode
271 | };
272 | }
273 |
274 | private static readonly HashSet HealthyCodes = new HashSet{HttpStatusCode.OK, HttpStatusCode.NotFound};
275 |
276 | private async Task> CallConsulAsync(Func>> call,
277 | string monitoringOperation, IDictionary monitoringProperties)
278 | {
279 | using (var eventContext = new EventContext("ConsulRx.Client", monitoringOperation))
280 | {
281 | eventContext.AddValues(monitoringProperties);
282 | try
283 | {
284 | var result = await call().ConfigureAwait(false);
285 | eventContext.IncludeConsulResult(result);
286 | if (HealthyCodes.Contains(result.StatusCode))
287 | {
288 | eventContext.Suppress();
289 | }
290 | else
291 | {
292 | //if we got an error that indicates either server or client aren't healthy (e.g. 500 or 403)
293 | //then model this as an exception (same as if server can't be contacted). We will figure out what to do below
294 | throw new ConsulErrorException(result);
295 | }
296 | return result;
297 | }
298 | catch (Exception ex)
299 | {
300 | eventContext.IncludeException(ex);
301 | throw;
302 | }
303 | }
304 | }
305 |
306 | private IObservable LongPoll(Func>> poll, Func,TObservation> toObservation, string monitoringOperation, IDictionary monitoringProperties) where TObservation : class
307 | {
308 | return Observable.Create(async (o, cancel) =>
309 | {
310 | ulong index = default(ulong);
311 | var successfullyContactedConsulAtLeastOnce = false;
312 | while (true)
313 | {
314 | using (var eventContext = new EventContext("ConsulRx.Client", monitoringOperation))
315 | {
316 | eventContext["RequestIndex"] = index;
317 | eventContext.AddValues(monitoringProperties);
318 | QueryResult result = null;
319 | Exception exception = null;
320 | try
321 | {
322 | result = await poll(index).ConfigureAwait(false);
323 | }
324 | catch (Exception ex)
325 | {
326 | eventContext.IncludeException(ex);
327 | exception = ex;
328 | }
329 | if (cancel.IsCancellationRequested)
330 | {
331 | eventContext["Cancelled"] = true;
332 | o.OnCompleted();
333 | return;
334 | }
335 | if (result != null)
336 | {
337 | index = result.LastIndex;
338 | eventContext.IncludeConsulResult(result);
339 | if (HealthyCodes.Contains(result.StatusCode))
340 | {
341 | //200 or 404 are the only response codes we should expect if consul and client are both configured properly
342 | successfullyContactedConsulAtLeastOnce = true;
343 | }
344 | else
345 | {
346 | //if we got an error that indicates either server or client aren't healthy (e.g. 500 or 403)
347 | //then model this as an exception (same as if server can't be contacted). We will figure out what to do below
348 | exception = new ConsulErrorException(result);
349 | eventContext.SetLevel(Level.Error);
350 | }
351 | }
352 |
353 | if (exception == null)
354 | {
355 | o.OnNext(toObservation(result));
356 | }
357 | else
358 | {
359 | if (successfullyContactedConsulAtLeastOnce)
360 | {
361 | //if an error occurred, we reset the index so that we can start clean
362 | //this is necessary because if the consul cluster was restarted it won't recognize
363 | //the old index and will block until the longPollMaxWait expires
364 | index = default(ulong);
365 |
366 | //if we have been successful at contacting consul already, then we will retry under the assumption that
367 | //things will eventually get healthy again
368 | eventContext["SecondsUntilRetry"] = RetryDelay?.Seconds ?? 0;
369 | if (RetryDelay != null)
370 | {
371 | await Task.Delay(RetryDelay.Value, cancel).ConfigureAwait(false);
372 | }
373 | }
374 | else
375 | {
376 | //if we encountered an error at the very beginning, then we don't have enough confidence that retrying will actually help
377 | //so we will stream the exception out and let the consumer figure out what to do
378 | o.OnError(exception);
379 | return;
380 | }
381 | }
382 | }
383 | }
384 | });
385 | }
386 |
387 |
388 | }
389 |
390 | public static class ObservableConsulExtensions
391 | {
392 | public static IObservable ObserveKeys(this IObservableConsul client, params string[] keys)
393 | {
394 | return client.ObserveKeys((IEnumerable)keys);
395 | }
396 |
397 | public static IObservable ObserveKeys(this IObservableConsul client, IEnumerable keys)
398 | {
399 | return keys.Select(client.ObserveKey).Merge();
400 | }
401 |
402 | public static IObservable ObserveKeysRecursive(this IObservableConsul client, IEnumerable prefixes)
403 | {
404 | return prefixes.Select(client.ObserveKeyRecursive).Merge();
405 | }
406 |
407 | public static IObservable ObserveServices(this IObservableConsul client, IEnumerable serviceNames)
408 | {
409 | return serviceNames.Select(client.ObserveService).Merge();
410 | }
411 |
412 | public static IObservable ObserveServices(this IObservableConsul client, params string[] serviceNames)
413 | {
414 | return client.ObserveServices((IEnumerable)serviceNames);
415 | }
416 | }
417 | }
--------------------------------------------------------------------------------
/ConsulRx/ObservableConsulConfiguration.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Consul;
3 |
4 | namespace ConsulRx
5 | {
6 | public class ObservableConsulConfiguration
7 | {
8 | private string _endpoint;
9 |
10 | public string Endpoint
11 | {
12 | get
13 | {
14 | if (!string.IsNullOrEmpty(_endpoint))
15 | return _endpoint;
16 |
17 | return null;
18 | }
19 | set => _endpoint = value;
20 | }
21 | public string Datacenter { get; set; }
22 | public string AclToken { get; set; }
23 | public TimeSpan? LongPollMaxWait { get; set; }
24 | public TimeSpan? RetryDelay { get; set; } = Defaults.ErrorRetryInterval;
25 | public ConsistencyMode ConsistencyMode { get; set; } = ConsistencyMode.Default;
26 | }
27 | }
--------------------------------------------------------------------------------
/ConsulRx/Service.cs:
--------------------------------------------------------------------------------
1 | using System.Linq;
2 |
3 | namespace ConsulRx
4 | {
5 | public class Service
6 | {
7 | public string Id { get; set; }
8 | public string Name { get; set; }
9 |
10 | public ServiceNode[] Nodes { get; set; }
11 |
12 | #region Generated by ReSharper
13 |
14 | protected bool Equals(Service other)
15 | {
16 | return string.Equals(Id, other.Id) && string.Equals(Name, other.Name) && Nodes.SequenceEqual(other.Nodes);
17 | }
18 |
19 | public override bool Equals(object obj)
20 | {
21 | if (ReferenceEquals(null, obj)) return false;
22 | if (ReferenceEquals(this, obj)) return true;
23 | if (obj.GetType() != this.GetType()) return false;
24 | return Equals((Service) obj);
25 | }
26 |
27 | public override int GetHashCode()
28 | {
29 | unchecked
30 | {
31 | var hashCode = (Id != null ? Id.GetHashCode() : 0);
32 | hashCode = (hashCode * 397) ^ (Name != null ? Name.GetHashCode() : 0);
33 | hashCode = (hashCode * 397) ^ (Nodes != null ? Nodes.GetHashCode() : 0);
34 | return hashCode;
35 | }
36 | }
37 |
38 | #endregion
39 | }
40 |
41 | public class ServiceNode
42 | {
43 | public string Name { get; set; }
44 | public string Address { get; set; }
45 | public int Port { get; set; }
46 | public string[] Tags { get; set; }
47 |
48 | protected bool Equals(ServiceNode other)
49 | {
50 | return string.Equals(Name, other.Name)
51 | && string.Equals(Address, other.Address)
52 | && Port == other.Port
53 | && (Tags?.SequenceEqual(other.Tags) ?? other.Tags == null);
54 | }
55 |
56 | public override bool Equals(object obj)
57 | {
58 | if (ReferenceEquals(null, obj)) return false;
59 | if (ReferenceEquals(this, obj)) return true;
60 | if (obj.GetType() != this.GetType()) return false;
61 | return Equals((ServiceNode) obj);
62 | }
63 |
64 | public override int GetHashCode()
65 | {
66 | unchecked
67 | {
68 | var hashCode = (Name != null ? Name.GetHashCode() : 0);
69 | hashCode = (hashCode * 397) ^ (Address != null ? Address.GetHashCode() : 0);
70 | hashCode = (hashCode * 397) ^ Port;
71 | hashCode = (hashCode * 397) ^ (Tags != null ? Tags.GetHashCode() : 0);
72 | return hashCode;
73 | }
74 | }
75 | }
76 | }
--------------------------------------------------------------------------------
/ConsulRx/ServiceObservation.cs:
--------------------------------------------------------------------------------
1 | using System.Linq;
2 | using Consul;
3 |
4 | namespace ConsulRx
5 | {
6 | public class ServiceObservation : IConsulObservation
7 | {
8 | public string ServiceName { get; }
9 | public QueryResult Result { get; }
10 |
11 | QueryResult IConsulObservation.Result => Result;
12 |
13 | public ServiceObservation(string serviceName, QueryResult result)
14 | {
15 | ServiceName = serviceName;
16 | Result = result;
17 | }
18 |
19 | public Service ToService()
20 | {
21 | if (Result.Response == null || Result.Response.Length == 0)
22 | {
23 | return new Service { Name = ServiceName, Id = null, Nodes = new ServiceNode[0] };
24 | }
25 |
26 | return new Service
27 | {
28 | Id = Result.Response.First().ServiceID,
29 | Name = ServiceName,
30 | Nodes = Result.Response.Select(n => new ServiceNode
31 | {
32 | Address = string.IsNullOrWhiteSpace(n.ServiceAddress) ? n.Address : n.ServiceAddress,
33 | Name = n.Node,
34 | Port = n.ServicePort,
35 | Tags = n.ServiceTags
36 | })
37 | .ToArray()
38 | };
39 | }
40 | }
41 | }
--------------------------------------------------------------------------------
/Directory.Build.targets:
--------------------------------------------------------------------------------
1 |
2 |
3 | https://github.com/andyalm/consul-rx.git
4 | $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb
5 |
6 |
7 |
11 |
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 andyalm
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 |
--------------------------------------------------------------------------------
/NuGet.Config:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ConsulRx
2 | 
3 | [](https://www.nuget.org/packages/ConsulRx.Configuration)
4 | [](https://gitlab.com/andyalm/consul-rx/pipelines)
5 |
6 | A set of libraries for interacting with Hashicorp's Consul from .NET. These libraries leverage the [Reactive Extensions](https://github.com/Reactive-Extensions/Rx.NET) to allow you to consume Consul values as a stream so your application can react to changes very quickly.
7 |
8 | ## ConsulRx.Configuration
9 | Provides the ability to add config values into the `Microsoft.Extensions.Configuration` framework from Consul's KV Store and Service Catalog.
10 |
11 | ### Getting started
12 |
13 | In your `Startup.cs` (or equivilent bootstrapping file where you build your configuration):
14 |
15 | ```c#
16 | Configuration = new ConfigurationBuilder()
17 | .AddConsul(c =>
18 | c.Endpoint("http://myconsulserver:8500") //defaults to localhost if you omit
19 | .MapHttpService("mywidgetservice", "serviceEndpoints:widget") //maps the address of the consul service mywidgetservice to the serviceEndpoints:widget config key in IConfiguration
20 | .MapKeyPrefix("apps/myapp", "consul") //recursively maps all keys underneath apps/myapp to live in equivilent structure under the consul section in IConfiguration
21 | .MapKey("shared/key1", "key1")
22 | );
23 | ```
24 |
25 | ## ConsulRx.Templating
26 | Similar to Hashicorp's [consul-template](https://github.com/hashicorp/consul-template) but in .NET using Razor templates. Why? consul-template is a popular, capable and reliable solution for generating templates from consul values.
27 | Why write an alternative in .NET? After using consul-template in several production projects, I have been frustrated by the lack of flexibility of the go templates.
28 | In my experience, when trying to do anything somewhat complex, the templates become very hard to read and understand. This project is an experiment to see if I can get similar
29 | functionality as consul-template in .NET with a much more flexible templating language of Razor that is easier to write and understand.
--------------------------------------------------------------------------------
/Templating.CommandLine/ConsulRx.Templating.CommandLine.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | Exe
4 | net6.0
5 | true
6 | consul-rx-template
7 | true
8 | 7.1
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/Templating.CommandLine/Program.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using Microsoft.Extensions.CommandLineUtils;
5 | using Microsoft.Extensions.Configuration;
6 |
7 | namespace ConsulRx.Templating.CommandLine
8 | {
9 | class Program
10 | {
11 | static int Main(string[] args)
12 | {
13 | var config = new ConfigurationBuilder()
14 | .AddYamlFile("development.yml", optional: true)
15 | .Build();
16 |
17 | var consulConfig = config.GetSection("consul")?.Get() ?? new ObservableConsulConfiguration();
18 |
19 | var app = new CommandLineApplication
20 | {
21 | Name = "consul-rx-template"
22 | };
23 | var help = app.HelpOption("-h|--help");
24 | var consulEndpoint = app.Option("-e|--consul-endpoint", "The HTTP endpoint for consul", CommandOptionType.SingleValue);
25 | var templatePath = app.Option("-t|--template", "The path to the template and the path to the output (delimited by a colon)", CommandOptionType.SingleValue);
26 | var aclToken = app.Option("-a|--acl-token", "The ACL token used when reading from the KV store", CommandOptionType.SingleValue);
27 | var properties = app.Option("-p|--properties",
28 | "The template properties to pass to the templates in the format name=value",
29 | CommandOptionType.MultipleValue);
30 | app.OnExecute(async () =>
31 | {
32 | if (help.HasValue())
33 | {
34 | app.Out.WriteLine(app.GetHelpText());
35 | return 0;
36 | }
37 |
38 | if (consulEndpoint.HasValue())
39 | consulConfig.Endpoint = consulEndpoint.Value();
40 | if (aclToken.HasValue())
41 | consulConfig.AclToken = aclToken.Value();
42 |
43 | var template = ParseTemplatePath(templatePath, out var outputPath);
44 |
45 | var templateProcessor = new TemplateProcessorBuilder(template, outputPath)
46 | .ConsulConfiguration(consulConfig)
47 | .TemplateProperties(ParseProperties(properties.Values))
48 | .Build();
49 |
50 | try
51 | {
52 | await templateProcessor.RunAsync();
53 | }
54 | catch (Exception ex)
55 | {
56 | Console.Error.WriteLine(ex);
57 | return 1;
58 | }
59 |
60 | return 0;
61 | });
62 |
63 |
64 | return app.Execute(args);
65 | }
66 |
67 | private static string ParseTemplatePath(CommandOption templateArg, out string outputPath)
68 | {
69 | outputPath = null;
70 | if (!templateArg.HasValue())
71 | return "example.yml.razor";
72 |
73 | var args = templateArg.Value().Split(':');
74 | if (args.Length > 1)
75 | outputPath = args[1];
76 | return args[0];
77 | }
78 |
79 | private static IDictionary ParseProperties(IEnumerable args)
80 | {
81 | return args.Select(a =>
82 | {
83 | var equalsIndex = a.IndexOf('=');
84 | if (equalsIndex <= 0)
85 | {
86 | throw new FormatException("Expected the property argument to be of the format name=value");
87 | }
88 |
89 | return new KeyValuePair(a.Substring(0, equalsIndex), a.Substring(equalsIndex + 1));
90 | })
91 | .ToDictionary(p => p.Key, p => p.Value, StringComparer.OrdinalIgnoreCase);
92 | }
93 | }
94 |
95 |
96 | }
97 |
--------------------------------------------------------------------------------
/Templating.CommandLine/_build.razor:
--------------------------------------------------------------------------------
1 | @{
2 | var app = Property("app");
3 | }
4 | Active build for @app is @Value($"uni/apps/{app}/activeBuild")
--------------------------------------------------------------------------------
/Templating.CommandLine/build.txt:
--------------------------------------------------------------------------------
1 |
2 | My apps:
3 | Active build for search is 88384
4 | Active build for assetdetail is 88364
5 | Active build for gallery is 88094
6 |
--------------------------------------------------------------------------------
/Templating.CommandLine/builds.razor:
--------------------------------------------------------------------------------
1 | @{
2 | var apps = @Property("apps").Split(",");
3 | }
4 |
5 | My apps:
6 | @foreach(var app in apps)
7 | {
8 | @Partial("build", new { app })@:
9 | }
--------------------------------------------------------------------------------
/Templating.CommandLine/builds.txt:
--------------------------------------------------------------------------------
1 |
2 | My apps:
3 | Active build for search is 87707
4 | Active build for assetdetail is 87646
5 |
--------------------------------------------------------------------------------
/Templating.CommandLine/example.yml.razor:
--------------------------------------------------------------------------------
1 | services:
2 | consul:
3 | @foreach(var node in ServiceNodes("consul"))
4 | {
5 | @:http://@node.Address:@node.Port
6 | }
7 |
8 | keys:
9 | @foreach(var node in Tree("test"))
10 | {
11 | @:@node.FullKey = @node.Value
12 | }
--------------------------------------------------------------------------------
/Templating.UnitTests/ConsulRx.Templating.UnitTests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net6.0
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | all
14 | runtime; build; native; contentfiles; analyzers; buildtransitive
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/Templating.UnitTests/TemplateProcessorSpec.cs:
--------------------------------------------------------------------------------
1 | using System.IO;
2 | using ConsulRx.TestSupport;
3 | using FluentAssertions;
4 | using Moq;
5 | using Xunit;
6 |
7 | namespace ConsulRx.Templating.UnitTests
8 | {
9 | public class TemplateProcessorSpec
10 | {
11 | private readonly ConsulDependencies _consulDependencies = new ConsulDependencies();
12 | private readonly Mock _renderer = new Mock();
13 | private readonly FakeObservableConsul _consul = new FakeObservableConsul();
14 |
15 | public TemplateProcessorSpec()
16 | {
17 | _renderer.Setup(r => r.AnalyzeDependencies(It.IsAny(), It.IsAny())).Returns(_consulDependencies);
18 | }
19 |
20 | [Fact]
21 | public void AnalyzedDependenciesAreObserved()
22 | {
23 | _consulDependencies.Services.Add("myservice1");
24 | _consulDependencies.Keys.Add("mykey1");
25 | _consulDependencies.KeyPrefixes.Add("mykeyprefix1");
26 |
27 | CreateProcessor().RunAsync();
28 | _consul.ObservingDependencies.Should().Contain(d => d.Services.Contains("myservice1"));
29 | _consul.ObservingDependencies.Should().Contain(d => d.Keys.Contains("mykey1"));
30 | _consul.ObservingDependencies.Should().Contain(d => d.KeyPrefixes.Contains("mykeyprefix1"));
31 | }
32 |
33 | [Fact]
34 | public void TemplateIsNotRenderedUntilDependenciesHaveResponded()
35 | {
36 | _consulDependencies.Services.Add("myservice1");
37 | CreateProcessor().RunAsync();
38 | VerifyRenderIsCalled(Times.Never());
39 | _consul.DependencyObservations.OnNext(new ConsulState());
40 | VerifyRenderIsCalled(Times.Once());
41 | }
42 |
43 | private void VerifyRenderIsCalled(Times times)
44 | {
45 | _renderer.Verify(r => r.Render(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), times);
46 | }
47 |
48 | private TemplateProcessor CreateProcessor() => new TemplateProcessor(_renderer.Object, _consul, "mytemplate.razor", null, new PropertyBag());
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/Templating/ConsulRx.Templating.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | netstandard1.5
4 | True
5 | ConsulRx.Templating
6 | 0.1.0-alpha
7 | Andy Alm
8 | .NET API for executing templates that plug into Consul values and automatically update when consul values change.
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/Templating/ConsulTemplateBase.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.IO;
3 | using System.Linq;
4 | using System.Threading.Tasks;
5 |
6 | namespace ConsulRx.Templating
7 | {
8 | public abstract class ConsulTemplateBase
9 | {
10 | private ConsulState Model { get; set; }
11 | private TextWriter Writer { get; set; }
12 |
13 | private ConsulDependencies Dependencies { get; set; }
14 |
15 | private PropertyBag Properties { get; set; }
16 |
17 | public abstract Task ExecuteAsync();
18 |
19 | public string TemplatePath { get; internal set; }
20 |
21 | private bool AnalysisMode { get; set; }
22 |
23 | private ITemplateRenderer Renderer { get; set; }
24 |
25 | public ConsulDependencies AnalyzeDependencies(PropertyBag properties, ITemplateRenderer renderer)
26 | {
27 | AnalysisMode = true;
28 | try
29 | {
30 | Dependencies = new ConsulDependencies();
31 | Model = new ConsulState();
32 | Properties = properties ?? new PropertyBag();
33 | Renderer = renderer;
34 | ExecuteAsync().GetAwaiter().GetResult();
35 |
36 | return Dependencies;
37 | }
38 | finally
39 | {
40 | AnalysisMode = false;
41 | }
42 | }
43 |
44 | public void Render(TextWriter writer, ConsulState model, ITemplateRenderer renderer, PropertyBag properties)
45 | {
46 | Writer = writer;
47 | Model = model;
48 | Renderer = renderer;
49 | Properties = properties ?? new PropertyBag();
50 | ExecuteAsync().GetAwaiter().GetResult();
51 | }
52 |
53 | public void Write(object value)
54 | {
55 | if(!AnalysisMode)
56 | {
57 | WriteLiteral(value);
58 | }
59 | }
60 |
61 | public void WriteLiteral(object value)
62 | {
63 | if (!AnalysisMode)
64 | {
65 | Writer.Write(value);
66 | }
67 | }
68 |
69 | public IEnumerable ServiceNodes(string serviceName)
70 | {
71 | if (AnalysisMode)
72 | {
73 | Dependencies.Services.Add(serviceName);
74 | return Enumerable.Empty();
75 | }
76 |
77 | return Model.GetService(serviceName)?.Nodes ?? Enumerable.Empty();
78 | }
79 |
80 | public string Value(string key)
81 | {
82 | if (AnalysisMode)
83 | {
84 | Dependencies.Keys.Add(key);
85 | return string.Empty;
86 | }
87 |
88 | return Model.KVStore.GetValue(key);
89 | }
90 |
91 | public IEnumerable Children(string keyPrefix)
92 | {
93 | if (AnalysisMode)
94 | {
95 | Dependencies.KeyPrefixes.Add(keyPrefix);
96 | return Enumerable.Empty();
97 | }
98 |
99 | return Model.KVStore.GetChildren(keyPrefix);
100 | }
101 |
102 | public IEnumerable Tree(string keyPrefix)
103 | {
104 | if (AnalysisMode)
105 | {
106 | Dependencies.KeyPrefixes.Add(keyPrefix);
107 | return Enumerable.Empty();
108 | }
109 |
110 | return Model.KVStore.GetTree(keyPrefix);
111 | }
112 |
113 | public string Partial(string name, object args = null)
114 | {
115 | if (AnalysisMode)
116 | {
117 | Renderer.AnalyzePartialDependencies(name, TemplatePath, new PropertyBag(args)).CopyTo(Dependencies);
118 | return string.Empty;
119 | }
120 |
121 | Renderer.RenderPartial(name, TemplatePath, Writer, Model, new PropertyBag(args));
122 | return string.Empty;
123 | }
124 |
125 | public T Property(string name)
126 | {
127 | return Properties.Value(name);
128 | }
129 |
130 | public string Property(string name)
131 | {
132 | return Property