diff --git a/README.md b/README.md index edd9404..d09f236 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ [![Pharo 8.0](https://img.shields.io/badge/Pharo-8.0-informational)](https://pharo.org) -The integration provided in the operational plugin interacts with Consult HTTP API to register and deregister the configured services when the API starts/stops. +The integration provided in the operational plugin interacts with Consul HTTP API to register and deregister the configured services when the API starts/stops. ## License diff --git a/docs/ServiceDiscovery.md b/docs/ServiceDiscovery.md new file mode 100644 index 0000000..a351ca5 --- /dev/null +++ b/docs/ServiceDiscovery.md @@ -0,0 +1,30 @@ +# Consul Service Discovery + +One of the operational plugins. When enabled interacts with the [Consul Agent HTTP API](https://www.consul.io/api/index.html) to register the running service when the API starts and to deregister it when the API shuts down. + +This plugin is disabled by default and allows configuring the services to register. This configuration is made via the `#operations` config. + +For example: + +```smalltalk +Dictionary new + at: #operations put: ( + Dictionary new + at: 'consul-service-discovery' + put: { + #enabled -> true. + #consulAgentLocation -> 'http://localhost:8500' asUrl. + #definitions -> (Array with: self serviceDefinition) + } asDictionary; + yourself + ); + yourself +``` + +To create service definitions you can use `ConsulServiceDefinitionBuilder` instances to build the service definitions. Two type of checks are implemented and can be attached to a service definition by sending `addCheck:` to the builder: +- `ConsulAgentDockerBasedCheck` models a health check using the docker infrastructure and can be used when the services are run in docker containers. +- `ConsulAgentHTTPBasedCheck` models a health check performing an HTTP request and can be combined with the `HealthCheckPlugin` in Stargate. + +For more details review the [official Consul documentation on Services](https://www.consul.io/api/agent/service.html) and [Checks](https://www.consul.io/api/agent/check.html). + +This plugin does not add any new resources to the `/operations` endpoint available in [Stargate](https://github.com/ba-st/Stargate). diff --git a/source/BaselineOfStargateConsul/BaselineOfStargateConsul.class.st b/source/BaselineOfStargateConsul/BaselineOfStargateConsul.class.st new file mode 100644 index 0000000..5f6a090 --- /dev/null +++ b/source/BaselineOfStargateConsul/BaselineOfStargateConsul.class.st @@ -0,0 +1,49 @@ +Class { + #name : #BaselineOfStargateConsul, + #superclass : #BaselineOf, + #category : #BaselineOfStargateConsul +} + +{ #category : #baselines } +BaselineOfStargateConsul >> baseline: spec [ + + + spec + for: #pharo + do: [ self + setUpDependencies: spec; + setUpPackages: spec. + spec + group: 'CI' with: 'Tests'; + group: 'Tools' with: #('Stargate-Tools'); + group: 'Development' with: #('Tests' 'Tools') + ] +] + +{ #category : #accessing } +BaselineOfStargateConsul >> projectClass [ + + ^ MetacelloCypressBaselineProject +] + +{ #category : #baselines } +BaselineOfStargateConsul >> setUpDependencies: spec [ + + spec + baseline: 'Stargate' with: [ spec repository: 'github://ba-st/Stargate:v4/source' ]; + project: 'Stargate-Core' copyFrom: 'Stargate' with: [ spec loads: 'Core' ]; + project: 'Stargate-SUnit' copyFrom: 'Stargate' with: [ spec loads: 'Dependent-SUnit-Extensions' ]; + project: 'Stargate-Tools' copyFrom: 'Stargate' with: [ spec loads: 'Tools' ] +] + +{ #category : #baselines } +BaselineOfStargateConsul >> setUpPackages: spec [ + + spec + package: 'Stargate-Consul' with: [ spec requires: #('Stargate-Core') ]; + group: 'Deployment' with: 'Stargate-Consul'. + + spec + package: 'Stargate-Consul-Tests' with: [ spec requires: #('Stargate-Consul' 'Stargate-SUnit') ]; + group: 'Tests' with: 'Stargate-Consul-Tests' +] diff --git a/source/BaselineOfStargateConsul/package.st b/source/BaselineOfStargateConsul/package.st new file mode 100644 index 0000000..a510088 --- /dev/null +++ b/source/BaselineOfStargateConsul/package.st @@ -0,0 +1 @@ +Package { #name : #BaselineOfStargateConsul } diff --git a/source/Stargate-Consul-Tests/ConsulAgentCheckTest.class.st b/source/Stargate-Consul-Tests/ConsulAgentCheckTest.class.st new file mode 100644 index 0000000..611d44b --- /dev/null +++ b/source/Stargate-Consul-Tests/ConsulAgentCheckTest.class.st @@ -0,0 +1,16 @@ +" +A ConsulAgentCheckTest is a test class for testing the behavior of ConsulAgentCheck +" +Class { + #name : #ConsulAgentCheckTest, + #superclass : #TestCase, + #category : #'Stargate-Consul-Tests' +} + +{ #category : #test } +ConsulAgentCheckTest >> testAsGoTimeFormat [ + + self + assert: ( ConsulAgentCheck new asGoTimeFormat: 1.5644648441 hours ) + equals: '1h33m52s73ms438us760ns' +] diff --git a/source/Stargate-Consul-Tests/ConsulAgentDockerBasedCheckTest.class.st b/source/Stargate-Consul-Tests/ConsulAgentDockerBasedCheckTest.class.st new file mode 100644 index 0000000..6468795 --- /dev/null +++ b/source/Stargate-Consul-Tests/ConsulAgentDockerBasedCheckTest.class.st @@ -0,0 +1,105 @@ +" +A ConsulAgentDockerBasedCheckTest is a test class for testing the behavior of ConsulAgentDockerBasedCheck +" +Class { + #name : #ConsulAgentDockerBasedCheckTest, + #superclass : #TestCase, + #category : #'Stargate-Consul-Tests' +} + +{ #category : #tests } +ConsulAgentDockerBasedCheckTest >> testAsJSON [ + + | check json | + + check := ConsulAgentDockerBasedCheck + named: 'Check memory' + executing: '/bin/bash' + withArguments: #('/usr/local/bin/check-memory.sh') + inContainer: 'f972c95ebf0e' + every: 10 seconds. + + json := NeoJSONObject fromString: ( NeoJSONWriter toStringPretty: check ). + + self + assert: json Name equals: 'Check memory'; + assert: json Shell equals: '/bin/bash'; + assert: json Args equals: #('/usr/local/bin/check-memory.sh'); + assert: json DockerContainerID equals: 'f972c95ebf0e'; + assert: json Interval equals: '10s' +] + +{ #category : #tests } +ConsulAgentDockerBasedCheckTest >> testCantCreateUnnamed [ + + self + should: [ ConsulAgentDockerBasedCheck + named: '' + executing: '/bin/bash' + withArguments: #('/usr/local/bin/check-memory.sh') + inContainer: 'f972c95ebf0e' + every: 10 seconds + ] + raise: InstanceCreationFailed + withMessageText: 'The check name cannot be empty' +] + +{ #category : #tests } +ConsulAgentDockerBasedCheckTest >> testCantCreateWhenShellCommandIsMissing [ + + self + should: [ ConsulAgentDockerBasedCheck + named: 'xxx' + executing: '' + withArguments: #('/usr/local/bin/check-memory.sh') + inContainer: 'f972c95ebf0e' + every: 10 seconds + ] + raise: InstanceCreationFailed + withMessageText: 'The command to execute cannot be empty' +] + +{ #category : #tests } +ConsulAgentDockerBasedCheckTest >> testCantCreateWithEmptyContainerId [ + + self + should: [ ConsulAgentDockerBasedCheck + named: 'xx' + executing: '/bin/bash' + withArguments: #('/usr/local/bin/check-memory.sh') + inContainer: '' + every: 10 seconds + ] + raise: InstanceCreationFailed + withMessageText: 'The target container id cannot be empty' +] + +{ #category : #tests } +ConsulAgentDockerBasedCheckTest >> testCantCreateWithNegativeDuration [ + + self + should: [ ConsulAgentDockerBasedCheck + named: 'xx' + executing: '/bin/bash' + withArguments: #('/usr/local/bin/check-memory.sh') + inContainer: 'f972c95ebf0e' + every: -10 seconds + ] + raise: InstanceCreationFailed + withMessageText: 'The execution interval must be strictly positive' +] + +{ #category : #tests } +ConsulAgentDockerBasedCheckTest >> testCantCreateWithZeroDuration [ + + self + should: [ ConsulAgentDockerBasedCheck + named: 'xx' + executing: '/bin/bash' + withArguments: #('/usr/local/bin/check-memory.sh') + inContainer: 'f972c95ebf0e' + every: 0 seconds + ] + raise: InstanceCreationFailed + withMessageText: 'The execution interval must be strictly positive' +] diff --git a/source/Stargate-Consul-Tests/ConsulAgentHTTPBasedCheckTest.class.st b/source/Stargate-Consul-Tests/ConsulAgentHTTPBasedCheckTest.class.st new file mode 100644 index 0000000..6146f00 --- /dev/null +++ b/source/Stargate-Consul-Tests/ConsulAgentHTTPBasedCheckTest.class.st @@ -0,0 +1,136 @@ +" +A ConsulAgentHTTPBasedCheckTest is a test class for testing the behavior of ConsulAgentHTTPBasedCheck +" +Class { + #name : #ConsulAgentHTTPBasedCheckTest, + #superclass : #TestCase, + #category : #'Stargate-Consul-Tests' +} + +{ #category : #tests } +ConsulAgentHTTPBasedCheckTest >> testAsJSON [ + + | check json | + + check := ConsulAgentHTTPBasedCheck + named: 'HTTP check' + executing: #POST + against: 'http://api.example.com' asUrl + withHeaders: #() + every: 10 seconds + timeoutAfter: 1.5 minutes. + + json := NeoJSONObject fromString: ( NeoJSONWriter toStringPretty: check ). + + self + assert: json Name equals: 'HTTP check'; + assert: json Method equals: 'POST'; + assert: json Header isEmpty; + assert: json HTTP equals: 'http://api.example.com/'; + assert: json Timeout equals: '1m30s'; + assert: json Interval equals: '10s' +] + +{ #category : #tests } +ConsulAgentHTTPBasedCheckTest >> testAsJSONWhenHeadersArePresent [ + + | check json | + + check := ConsulAgentHTTPBasedCheck + named: 'HTTP check' + executing: #POST + against: 'http://api.example.com' asUrl + withHeaders: { #accept -> ZnMimeType applicationJson } + every: 10 seconds + timeoutAfter: 1.5 minutes. + + json := NeoJSONObject fromString: ( NeoJSONWriter toStringPretty: check ). + + self + assert: json Name equals: 'HTTP check'; + assert: json Method equals: 'POST'; + assert: json Header accept equals: 'application/json'; + assert: json HTTP equals: 'http://api.example.com/'; + assert: json Timeout equals: '1m30s'; + assert: json Interval equals: '10s' +] + +{ #category : #tests } +ConsulAgentHTTPBasedCheckTest >> testCantCreateUnnamed [ + + self + should: [ ConsulAgentHTTPBasedCheck + named: '' + executing: #POST + against: 'http://api.example.com' asUrl + withHeaders: #() + every: 10 seconds + timeoutAfter: 1.5 minutes + ] + raise: InstanceCreationFailed + withMessageText: 'The check name cannot be empty' +] + +{ #category : #tests } +ConsulAgentHTTPBasedCheckTest >> testCantCreateWithNegativeDuration [ + + self + should: [ ConsulAgentHTTPBasedCheck + named: 'xxx' + executing: #POST + against: 'http://api.example.com' asUrl + withHeaders: #() + every: -10 seconds + timeoutAfter: 1.5 minutes + ] + raise: InstanceCreationFailed + withMessageText: 'The execution interval must be strictly positive' +] + +{ #category : #tests } +ConsulAgentHTTPBasedCheckTest >> testCantCreateWithNegativeTimeout [ + + self + should: [ ConsulAgentHTTPBasedCheck + named: 'xxx' + executing: #POST + against: 'http://api.example.com' asUrl + withHeaders: #() + every: 10 seconds + timeoutAfter: -1 minutes + ] + raise: InstanceCreationFailed + withMessageText: 'The timeout must be strictly positive' +] + +{ #category : #tests } +ConsulAgentHTTPBasedCheckTest >> testCantCreateWithZeroDuration [ + + self + should: [ ConsulAgentHTTPBasedCheck + named: 'xxx' + executing: #POST + against: 'http://api.example.com' asUrl + withHeaders: #() + every: 0 seconds + timeoutAfter: 1.5 minutes + ] + raise: InstanceCreationFailed + withMessageText: 'The execution interval must be strictly positive' +] + +{ #category : #tests } +ConsulAgentHTTPBasedCheckTest >> testCantCreateWithZeroTimeout [ + + self + should: [ ConsulAgentHTTPBasedCheck + named: 'xxx' + executing: #POST + against: 'http://api.example.com' asUrl + withHeaders: #() + every: 10 seconds + timeoutAfter: 0 minutes + ] + raise: InstanceCreationFailed + withMessageText: 'The timeout must be strictly positive' +] diff --git a/source/Stargate-Consul-Tests/ConsulServiceDefinitionBuilderTest.class.st b/source/Stargate-Consul-Tests/ConsulServiceDefinitionBuilderTest.class.st new file mode 100644 index 0000000..385dd1c --- /dev/null +++ b/source/Stargate-Consul-Tests/ConsulServiceDefinitionBuilderTest.class.st @@ -0,0 +1,171 @@ +" +A ConsulServiceDefinitionBuilderTest is a test class for testing the behavior of ConsulServiceDefinitionBuilder +" +Class { + #name : #ConsulServiceDefinitionBuilderTest, + #superclass : #TestCase, + #instVars : [ + 'builder' + ], + #category : #'Stargate-Consul-Tests' +} + +{ #category : #'private - asserting' } +ConsulServiceDefinitionBuilderTest >> assertDefinition: aServiceDefinition jsonEquals: aJSONString [ + + self + assert: ( NeoJSONObject fromString: ( NeoJSONWriter toString: aServiceDefinition ) ) + equals: ( NeoJSONObject fromString: aJSONString ) +] + +{ #category : #'private - asserting' } +ConsulServiceDefinitionBuilderTest >> assertNameIn: definition equals: aString [ + + self assert: definition Name equals: aString +] + +{ #category : #running } +ConsulServiceDefinitionBuilderTest >> setUp [ + + super setUp. + builder := ConsulServiceDefinitionBuilder new +] + +{ #category : #tests } +ConsulServiceDefinitionBuilderTest >> testEnableTagOverride [ + + | definition | + + definition := builder + enableTagOverride; + buildNamed: 'redis'. + + self + assertNameIn: definition equals: 'redis'; + assertDefinition: definition jsonEquals: '{"Name":"redis","EnableTagOverride":true}' +] + +{ #category : #tests } +ConsulServiceDefinitionBuilderTest >> testServiceWithMetadata [ + + | definition | + + definition := builder + metadataAt: 'version' put: '1.2.5'; + metadataAt: 'author' put: 'anonymous'; + buildNamed: 'redis'. + + self + assertNameIn: definition equals: 'redis'; + assertDefinition: definition + jsonEquals: '{"Name":"redis","Meta":{"version":"1.2.5","author":"anonymous"}}' +] + +{ #category : #tests } +ConsulServiceDefinitionBuilderTest >> testServiceWithOnlyOneCheck [ + + | definition | + + definition := builder + addCheck: + ( ConsulAgentDockerBasedCheck + named: 'check' + executing: '/bin/bash' + withArguments: #('true') + inContainer: 'xxx' + every: 1 milliSecond ); + buildNamed: 'redis'. + + self + assertNameIn: definition equals: 'redis'; + assertDefinition: definition + jsonEquals: + '{"Name":"redis","Checks":[{"Name":"check","Shell":"/bin/bash","Args":["true"],"DockerContainerID":"xxx","Interval":"1ms"}]}' +] + +{ #category : #tests } +ConsulServiceDefinitionBuilderTest >> testServiceWithSeveralChecks [ + + | definition | + + definition := builder + addCheck: + ( ConsulAgentDockerBasedCheck + named: 'check' + executing: '/bin/bash' + withArguments: #('true') + inContainer: 'xxx' + every: 1 milliSecond ); + addCheck: + ( ConsulAgentHTTPBasedCheck + named: 'ping' + executing: #GET + against: 'https://api.example.com' + withHeaders: #() + every: 100 milliSeconds + timeoutAfter: 1 minute ); + buildNamed: 'redis'. + + self + assertNameIn: definition equals: 'redis'; + assertDefinition: definition + jsonEquals: + '{ + "Name":"redis", + "Checks":[ + {"Name":"check","Shell":"/bin/bash","Args":["true"],"DockerContainerID":"xxx","Interval":"1ms"}, + {"Name":"ping","HTTP":"https://api.example.com/","Method":"GET","Header":{},"Interval":"100ms","Timeout":"1m"} + ] + }' +] + +{ #category : #tests } +ConsulServiceDefinitionBuilderTest >> testServiceWithTaggedAddresses [ + + | definition | + + definition := builder + addAsLANAddress: '127.0.0.0'; + addAsWANAddress: '198.18.0.53' at: 80; + buildNamed: 'redis'. + + self + assertNameIn: definition equals: 'redis'; + assertDefinition: definition + jsonEquals: + '{"Name":"redis","TaggedAddresses":{"lan":{"address":"127.0.0.0"},"wan":{"address":"198.18.0.53","port":80}}}' +] + +{ #category : #tests } +ConsulServiceDefinitionBuilderTest >> testSimpleServiceCreation [ + + | definition | + + definition := builder + identifiedBy: 'redis1'; + servedAtLocalhost; + addTag: 'primary'; + addTag: 'cache'; + port: 8800; + buildNamed: 'redis'. + + self + assertNameIn: definition equals: 'redis'; + assert: definition ID equals: 'redis1'; + assert: definition Address equals: 'localhost'; + assert: definition Port equals: 8800; + assertDefinition: definition + jsonEquals: '{"Name":"redis","ID":"redis1","Address":"localhost","Tags":["primary","cache"],"Port":8800}' +] + +{ #category : #tests } +ConsulServiceDefinitionBuilderTest >> testSimplestServiceCreation [ + + | definition | + + definition := builder buildNamed: 'redis'. + + self + assertNameIn: definition equals: 'redis'; + assertDefinition: definition jsonEquals: '{"Name":"redis"}' +] diff --git a/source/Stargate-Consul-Tests/ConsulServiceDiscoveryPluginAPITest.class.st b/source/Stargate-Consul-Tests/ConsulServiceDiscoveryPluginAPITest.class.st new file mode 100644 index 0000000..b03a717 --- /dev/null +++ b/source/Stargate-Consul-Tests/ConsulServiceDiscoveryPluginAPITest.class.st @@ -0,0 +1,68 @@ +Class { + #name : #ConsulServiceDiscoveryPluginAPITest, + #superclass : #OperationalPluginAPITest, + #instVars : [ + 'consulAgent' + ], + #category : #'Stargate-Consul-Tests' +} + +{ #category : #private } +ConsulServiceDiscoveryPluginAPITest >> consulAgentLocation [ + + ^ 'http://localhost:9998' asUrl +] + +{ #category : #private } +ConsulServiceDiscoveryPluginAPITest >> operationsConfiguration [ + + ^ super operationsConfiguration + at: ConsulServiceDiscoveryPlugin endpoint + put: { + #enabled -> true. + #definitions -> self serviceDefinitions. + #consulAgentLocation -> self consulAgentLocation + } asDictionary; + yourself +] + +{ #category : #private } +ConsulServiceDiscoveryPluginAPITest >> requiredPermissions [ + + ^ #() +] + +{ #category : #private } +ConsulServiceDiscoveryPluginAPITest >> serviceDefinitions [ + + ^ { ConsulServiceDefinitionBuilder new buildNamed: 'test' } +] + +{ #category : #tests } +ConsulServiceDiscoveryPluginAPITest >> setUp [ + + consulAgent := FakeConsulAgentAPI + configuredBy: + {( #port -> 9998 ). + ( #serverUrl -> self consulAgentLocation ). + ( #debugMode -> true )} + on: self. + consulAgent start. + super setUp +] + +{ #category : #tests } +ConsulServiceDiscoveryPluginAPITest >> tearDown [ + + super tearDown. + self assert: consulAgent registeredServiceCount equals: 0. + consulAgent stop +] + +{ #category : #tests } +ConsulServiceDiscoveryPluginAPITest >> testPluginIsEnabled [ + + self + assert: ( api isEnabled: ConsulServiceDiscoveryPlugin ); + assert: consulAgent registeredServiceCount equals: 1 +] diff --git a/source/Stargate-Consul-Tests/ConsulServiceDiscoveryPluginInteractionTest.class.st b/source/Stargate-Consul-Tests/ConsulServiceDiscoveryPluginInteractionTest.class.st new file mode 100644 index 0000000..eda7be1 --- /dev/null +++ b/source/Stargate-Consul-Tests/ConsulServiceDiscoveryPluginInteractionTest.class.st @@ -0,0 +1,88 @@ +Class { + #name : #ConsulServiceDiscoveryPluginInteractionTest, + #superclass : #TestCase, + #instVars : [ + 'consulAgent' + ], + #category : #'Stargate-Consul-Tests' +} + +{ #category : #accessing } +ConsulServiceDiscoveryPluginInteractionTest >> apiServer [ + + ^ Teapot new +] + +{ #category : #accessing } +ConsulServiceDiscoveryPluginInteractionTest >> baseUrl [ + + ^ 'http://localhost' asUrl port: self port +] + +{ #category : #accessing } +ConsulServiceDiscoveryPluginInteractionTest >> port [ + + ^ 9998 +] + +{ #category : #running } +ConsulServiceDiscoveryPluginInteractionTest >> setUp [ + + super setUp. + consulAgent := FakeConsulAgentAPI + configuredBy: + {( #port -> self port ). + ( #serverUrl -> self baseUrl ). + ( #debugMode -> true )} + on: self. + consulAgent start +] + +{ #category : #running } +ConsulServiceDiscoveryPluginInteractionTest >> tearDown [ + + consulAgent stop. + super tearDown +] + +{ #category : #tests } +ConsulServiceDiscoveryPluginInteractionTest >> testStartOn [ + + | plugin | + + plugin := ConsulServiceDiscoveryPlugin + reportingLifecycleOf: ( ConsulServiceDefinitionBuilder new buildNamed: 'test' ) + toAgentOn: self baseUrl. + + self assert: consulAgent registeredServices isEmpty. + + plugin startOn: self apiServer. + + self + withTheOnlyOneIn: consulAgent registeredServices + do: [ :registeredService | + self + assert: registeredService Name equals: 'test'; + assert: registeredService ID equals: 'test' + ] +] + +{ #category : #tests } +ConsulServiceDiscoveryPluginInteractionTest >> testStop [ + + | plugin | + + plugin := ConsulServiceDiscoveryPlugin + reportingLifecycleOf: ( ConsulServiceDefinitionBuilder new buildNamed: 'test' ) + toAgentOn: self baseUrl. + + self assert: consulAgent registeredServices isEmpty. + + plugin startOn: self apiServer. + + self assert: consulAgent registeredServiceCount equals: 1. + + plugin stop. + + self assert: consulAgent registeredServices isEmpty +] diff --git a/source/Stargate-Consul-Tests/ConsulServiceDiscoveryPluginTest.class.st b/source/Stargate-Consul-Tests/ConsulServiceDiscoveryPluginTest.class.st new file mode 100644 index 0000000..03c9d7a --- /dev/null +++ b/source/Stargate-Consul-Tests/ConsulServiceDiscoveryPluginTest.class.st @@ -0,0 +1,26 @@ +" +A ConsulServiceDiscoveryPluginTest is a test class for testing the behavior of ConsulServiceDiscoveryPlugin +" +Class { + #name : #ConsulServiceDiscoveryPluginTest, + #superclass : #TestCase, + #category : #'Stargate-Consul-Tests' +} + +{ #category : #tests } +ConsulServiceDiscoveryPluginTest >> testEnabledByDefault [ + + self deny: ConsulServiceDiscoveryPlugin enabledByDefault +] + +{ #category : #tests } +ConsulServiceDiscoveryPluginTest >> testEndpoint [ + + self assert: ConsulServiceDiscoveryPlugin endpoint equals: 'consul-service-discovery' +] + +{ #category : #tests } +ConsulServiceDiscoveryPluginTest >> testPluginName [ + + self assert: ConsulServiceDiscoveryPlugin pluginName equals: 'Consul Service Discovery' +] diff --git a/source/Stargate-Consul-Tests/FakeConsulAgentAPI.class.st b/source/Stargate-Consul-Tests/FakeConsulAgentAPI.class.st new file mode 100644 index 0000000..55404e8 --- /dev/null +++ b/source/Stargate-Consul-Tests/FakeConsulAgentAPI.class.st @@ -0,0 +1,87 @@ +" +I'm a fake object. I will impersonate the Consul Agent HTTP API, and can be used in tests without requiring a real Consul installation. +" +Class { + #name : #FakeConsulAgentAPI, + #superclass : #Object, + #instVars : [ + 'server', + 'registeredServices', + 'asserter' + ], + #category : #'Stargate-Consul-Tests' +} + +{ #category : #'instance creation' } +FakeConsulAgentAPI class >> configuredBy: configuration on: aTestAsserter [ + + ^ self new initializeConfiguredBy: configuration on: aTestAsserter +] + +{ #category : #initialization } +FakeConsulAgentAPI >> configureTeapotServerWith: configuration [ + + server := Teapot configure: configuration. + server + PUT: '/agent/service/register' -> [ :request | self handleServiceRegistration: request ]; + PUT: + '/agent/service/deregister/' + -> [ :request | self handleServiceDeregistration: request ] +] + +{ #category : #private } +FakeConsulAgentAPI >> handleServiceDeregistration: request [ + + | serviceId | + + asserter assert: request method equals: #PUT. + serviceId := request at: #identifier. + registeredServices removeAllSuchThat: [ :service | service ID = serviceId ]. + ^ TeaResponse ok +] + +{ #category : #private } +FakeConsulAgentAPI >> handleServiceRegistration: request [ + + | service | + + asserter + assert: request method equals: #PUT; + assert: request contents notEmpty. + service := NeoJSONObject fromString: request contents. + service at: #ID ifAbsentPut: [ service at: #Name ]. + registeredServices add: service. + ^ TeaResponse ok +] + +{ #category : #initialization } +FakeConsulAgentAPI >> initializeConfiguredBy: configuration on: aTestAsserter [ + + registeredServices := OrderedCollection new. + asserter := aTestAsserter. + self configureTeapotServerWith: configuration +] + +{ #category : #accessing } +FakeConsulAgentAPI >> registeredServiceCount [ + + ^ registeredServices size +] + +{ #category : #accessing } +FakeConsulAgentAPI >> registeredServices [ + + ^ registeredServices +] + +{ #category : #controlling } +FakeConsulAgentAPI >> start [ + + server start +] + +{ #category : #controlling } +FakeConsulAgentAPI >> stop [ + + server stop +] diff --git a/source/Stargate-Consul-Tests/package.st b/source/Stargate-Consul-Tests/package.st new file mode 100644 index 0000000..ea30ec3 --- /dev/null +++ b/source/Stargate-Consul-Tests/package.st @@ -0,0 +1 @@ +Package { #name : #'Stargate-Consul-Tests' } diff --git a/source/Stargate-Consul/ConsulAgentCheck.class.st b/source/Stargate-Consul/ConsulAgentCheck.class.st new file mode 100644 index 0000000..2cf2d37 --- /dev/null +++ b/source/Stargate-Consul/ConsulAgentCheck.class.st @@ -0,0 +1,64 @@ +" +I represent an application-level health check to be performed by a Consul agent and associated with a service. +There are several checks represented by my subclasses. +" +Class { + #name : #ConsulAgentCheck, + #superclass : #Object, + #category : #'Stargate-Consul' +} + +{ #category : #converting } +ConsulAgentCheck >> asDictionary [ + + ^ Dictionary new + at: 'Name' put: self name; + at: 'Interval' put: ( self asGoTimeFormat: self invokationInterval ); + yourself +] + +{ #category : #private } +ConsulAgentCheck >> asGoTimeFormat: aDuration [ + + "A duration string is a possibly signed sequence of decimal numbers, each with optional fraction and a unit suffix, such as 300ms, -1.5h or 2h45m. + Valid time units are ns, us (or µs), ms, s, m, h." + + ^ String + streamContents: [ :stream | + self + nextPut: aDuration hours of: 'h' in: stream; + nextPut: aDuration minutes of: 'm' in: stream; + nextPut: aDuration seconds of: 's' in: stream; + nextPut: aDuration wholeMilliseconds of: 'ms' in: stream; + nextPut: aDuration wholeMicroseconds of: 'us' in: stream; + nextPut: aDuration wholeNanoseconds of: 'ns' in: stream + ] +] + +{ #category : #accessing } +ConsulAgentCheck >> invokationInterval [ + + ^ self subclassResponsibility +] + +{ #category : #accessing } +ConsulAgentCheck >> name [ + + ^ self subclassResponsibility +] + +{ #category : #encoding } +ConsulAgentCheck >> neoJsonOn: neoJSONWriter [ + + neoJSONWriter writeMap: self asDictionary +] + +{ #category : #private } +ConsulAgentCheck >> nextPut: aNumber of: aTimeUnitString in: stream [ + + aNumber strictlyPositive + then: [ stream + nextPutAll: aNumber asString; + nextPutAll: aTimeUnitString + ] +] diff --git a/source/Stargate-Consul/ConsulAgentDockerBasedCheck.class.st b/source/Stargate-Consul/ConsulAgentDockerBasedCheck.class.st new file mode 100644 index 0000000..f2b70d5 --- /dev/null +++ b/source/Stargate-Consul/ConsulAgentDockerBasedCheck.class.st @@ -0,0 +1,73 @@ +" +I'm a kind of Consul Agent health check depending on invoking an external application which is packaged within a Docker Container. + +The application is triggered within the running container via the Docker Exec API. We expect that the Consul agent user has access to either the Docker HTTP API or the unix socket. +Consul uses $DOCKER_HOST to determine the Docker API endpoint. The application is expected to run, perform a health check of the service running inside the container, and exit with an appropriate exit code. The check should be paired with an invocation interval. The shell on which the check has to be performed is configurable, which make possible to run containers which have different shells on the same host. Check output for Docker is limited to 4KB. Any output larger than this will be truncated. +" +Class { + #name : #ConsulAgentDockerBasedCheck, + #superclass : #ConsulAgentCheck, + #instVars : [ + 'name', + 'shell', + 'arguments', + 'containerId', + 'invokationInterval' + ], + #category : #'Stargate-Consul' +} + +{ #category : #'instance creation' } +ConsulAgentDockerBasedCheck class >> named: aName executing: aCommand withArguments: anArgumentArray inContainer: aContainerId every: aDuration [ + + AssertionCheckerBuilder new + raising: InstanceCreationFailed; + checking: [ :asserter | + asserter + enforce: [ aName notEmpty ] because: 'The check name cannot be empty'; + enforce: [ aCommand notEmpty ] because: 'The command to execute cannot be empty'; + enforce: [ aContainerId notEmpty ] because: 'The target container id cannot be empty'; + enforce: [ aDuration positive and: [ aDuration isZero not ] ] + because: 'The execution interval must be strictly positive' + ]; + buildAndCheck. + + ^ self new + initializeNamed: aName + executing: aCommand + withArguments: anArgumentArray + inContainer: aContainerId + every: aDuration +] + +{ #category : #converting } +ConsulAgentDockerBasedCheck >> asDictionary [ + + ^ super asDictionary + at: 'DockerContainerID' put: containerId; + at: 'Shell' put: shell; + at: 'Args' put: arguments; + yourself +] + +{ #category : #initialization } +ConsulAgentDockerBasedCheck >> initializeNamed: aName executing: aCommand withArguments: anArgumentArray inContainer: aContainerId every: aDuration [ + + name := aName. + shell := aCommand. + arguments := anArgumentArray. + containerId := aContainerId. + invokationInterval := aDuration +] + +{ #category : #accessing } +ConsulAgentDockerBasedCheck >> invokationInterval [ + + ^ invokationInterval +] + +{ #category : #accessing } +ConsulAgentDockerBasedCheck >> name [ + + ^ name +] diff --git a/source/Stargate-Consul/ConsulAgentHTTPBasedCheck.class.st b/source/Stargate-Consul/ConsulAgentHTTPBasedCheck.class.st new file mode 100644 index 0000000..489c7d4 --- /dev/null +++ b/source/Stargate-Consul/ConsulAgentHTTPBasedCheck.class.st @@ -0,0 +1,82 @@ +" +I'm a kind of Consul Agent health check depending on performing an HTTP request. +" +Class { + #name : #ConsulAgentHTTPBasedCheck, + #superclass : #ConsulAgentCheck, + #instVars : [ + 'name', + 'url', + 'method', + 'headers', + 'invokationInterval', + 'timeout' + ], + #category : #'Stargate-Consul' +} + +{ #category : #'instance creation' } +ConsulAgentHTTPBasedCheck class >> named: aName executing: anHttpMethod against: anUrl withHeaders: aHeaderCollection every: aDuration timeoutAfter: timeoutDuration [ + + AssertionCheckerBuilder new + raising: InstanceCreationFailed; + checking: [ :asserter | + asserter + enforce: [ aName notEmpty ] because: 'The check name cannot be empty'; + enforce: [ aDuration positive and: [ aDuration isZero not ] ] + because: 'The execution interval must be strictly positive'; + enforce: [ timeoutDuration positive and: [ timeoutDuration isZero not ] ] + because: 'The timeout must be strictly positive' + ]; + buildAndCheck. + + ^ self new + initializeNamed: aName + executing: anHttpMethod + against: anUrl asUrl + withHeaders: aHeaderCollection + every: aDuration + timeoutAfter: timeoutDuration +] + +{ #category : #converting } +ConsulAgentHTTPBasedCheck >> asDictionary [ + + ^ super asDictionary + at: 'HTTP' put: url; + at: 'Method' put: method; + at: 'Header' put: headers; + at: 'Timeout' put: ( self asGoTimeFormat: timeout ); + yourself +] + +{ #category : #initialization } +ConsulAgentHTTPBasedCheck >> initializeNamed: aName executing: anHttpMethod against: anUrl withHeaders: aHeaderCollection every: aDuration timeoutAfter: timeoutDuration [ + + name := aName. + method := anHttpMethod. + url := anUrl. + headers := aHeaderCollection asDictionary. + invokationInterval := aDuration. + timeout := timeoutDuration +] + +{ #category : #accessing } +ConsulAgentHTTPBasedCheck >> invokationInterval [ + + ^ invokationInterval +] + +{ #category : #accessing } +ConsulAgentHTTPBasedCheck >> name [ + + ^ name +] + +{ #category : #encoding } +ConsulAgentHTTPBasedCheck >> neoJsonOn: neoJSONWriter [ + + ( neoJSONWriter customMappingFor: ZnMimeType ) encoder: [ :mediaType | mediaType asString ]. + ( neoJSONWriter customMappingFor: ZnUrl ) encoder: [ :theUrl | theUrl asString ]. + super neoJsonOn: neoJSONWriter +] diff --git a/source/Stargate-Consul/ConsulServiceDefinitionBuilder.class.st b/source/Stargate-Consul/ConsulServiceDefinitionBuilder.class.st new file mode 100644 index 0000000..82a05b9 --- /dev/null +++ b/source/Stargate-Consul/ConsulServiceDefinitionBuilder.class.st @@ -0,0 +1,132 @@ +" +I'm a builder intended to simplify the creation of service definitions under the Consult umbrella (See https://www.consul.io/docs/agent/services.html for details). +" +Class { + #name : #ConsulServiceDefinitionBuilder, + #superclass : #Object, + #instVars : [ + 'serviceDefinition' + ], + #category : #'Stargate-Consul' +} + +{ #category : #configuring } +ConsulServiceDefinitionBuilder >> addAsLANAddress: aString [ + + self taggedAddressesAt: 'lan' on: 'address' put: aString +] + +{ #category : #configuring } +ConsulServiceDefinitionBuilder >> addAsLANAddress: aString at: aPortNumber [ + + self + addAsLANAddress: aString; + assertIsValidPort: aPortNumber; + taggedAddressesAt: 'lan' on: 'port' put: aPortNumber +] + +{ #category : #configuring } +ConsulServiceDefinitionBuilder >> addAsWANAddress: aString [ + + self taggedAddressesAt: 'wan' on: 'address' put: aString +] + +{ #category : #configuring } +ConsulServiceDefinitionBuilder >> addAsWANAddress: aString at: aPortNumber [ + + self + addAsWANAddress: aString; + assertIsValidPort: aPortNumber; + taggedAddressesAt: 'wan' on: 'port' put: aPortNumber +] + +{ #category : #configuring } +ConsulServiceDefinitionBuilder >> addCheck: aConsulAgentCheck [ + + | checks | + + checks := serviceDefinition at: 'Checks' ifAbsentPut: [ OrderedCollection new ]. + checks add: aConsulAgentCheck +] + +{ #category : #configuring } +ConsulServiceDefinitionBuilder >> addTag: aString [ + + | tags | + + AssertionChecker enforce: [ aString notEmpty ] because: 'A tag cannot be empty'. + tags := serviceDefinition at: 'Tags' ifAbsentPut: [ OrderedCollection new ]. + tags add: aString +] + +{ #category : #configuring } +ConsulServiceDefinitionBuilder >> address: aString [ + + serviceDefinition at: 'Address' put: aString +] + +{ #category : #private } +ConsulServiceDefinitionBuilder >> assertIsValidPort: aPortNumber [ + + AssertionChecker + enforce: [ aPortNumber isInteger and: [ aPortNumber positive ] ] + because: 'A port cannot be a negative number' +] + +{ #category : #building } +ConsulServiceDefinitionBuilder >> buildNamed: aServiceName [ + + AssertionChecker enforce: [ aServiceName notEmpty ] because: 'A service name cannot be empty'. + serviceDefinition at: 'Name' put: aServiceName. + ^ NeoJSONObject newFrom: serviceDefinition +] + +{ #category : #configuring } +ConsulServiceDefinitionBuilder >> enableTagOverride [ + + serviceDefinition at: 'EnableTagOverride' put: true +] + +{ #category : #configuring } +ConsulServiceDefinitionBuilder >> identifiedBy: aString [ + + AssertionChecker enforce: [ aString notEmpty ] because: 'An ID cannot be empty'. + serviceDefinition at: 'ID' put: aString +] + +{ #category : #initialization } +ConsulServiceDefinitionBuilder >> initialize [ + + super initialize . + serviceDefinition := OrderedDictionary new +] + +{ #category : #configuring } +ConsulServiceDefinitionBuilder >> metadataAt: aKey put: aValue [ + + serviceDefinition at: 'Meta' at: aKey put: aValue +] + +{ #category : #configuring } +ConsulServiceDefinitionBuilder >> port: anInteger [ + + self assertIsValidPort: anInteger. + serviceDefinition at: 'Port' put: anInteger +] + +{ #category : #configuring } +ConsulServiceDefinitionBuilder >> servedAtLocalhost [ + + self address: 'localhost' +] + +{ #category : #private } +ConsulServiceDefinitionBuilder >> taggedAddressesAt: aTag on: aKey put: aValue [ + + | taggedAddresses | + + taggedAddresses := serviceDefinition + at: 'TaggedAddresses' + ifAbsentPut: [ serviceDefinition species new ]. + taggedAddresses at: aTag at: aKey put: aValue +] diff --git a/source/Stargate-Consul/ConsulServiceDiscoveryPlugin.class.st b/source/Stargate-Consul/ConsulServiceDiscoveryPlugin.class.st new file mode 100644 index 0000000..f92b734 --- /dev/null +++ b/source/Stargate-Consul/ConsulServiceDiscoveryPlugin.class.st @@ -0,0 +1,100 @@ +" +I'm one of the operational plugins. +I provide support for registering and deregistering services on a Consul Agent (https://www.consul.io/) using the HTTP API. +" +Class { + #name : #ConsulServiceDiscoveryPlugin, + #superclass : #OperationalPlugin, + #instVars : [ + 'serviceDefinitions', + 'consulAgentLocation' + ], + #category : #'Stargate-Consul' +} + +{ #category : #configuring } +ConsulServiceDiscoveryPlugin class >> configureMediaControlsIn: builder within: requestContext [ + + +] + +{ #category : #'instance creation' } +ConsulServiceDiscoveryPlugin class >> configuredBy: configuration [ + + | selfConfiguration | + + selfConfiguration := self pluginConfigurationOn: configuration. + ^ self + reportingLifecycleOfAll: ( selfConfiguration at: #definitions ) + toAgentOn: ( selfConfiguration at: #consulAgentLocation ifAbsent: [ 'http://localhost:8500' asUrl ] ) +] + +{ #category : #accessing } +ConsulServiceDiscoveryPlugin class >> endpoint [ + + ^ 'consul-service-discovery' +] + +{ #category : #accessing } +ConsulServiceDiscoveryPlugin class >> pluginName [ + + ^ 'Consul Service Discovery' +] + +{ #category : #'instance creation' } +ConsulServiceDiscoveryPlugin class >> reportingLifecycleOf: aServiceDefinition toAgentOn: aConsulAPIUrl [ + + ^ self reportingLifecycleOfAll: ( Array with: aServiceDefinition ) toAgentOn: aConsulAPIUrl +] + +{ #category : #'instance creation' } +ConsulServiceDiscoveryPlugin class >> reportingLifecycleOfAll: aServiceDefinitionCollection toAgentOn: aConsulAPIUrl [ + + ^ self new initializeReportingLifecycleOfAll: aServiceDefinitionCollection toAgentOn: aConsulAPIUrl +] + +{ #category : #private } +ConsulServiceDiscoveryPlugin >> deregistrationUrlFor: serviceDefinition [ + + | serviceId | + + serviceId := serviceDefinition at: #ID ifAbsent: [ serviceDefinition Name ]. + + ^ consulAgentLocation / ( 'agent/service/deregister/<1s>' expandMacrosWith: serviceId ) +] + +{ #category : #configuring } +ConsulServiceDiscoveryPlugin >> includeControllersIn: api [ +] + +{ #category : #initialization } +ConsulServiceDiscoveryPlugin >> initializeReportingLifecycleOfAll: aServiceDefinitionCollection toAgentOn: aConsulAPIUrl [ + + serviceDefinitions := aServiceDefinitionCollection. + consulAgentLocation := aConsulAPIUrl +] + +{ #category : #controlling } +ConsulServiceDiscoveryPlugin >> startOn: teapotServer [ + + serviceDefinitions + do: [ :serviceDefinition | + ZnClient new + beOneShot; + enforceHttpSuccess; + put: consulAgentLocation / 'agent/service/register' + contents: ( NeoJSONWriter toString: serviceDefinition ) + ] +] + +{ #category : #controlling } +ConsulServiceDiscoveryPlugin >> stop [ + + serviceDefinitions + do: [ :serviceDefinition | + ZnClient new + beOneShot; + enforceHttpSuccess; + put: ( self deregistrationUrlFor: serviceDefinition ) contents: '' + ] +] diff --git a/source/Stargate-Consul/package.st b/source/Stargate-Consul/package.st new file mode 100644 index 0000000..ad6e7db --- /dev/null +++ b/source/Stargate-Consul/package.st @@ -0,0 +1 @@ +Package { #name : #'Stargate-Consul' }