├── .gitignore ├── CHANGELOG.md ├── composer.json ├── src └── KubernetesClient │ ├── WatchIteratorInterface.php │ ├── WatchCollection.php │ ├── ResourceList.php │ ├── Dotty │ └── DotAccess.php │ ├── Client.php │ ├── Config.php │ └── Watch.php ├── README.md ├── sample.php └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # eclipse 3 | /.project 4 | /.settings 5 | 6 | # do not store the PHPStorm files 7 | /.idea* 8 | 9 | /.buildpath 10 | 11 | # composer 12 | /composer.phar 13 | /vendor 14 | composer.lock 15 | 16 | # code style 17 | /php-cs-fixer.phar 18 | .php_cs.cache 19 | 20 | /dev 21 | 22 | # always keep .keep files around 23 | !.keep 24 | 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.4.5 2 | 3 | Released 2025-03-22 4 | 5 | - force proper `json_encode` flags (see #12) 6 | - fix php 8.4 warnings (see #17) 7 | 8 | # v0.4.2 9 | 10 | Released 2023-10-10 11 | 12 | - support for non-associative array responses 13 | 14 | # v0.4.1 15 | 16 | Released 2023-10-10 17 | 18 | - add `unset` method to `Dotty` 19 | 20 | # v0.4.0 21 | 22 | Released 2023-10-09 23 | 24 | - better support for `pcntl` signal handling 25 | - more control over how requests / responses are handled (allow control of encode/decoding options) 26 | - support for `ReactPHP` loops 27 | - small internal library (`Dotty`) useful for interacting with structured data (arrays, stdobject) 28 | - update composer deps 29 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "travisghansen/kubernetes-client-php", 3 | "type": "library", 4 | "description": "Kubernetes API client in PHP supporting REST operations and Watches", 5 | "license": "Apache-2.0", 6 | "keywords": ["kubernetes", "api", "client", "k8s", "php", "rest"], 7 | "homepage": "https://github.com/travisghansen/kubernetes-client-php/", 8 | "authors": [ 9 | { 10 | "name" : "Travis Glenn Hansen" 11 | } 12 | ], 13 | "require": { 14 | "php": ">=7.2.0", 15 | "ext-json": "*", 16 | "symfony/yaml": "^6.3|^7.0", 17 | "softcreatr/jsonpath": "^0.8.3" 18 | }, 19 | "suggest": { 20 | "ext-pcntl": "support forking of watches", 21 | "ext-yaml": "support parsing yaml with native extensions" 22 | }, 23 | "autoload": { 24 | "psr-0": {"KubernetesClient\\": "src/"} 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/KubernetesClient/WatchIteratorInterface.php: -------------------------------------------------------------------------------- 1 | 0 then $cycles fread operations will 15 | * occur and then the loop will break. 16 | * 17 | * @param $cycles 18 | * @return mixed 19 | */ 20 | public function start($cycles); 21 | 22 | /** 23 | * Break a read loop 24 | * 25 | * @return mixed 26 | */ 27 | public function stop(); 28 | 29 | /** 30 | * Generator interface (foreach) looping 31 | * 32 | * @param $cycles 33 | * @return mixed 34 | */ 35 | public function stream($cycles); 36 | } 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Intro 2 | No nonsense PHP client for the Kubernetes API. It supports standard `REST` calls along with `watch`es for a continuous 3 | feed of data. Because no models are used it's usable with `CRD`s and other functionality/endpoints that may not be 4 | built-in. 5 | 6 | # Example 7 | See [sample.php](sample.php) 8 | 9 | # Watches 10 | Watches can (will) stay connected indefinitely, automatically reconnecting after server-side timeout. The client will 11 | keep track of the most recent `resourceVersion` processed to automatically start where you left off. 12 | 13 | Watch callback closures should have the following signature: 14 | ``` 15 | $callback = function($event, $watch).. 16 | ``` 17 | Receiving the watch allows access to the client (and any other details on the watch) and also provides an ability to 18 | stop the watch (break the loop) based off of event logic. 19 | 20 | * `GET /apis/batch/v1beta1/watch/namespaces/{namespace}/cronjobs/{name}` (specific resource) 21 | * `GET /apis/batch/v1beta1/watch/namespaces/{namespace}/cronjobs` (resource type namespaced) 22 | * `GET /apis/batch/v1beta1/watch/cronjobs` (resource type cluster-wide) 23 | * https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.10/#watch 24 | 25 | Other notes: 26 | - if using labelSelectors triggered events will fire with ADDED / DELETED types if the label is added/delete 27 | (ie: ADDED/DELETED do not necessarily equate to literally being added/deleted from k8s) 28 | 29 | # Development 30 | Note on `resourceVersion` per the doc: 31 | > When specified with a watch call, shows changes that occur after that particular version of a resource. Defaults to 32 | > changes from the beginning of history. When specified for list: - if unset, then the result is returned from remote 33 | > storage based on quorum-read flag; - if it's 0, then we simply return what we currently have in cache, no guarantee; - 34 | > if set to non zero, then the result is at least as fresh as given rv. 35 | 36 | Note that it's only changes **after** the version. 37 | 38 | ## TODO 39 | * Introduce threads for callbacks? 40 | * Do codegen on swagger docs to provide and OO interface to requests/responses? 41 | 42 | ## Links 43 | * https://github.com/swagger-api/swagger-codegen/blob/master/README.md 44 | * https://github.com/kubernetes/community/blob/master/contributors/devel/api-conventions.md#api-conventions 45 | * https://kubernetes.io/docs/reference/using-api/client-libraries/#community-maintained-client-libraries 46 | * https://kubernetes.io/docs/tasks/administer-cluster/access-cluster-api/ 47 | * https://kubernetes.io/docs/reference/using-api/api-concepts/ 48 | * https://kubernetes.io/docs/concepts/overview/kubernetes-api/ 49 | * https://stackoverflow.com/questions/1342583/manipulate-a-string-that-is-30-million-characters-long/1342760#1342760 50 | * https://github.com/kubernetes/client-go/blob/master/README.md 51 | * https://github.com/kubernetes-client/python-base/blob/master/watch/watch.py 52 | * https://github.com/kubernetes-client/python/issues/124 53 | 54 | ## Async 55 | * https://github.com/spatie/async/blob/master/README.md 56 | * https://github.com/krakjoe/pthreads/blob/master/README.md 57 | * http://php.net/manual/en/function.pcntl-fork.php 58 | -------------------------------------------------------------------------------- /src/KubernetesClient/WatchCollection.php: -------------------------------------------------------------------------------- 1 | watches[] = $watch; 35 | } 36 | 37 | /** 38 | * Get list of watches in the collection 39 | * 40 | * @return array 41 | */ 42 | public function getWatches() 43 | { 44 | return $this->watches; 45 | } 46 | 47 | /** 48 | * Get stop 49 | * 50 | * @return bool 51 | */ 52 | private function getStop() 53 | { 54 | return $this->stop; 55 | } 56 | 57 | /** 58 | * Set stop 59 | * 60 | * @param $value 61 | */ 62 | private function setStop($value) 63 | { 64 | $this->stop = (bool) $value; 65 | } 66 | 67 | /** 68 | * Internal logic for loop breakage 69 | */ 70 | private function internal_stop() 71 | { 72 | $this->setStop(false); 73 | } 74 | 75 | /** 76 | * Stop all watches in the collection and break the loop 77 | */ 78 | public function stop() 79 | { 80 | $this->setStop(true); 81 | foreach ($this->getWatches() as $watch) { 82 | $watch->stop(); 83 | } 84 | } 85 | 86 | /** 87 | * Synchronously process watches 88 | * 89 | * @param int $cycles 90 | * @throws \Exception 91 | */ 92 | public function start($cycles = 0) 93 | { 94 | foreach ($this->getWatches() as $watch) { 95 | $watch->start($cycles); 96 | } 97 | } 98 | 99 | /** 100 | * Generator interface for looping 101 | * 102 | * @param int $cycles 103 | * @return \Generator|void 104 | */ 105 | public function stream($cycles = 0) 106 | { 107 | $i_cycles = 0; 108 | while (true) { 109 | if ($this->getStop()) { 110 | $this->internal_stop(); 111 | return; 112 | } 113 | foreach ($this->getWatches() as $watch) { 114 | if ($this->getStop()) { 115 | $this->internal_stop(); 116 | return; 117 | } 118 | foreach ($watch->stream(1) as $message) { 119 | if ($this->getStop()) { 120 | $this->internal_stop(); 121 | return; 122 | } 123 | yield $message; 124 | } 125 | } 126 | $i_cycles++; 127 | if ($cycles > 0 && $cycles >= $i_cycles) { 128 | return; 129 | } 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /sample.php: -------------------------------------------------------------------------------- 1 | 'ConfigMap', 21 | 'metadata' => [ 22 | 'name' => $configMapName 23 | ], 24 | 'data' => [ 25 | 'foo' => 'bar', 26 | ], 27 | ]; 28 | $response = $client->request("/api/v1/namespaces/${configMapNamespace}/configmaps", 'POST', [], $data); 29 | var_dump($response); 30 | 31 | //PATCH 32 | $data = [ 33 | 'kind' => 'ConfigMap', 34 | 'metadata' => [ 35 | 'name' => $configMapName 36 | ], 37 | 'data' => [ 38 | 'bar' => 'baz', 39 | ], 40 | ]; 41 | $response = $client->request("/api/v1/namespaces/${configMapNamespace}/configmaps/${configMapName}", 'PATCH', [], $data); 42 | var_dump($response); 43 | 44 | //GET 45 | $response = $client->request("/api/v1/namespaces/${configMapNamespace}/configmaps/${configMapName}"); 46 | var_dump($response); 47 | 48 | //DELETE 49 | $response = $client->request("/api/v1/namespaces/${configMapNamespace}/configmaps/${configMapName}", 'DELETE'); 50 | var_dump($response); 51 | 52 | //LIST (retrieve large responses) 53 | $params = [ 54 | 'limit' => 1 55 | ]; 56 | $list = $client->createList('/api/v1/nodes', $params); 57 | 58 | // get all 59 | $items = $list->get(); 60 | var_dump($items); 61 | 62 | // get 1 page 63 | $pages = 1; 64 | $items = $list->get($pages); 65 | var_dump($items); 66 | 67 | // iterate 68 | foreach ($list->stream() as $item) { 69 | var_dump($item); 70 | } 71 | 72 | // shared state for closures 73 | $state = []; 74 | $response = $client->request('/api/v1/nodes'); 75 | $state['nodes']['list'] = $response; 76 | 77 | $callback = function ($event, $watch) use (&$state) { 78 | echo date("c") . ': ' . $event['object']['kind'] . ' ' . $event['object']['metadata']['name'] . ' ' . $event['type'] . ' - ' . $event['object']['metadata']['resourceVersion'] . "\n"; 79 | }; 80 | $params = [ 81 | 'watch' => '1', 82 | //'timeoutSeconds' => 10,//if set, the loop will break after the server has severed the connection 83 | 'resourceVersion' => $state['nodes']['list']['metadata']['resourceVersion'], 84 | ]; 85 | $watch = $client->createWatch('/api/v1/nodes?', $params, $callback); 86 | //$watch->setStreamReadLength(55); 87 | 88 | // blocking (unless timeoutSeconds has been supplied) 89 | //$watch->start(); 90 | 91 | // non blocking 92 | $i = 0; 93 | while (true) { 94 | $watch->start(1); 95 | usleep(100 * 1000); 96 | $i++; 97 | if ($i > 100) { 98 | echo date("c").": breaking while loop\n"; 99 | break; 100 | } 101 | } 102 | 103 | 104 | // generator style, blocking 105 | $i = 0; 106 | foreach ($watch->stream() as $event) { 107 | echo date("c") . ': ' . $event['object']['kind'] . ' ' . $event['object']['metadata']['name'] . ' ' . $event['type'] . ' - ' . $event['object']['metadata']['resourceVersion'] . "\n"; 108 | //$watch->stop(); 109 | $i++; 110 | if ($i > 10) { 111 | echo date("c").": breaking foreach loop\n"; 112 | break; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/KubernetesClient/ResourceList.php: -------------------------------------------------------------------------------- 1 | client = $client; 46 | $this->endpoint = $endpoint; 47 | $this->params = $params; 48 | } 49 | 50 | /** 51 | * Get client instance 52 | * 53 | * @return Client 54 | */ 55 | private function getClient() 56 | { 57 | return $this->client; 58 | } 59 | 60 | /** 61 | * Get endpoint 62 | * 63 | * @return string 64 | */ 65 | private function getEndpoint() 66 | { 67 | return $this->endpoint; 68 | } 69 | 70 | /** 71 | * Get params 72 | * 73 | * @return array 74 | */ 75 | private function getParams() 76 | { 77 | return $this->params; 78 | } 79 | 80 | /** 81 | * Get all values from a list endpoint. Full list is returned as if made in a single call. 82 | * 83 | * @param int $pages 84 | * @return bool|mixed|string 85 | * @throws \Exception 86 | */ 87 | public function get($pages = 0) 88 | { 89 | $endpoint = $this->getEndpoint(); 90 | $params = $this->getParams(); 91 | $list = $this->getClient()->request($endpoint, 'GET', $params); 92 | 93 | $i = 1; 94 | while (DotAccess::get($list, 'metadata.continue', false)) { 95 | if ($pages > 0 && $pages >= $i) { 96 | return $list; 97 | } 98 | $params['continue'] = DotAccess::get($list, 'metadata.continue'); 99 | $i_list = $this->getClient()->request($endpoint, 'GET', $params); 100 | DotAccess::set($i_list, 'items', array_merge(DotAccess::get($list, 'items'), DotAccess::get($i_list, 'items'))); 101 | $list = $i_list; 102 | unset($i_list); 103 | $i++; 104 | } 105 | 106 | return $list; 107 | } 108 | 109 | /** 110 | * Get all values from a list endpoint. Used for iterators like foreach(). 111 | * 112 | * @return \Generator 113 | * @throws \Exception 114 | */ 115 | public function stream() 116 | { 117 | $endpoint = $this->getEndpoint(); 118 | $params = $this->getParams(); 119 | $list = $this->getClient()->request($endpoint, 'GET', $params); 120 | foreach (DotAccess::get($list, 'items') as $item) { 121 | yield $item; 122 | } 123 | 124 | while (DotAccess::get($list, 'metadata.continue', false)) { 125 | $params['continue'] = DotAccess::get($list, 'metadata.continue'); 126 | $list = $this->getClient()->request($endpoint, 'GET', $params); 127 | foreach (DotAccess::get($list, 'items', false) as $item) { 128 | yield $item; 129 | } 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/KubernetesClient/Dotty/DotAccess.php: -------------------------------------------------------------------------------- 1 | {$key}; 39 | } 40 | if (is_array($data)) { 41 | return $data[$key]; 42 | } 43 | } 44 | 45 | public static function propSet(&$data, $key, $value) { 46 | if (is_object($data)) { 47 | $data->{$key} = $value; 48 | } 49 | if (is_array($data)) { 50 | $data[$key] = $value; 51 | } 52 | } 53 | 54 | public static function propUnset(&$data, $key) { 55 | if (is_object($data)) { 56 | unset($data->{$key}); 57 | } 58 | if (is_array($data)) { 59 | unset($data[$key]); 60 | } 61 | } 62 | 63 | public static function isStructuredData(&$data) { 64 | if (is_object($data) || is_array($data)) { 65 | return true; 66 | } 67 | return false; 68 | } 69 | 70 | public static function &get(&$currentValue, $key, $default = null) { 71 | $hasDefault = \func_num_args() > 2; 72 | 73 | if (is_string($key)) { 74 | $keyPath = self::keyToPathArray($key); 75 | } else { 76 | $keyPath = $key; 77 | } 78 | 79 | foreach ($keyPath as $currentKey) { 80 | if (!self::isStructuredData($currentValue) || !self::propExists($currentValue, $currentKey)) { 81 | if ($hasDefault) { 82 | return $default; 83 | } 84 | 85 | throw new \Exception('path not present'); 86 | } 87 | 88 | $currentValue = &self::propGet($currentValue, $currentKey); 89 | } 90 | 91 | if ($currentValue === null) 92 | return $default; 93 | else 94 | return $currentValue; 95 | } 96 | 97 | public static function exists(&$currentValue, $key) { 98 | if (is_string($key)) { 99 | $keyPath = self::keyToPathArray($key); 100 | } else { 101 | $keyPath = $key; 102 | } 103 | 104 | foreach ($keyPath as $currentKey) { 105 | if (!self::isStructuredData($currentValue) || !self::propExists($currentValue, $currentKey)) { 106 | return false; 107 | } 108 | 109 | $currentValue = &self::propGet($currentValue, $currentKey); 110 | } 111 | 112 | return true; 113 | } 114 | 115 | public static function set(&$data, $key, $value, $options = []) { 116 | if (is_string($key)) { 117 | $keyPath = self::keyToPathArray($key); 118 | } else { 119 | $keyPath = $key; 120 | } 121 | 122 | $currentValue = &$data; 123 | 124 | $keySize = sizeof($keyPath); 125 | for ($i = 0; $i < $keySize; $i++) { 126 | $currentKey = $keyPath[$i]; 127 | 128 | if ($i == ($keySize - 1)) { 129 | self::propSet($currentValue, $currentKey, $value); 130 | return; 131 | } 132 | 133 | if (!self::isStructuredData($currentValue) && self::propExists($currentValue, $currentKey)) { 134 | throw new \Exception("key {$currentKey} already exists but it unstructured content"); 135 | } 136 | 137 | if (!self::propExists($currentValue, $currentKey)) { 138 | // if option to create is enabled create 139 | if (self::get($options, 'create_structure', true)) { 140 | $type = self::get($options, 'create_structure_type', 'obj'); 141 | if ($type == 'array') { 142 | self::propSet($currentValue, $currentKey, []); 143 | } 144 | 145 | if ($type == 'obj') { 146 | self::propSet($currentValue, $currentKey, new \stdClass()); 147 | } 148 | } else { 149 | throw new \Exception('necessary parents do not exist'); 150 | } 151 | } 152 | 153 | $currentValue = &self::propGet($currentValue, $currentKey); 154 | } 155 | } 156 | 157 | public static function unset(&$data, $key) { 158 | if (is_string($key)) { 159 | $keyPath = self::keyToPathArray($key); 160 | } else { 161 | $keyPath = $key; 162 | } 163 | 164 | $currentValue = &$data; 165 | 166 | $keySize = sizeof($keyPath); 167 | for ($i = 0; $i < $keySize; $i++) { 168 | $currentKey = $keyPath[$i]; 169 | 170 | if ($i == ($keySize - 1)) { 171 | self::propUnset($currentValue, $currentKey); 172 | return; 173 | } 174 | 175 | if (!self::isStructuredData($currentValue)) { 176 | return; 177 | } 178 | 179 | if (!self::propExists($currentValue, $currentKey)) { 180 | return; 181 | } 182 | 183 | $currentValue = &self::propGet($currentValue, $currentKey); 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/KubernetesClient/Client.php: -------------------------------------------------------------------------------- 1 | request() 11 | * - ->createWatch() 12 | * - ->createList() 13 | * 14 | * Class Client 15 | * @package KubernetesClient 16 | */ 17 | class Client 18 | { 19 | /** 20 | * Client configuration 21 | * 22 | * @var Config 23 | */ 24 | private $config; 25 | 26 | /** 27 | * Default request options 28 | * 29 | * @var array 30 | */ 31 | protected $defaultRequestOptions = []; 32 | 33 | public function __construct(Config $config) 34 | { 35 | $this->config = $config; 36 | } 37 | 38 | /** 39 | * Get client configuration 40 | * 41 | * @return Config 42 | */ 43 | public function getConfig() 44 | { 45 | return $this->config; 46 | } 47 | 48 | /** 49 | * Get common options to be used for the stream context 50 | * 51 | * @return array 52 | * @throws \Error 53 | * @throws JSONPathException 54 | */ 55 | protected function getContextOptions() 56 | { 57 | $opts = array( 58 | 'http'=>array( 59 | 'ignore_errors' => true, 60 | 'header' => "Accept: application/json, */*\r\nContent-Encoding: gzip\r\n" 61 | ), 62 | ); 63 | 64 | if (!$this->config->getVerifyPeerName()) { 65 | $opts['ssl']['verify_peer_name'] = false; 66 | } 67 | 68 | if (!empty($this->config->getCertificateAuthorityPath())) { 69 | $opts['ssl']['cafile'] = $this->config->getCertificateAuthorityPath(); 70 | } 71 | 72 | if (!empty($this->config->getClientCertificatePath())) { 73 | $opts['ssl']['local_cert'] = $this->config->getClientCertificatePath(); 74 | } 75 | 76 | if (!empty($this->config->getClientKeyPath())) { 77 | $opts['ssl']['local_pk'] = $this->config->getClientKeyPath(); 78 | } 79 | 80 | $token = $this->config->getToken(); 81 | if (!empty($token)) { 82 | $opts['http']['header'] .= "Authorization: Bearer {$token}\r\n"; 83 | } 84 | 85 | return $opts; 86 | } 87 | 88 | /** 89 | * Get the stream context 90 | * 91 | * @param string $verb 92 | * @param array $opts 93 | * @return resource 94 | * @throws \Error 95 | * @throws JSONPathException 96 | */ 97 | public function getStreamContext($verb = 'GET', $opts = []) 98 | { 99 | $o = array_merge_recursive($this->getContextOptions(), $opts); 100 | $o['http']['method'] = $verb; 101 | 102 | if (substr($verb, 0, 5) == 'PATCH') { 103 | $o['http']['method'] = 'PATCH'; 104 | 105 | /** 106 | * https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#patch-operations 107 | * 108 | * Content-Type: application/json-patch+json 109 | * Content-Type: application/merge-patch+json 110 | * Content-Type: application/strategic-merge-patch+json 111 | */ 112 | switch ($verb) { 113 | case 'PATCH-JSON': 114 | $o['http']['header'] .= "Content-Type: application/json-patch+json\r\n"; 115 | break; 116 | case 'PATCH-STRATEGIC-MERGE': 117 | $o['http']['header'] .= "Content-Type: application/strategic-merge-patch+json\r\n"; 118 | break; 119 | case 'PATCH-APPLY': 120 | $o['http']['header'] .= "Content-Type: application/apply-patch+yaml\r\n"; 121 | break; 122 | case 'PATCH': 123 | case 'PATCH-MERGE': 124 | default: 125 | $o['http']['header'] .= "Content-Type: application/merge-patch+json\r\n"; 126 | break; 127 | } 128 | } else { 129 | $o['http']['header'] .= "Content-Type: application/json\r\n"; 130 | } 131 | 132 | return stream_context_create($o); 133 | } 134 | 135 | protected function setStreamBody($context, $verb = 'GET', $data = null, $options = []) 136 | { 137 | if (is_array($data) || is_object($data)) { 138 | switch ($verb) { 139 | case 'PATCH-APPLY': 140 | stream_context_set_option($context, array('http' => array('content' => $this->encodeYamlBody($data, $options)))); 141 | break; 142 | default: 143 | stream_context_set_option($context, array('http' => array('content' => $this->encodeJsonBody($data, $options)))); 144 | break; 145 | } 146 | } else { 147 | stream_context_set_option($context, array('http' => array('content' => $data))); 148 | } 149 | } 150 | 151 | private function encodeJsonBody($data, $options = []) 152 | { 153 | $encode_flags = $this->getRequestOption('encode_flags', $options); 154 | return json_encode($data, $encode_flags | \JSON_UNESCAPED_SLASHES); 155 | } 156 | 157 | private function encodeYamlBody($data, $options = []) 158 | { 159 | if (function_exists('yaml_emit')) { 160 | return yaml_emit($data); 161 | } else { 162 | return Yaml::dump( 163 | $data, 164 | // This is the depth that symfony/yaml switches to "inline" (JSON-ish) YAML. 165 | // Set to a high number to try and keep behaviour vaguely consistent with 166 | // yaml_emit which never does this. 167 | PHP_INT_MAX, 168 | // Default to 2 spaces, as in yaml_emit 169 | 2, 170 | // When dumping associative arrays, yaml_emit will output an empty array as `[]` 171 | // by default, where-as symfony/yaml will output as `{}`. This flag has it dumped 172 | // as `[]` to keep them consistent. 173 | Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE 174 | ); 175 | } 176 | } 177 | 178 | /** 179 | * Make a request to the API 180 | * 181 | * @param $endpoint 182 | * @param string $verb 183 | * @param array $params 184 | * @param mixed $data 185 | * @param array $options 186 | * @throws \Exception 187 | * @return bool|mixed|string 188 | */ 189 | public function request($endpoint, $verb = 'GET', $params = [], $data = null, $options = []) 190 | { 191 | $decode_flags = $this->getRequestOption('decode_flags', $options); 192 | 193 | $context = $this->getStreamContext($verb); 194 | if ($data) { 195 | $this->setStreamBody($context, $verb, $data, $options); 196 | } 197 | 198 | $query = http_build_query($params); 199 | $base = $this->getConfig()->getServer().$endpoint; 200 | $url = $base; 201 | 202 | if (!empty($query)) { 203 | $parsed = parse_url($base); 204 | if (key_exists('query', $parsed) || substr($base, -1) == "?") { 205 | $url .= '&'.$query; 206 | } else { 207 | $url .= '?'.$query; 208 | } 209 | } 210 | 211 | $handle = fopen($url, 'r', false, $context); 212 | if ($handle === false) { 213 | $e = error_get_last(); 214 | throw new \Exception($e['message'], $e['type']); 215 | } 216 | $response = stream_get_contents($handle); 217 | fclose($handle); 218 | 219 | $decode_response = $this->getRequestOption('decode_response', $options); 220 | if ($decode_response) { 221 | $associative = $this->getRequestOption('decode_associative', $options); 222 | $response = json_decode($response, $associative, 512, $decode_flags); 223 | } 224 | 225 | return $response; 226 | } 227 | 228 | /** 229 | * Create a Watch for api feed 230 | * 231 | * @param $endpoint 232 | * @param array $params 233 | * @param \Closure $callback 234 | * @return Watch 235 | */ 236 | public function createWatch($endpoint, $params, \Closure $callback) 237 | { 238 | return new Watch($this, $endpoint, $params, $callback); 239 | } 240 | 241 | /** 242 | * Create a List for retrieving large lists 243 | * 244 | * @param $endpoint 245 | * @param array $params 246 | * @return ResourceList 247 | */ 248 | public function createList($endpoint, $params = []) 249 | { 250 | return new ResourceList($this, $endpoint, $params); 251 | } 252 | 253 | /** 254 | * Set default request options 255 | * 256 | * @param $options 257 | * @return void 258 | */ 259 | public function setDefaultRequestOptions($options) { 260 | $this->defaultRequestOptions = $options; 261 | } 262 | 263 | /** 264 | * Get request option value 265 | * 266 | * @param $option 267 | * @param $options 268 | * @return mixed|void 269 | */ 270 | public function getRequestOption($option, $options) { 271 | $defaults = [ 272 | 'encode_flags' => 0, 273 | 'decode_flags' => 0, 274 | 'decode_response' => true, 275 | 'decode_associative' => true, 276 | ]; 277 | 278 | // request specific 279 | if (key_exists($option, $options)) { 280 | return $options[$option]; 281 | } 282 | 283 | // client defaults 284 | if (key_exists($option, $this->defaultRequestOptions)) { 285 | return $this->defaultRequestOptions[$option]; 286 | } 287 | 288 | // system defaults 289 | if (key_exists($option, $defaults)) { 290 | return $defaults[$option]; 291 | } 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /src/KubernetesClient/Config.php: -------------------------------------------------------------------------------- 1 | setToken(file_get_contents('/var/run/secrets/kubernetes.io/serviceaccount/token')); 216 | $config->setCertificateAuthorityPath('/var/run/secrets/kubernetes.io/serviceaccount/ca.crt'); 217 | 218 | if (strlen(getenv('KUBERNETES_SERVICE_HOST')) > 0) { 219 | $server = 'https://' . getenv('KUBERNETES_SERVICE_HOST') . ':' . getenv('KUBERNETES_SERVICE_PORT'); 220 | } else { 221 | $server = 'https://kubernetes.default.svc'; 222 | } 223 | 224 | $config->setServer($server); 225 | 226 | return $config; 227 | } 228 | 229 | /** 230 | * Create a config from file will auto fallback to KUBECONFIG env variable or ~/.kube/config if no path supplied 231 | * 232 | * @param null $path 233 | * @param null $contextName 234 | * @return Config 235 | * @throws \Error 236 | */ 237 | public static function BuildConfigFromFile($path = null, $contextName = null) 238 | { 239 | if (empty($path)) { 240 | $path = getenv('KUBECONFIG'); 241 | } 242 | 243 | if (empty($path)) { 244 | $path = getenv('HOME').'/.kube/config'; 245 | } 246 | 247 | if (!file_exists($path)) { 248 | throw new \Error('Config file does not exist: ' . $path); 249 | } 250 | 251 | 252 | if (function_exists('yaml_parse_file')) { 253 | $yaml = yaml_parse_file($path); 254 | if (false === $yaml) { 255 | throw new \Error('Unable to parse YAML.'); 256 | } 257 | } else { 258 | try { 259 | $yaml = Yaml::parseFile($path); 260 | } catch (\Throwable $th) { 261 | throw new \Error('Unable to parse', 0, $th); 262 | } 263 | } 264 | 265 | if (empty($contextName)) { 266 | $contextName = $yaml['current-context']; 267 | } 268 | 269 | $config = new Config(); 270 | $config->setPath($path); 271 | $config->setParsedConfigFile($yaml); 272 | $config->useContext($contextName); 273 | 274 | return $config; 275 | } 276 | 277 | /** 278 | * destruct 279 | */ 280 | public function __destruct() 281 | { 282 | /** 283 | * @note these are only deleted if they were created as temp files 284 | */ 285 | self::deleteTempFile($this->certificateAuthorityPath); 286 | self::deleteTempFile($this->clientCertificatePath); 287 | self::deleteTempFile($this->clientKeyPath); 288 | } 289 | 290 | /** 291 | * Switch contexts 292 | * 293 | * @param $contextName 294 | */ 295 | public function useContext($contextName) 296 | { 297 | $this->resetAuthData(); 298 | $this->setActiveContextName($contextName); 299 | $yaml = $this->getParsedConfigFile(); 300 | $context = null; 301 | foreach ($yaml['contexts'] as $item) { 302 | if ($item['name'] == $contextName) { 303 | $context = $item['context']; 304 | break; 305 | } 306 | } 307 | 308 | $cluster = null; 309 | foreach ($yaml['clusters'] as $item) { 310 | if ($item['name'] == $context['cluster']) { 311 | $cluster = $item['cluster']; 312 | break; 313 | } 314 | } 315 | 316 | $user = null; 317 | foreach ($yaml['users'] as $item) { 318 | if ($item['name'] == $context['user']) { 319 | $user = $item['user']; 320 | break; 321 | } 322 | } 323 | 324 | $this->setContext($context); 325 | $this->setCluster($cluster); 326 | $this->setUser($user); 327 | $this->setServer($cluster['server']); 328 | 329 | if (!empty($cluster['certificate-authority-data'])) { 330 | $path = self::writeTempFile(base64_decode($cluster['certificate-authority-data'], true)); 331 | $this->setCertificateAuthorityPath($path); 332 | } 333 | 334 | if (!empty($user['client-certificate-data'])) { 335 | $path = self::writeTempFile(base64_decode($user['client-certificate-data'])); 336 | $this->setClientCertificatePath($path); 337 | } 338 | 339 | if (!empty($user['client-key-data'])) { 340 | $path = self::writeTempFile(base64_decode($user['client-key-data'])); 341 | $this->setClientKeyPath($path); 342 | } 343 | 344 | if (!empty($user['token'])) { 345 | $this->setToken($user['token']); 346 | } 347 | 348 | // should never have both auth-provider and exec at the same time 349 | 350 | if (!empty($user['auth-provider'])) { 351 | $this->setIsAuthProvider(true); 352 | } 353 | 354 | if (!empty($user['exec'])) { 355 | $this->setIsExecProvider(true); 356 | // we pre-emptively invoke this in this case 357 | $this->getExecProviderAuth(); 358 | } 359 | } 360 | 361 | /** 362 | * Reset relevant data when context switching 363 | */ 364 | protected function resetAuthData() 365 | { 366 | $this->setCertificateAuthorityPath(null); 367 | $this->setClientCertificatePath(null); 368 | $this->setClientKeyPath(null); 369 | $this->setExpiry(null); 370 | $this->setToken(null); 371 | $this->setIsAuthProvider(false); 372 | $this->setIsExecProvider(false); 373 | } 374 | 375 | /** 376 | * Set path 377 | * 378 | * @param $path 379 | */ 380 | public function setPath($path) 381 | { 382 | if (!empty($path)) { 383 | $path = realpath(($path)); 384 | } 385 | 386 | $this->path = $path; 387 | } 388 | 389 | /** 390 | * Get path 391 | * 392 | * @return string 393 | */ 394 | public function getPath() 395 | { 396 | return $this->path; 397 | } 398 | 399 | /** 400 | * Set server 401 | * 402 | * @param $server 403 | */ 404 | public function setServer($server) 405 | { 406 | $this->server = $server; 407 | } 408 | 409 | /** 410 | * Get server 411 | * 412 | * @return string 413 | */ 414 | public function getServer() 415 | { 416 | return $this->server; 417 | } 418 | 419 | /** 420 | * Set user 421 | * 422 | * @param $user array 423 | */ 424 | public function setUser($user) 425 | { 426 | $this->user = $user; 427 | } 428 | 429 | /** 430 | * Get user 431 | * 432 | * @return array 433 | */ 434 | public function getUser() 435 | { 436 | return $this->user; 437 | } 438 | 439 | /** 440 | * Set context 441 | * 442 | * @param $context array 443 | */ 444 | public function setContext($context) 445 | { 446 | $this->context = $context; 447 | } 448 | 449 | /** 450 | * Set context 451 | * 452 | * @return array 453 | */ 454 | public function getContext() 455 | { 456 | return $this->context; 457 | } 458 | 459 | /** 460 | * Set cluster 461 | * 462 | * @param $cluster array 463 | */ 464 | public function setCluster($cluster) 465 | { 466 | $this->cluster = $cluster; 467 | } 468 | 469 | /** 470 | * Get cluster 471 | * 472 | * @return array 473 | */ 474 | public function getCluster() 475 | { 476 | return $this->cluster; 477 | } 478 | 479 | /** 480 | * Set activeContextName 481 | * 482 | * @param $name string 483 | */ 484 | protected function setActiveContextName($name) 485 | { 486 | $this->activeContextName = $name; 487 | } 488 | 489 | /** 490 | * Get activeContextName 491 | * 492 | * @return string 493 | */ 494 | public function getActiveContextName() 495 | { 496 | return $this->activeContextName; 497 | } 498 | 499 | /** 500 | * Set token 501 | * 502 | * @param $token 503 | */ 504 | public function setToken($token) 505 | { 506 | $this->token = $token; 507 | } 508 | 509 | /** 510 | * Get token 511 | * 512 | * @throws JSONPathException 513 | * @return string 514 | */ 515 | public function getToken() 516 | { 517 | if ($this->getIsAuthProvider()) { 518 | // set token if expired 519 | if ($this->getExpiry() && time() >= $this->getExpiry()) { 520 | $this->getAuthProviderToken(); 521 | } 522 | 523 | // set token if we do not have one yet 524 | if (empty($this->token)) { 525 | $this->getAuthProviderToken(); 526 | } 527 | } 528 | 529 | // only do this if token is present to begin with 530 | if ($this->getIsExecProvider() && !empty($this->token)) { 531 | // set token if expired 532 | if ($this->getExpiry() && time() >= $this->getExpiry()) { 533 | $this->getExecProviderAuth(); 534 | } 535 | } 536 | 537 | return $this->token; 538 | } 539 | 540 | /** 541 | * @link https://github.com/kubernetes-client/javascript/blob/master/src/cloud_auth.ts - Official JS Implementation 542 | * 543 | * Set the token and expiry when using an auth provider 544 | * 545 | * @throws JSONPathException 546 | * @throws Error 547 | */ 548 | protected function getAuthProviderToken() 549 | { 550 | $user = $this->getUser(); 551 | 552 | // gcp, azure, etc 553 | //$name = (new JSONPath($user))->find('$.auth-provider.name')->first(); 554 | 555 | // build command 556 | $cmd_path = (new JSONPath($user))->find('$.auth-provider.config.cmd-path')->first(); 557 | $cmd_args = (new JSONPath($user))->find('$.auth-provider.config.cmd-args')->first(); 558 | 559 | if (!$cmd_path) { 560 | throw new \Error('error finding access token command. No command found.'); 561 | } 562 | 563 | $command = $cmd_path; 564 | if ($cmd_args) { 565 | $command .= ' ' . $cmd_args; 566 | } 567 | 568 | // execute command and store output 569 | $output = []; 570 | $exit_code = null; 571 | exec($command, $output, $exit_code); 572 | $output = implode("\n", $output); 573 | 574 | if ($exit_code !== 0) { 575 | throw new \Error("error executing access token command \"{$command}\": {$output}"); 576 | } else { 577 | $output = json_decode($output, true); 578 | } 579 | 580 | if (!is_array($output) || empty($output)) { 581 | throw new \Error("error retrieving token: auth provider failed to return valid data"); 582 | } 583 | 584 | $expiry_key = (new JSONPath($user))->find('$.auth-provider.config.expiry-key')->first(); 585 | $token_key = (new JSONPath($user))->find('$.auth-provider.config.token-key')->first(); 586 | 587 | if ($expiry_key) { 588 | $expiry_key = '$' . trim($expiry_key, "{}"); 589 | $expiry = (new JSONPath($output))->find($expiry_key)->first(); 590 | if ($expiry) { 591 | // No expiry should be ok, thus never expiring token 592 | $this->setExpiry($expiry); 593 | } 594 | } 595 | 596 | if ($token_key) { 597 | $token_key = '$' . trim($token_key, "{}"); 598 | $token = (new JSONPath($output))->find($token_key)->first(); 599 | if (!$token) { 600 | throw new \Error(sprintf('error retrieving token: token not found. Searching for key: "%s"', $token_key)); 601 | } 602 | $this->setToken($token); 603 | } 604 | } 605 | 606 | /** 607 | * @link https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins 608 | * @link https://banzaicloud.com/blog/kubeconfig-security/#exec-helper 609 | * 610 | * Set the auth data using the exec provider 611 | */ 612 | protected function getExecProviderAuth() 613 | { 614 | $user = $this->getUser(); 615 | $path = $this->getPath(); 616 | 617 | $command = $user['exec']['command']; 618 | 619 | // relative commands should be executed relative to the directory holding the config file 620 | if (substr($command, 0, 1) == ".") { 621 | $dir = dirname($path); 622 | $command = $dir . substr($command, 1); 623 | } 624 | 625 | // add args 626 | if (!empty($user['exec']['args'])) { 627 | foreach ($user['exec']['args'] as $arg) { 628 | $command .= " " . $arg; 629 | } 630 | } 631 | 632 | // set env 633 | // beware this sets the env var for the whole process indefinitely 634 | if (!empty($user['exec']['env'])) { 635 | foreach ($user['exec']['env'] as $env) { 636 | putenv("{$env['name']}={$env['value']}"); 637 | } 638 | } 639 | 640 | // execute command and store output 641 | $output = []; 642 | $exit_code = null; 643 | exec($command, $output, $exit_code); 644 | $output = implode("\n", $output); 645 | 646 | if ($exit_code !== 0) { 647 | throw new \Error("error executing access token command \"{$command}\": {$output}"); 648 | } else { 649 | $output = json_decode($output, true); 650 | } 651 | 652 | if (!is_array($output)) { 653 | throw new \Error("error retrieving token: auth exec failed to return valid data"); 654 | } 655 | 656 | if ($output["kind"] != "ExecCredential") { 657 | throw new \Error("error retrieving auth: auth exec failed to return valid data"); 658 | } 659 | 660 | if ($output['apiVersion'] != 'client.authentication.k8s.io/v1beta1') { 661 | throw new \Error("error retrieving auth: auth exec unsupported apiVersion"); 662 | } 663 | 664 | if (!empty($output['status']['clientCertificateData'])) { 665 | $path = self::writeTempFile($output['status']['clientCertificateData']); 666 | $this->setClientCertificatePath($path); 667 | } 668 | 669 | if (!empty($output['status']['clientKeyData'])) { 670 | $path = self::writeTempFile($output['status']['clientKeyData']); 671 | $this->setClientKeyPath($path); 672 | } 673 | 674 | if (!empty($output['status']['expirationTimestamp'])) { 675 | $this->setExpiry($output['status']['expirationTimestamp']); 676 | } 677 | 678 | if (!empty($output['status']['token'])) { 679 | $this->setToken($output['status']['token']); 680 | } 681 | } 682 | 683 | /** 684 | * Set expiry 685 | * 686 | * @param $expiry 687 | */ 688 | public function setExpiry($expiry) 689 | { 690 | if (!empty($expiry) && !is_int($expiry)) { 691 | $expiry = strtotime($expiry); 692 | } 693 | $this->expiry = $expiry; 694 | } 695 | 696 | /** 697 | * Get expiry 698 | * 699 | * @return int 700 | */ 701 | public function getExpiry() 702 | { 703 | return $this->expiry; 704 | } 705 | 706 | /** 707 | * Set SSL peer name verification enabled or disabled 708 | * 709 | * @param $path 710 | */ 711 | public function setVerifyPeerName($enabled) 712 | { 713 | $this->verifyPeerName = $enabled; 714 | } 715 | 716 | /** 717 | * Check if SSL peer name verification is enabled or disabled 718 | * 719 | * @return int 720 | */ 721 | public function getVerifyPeerName() 722 | { 723 | return $this->verifyPeerName; 724 | } 725 | 726 | /** 727 | * Set client certificate path 728 | * 729 | * @param $path 730 | */ 731 | public function setClientCertificatePath($path) 732 | { 733 | self::deleteTempFile($this->clientCertificatePath); 734 | $this->clientCertificatePath = $path; 735 | } 736 | 737 | /** 738 | * Get client certificate path 739 | * 740 | * @return string 741 | */ 742 | public function getClientCertificatePath() 743 | { 744 | return $this->clientCertificatePath; 745 | } 746 | 747 | /** 748 | * Set client key path 749 | * 750 | * @param $path 751 | */ 752 | public function setClientKeyPath($path) 753 | { 754 | self::deleteTempFile($this->clientKeyPath); 755 | $this->clientKeyPath = $path; 756 | } 757 | 758 | /** 759 | * Get client key path 760 | * 761 | * @return string 762 | */ 763 | public function getClientKeyPath() 764 | { 765 | return $this->clientKeyPath; 766 | } 767 | 768 | /** 769 | * Set cluster CA path 770 | * 771 | * @param $path 772 | */ 773 | public function setCertificateAuthorityPath($path) 774 | { 775 | self::deleteTempFile($this->certificateAuthorityPath); 776 | $this->certificateAuthorityPath = $path; 777 | } 778 | 779 | /** 780 | * Get cluster CA path 781 | * 782 | * @return string 783 | */ 784 | public function getCertificateAuthorityPath() 785 | { 786 | return $this->certificateAuthorityPath; 787 | } 788 | 789 | /** 790 | * Set if user credentials use auth provider 791 | * 792 | * @param $v bool 793 | */ 794 | public function setIsAuthProvider($v) 795 | { 796 | $this->isAuthProvider = $v; 797 | } 798 | 799 | /** 800 | * Get if user credentials use auth provider 801 | * 802 | * @return bool 803 | */ 804 | public function getIsAuthProvider() 805 | { 806 | return $this->isAuthProvider; 807 | } 808 | 809 | /** 810 | * Set if user credentials use exec provider 811 | * 812 | * @param $v bool 813 | */ 814 | public function setIsExecProvider($v) 815 | { 816 | $this->isExecProvider = $v; 817 | } 818 | 819 | /** 820 | * Get if user credentials use exec provider 821 | * 822 | * @return bool 823 | */ 824 | public function getIsExecProvider() 825 | { 826 | return $this->isExecProvider; 827 | } 828 | 829 | /** 830 | * Set the data of the parsed config file 831 | * 832 | * @param $data array 833 | */ 834 | public function setParsedConfigFile($data) 835 | { 836 | $this->parsedConfigFile = $data; 837 | } 838 | 839 | /** 840 | * Get the data of the parsed config file 841 | * 842 | * @return array 843 | */ 844 | public function getParsedConfigFile() 845 | { 846 | return $this->parsedConfigFile; 847 | } 848 | } 849 | 850 | register_shutdown_function(array('KubernetesClient\Config', 'shutdown')); 851 | -------------------------------------------------------------------------------- /src/KubernetesClient/Watch.php: -------------------------------------------------------------------------------- 1 | client = $client; 142 | $this->endpoint = $endpoint; 143 | $this->callback = $callback; 144 | $this->params = $params; 145 | 146 | // cleanse the resourceVersion to prevent usage after initial read 147 | if (isset($this->params['resourceVersion'])) { 148 | $this->setResourceVersion($this->params['resourceVersion']); 149 | $this->setResourceVersionLastSuccess($this->params['resourceVersion']); 150 | unset($this->params['resourceVersion']); 151 | } 152 | } 153 | 154 | /** 155 | * Watch destructor. 156 | */ 157 | public function __destruct() 158 | { 159 | $this->closeHandle(); 160 | } 161 | 162 | /** 163 | * Get client 164 | * 165 | * @return Client 166 | */ 167 | public function getClient() 168 | { 169 | return $this->client; 170 | } 171 | 172 | /** 173 | * Retrieve (open if necessary) the HTTP connection 174 | * 175 | * @throws \Exception 176 | * @return bool|resource 177 | */ 178 | private function getHandle() 179 | { 180 | // make sure to clean up old handles 181 | if ($this->handle !== null && feof($this->handle)) { 182 | $this->resetHandle(); 183 | } 184 | 185 | // provision new handle 186 | if ($this->handle == null) { 187 | $params = $this->params; 188 | if (!empty($this->getResourceVersion())) { 189 | $params['resourceVersion'] = $this->getResourceVersion(); 190 | } 191 | $query = http_build_query($params); 192 | $base = $this->getClient()->getConfig()->getServer().$this->endpoint; 193 | $url = $base; 194 | 195 | if (!empty($query)) { 196 | $parsed = parse_url($base); 197 | if (key_exists('query', $parsed) || substr($base, -1) == "?") { 198 | $url .= '&'.$query; 199 | } else { 200 | $url .= '?'.$query; 201 | } 202 | } 203 | $handle = @fopen($url, 'r', false, $this->getClient()->getStreamContext()); 204 | if ($handle === false) { 205 | $e = error_get_last(); 206 | var_dump($e); 207 | throw new \Exception($e['message'], $e['type']); 208 | } 209 | stream_set_timeout($handle, 0, $this->getStreamTimeout()); 210 | $this->handle = $handle; 211 | $this->setHandleStartTimestamp(time()); 212 | } 213 | 214 | return $this->handle; 215 | } 216 | 217 | /** 218 | * Cleanly reset connection 219 | */ 220 | private function resetHandle() 221 | { 222 | $this->closeHandle(); 223 | $this->handle = null; 224 | $this->buffer = null; 225 | } 226 | 227 | /** 228 | * Close the connection handle 229 | * 230 | * @return bool 231 | */ 232 | private function closeHandle() 233 | { 234 | if ($this->handle == null) { 235 | return true; 236 | } 237 | 238 | return fclose($this->handle); 239 | } 240 | 241 | /** 242 | * Set streamTimeout (microseconds) 243 | * 244 | * @param $value 245 | */ 246 | public function setStreamTimeout($value) 247 | { 248 | if ($value < 1) { 249 | $value = self::DEFAULT_STREAM_TIMEOUT; 250 | } 251 | 252 | $this->streamTimeout = (int) $value; 253 | if ($this->handle !== null) { 254 | stream_set_timeout($this->handle, 0, $this->getStreamTimeout()); 255 | } 256 | } 257 | 258 | /** 259 | * Get streamTimeout (microseconds) 260 | * 261 | * @return float|int 262 | */ 263 | public function getStreamTimeout() 264 | { 265 | if ($this->streamTimeout < 1) { 266 | $this->setStreamTimeout(self::DEFAULT_STREAM_TIMEOUT); 267 | } 268 | 269 | return $this->streamTimeout; 270 | } 271 | 272 | /** 273 | * Set streamReadLength (bytes) 274 | * 275 | * @param $value 276 | */ 277 | public function setStreamReadLength($value) 278 | { 279 | if ($value < 1) { 280 | $value = self::DEFAULT_STREAM_READ_LENGTH; 281 | } 282 | 283 | $this->streamReadLength = (int) $value; 284 | } 285 | 286 | /** 287 | * Get streamReadLength (bytes) 288 | * 289 | * @return int 290 | */ 291 | public function getStreamReadLength() 292 | { 293 | if ($this->streamReadLength < 1) { 294 | $this->setStreamReadLength(self::DEFAULT_STREAM_READ_LENGTH); 295 | } 296 | 297 | return $this->streamReadLength; 298 | } 299 | 300 | /** 301 | * Set deadPeerDetectionTimeout (seconds) 302 | * 303 | * @param $value 304 | */ 305 | public function setDeadPeerDetectionTimeout($value) 306 | { 307 | $this->deadPeerDetectionTimeout = (int) $value; 308 | } 309 | 310 | /** 311 | * Get deadPeerDetectionTimeout (seconds) 312 | * 313 | * @return int 314 | */ 315 | public function getDeadPeerDetectionTimeout() 316 | { 317 | return $this->deadPeerDetectionTimeout; 318 | } 319 | 320 | /** 321 | * Get handleStartTimestamp 322 | * 323 | * @return int 324 | */ 325 | private function getHandleStartTimestamp() 326 | { 327 | return $this->handleStartTimestamp; 328 | } 329 | 330 | /** 331 | * Set handleStartTimestamp 332 | * 333 | * @param $value 334 | */ 335 | private function setHandleStartTimestamp($value) 336 | { 337 | $this->handleStartTimestamp = (int) $value; 338 | } 339 | 340 | /** 341 | * Set resourceVersion 342 | * 343 | * @param $value 344 | */ 345 | private function setResourceVersion($value) 346 | { 347 | if ($value > $this->resourceVersion || $value === null) { 348 | $this->resourceVersion = $value; 349 | } 350 | } 351 | 352 | /** 353 | * Get resourceVersion 354 | * 355 | * @return string 356 | */ 357 | private function getResourceVersion() 358 | { 359 | return $this->resourceVersion; 360 | } 361 | 362 | /** 363 | * Set resourceVersionLastSuccess 364 | * 365 | * @param $value 366 | */ 367 | private function setResourceVersionLastSuccess($value) 368 | { 369 | if ($value > $this->resourceVersionLastSuccess) { 370 | $this->resourceVersionLastSuccess = $value; 371 | } 372 | } 373 | 374 | /** 375 | * Get resourceVersionLastSuccess 376 | * 377 | * @return string 378 | */ 379 | private function getResourceVersionLastSuccess() 380 | { 381 | return $this->resourceVersionLastSuccess; 382 | } 383 | 384 | /** 385 | * Set lastBytesReadTimestamp 386 | * 387 | * @param $value 388 | */ 389 | private function setLastBytesReadTimestamp($value) 390 | { 391 | $this->lastBytesReadTimestamp = (int) $value; 392 | } 393 | 394 | /** 395 | * Get lastBytesReadTimestamp 396 | * 397 | * @return int 398 | */ 399 | private function getLastBytesReadTimestamp() 400 | { 401 | return $this->lastBytesReadTimestamp; 402 | } 403 | 404 | /** 405 | * Read and process event messages (closure/callback) 406 | * 407 | * @param int $cycles 408 | * @throws \Exception 409 | */ 410 | private function internal_iterator($cycles = 0) 411 | { 412 | $handle = $this->getHandle(); 413 | $i_cycles = 0; 414 | 415 | $associative = $this->getClient()->getRequestOption('decode_associative', []); 416 | $decode_flags = $this->getClient()->getRequestOption('decode_flags', []); 417 | $decode_response = $this->getClient()->getRequestOption('decode_response', []); 418 | 419 | /** 420 | * Mitigation for improper ordering especially during initial load 421 | * This acts as a tripwire, once tripped it should never go back to false 422 | * 423 | * https://github.com/kubernetes/kubernetes/issues/49745 424 | */ 425 | $initial_load_finished = false; 426 | while (true) { 427 | if (function_exists('pcntl_signal_dispatch')) { 428 | \pcntl_signal_dispatch(); 429 | } 430 | if ($this->getStop()) { 431 | $this->internal_stop(); 432 | return; 433 | } 434 | 435 | // detect dead peers 436 | $now = time(); 437 | if ($this->getDeadPeerDetectionTimeout() > 0 && 438 | $now >= ($this->getHandleStartTimestamp() + $this->getDeadPeerDetectionTimeout()) && 439 | $now >= ($this->getLastBytesReadTimestamp() + $this->getDeadPeerDetectionTimeout()) 440 | ) { 441 | $this->resetHandle(); 442 | $handle = $this->getHandle(); 443 | } 444 | 445 | //$meta = stream_get_meta_data($handle); 446 | if (feof($handle)) { 447 | if (key_exists('timeoutSeconds', $this->params) && $this->params['timeoutSeconds'] > 0) { 448 | //assume we've reached a successful end of watch 449 | return; 450 | } else { 451 | $this->resetHandle(); 452 | $handle = $this->getHandle(); 453 | } 454 | } 455 | 456 | $data = fread($handle, $this->getStreamReadLength()); 457 | if ($data === false) { 458 | // PHP 7.4 now returns false when the timeout is hit 459 | if (version_compare(PHP_VERSION, '7.4', 'ge')) { 460 | $data = ""; 461 | } else { 462 | throw new \Exception('Failed to read bytes from stream: ' . $this->getClient()->getConfig()->getServer()); 463 | } 464 | } 465 | 466 | if (strlen($data) > 0) { 467 | $this->setLastBytesReadTimestamp(time()); 468 | } 469 | 470 | if (!$initial_load_finished && empty($data)) { 471 | $initial_load_finished = true; 472 | } 473 | 474 | $this->buffer .= $data; 475 | 476 | //break immediately if nothing is on the buffer 477 | if (empty($this->buffer) && $cycles > 0) { 478 | return; 479 | } 480 | 481 | if ((bool) strstr($this->buffer, "\n")) { 482 | $parts = explode("\n", $this->buffer); 483 | $parts_count = count($parts); 484 | for ($x = 0; $x < ($parts_count - 1); $x++) { 485 | if (!empty($parts[$x])) { 486 | try { 487 | $response = json_decode($parts[$x], $associative, 512, $decode_flags); 488 | $code = $this->preProcessResponse($response); 489 | if ($code != 0) { 490 | $this->resetHandle(); 491 | $this->resourceVersion = null; 492 | $handle = $this->getHandle(); 493 | goto end; 494 | } 495 | 496 | if (!$initial_load_finished && DotAccess::get($response, 'type') != "ADDED") { 497 | $initial_load_finished = true; 498 | } 499 | 500 | $rv = DotAccess::get($response, 'object.metadata.resourceVersion'); 501 | 502 | if (!$initial_load_finished || $rv > $this->getResourceVersionLastSuccess()) { 503 | if (!$decode_response) { 504 | ($this->callback)($parts[$x], $this); 505 | } else { 506 | ($this->callback)($response, $this); 507 | } 508 | } 509 | 510 | if ($rv > $this->getResourceVersionLastSuccess()) { 511 | $this->setResourceVersion($rv); 512 | $this->setResourceVersionLastSuccess($rv); 513 | } 514 | 515 | if ($this->getStop()) { 516 | $this->internal_stop(); 517 | return; 518 | } 519 | } catch (\Exception $e) { 520 | //TODO: log failure 521 | } 522 | } 523 | } 524 | $this->buffer = $parts[($parts_count - 1)]; 525 | } 526 | 527 | end: 528 | $i_cycles++; 529 | if ($cycles > 0 && $cycles >= $i_cycles) { 530 | return; 531 | } 532 | } 533 | } 534 | 535 | /** 536 | * Read and process event messages (generator) 537 | * 538 | * @param int $cycles 539 | * @throws \Exception 540 | * @return void 541 | */ 542 | private function internal_generator($cycles = 0) 543 | { 544 | $handle = $this->getHandle(); 545 | $i_cycles = 0; 546 | 547 | $associative = $this->getClient()->getRequestOption('decode_associative', []); 548 | $decode_flags = $this->getClient()->getRequestOption('decode_flags', []); 549 | $decode_response = $this->getClient()->getRequestOption('decode_response', []); 550 | 551 | /** 552 | * Mitigation for improper ordering especially during initial load 553 | * This acts as a tripwire, once tripped it should never go back to false 554 | * 555 | * https://github.com/kubernetes/kubernetes/issues/49745 556 | */ 557 | $initial_load_finished = false; 558 | while (true) { 559 | if (function_exists('pcntl_signal_dispatch')) { 560 | \pcntl_signal_dispatch(); 561 | } 562 | 563 | if ($this->getStop()) { 564 | $this->internal_stop(); 565 | return; 566 | } 567 | 568 | // detect dead peers 569 | $now = time(); 570 | if ($this->getDeadPeerDetectionTimeout() > 0 && 571 | $now >= ($this->getHandleStartTimestamp() + $this->getDeadPeerDetectionTimeout()) && 572 | $now >= ($this->getLastBytesReadTimestamp() + $this->getDeadPeerDetectionTimeout()) 573 | ) { 574 | $this->resetHandle(); 575 | $handle = $this->getHandle(); 576 | } 577 | 578 | //$meta = stream_get_meta_data($handle); 579 | if (feof($handle)) { 580 | if (key_exists('timeoutSeconds', $this->params) && $this->params['timeoutSeconds'] > 0) { 581 | //assume we've reached a successful end of watch 582 | return; 583 | } else { 584 | $this->resetHandle(); 585 | $handle = $this->getHandle(); 586 | } 587 | } 588 | 589 | $data = fread($handle, $this->getStreamReadLength()); 590 | if ($data === false) { 591 | // PHP 7.4 now returns false when the timeout is hit 592 | if (version_compare(PHP_VERSION, '7.4', 'ge')) { 593 | $data = ""; 594 | } else { 595 | throw new \Exception('Failed to read bytes from stream: ' . $this->getClient()->getConfig()->getServer()); 596 | } 597 | } 598 | 599 | if (strlen($data) > 0) { 600 | $this->setLastBytesReadTimestamp(time()); 601 | } 602 | 603 | if (!$initial_load_finished && empty($data)) { 604 | $initial_load_finished = true; 605 | } 606 | 607 | $this->buffer .= $data; 608 | 609 | //break immediately if nothing is on the buffer 610 | if (empty($this->buffer) && $cycles > 0) { 611 | return; 612 | } 613 | 614 | if ((bool) strstr($this->buffer, "\n")) { 615 | $parts = explode("\n", $this->buffer); 616 | $parts_count = count($parts); 617 | for ($x = 0; $x < ($parts_count - 1); $x++) { 618 | if (!empty($parts[$x])) { 619 | try { 620 | $response = json_decode($parts[$x], $associative, 512, $decode_flags); 621 | $code = $this->preProcessResponse($response); 622 | if ($code != 0) { 623 | $this->resetHandle(); 624 | $this->resourceVersion = null; 625 | $handle = $this->getHandle(); 626 | goto end; 627 | } 628 | 629 | if (!$initial_load_finished && DotAccess::get($response, 'type') != "ADDED") { 630 | $initial_load_finished = true; 631 | } 632 | 633 | $rv = DotAccess::get($response, 'object.metadata.resourceVersion'); 634 | 635 | // https://github.com/kubernetes/kubernetes/issues/49745 636 | $yield = (!$initial_load_finished || $rv > $this->getResourceVersionLastSuccess()); 637 | 638 | if ($rv > $this->getResourceVersionLastSuccess()) { 639 | $this->setResourceVersion($rv); 640 | $this->setResourceVersionLastSuccess($rv); 641 | } 642 | 643 | if ($yield) { 644 | if (!$decode_response) { 645 | yield $parts[$x]; 646 | } else { 647 | yield $response; 648 | } 649 | } 650 | 651 | if ($this->getStop()) { 652 | $this->internal_stop(); 653 | return; 654 | } 655 | } catch (\Exception $e) { 656 | //TODO: log failure 657 | } 658 | } 659 | } 660 | $this->buffer = $parts[($parts_count - 1)]; 661 | } 662 | 663 | end: 664 | $i_cycles++; 665 | if ($cycles > 0 && $cycles >= $i_cycles) { 666 | return; 667 | } 668 | } 669 | } 670 | 671 | private function preProcessResponse($response) 672 | { 673 | if (!DotAccess::isStructuredData($response)) { 674 | return 1; 675 | } 676 | 677 | if(DotAccess::get($response, 'kind', null) == 'Status' && DotAccess::get($response, 'status', null) == 'Failure') { 678 | return 1; 679 | } 680 | 681 | // resourceVersion is too old 682 | if (DotAccess::get($response, 'type', null) == 'ERROR' && DotAccess::get($response, 'object.code', null) == 410) { 683 | return 1; 684 | } 685 | 686 | return 0; 687 | } 688 | 689 | /** 690 | * Get stop 691 | * 692 | * @return bool 693 | */ 694 | private function getStop() 695 | { 696 | return $this->stop; 697 | } 698 | 699 | /** 700 | * Set stop 701 | * 702 | * @param $value 703 | */ 704 | private function setStop($value) 705 | { 706 | $this->stop = (bool) $value; 707 | } 708 | 709 | /** 710 | * Internal logic for loop breakage 711 | */ 712 | private function internal_stop() 713 | { 714 | $this->resetHandle(); 715 | $this->setStop(false); 716 | } 717 | 718 | /** 719 | * Stop the watch and break the loop 720 | */ 721 | public function stop() 722 | { 723 | $this->setStop(true); 724 | } 725 | 726 | /** 727 | * Start the loop. If cycles is provided the loop is broken after N reads() of the connection 728 | * 729 | * @param int $cycles 730 | * @throws \Exception 731 | */ 732 | public function start($cycles = 0) 733 | { 734 | return $this->internal_iterator($cycles); 735 | } 736 | 737 | /** 738 | * Start the loop. If cycles is provided the loop is broken after N reads() of the connection 739 | * 740 | * @param int $cycles 741 | * @throws \Exception 742 | * @return void 743 | */ 744 | public function stream($cycles = 0) 745 | { 746 | return $this->internal_generator($cycles); 747 | } 748 | 749 | /** 750 | * Fork a watch into a new process 751 | * 752 | * @return bool 753 | * @throws \Exception 754 | */ 755 | public function fork() 756 | { 757 | if (!function_exists('pcntl_fork')) { 758 | return false; 759 | } 760 | $pid = pcntl_fork(); 761 | 762 | if ($pid == -1) { 763 | //failure 764 | return false; 765 | } elseif ($pid) { 766 | return true; 767 | } else { 768 | $this->start(); 769 | exit(0); 770 | } 771 | } 772 | } 773 | --------------------------------------------------------------------------------