├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .php-cs-fixer.php ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── composer.json ├── phpstan-baseline.neon ├── phpstan.neon ├── phpunit.xml ├── src ├── Client.php ├── ClientInterface.php ├── ConsulResponse.php ├── DsnResolver.php ├── Exception │ ├── ClientException.php │ ├── ConsulExceptionInterface.php │ └── ServerException.php ├── Helper │ ├── LockHandler.php │ ├── MultiLockHandler.php │ ├── MultiSemaphore.php │ └── MultiSemaphore │ │ └── Resource.php ├── OptionsResolver.php └── Services │ ├── Agent.php │ ├── Catalog.php │ ├── Health.php │ ├── KV.php │ ├── Session.php │ └── TXN.php └── tests ├── DsnResolverTest.php ├── Helper ├── MultiLockHandlerTest.php └── MultiSemaphoreTest.php ├── OptionsResolverTest.php └── Services └── KVTest.php /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | php-cs-fixer: 10 | name: Check PHP Coding Standards 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | 16 | - name: PHP-CS-Fixer 17 | uses: docker://oskarstark/php-cs-fixer-ga 18 | with: 19 | args: --config=.php-cs-fixer.php --diff --dry-run 20 | 21 | phpstan: 22 | name: PHPStan 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v2 27 | 28 | - name: PHPStan 29 | uses: docker://oskarstark/phpstan-ga 30 | with: 31 | args: analyse 32 | 33 | ci: 34 | name: Test PHP ${{ matrix.php-version }} ${{ matrix.name }} 35 | runs-on: ubuntu-latest 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | php-version: ["8.2", "8.3"] 40 | composer-flags: [""] 41 | name: [""] 42 | include: 43 | - php-version: 8.1 44 | composer-flags: "--prefer-lowest" 45 | name: "(prefer lowest dependencies)" 46 | 47 | services: 48 | service-name-1: 49 | image: consul:1.15 50 | ports: 51 | - 8500:8500 52 | 53 | steps: 54 | - name: Checkout 55 | uses: actions/checkout@v2 56 | 57 | - name: Setup PHP, with composer and extensions 58 | uses: shivammathur/setup-php@v2 59 | with: 60 | php-version: ${{ matrix.php-version }} 61 | extensions: xml 62 | 63 | - name: Install Composer dependencies 64 | run: composer update --prefer-dist --no-interaction ${{ matrix.composer-flags }} 65 | 66 | - name: Run Tests 67 | run: make test-phpunit 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.phpunit.cache/ 2 | /composer.lock 3 | /vendor/ 4 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | in(__DIR__) 5 | ->append([ 6 | __FILE__, 7 | ]) 8 | ; 9 | 10 | return (new PhpCsFixer\Config()) 11 | ->setRiskyAllowed(true) 12 | ->setRules([ 13 | '@PHP81Migration' => true, 14 | '@PhpCsFixer' => true, 15 | '@Symfony' => true, 16 | '@Symfony:risky' => true, 17 | 'php_unit_internal_class' => false, // From @PhpCsFixer but we don't want it 18 | 'php_unit_test_class_requires_covers' => false, // From @PhpCsFixer but we don't want it 19 | 'phpdoc_add_missing_param_annotation' => false, // From @PhpCsFixer but we don't want it 20 | 'ordered_class_elements' => true, // Symfony(PSR12) override the default value, but we don't want 21 | 'blank_line_before_statement' => true, // Symfony(PSR12) override the default value, but we don't want 22 | ]) 23 | ->setFinder($finder) 24 | ; 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 5.3.0 (not released yet) 4 | 5 | ## 5.2.0 (2024-03-04) 6 | 7 | * Drop support for PHP < .8.0 8 | * Add support for PHP 8.2, and 8.3 9 | * Drop support for Symfony < 5.4, and 6.0, 6.1, 6.2, and 6.3 10 | 11 | ## 5.1.0 (2023-12-21) 12 | 13 | * Add support for Support symfony/http-client 7.x 14 | 15 | ## 5.0.0 (2022-06-13) 16 | 17 | Release notes: 18 | 19 | This is the first big release under friendsofphp umbrella. There are lot of BC 20 | breaks, but they should be easy to fix. From now on, a particular attention will 21 | be given to not break the BC and to provide a nice upgrade path. 22 | 23 | * Rename package from `sensiolabs/consul-php-sdk` to `friendsofphp/consul-php-sdk` 24 | * Get ride of SensioLabs namespace (from `SensioLabs\Consul` to `Consul`) 25 | * Add typehint where possible 26 | * Force JSON body request where possible (now you must pass an array as body) 27 | * Remove the factory and almost all interfaces 28 | * Bump to PHP 7.4+ 29 | * Add support for missing scheme in DSN 30 | * Switch from Travis to GitHub Action 31 | * Add some internal tooling (php-cs-fixer, phpstan, phpunit, Makefile) 32 | * Add MultiLockHandler and MultiSemaphore helpers 33 | 34 | --- 35 | 36 | Previous CHANGELOGs are missing 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 SensioLabs 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help test-phpcsfixer test-phpstan test-phpunit test-all 2 | 3 | .DEFAULT_GOAL := help 4 | 5 | help: 6 | @grep -h -e ' ### ' $(MAKEFILE_LIST) | fgrep -v fgrep | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 7 | 8 | test-phpcsfixer: ### Execute phpcsfixer 9 | php-cs-fixer fix 10 | 11 | test-phpstan: ### Execute phpstan 12 | phpstan 13 | 14 | test-phpunit: ### Execute phpunit 15 | vendor/bin/simple-phpunit 16 | 17 | test-all: test-phpcsfixer test-phpstan test-phpunit ### Test everything 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Consul PHP SDK 2 | 3 | Consul PHP SDK is a thin wrapper around the [Consul](https://consul.io/) HTTP API. 4 | 5 | ## Compatibility 6 | 7 | See previous version of 8 | [README.md](https://github.com/FriendsOfPHP/consul-php-sdk/tree/404366acbce4285d08126c0a55ace84c10e361d1) 9 | to find some version compatible with older version of symfony/http-client or 10 | guzzle 11 | 12 | ## Installation 13 | 14 | This library can be installed with composer: 15 | 16 | composer require friendsofphp/consul-php-sdk 17 | 18 | ## Supported services 19 | 20 | * agent 21 | * catalog 22 | * health 23 | * kv 24 | * session 25 | * txn 26 | 27 | ## Usage 28 | 29 | Instantiate a services, and start using it: 30 | 31 | ```php 32 | 33 | $kv = new Consul\Services\KV(); 34 | 35 | $kv->put('test/foo/bar', 'bazinga'); 36 | $kv->get('test/foo/bar', ['raw' => true]); 37 | $kv->delete('test/foo/bar'); 38 | ``` 39 | 40 | A service exposes few methods mapped from the consul [API](https://consul.io/docs/agent/http.html): 41 | 42 | **All services methods follow the same convention:** 43 | 44 | ```php 45 | $response = $service->method($mandatoryArgument, $someOptions); 46 | ``` 47 | 48 | * All API mandatory arguments are placed as first; 49 | * All API optional arguments are directly mapped from `$someOptions`; 50 | * All methods return a `Consul\ConsulResponse`; 51 | * If the API responds with a 4xx response, a `Consul\Exception\ClientException` is thrown; 52 | * If the API responds with a 5xx response, a `Consul\Exception\ServeException` is thrown. 53 | 54 | ## Cookbook 55 | 56 | ### How to acquire an exclusive lock? 57 | 58 | ```php 59 | $session = new Consul\Services\Session(); 60 | 61 | $sessionId = $session->create()->json()['ID']; 62 | 63 | // Lock a key / value with the current session 64 | $lockAcquired = $kv->put('tests/session/a-lock', 'a value', ['acquire' => $sessionId])->json(); 65 | 66 | if (false === $lockAcquired) { 67 | $session->destroy($sessionId); 68 | 69 | echo "The lock is already acquire by another node.\n"; 70 | exit(1); 71 | } 72 | 73 | echo "Do you jobs here...."; 74 | sleep(5); 75 | echo "End\n"; 76 | 77 | $kv->delete('tests/session/a-lock'); 78 | $session->destroy($sessionId); 79 | ``` 80 | 81 | ### How to use MultiLockHandler? 82 | 83 | ```php 84 | $resources = ['resource1', 'resource2']; 85 | 86 | $multiLockHandler = new MultiLockHandler($resources, 60, new Session(), new KV(), 'my/lock/'); 87 | 88 | if ($multiLockHandler->lock()) { 89 | try { 90 | echo "Do you jobs here...."; 91 | } finally { 92 | $multiLockHandler->release(); 93 | } 94 | } 95 | ``` 96 | 97 | 98 | ### How to use MultiSemaphore? 99 | 100 | ```php 101 | $resources = [ 102 | new Resource('resource1', 2, 7), 103 | new Resource('resource2', 3, 6), 104 | new Resource('resource3', 1, 1), 105 | ]; 106 | 107 | $semaphore = new MultiSemaphore($resources, 60, new Session(), new KV(), 'my/semaphore'); 108 | 109 | if ($semaphore->acquire()) { 110 | try { 111 | echo "Do you jobs here...."; 112 | } finally { 113 | $semaphore->release(); 114 | } 115 | } 116 | ``` 117 | 118 | ## Some utilities 119 | 120 | * `Consul\Helper\LockHandler`: Simple class that implement a distributed lock 121 | * `Consul\Helper\MultiLockHandler`: Simple class that implements a distributed lock for many resources 122 | * `Consul\Helper\MultiSemaphore`: Simple class that implements a distributed semaphore for many resources 123 | 124 | ## Run the test suite 125 | 126 | You need a consul agent running on `localhost:8500`. 127 | 128 | But you ca override this address: 129 | 130 | ``` 131 | export CONSUL_HTTP_ADDR=172.17.0.2:8500 132 | ``` 133 | 134 | If you don't want to install Consul locally you can use a docker container: 135 | 136 | ``` 137 | docker run -d --name=dev-consul -e CONSUL_BIND_INTERFACE=eth0 consul 138 | ``` 139 | 140 | Then, run the test suite 141 | 142 | ``` 143 | vendor/bin/simple-phpunit 144 | ``` 145 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "friendsofphp/consul-php-sdk", 3 | "description": "SDK to talk with consul.io API", 4 | "keywords": ["hashicorp", "consul", "sdk"], 5 | "license": "MIT", 6 | "type": "library", 7 | "authors": [ 8 | { 9 | "name": "Grégoire Pineau", 10 | "email": "lyrixx@lyrixx.info" 11 | } 12 | ], 13 | "require": { 14 | "php": ">=8.1", 15 | "psr/log": "^1|^2|^3", 16 | "symfony/http-client": "^5.4|^6.4|^7.0" 17 | }, 18 | "require-dev": { 19 | "symfony/phpunit-bridge": "^6.0|^7.0" 20 | }, 21 | "autoload": { 22 | "psr-4": { 23 | "Consul\\": "src/" 24 | } 25 | }, 26 | "autoload-dev": { 27 | "psr-4": { 28 | "Consul\\Tests\\": "tests/" 29 | } 30 | }, 31 | "extra": { 32 | "branch-alias": { 33 | "dev-master": "5.x-dev" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriendsOfPHP/consul-php-sdk/8ae4e60b48ae1abe2d235838db290d0b7e5dcfa2/phpstan-baseline.neon -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan-baseline.neon 3 | 4 | parameters: 5 | level: 5 6 | paths: 7 | - src 8 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | tests/ 17 | 18 | 19 | 20 | 22 | 23 | src/ 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | client = $client; 27 | $this->logger = $logger ?? new NullLogger(); 28 | } 29 | 30 | public function get(?string $url = null, array $options = []): ConsulResponse 31 | { 32 | return $this->doRequest('GET', $url, $options); 33 | } 34 | 35 | public function head(string $url, array $options = []): ConsulResponse 36 | { 37 | return $this->doRequest('HEAD', $url, $options); 38 | } 39 | 40 | public function delete(string $url, array $options = []): ConsulResponse 41 | { 42 | return $this->doRequest('DELETE', $url, $options); 43 | } 44 | 45 | public function put(string $url, array $options = []): ConsulResponse 46 | { 47 | return $this->doRequest('PUT', $url, $options); 48 | } 49 | 50 | public function patch(string $url, array $options = []): ConsulResponse 51 | { 52 | return $this->doRequest('PATCH', $url, $options); 53 | } 54 | 55 | public function post(string $url, array $options = []): ConsulResponse 56 | { 57 | return $this->doRequest('POST', $url, $options); 58 | } 59 | 60 | public function options(string $url, array $options = []): ConsulResponse 61 | { 62 | return $this->doRequest('OPTIONS', $url, $options); 63 | } 64 | 65 | private function doRequest(string $method, string $url, array $options): ConsulResponse 66 | { 67 | if (isset($options['body']) && \is_array($options['body'])) { 68 | $options['body'] = json_encode($options['body'], \JSON_THROW_ON_ERROR); 69 | } 70 | 71 | $this->logger->info(sprintf('%s "%s"', $method, $url)); 72 | $this->logger->debug(sprintf('Requesting %s %s', $method, $url), ['options' => $options]); 73 | 74 | try { 75 | $response = $this->client->request($method, $url, $options); 76 | } catch (TransportExceptionInterface $e) { 77 | $message = sprintf('Something went wrong when calling consul (%s).', $e->getMessage()); 78 | 79 | $this->logger->error($message); 80 | 81 | throw new ServerException($message); 82 | } 83 | 84 | $this->logger->debug(sprintf("Response:\n%s", $this->formatResponse($response))); 85 | 86 | if (400 <= $response->getStatusCode()) { 87 | $message = sprintf('Something went wrong when calling consul (%s).', $response->getStatusCode()); 88 | 89 | $this->logger->error($message); 90 | 91 | $message .= "\n".(string) $response->getContent(false); 92 | if (500 <= $response->getStatusCode()) { 93 | throw new ServerException($message, $response->getStatusCode()); 94 | } 95 | 96 | throw new ClientException($message, $response->getStatusCode()); 97 | } 98 | 99 | return new ConsulResponse($response->getHeaders(), (string) $response->getContent(), $response->getStatusCode()); 100 | } 101 | 102 | private function formatResponse(ResponseInterface $response): string 103 | { 104 | $headers = []; 105 | 106 | foreach ($response->getHeaders(false) as $key => $values) { 107 | foreach ($values as $value) { 108 | $headers[] = sprintf('%s: %s', $key, $value); 109 | } 110 | } 111 | 112 | return sprintf("%s\n\n%s", implode("\n", $headers), $response->getContent(false)); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/ClientInterface.php: -------------------------------------------------------------------------------- 1 | headers = $headers; 14 | $this->body = $body; 15 | $this->status = $status; 16 | } 17 | 18 | public function getHeaders(): array 19 | { 20 | return $this->headers; 21 | } 22 | 23 | public function getBody(): string 24 | { 25 | return $this->body; 26 | } 27 | 28 | public function getStatusCode(): int 29 | { 30 | return $this->status; 31 | } 32 | 33 | public function json() 34 | { 35 | return json_decode($this->body, true, 512, \JSON_THROW_ON_ERROR); 36 | } 37 | 38 | public function isSuccessful(): bool 39 | { 40 | return $this->status >= 200 && $this->status < 300; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/DsnResolver.php: -------------------------------------------------------------------------------- 1 | key = $key; 20 | $this->value = $value; 21 | $this->session = $session ?: new Session(); 22 | $this->kv = $kv ?: new KV(); 23 | } 24 | 25 | public function lock() 26 | { 27 | // Start a session 28 | $session = $this->session->create()->json(); 29 | $this->sessionId = $session['ID']; 30 | 31 | // Lock a key / value with the current session 32 | $lockAcquired = $this->kv->put($this->key, (string) $this->value, ['acquire' => $this->sessionId])->json(); 33 | 34 | if (false === $lockAcquired) { 35 | $this->session->destroy($this->sessionId); 36 | 37 | return false; 38 | } 39 | 40 | register_shutdown_function([$this, 'release']); 41 | 42 | return true; 43 | } 44 | 45 | public function release() 46 | { 47 | $this->kv->delete($this->key); 48 | $this->session->destroy($this->sessionId); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Helper/MultiLockHandler.php: -------------------------------------------------------------------------------- 1 | resources = $resources; 20 | $this->ttl = $ttl; 21 | $this->session = $session; 22 | $this->kv = $kv; 23 | $this->lockPath = $lockPath; 24 | } 25 | 26 | public function lock(): bool 27 | { 28 | // Start a session 29 | $this->sessionId = $this->session->create(['LockDelay' => 0, 'TTL' => "{$this->ttl}s"])->json()['ID']; 30 | 31 | $result = true; 32 | $lockedResources = []; 33 | 34 | try { 35 | foreach ($this->resources as $resource) { 36 | // Lock a key / value with the current session 37 | $lockAcquired = $this->kv->put($this->lockPath.$resource, '', ['acquire' => $this->sessionId])->json(); 38 | 39 | if (false === $lockAcquired) { 40 | $result = false; 41 | 42 | break; 43 | } 44 | 45 | $lockedResources[] = $resource; 46 | } 47 | } catch (\Exception $e) { 48 | $result = false; 49 | } finally { 50 | if (!$result) { 51 | $this->releaseResources($lockedResources); 52 | } 53 | } 54 | 55 | return $result; 56 | } 57 | 58 | public function release(): void 59 | { 60 | $this->releaseResources($this->resources); 61 | } 62 | 63 | public function renew(): bool 64 | { 65 | return $this->session->renew($this->sessionId)->isSuccessful(); 66 | } 67 | 68 | public function getResources(): array 69 | { 70 | return $this->resources; 71 | } 72 | 73 | private function releaseResources(array $resources): void 74 | { 75 | foreach ($resources as $resource) { 76 | $this->kv->delete($this->lockPath.$resource); 77 | } 78 | 79 | $this->session->destroy($this->sessionId); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Helper/MultiSemaphore.php: -------------------------------------------------------------------------------- 1 | resources = $resources; 22 | $this->ttl = $ttl; 23 | $this->session = $session; 24 | $this->kv = $kv; 25 | $this->keyPrefix = trim($keyPrefix, '/'); 26 | } 27 | 28 | public function getResources(): array 29 | { 30 | return $this->resources; 31 | } 32 | 33 | public function acquire(): bool 34 | { 35 | if (null !== $this->sessionId) { 36 | throw new \RuntimeException('Resources are acquired already'); 37 | } 38 | 39 | // Start a session 40 | $session = $this->session->create(['Name' => 'semaphore', 'LockDelay' => 0, 'TTL' => "{$this->ttl}s"])->json(); 41 | $this->sessionId = $session['ID']; 42 | 43 | $result = false; 44 | 45 | try { 46 | $result = $this->acquireResources(); 47 | } finally { 48 | if (!$result) { 49 | $this->release(); 50 | } 51 | } 52 | 53 | return $result; 54 | } 55 | 56 | public function renew(): bool 57 | { 58 | return $this->session->renew($this->sessionId)->isSuccessful(); 59 | } 60 | 61 | public function release(): void 62 | { 63 | if ($this->sessionId) { 64 | foreach ($this->resources as $resource) { 65 | $this->kv->delete($this->getResourceKey($resource, $this->sessionId)); 66 | } 67 | 68 | $this->session->destroy($this->sessionId); 69 | $this->sessionId = null; 70 | } 71 | } 72 | 73 | private function acquireResources(): bool 74 | { 75 | $result = true; 76 | 77 | foreach ($this->resources as $resource) { 78 | if (false === $this->kv->put($this->getResourceKey($resource, $this->sessionId), '', ['acquire' => $this->sessionId])->json()) { 79 | $result = false; 80 | } else { 81 | $semaphoreMetaDataValue = [ 82 | 'limit' => $resource->getLimit(), 83 | 'sessions' => [], 84 | ]; 85 | 86 | // get actual metadata 87 | $semaphoreDataItems = $this->kv->get($this->getResourceKeyPrefix($resource), ['recurse' => true])->json(); 88 | foreach ($semaphoreDataItems as $key => $item) { 89 | if ($item['Key'] == $this->getResourceKey($resource, $this->metaDataKey)) { 90 | $semaphoreMetaDataActual = $item; 91 | $semaphoreMetaDataActual['Value'] = json_decode(base64_decode($semaphoreMetaDataActual['Value']), true); 92 | unset($semaphoreDataItems[$key]); 93 | 94 | break; 95 | } 96 | } 97 | 98 | // build new metadata 99 | if (isset($semaphoreMetaDataActual)) { 100 | foreach ($semaphoreDataItems as $item) { 101 | if (isset($item['Session'])) { 102 | if (isset($semaphoreMetaDataActual['Value']['sessions'][$item['Session']])) { 103 | $semaphoreMetaDataValue['sessions'][$item['Session']] = $semaphoreMetaDataActual['Value']['sessions'][$item['Session']]; 104 | } 105 | } else { 106 | $this->kv->delete($item['Key']); 107 | } 108 | } 109 | } 110 | 111 | $resource->setAcquired( 112 | min($resource->getAcquire(), $semaphoreMetaDataValue['limit'] - array_sum($semaphoreMetaDataValue['sessions'])) 113 | ); 114 | 115 | // add new element to metadata and save it 116 | if ($resource->getAcquired() > 0) { 117 | $semaphoreMetaDataValue['sessions'][$this->sessionId] = $resource->getAcquired(); 118 | $result = $this->kv->put( 119 | $this->getResourceKey($resource, $this->metaDataKey), 120 | $semaphoreMetaDataValue, 121 | ['cas' => isset($semaphoreMetaDataActual) ? $semaphoreMetaDataActual['ModifyIndex'] : 0] 122 | )->json(); 123 | } else { 124 | $result = false; 125 | } 126 | } 127 | 128 | if (!$result) { 129 | break; 130 | } 131 | } 132 | 133 | return $result; 134 | } 135 | 136 | private function getResourceKeyPrefix(Resource $resource): string 137 | { 138 | return $this->keyPrefix.'/'.$resource->getName(); 139 | } 140 | 141 | private function getResourceKey(Resource $resource, string $name): string 142 | { 143 | return $this->getResourceKeyPrefix($resource).'/'.$name; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/Helper/MultiSemaphore/Resource.php: -------------------------------------------------------------------------------- 1 | name = $name; 15 | $this->acquire = $acquire; 16 | $this->acquired = 0; 17 | $this->limit = $limit; 18 | } 19 | 20 | public function getName(): string 21 | { 22 | return $this->name; 23 | } 24 | 25 | public function getAcquire(): int 26 | { 27 | return $this->acquire; 28 | } 29 | 30 | public function getAcquired(): int 31 | { 32 | return $this->acquired; 33 | } 34 | 35 | public function getLimit(): int 36 | { 37 | return $this->limit; 38 | } 39 | 40 | public function setAcquired(int $acquired): void 41 | { 42 | $this->acquired = $acquired; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/OptionsResolver.php: -------------------------------------------------------------------------------- 1 | client = $client ?: new Client(); 17 | } 18 | 19 | public function checks(): ConsulResponse 20 | { 21 | return $this->client->get('/v1/agent/checks'); 22 | } 23 | 24 | public function services(): ConsulResponse 25 | { 26 | return $this->client->get('/v1/agent/services'); 27 | } 28 | 29 | public function members(array $options = []): ConsulResponse 30 | { 31 | $params = [ 32 | 'query' => OptionsResolver::resolve($options, ['wan']), 33 | ]; 34 | 35 | return $this->client->get('/v1/agent/members', $params); 36 | } 37 | 38 | public function self(): ConsulResponse 39 | { 40 | return $this->client->get('/v1/agent/self'); 41 | } 42 | 43 | public function join(string $address, array $options = []): ConsulResponse 44 | { 45 | $params = [ 46 | 'query' => OptionsResolver::resolve($options, ['wan']), 47 | ]; 48 | 49 | return $this->client->get('/v1/agent/join/'.$address, $params); 50 | } 51 | 52 | public function forceLeave(string $node): ConsulResponse 53 | { 54 | return $this->client->get('/v1/agent/force-leave/'.$node); 55 | } 56 | 57 | public function registerCheck(array $check): ConsulResponse 58 | { 59 | $params = [ 60 | 'json' => $check, 61 | ]; 62 | 63 | return $this->client->put('/v1/agent/check/register', $params); 64 | } 65 | 66 | public function deregisterCheck(string $checkId): ConsulResponse 67 | { 68 | return $this->client->put('/v1/agent/check/deregister/'.$checkId); 69 | } 70 | 71 | public function passCheck(string $checkId, array $options = []): ConsulResponse 72 | { 73 | $params = [ 74 | 'query' => OptionsResolver::resolve($options, ['note']), 75 | ]; 76 | 77 | return $this->client->put('/v1/agent/check/pass/'.$checkId, $params); 78 | } 79 | 80 | public function warnCheck(string $checkId, array $options = []): ConsulResponse 81 | { 82 | $params = [ 83 | 'query' => OptionsResolver::resolve($options, ['note']), 84 | ]; 85 | 86 | return $this->client->put('/v1/agent/check/warn/'.$checkId, $params); 87 | } 88 | 89 | public function failCheck(string $checkId, array $options = []): ConsulResponse 90 | { 91 | $params = [ 92 | 'query' => OptionsResolver::resolve($options, ['note']), 93 | ]; 94 | 95 | return $this->client->put('/v1/agent/check/fail/'.$checkId, $params); 96 | } 97 | 98 | public function registerService(array $service): ConsulResponse 99 | { 100 | $params = [ 101 | 'json' => $service, 102 | ]; 103 | 104 | return $this->client->put('/v1/agent/service/register', $params); 105 | } 106 | 107 | public function deregisterService(string $serviceId): ConsulResponse 108 | { 109 | return $this->client->put('/v1/agent/service/deregister/'.$serviceId); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Services/Catalog.php: -------------------------------------------------------------------------------- 1 | client = $client ?: new Client(); 17 | } 18 | 19 | public function register(array $node): ConsulResponse 20 | { 21 | $params = [ 22 | 'json' => $node, 23 | ]; 24 | 25 | return $this->client->put('/v1/catalog/register', $params); 26 | } 27 | 28 | public function deregister(array $node): ConsulResponse 29 | { 30 | $params = [ 31 | 'json' => $node, 32 | ]; 33 | 34 | return $this->client->put('/v1/catalog/deregister', $params); 35 | } 36 | 37 | public function datacenters(): ConsulResponse 38 | { 39 | return $this->client->get('/v1/catalog/datacenters'); 40 | } 41 | 42 | public function nodes(array $options = []): ConsulResponse 43 | { 44 | $params = [ 45 | 'query' => OptionsResolver::resolve($options, ['dc']), 46 | ]; 47 | 48 | return $this->client->get('/v1/catalog/nodes', $params); 49 | } 50 | 51 | public function node(string $node, array $options = []): ConsulResponse 52 | { 53 | $params = [ 54 | 'query' => OptionsResolver::resolve($options, ['dc']), 55 | ]; 56 | 57 | return $this->client->get('/v1/catalog/node/'.$node, $params); 58 | } 59 | 60 | public function services(array $options = []): ConsulResponse 61 | { 62 | $params = [ 63 | 'query' => OptionsResolver::resolve($options, ['dc']), 64 | ]; 65 | 66 | return $this->client->get('/v1/catalog/services', $params); 67 | } 68 | 69 | public function service(string $service, array $options = []): ConsulResponse 70 | { 71 | $params = [ 72 | 'query' => OptionsResolver::resolve($options, ['dc', 'tag']), 73 | ]; 74 | 75 | return $this->client->get('/v1/catalog/service/'.$service, $params); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Services/Health.php: -------------------------------------------------------------------------------- 1 | client = $client ?: new Client(); 17 | } 18 | 19 | public function node(string $node, array $options = []): ConsulResponse 20 | { 21 | $params = [ 22 | 'query' => OptionsResolver::resolve($options, ['dc']), 23 | ]; 24 | 25 | return $this->client->get('/v1/health/node/'.$node, $params); 26 | } 27 | 28 | public function checks(string $service, array $options = []): ConsulResponse 29 | { 30 | $params = [ 31 | 'query' => OptionsResolver::resolve($options, ['dc']), 32 | ]; 33 | 34 | return $this->client->get('/v1/health/checks/'.$service, $params); 35 | } 36 | 37 | public function service(string $service, array $options = []): ConsulResponse 38 | { 39 | $params = [ 40 | 'query' => OptionsResolver::resolve($options, ['dc', 'tag', 'passing']), 41 | ]; 42 | 43 | return $this->client->get('/v1/health/service/'.$service, $params); 44 | } 45 | 46 | public function state(string $state, array $options = []): ConsulResponse 47 | { 48 | $params = [ 49 | 'query' => OptionsResolver::resolve($options, ['dc']), 50 | ]; 51 | 52 | return $this->client->get('/v1/health/state/'.$state, $params); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Services/KV.php: -------------------------------------------------------------------------------- 1 | client = $client ?: new Client(); 17 | } 18 | 19 | public function get(string $key, array $options = []): ConsulResponse 20 | { 21 | $params = [ 22 | 'query' => OptionsResolver::resolve($options, ['dc', 'recurse', 'keys', 'separator', 'raw', 'stale', 'consistent', 'default']), 23 | ]; 24 | 25 | return $this->client->get('v1/kv/'.$key, $params); 26 | } 27 | 28 | public function put(string $key, $value, array $options = []): ConsulResponse 29 | { 30 | $params = [ 31 | 'body' => $value, 32 | 'query' => OptionsResolver::resolve($options, ['dc', 'flags', 'cas', 'acquire', 'release']), 33 | ]; 34 | 35 | return $this->client->put('v1/kv/'.$key, $params); 36 | } 37 | 38 | public function delete(string $key, array $options = []): ConsulResponse 39 | { 40 | $params = [ 41 | 'query' => OptionsResolver::resolve($options, ['dc', 'recurse']), 42 | ]; 43 | 44 | return $this->client->delete('v1/kv/'.$key, $params); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Services/Session.php: -------------------------------------------------------------------------------- 1 | client = $client ?: new Client(); 17 | } 18 | 19 | public function create(array $session = [], array $options = []): ConsulResponse 20 | { 21 | $params = [ 22 | 'json' => $session, 23 | 'query' => OptionsResolver::resolve($options, ['dc']), 24 | ]; 25 | 26 | return $this->client->put('/v1/session/create', $params); 27 | } 28 | 29 | public function destroy(string $sessionId, array $options = []): ConsulResponse 30 | { 31 | $params = [ 32 | 'query' => OptionsResolver::resolve($options, ['dc']), 33 | ]; 34 | 35 | return $this->client->put('/v1/session/destroy/'.$sessionId, $params); 36 | } 37 | 38 | public function info(string $sessionId, array $options = []): ConsulResponse 39 | { 40 | $params = [ 41 | 'query' => OptionsResolver::resolve($options, ['dc']), 42 | ]; 43 | 44 | return $this->client->get('/v1/session/info/'.$sessionId, $params); 45 | } 46 | 47 | public function node(string $node, array $options = []): ConsulResponse 48 | { 49 | $params = [ 50 | 'query' => OptionsResolver::resolve($options, ['dc']), 51 | ]; 52 | 53 | return $this->client->get('/v1/session/node/'.$node, $params); 54 | } 55 | 56 | public function all(array $options = []): ConsulResponse 57 | { 58 | $params = [ 59 | 'query' => OptionsResolver::resolve($options, ['dc']), 60 | ]; 61 | 62 | return $this->client->get('/v1/session/list', $params); 63 | } 64 | 65 | public function renew(string $sessionId, array $options = []): ConsulResponse 66 | { 67 | $params = [ 68 | 'query' => OptionsResolver::resolve($options, ['dc']), 69 | ]; 70 | 71 | return $this->client->put('/v1/session/renew/'.$sessionId, $params); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Services/TXN.php: -------------------------------------------------------------------------------- 1 | client = $client ?: new Client(); 17 | } 18 | 19 | public function put(array $operations = [], array $options = []): ConsulResponse 20 | { 21 | $this->validate($operations); 22 | 23 | $params = [ 24 | 'json' => $operations, 25 | 'query' => OptionsResolver::resolve($options, ['dc']), 26 | ]; 27 | 28 | return $this->client->put('v1/txn', $params); 29 | } 30 | 31 | /** 32 | * Validate Transaction Available Operations. 33 | * 34 | * @throws \InvalidArgumentException 35 | */ 36 | private function validate(array $operations = []): void 37 | { 38 | foreach ($operations as $index => $operation) { 39 | if (!\is_int($index)) { 40 | throw new \InvalidArgumentException('Invalid Operations Array!'); 41 | } 42 | 43 | $invalidOperations = array_diff(array_keys($operation), ['KV', 'Node', 'Service', 'Check']); 44 | if (\count($invalidOperations)) { 45 | throw new \InvalidArgumentException('Invalid Operations!'); 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/DsnResolverTest.php: -------------------------------------------------------------------------------- 1 | assertSame($expected, DsnResolver::resolve(['base_uri' => $dsn])); 29 | } finally { 30 | if (null !== $previousValue) { 31 | $_SERVER['CONSUL_HTTP_ADDR'] = $previousValue; 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Helper/MultiLockHandlerTest.php: -------------------------------------------------------------------------------- 1 | lock()); 21 | self::assertFalse($multiLockHandler2->lock()); 22 | 23 | $multiLockHandler1->release(); 24 | } 25 | 26 | public function testLockDifferentSameResource(): void 27 | { 28 | $multiLockHandler1 = new MultiLockHandler(['resource1', 'resource2', 'resource3'], 10, new Session(), new KV(), 'test/lock/'); 29 | $multiLockHandler2 = new MultiLockHandler(['resource4', 'resource5', 'resource6'], 10, new Session(), new KV(), 'test/lock/'); 30 | $multiLockHandler3 = new MultiLockHandler(['resource7', 'resource8'], 10, new Session(), new KV(), 'test/lock/'); 31 | 32 | self::assertTrue($multiLockHandler1->lock()); 33 | self::assertTrue($multiLockHandler2->lock()); 34 | self::assertTrue($multiLockHandler3->lock()); 35 | 36 | $multiLockHandler1->release(); 37 | $multiLockHandler2->release(); 38 | $multiLockHandler3->release(); 39 | } 40 | 41 | public function testRenew(): void 42 | { 43 | $resources = ['resource1', 'resource2']; 44 | 45 | $multiLockHandler1 = new MultiLockHandler($resources, 10, new Session(), new KV(), 'test/lock/'); 46 | $multiLockHandler2 = new MultiLockHandler($resources, 10, new Session(), new KV(), 'test/lock/'); 47 | $multiLockHandler3 = new MultiLockHandler($resources, 10, new Session(), new KV(), 'test/lock/'); 48 | $multiLockHandler4 = new MultiLockHandler($resources, 10, new Session(), new KV(), 'test/lock/'); 49 | 50 | self::assertTrue($multiLockHandler1->lock()); 51 | self::assertFalse($multiLockHandler2->lock()); 52 | self::assertFalse($multiLockHandler3->lock()); 53 | self::assertFalse($multiLockHandler4->lock()); 54 | 55 | sleep(8); 56 | 57 | self::assertTrue($multiLockHandler1->renew()); 58 | 59 | sleep(8); 60 | 61 | self::assertFalse($multiLockHandler2->lock()); 62 | self::assertFalse($multiLockHandler3->lock()); 63 | self::assertFalse($multiLockHandler4->lock()); 64 | 65 | sleep(15); 66 | 67 | self::assertTrue($multiLockHandler2->lock()); 68 | self::assertFalse($multiLockHandler3->lock()); 69 | self::assertFalse($multiLockHandler4->lock()); 70 | 71 | $multiLockHandler1->release(); 72 | $multiLockHandler2->release(); 73 | } 74 | 75 | public function testRenewExpiredSession(): void 76 | { 77 | $this->expectException(ClientException::class); 78 | 79 | $resources = ['resource1', 'resource2']; 80 | $multiLockHandler = new MultiLockHandler($resources, 10, new Session(), new KV(), 'test/lock/'); 81 | self::assertTrue($multiLockHandler->lock()); 82 | sleep(21); 83 | $multiLockHandler->renew(); 84 | } 85 | 86 | public function testRelease(): void 87 | { 88 | $resources = ['resource1', 'resource2']; 89 | 90 | $multiLockHandler1 = new MultiLockHandler($resources, 10, new Session(), new KV(), 'test/lock/'); 91 | $multiLockHandler2 = new MultiLockHandler($resources, 10, new Session(), new KV(), 'test/lock/'); 92 | 93 | self::assertTrue($multiLockHandler1->lock()); 94 | self::assertFalse($multiLockHandler2->lock()); 95 | 96 | $multiLockHandler1->release(); 97 | 98 | self::assertTrue($multiLockHandler2->lock()); 99 | 100 | $multiLockHandler2->release(); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /tests/Helper/MultiSemaphoreTest.php: -------------------------------------------------------------------------------- 1 | acquire()); 23 | 24 | $semaphore2 = new MultiSemaphore($resources, 60, new Session(), new KV(), 'test/semaphore'); 25 | static::assertFalse($semaphore2->acquire()); 26 | 27 | $semaphore3 = new MultiSemaphore($resources, 60, new Session(), new KV(), 'test/semaphore'); 28 | static::assertFalse($semaphore3->acquire()); 29 | 30 | $semaphore1->release(); 31 | static::assertTrue($semaphore3->acquire()); 32 | 33 | $resources = [ 34 | new Resource('resource1', 2, 7), 35 | new Resource('resource2', 3, 6), 36 | ]; 37 | 38 | $semaphore4 = new MultiSemaphore($resources, 60, new Session(), new KV(), 'test/semaphore'); 39 | static::assertTrue($semaphore4->acquire()); 40 | 41 | $semaphore5 = new MultiSemaphore($resources, 60, new Session(), new KV(), 'test/semaphore'); 42 | static::assertFalse($semaphore5->acquire()); 43 | 44 | $semaphore3->release(); 45 | $semaphore4->release(); 46 | } 47 | 48 | public function testTimeout(): void 49 | { 50 | $resources = [ 51 | new Resource('resource1', 7, 7), 52 | new Resource('resource2', 6, 6), 53 | ]; 54 | 55 | $semaphore1 = new MultiSemaphore($resources, 15, new Session(), new KV(), 'test/semaphore'); 56 | static::assertTrue($semaphore1->acquire()); 57 | 58 | $semaphore2 = new MultiSemaphore($resources, 15, new Session(), new KV(), 'test/semaphore'); 59 | static::assertFalse($semaphore2->acquire()); 60 | 61 | sleep(45); 62 | 63 | static::assertTrue($semaphore2->acquire()); 64 | 65 | $semaphore1->release(); 66 | $semaphore2->release(); 67 | } 68 | 69 | public function testRenew(): void 70 | { 71 | $resources = [ 72 | new Resource('resource1', 7, 7), 73 | new Resource('resource2', 2, 6), 74 | ]; 75 | 76 | $semaphore1 = new MultiSemaphore($resources, 15, new Session(), new KV(), 'test/semaphore'); 77 | $semaphore2 = new MultiSemaphore($resources, 15, new Session(), new KV(), 'test/semaphore'); 78 | 79 | static::assertTrue($semaphore1->acquire()); 80 | static::assertFalse($semaphore2->acquire()); 81 | 82 | for ($i = 0; $i < 4; ++$i) { 83 | sleep(15); 84 | $semaphore1->renew(); 85 | } 86 | 87 | static::assertFalse($semaphore2->acquire()); 88 | 89 | $semaphore1->release(); 90 | } 91 | 92 | public function testExceptionAcquireAcquired(): void 93 | { 94 | $this->expectExceptionObject(new \RuntimeException('Resources are acquired already')); 95 | 96 | $resources = [ 97 | new Resource('resource11', 7, 7), 98 | ]; 99 | 100 | $semaphore1 = new MultiSemaphore($resources, 15, new Session(), new KV(), 'test/semaphore'); 101 | $semaphore1->acquire(); 102 | $semaphore1->acquire(); 103 | } 104 | 105 | public function testReleaseNotAcquired(): void 106 | { 107 | $resources = [ 108 | new Resource('resource12', 7, 7), 109 | ]; 110 | 111 | $semaphore1 = new MultiSemaphore($resources, 15, new Session(), new KV(), 'test/semaphore'); 112 | $semaphore1->release(); 113 | $semaphore1->release(); 114 | 115 | static::assertTrue(true); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /tests/OptionsResolverTest.php: -------------------------------------------------------------------------------- 1 | 'bar', 14 | 'hello' => 'world', 15 | 'baz' => 'inga', 16 | ]; 17 | 18 | $availableOptions = [ 19 | 'foo', 'baz', 20 | ]; 21 | 22 | $result = OptionsResolver::resolve($options, $availableOptions); 23 | 24 | $expected = [ 25 | 'foo' => 'bar', 26 | 'baz' => 'inga', 27 | ]; 28 | 29 | $this->assertSame($expected, $result); 30 | } 31 | 32 | public function testResolveWithoutMatchingOptions() 33 | { 34 | $options = [ 35 | 'hello' => 'world', 36 | ]; 37 | 38 | $availableOptions = [ 39 | 'foo', 'baz', 40 | ]; 41 | 42 | $result = OptionsResolver::resolve($options, $availableOptions); 43 | 44 | $this->assertSame([], $result); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Services/KVTest.php: -------------------------------------------------------------------------------- 1 | kv = new KV(); 17 | $this->kv->delete('test', ['recurse' => true]); 18 | } 19 | 20 | public function testSetGetWithDefaultOptions() 21 | { 22 | $value = date('r'); 23 | $this->kv->put('test/my/key', $value); 24 | 25 | $response = $this->kv->get('test/my/key'); 26 | $this->assertInstanceOf(ConsulResponse::class, $response); 27 | 28 | $json = $response->json(); 29 | $this->assertSame($value, base64_decode($json[0]['Value'])); 30 | } 31 | 32 | public function testSetGetWithRawOption() 33 | { 34 | $value = date('r'); 35 | $this->kv->put('test/my/key', $value); 36 | 37 | $response = $this->kv->get('test/my/key', ['raw' => true]); 38 | $this->assertInstanceOf(ConsulResponse::class, $response); 39 | 40 | $body = (string) $response->getBody(); 41 | $this->assertSame($value, $body); 42 | } 43 | 44 | public function testSetGetWithFlagsOption() 45 | { 46 | $flags = random_int(0, mt_getrandmax()); 47 | $this->kv->put('test/my/key', 'hello', ['flags' => $flags]); 48 | 49 | $response = $this->kv->get('test/my/key'); 50 | $this->assertInstanceOf(ConsulResponse::class, $response); 51 | 52 | $json = $response->json(); 53 | $this->assertSame($flags, $json[0]['Flags']); 54 | } 55 | 56 | public function testSetGetWithKeysOption() 57 | { 58 | $this->kv->put('test/my/key1', 'hello 1'); 59 | $this->kv->put('test/my/key2', 'hello 2'); 60 | $this->kv->put('test/my/key3', 'hello 3'); 61 | 62 | $response = $this->kv->get('test/my', ['keys' => true]); 63 | $this->assertInstanceOf(ConsulResponse::class, $response); 64 | 65 | $json = $response->json(); 66 | $this->assertSame(['test/my/key1', 'test/my/key2', 'test/my/key3'], $json); 67 | } 68 | 69 | public function testDeleteWithDefaultOptions() 70 | { 71 | $this->kv->put('test/my/key', 'hello'); 72 | $this->kv->get('test/my/key'); 73 | $this->kv->delete('test/my/key'); 74 | 75 | $this->expectException(ClientException::class); 76 | if (method_exists($this, 'expectExceptionMessageMatches')) { 77 | $this->expectExceptionMessageMatches('/404/'); 78 | } 79 | 80 | $this->kv->get('test/my/key'); 81 | } 82 | 83 | public function testDeleteWithRecurseOption() 84 | { 85 | $this->kv->put('test/my/key1', 'hello 1'); 86 | $this->kv->put('test/my/key2', 'hello 2'); 87 | $this->kv->put('test/my/key3', 'hello 3'); 88 | 89 | $this->kv->get('test/my/key1'); 90 | $this->kv->get('test/my/key2'); 91 | $this->kv->get('test/my/key3'); 92 | 93 | $this->kv->delete('test/my', ['recurse' => true]); 94 | 95 | for ($i = 1; $i < 3; ++$i) { 96 | try { 97 | $this->kv->get('test/my/key'.$i); 98 | $this->fail('fail because the key does not exist anymore.'); 99 | } catch (\Exception $e) { 100 | $this->assertInstanceOf(ClientException::class, $e); 101 | $this->assertStringContainsString('404', $e->getMessage()); 102 | } 103 | } 104 | } 105 | } 106 | --------------------------------------------------------------------------------