diff --git a/libs/k8s-client/CLAUDE.md b/libs/k8s-client/CLAUDE.md index 3a981c693..b72ebd53c 100644 --- a/libs/k8s-client/CLAUDE.md +++ b/libs/k8s-client/CLAUDE.md @@ -47,11 +47,17 @@ docker compose run --rm dev-k8s-client bash -c "vendor/bin/phpunit --filter test ### Three-Layer Structure -1. **ClientFacadeFactory** - Creates configured client instances - - `GenericClientFacadeFactory` - For explicit cluster credentials - - `InClusterClientFacadeFactory` - For Pods running inside K8s (uses service account) - - `AutoDetectClientFacadeFactory` - Auto-detects environment - - `EnvVariablesClientFacadeFactory` - Loads config from environment variables +1. **ClientFactory** - Two separate concerns, both under `Keboola\K8sClient\ClientFactory\`: + - Client factories (`KubernetesApiClientFactory` implementations) resolve credentials and produce a single + configured `KubernetesApiClient`: + - `StaticKubernetesApiClientFactory` - For explicit cluster credentials + - `InClusterKubernetesApiClientFactory` - For Pods running inside K8s (uses service account) + - `EnvVariablesKubernetesApiClientFactory` - Loads config from environment variables + - `AutoDetectKubernetesApiClientFactory` - Tries env variables first, falls back to in-cluster + - `KubernetesApiClientFacadeFactory` - Universal factory that assembles a `KubernetesApiClientFacade` from any + already-configured `KubernetesApiClient` (regardless of which client factory produced it), via `create()` + - `ClientConfigurator` / `Token\{TokenInterface,StaticToken,InClusterToken}` - shared low-level helpers used by + the client factories to configure the underlying `kubernetes/php-client` `Client` singleton 2. **KubernetesApiClientFacade** - High-level facade providing: - Type-safe resource operations (`createModels`, `deleteModels`, `mergePatch`, etc.) @@ -59,6 +65,9 @@ docker compose run --rm dev-k8s-client bash -c "vendor/bin/phpunit --filter test - Waiting operations (`waitWhileExists`) - Resource listing with pagination (`listMatching`) - Access to specific API clients via getters + - `client(string $modelClass)` - generic accessor resolving any registered resource type (core or extra) + - `$extraClients` constructor param - lets consumers register their own CRD API clients (e.g. custom + Keboola CRDs) without the library needing to own the model/BaseApi/typed-client classes for them 3. **ApiClient Wrappers** - Namespace/cluster-scoped API wrappers - Wrap `kubernetes/php-client` API classes @@ -76,18 +85,16 @@ docker compose run --rm dev-k8s-client bash -c "vendor/bin/phpunit --filter test - `PodsApiClient` - Pods (includes log streaming) - `SecretsApiClient` - Secrets - `ServicesApiClient` - Services -- `AppsApiClient` - Custom App CRD (Keboola-specific) -- `AppRunsApiClient` - Custom AppRun CRD (Keboola-specific) **Cluster-scoped:** - `PersistentVolumesApiClient` - PVs ### Custom Resources (CRDs) -The library includes custom Keboola CRDs for App and AppRun resources: -- Models: `src/Model/Io/Keboola/Apps/V1/` -- Used for billing and cost tracking -- CRD definitions must be installed on K8s clusters for functional tests (see README.md) +The library does not own any custom Keboola CRD (model/BaseApi/typed-client) classes itself. Consumers that +need a custom CRD (e.g. the `App`/`AppRun` CRDs used by sandboxes-service for billing) implement their own +model/BaseApi/typed-client classes and register the typed client via `KubernetesApiClientFacade`'s +`$extraClients` constructor param; it is then accessed through `client(SomeModel::class)`. ## Implementing New API Support @@ -104,15 +111,14 @@ To add support for a new Kubernetes API: - Add resource class to `$resourceTypeClientMap` array - Update type annotations for generic methods (`createModels`, `deleteModels`, etc.) -3. **Update factories** in `ClientFacadeFactory/`: - - `GenericClientFacadeFactory` - Instantiate new API client and inject into facade +3. **Update `KubernetesApiClientFacadeFactory::create()`** in `ClientFactory/`: + - Instantiate new API client and inject into facade ## Code Quality Standards ### PHPStan Configuration - Level: `max` -- Custom ignoreErrors for external library issues: - - `src/BaseApi/*` - Guzzle and kubernetes-runtime return type mismatches +- Custom ignoreErrors: `missingType.iterableValue` (broad, library-wide) - Stub file: `tests/stubs/K8s.stub` for external type definitions ### PHP_CodeSniffer diff --git a/libs/k8s-client/README.md b/libs/k8s-client/README.md index fb21f23ac..24eadd94c 100644 --- a/libs/k8s-client/README.md +++ b/libs/k8s-client/README.md @@ -8,24 +8,31 @@ it in many ways: * high-level operations like `create` multiple resources at once, `waitWhileExists` to wait while given resource exists etc. ## Usage -To create a client, use one of provided client factories: -* `GenericClientFacadeFactory` if you have cluster credentials -* `InClusterClientFacadeFactory` if you run inside a Pod which has access to K8S API +To create a client, first pick a `Keboola\K8sClient\ClientFactory\KubernetesApiClientFactory` implementation that +matches how you obtain credentials, then use it together with the universal `KubernetesApiClientFacadeFactory` to +build the high-level facade: +* `StaticKubernetesApiClientFactory` if you have explicit cluster credentials +* `InClusterKubernetesApiClientFactory` if you run inside a Pod which has access to K8S API +* `EnvVariablesKubernetesApiClientFactory` if credentials are provided via `K8S_HOST`/`K8S_TOKEN`/`K8S_CA_CERT_PATH`/`K8S_NAMESPACE` env variables +* `AutoDetectKubernetesApiClientFactory` to try env variables first, falling back to in-cluster credentials ```php createClusterClient( +$clientFactory = new StaticKubernetesApiClientFactory( + $retryProxy, 'https://api.k8s-cluster.example.com', 'secret-token', 'var/k8s/caCert.pem', - 'default' + 'default', ); +$apiClient = $clientFactory->createApiClient(); +$client = KubernetesApiClientFacade::create($apiClient, $logger); $pod = new Pod([ 'metadata' => [ @@ -68,6 +75,54 @@ $client->deleteModels([ ]); ``` +## Custom resources (CRDs) +The facade also serves resources it doesn't ship. Implement an API client for your CRD by extending +`Keboola\K8sClient\ApiClient\BaseNamespaceApiClient` (or `BaseClusterApiClient`), then register it via the +`$extraClients` map — keyed by the CRD model class — when building the facade: + +```php +$facade = KubernetesApiClientFacade::create($apiClient, $logger, [ + My\Crd\Model::class => new My\Crd\ApiClient($apiClient), +]); + +$facade->client(My\Crd\Model::class)->get('my-resource'); // typed access +$facade->mergePatch($myCrdModel); // generic methods route via the map too +``` + +## Symfony integration +The library ships no bundle, so wire the pieces as services. Minimal setup with auto-detected credentials +(env vars in dev, in-cluster ServiceAccount token in prod) using the shared-client + `create()` split: + +```yaml +services: + # credential strategy — AutoDetect needs its two sub-factories defined (RetryProxy is not autowirable) + Keboola\K8sClient\ClientFactory\EnvVariablesKubernetesApiClientFactory: + arguments: + $retryProxy: !service { class: Retry\RetryProxy } + Keboola\K8sClient\ClientFactory\InClusterKubernetesApiClientFactory: + arguments: + $retryProxy: !service { class: Retry\RetryProxy } + Keboola\K8sClient\ClientFactory\AutoDetectKubernetesApiClientFactory: ~ + + # the shared low-level client + app.k8s.api_client: + class: Keboola\K8sClient\KubernetesApiClient + factory: ['@Keboola\K8sClient\ClientFactory\AutoDetectKubernetesApiClientFactory', 'createApiClient'] + arguments: + $namespace: '%env(K8S_NAMESPACE)%' + + # the high-level facade (static factory called as a class-string); register CRD clients via $extraClients + Keboola\K8sClient\KubernetesApiClientFacade: + factory: ['Keboola\K8sClient\KubernetesApiClientFacade', 'create'] + arguments: + $apiClient: '@app.k8s.api_client' + $logger: '@logger' + $extraClients: {} # e.g. 'App\Crd\Model': '@App\Crd\ApiClient' +``` + +For a single credential source you can skip `AutoDetect` and point the client at +`StaticKubernetesApiClientFactory` (explicit values) or `InClusterKubernetesApiClientFactory` directly. + ## Development Prerequisites: * configured `az` and `aws` CLI tools (run `az login` and `aws configure --profile keboola-dev-platform-services`) @@ -90,31 +145,6 @@ docker compose run --rm dev composer install docker compose run --rm dev composer ci ``` -### Installing AppRun CRD on Dev/CI Clusters -The functional tests require App and AppRun Custom Resource Definitions (CRDs) to be installed on the Kubernetes cluster. For manually maintained CI clusters, install CRDs manually on **all three clusters** (GCP, AWS, Azure): - -```bash -# Download App and AppRun CRD definitions from keboola-operator repository: -# - https://github.com/keboola/keboola-operator/blob/canary-operator/config/crd/bases/apps.keboola.com_apps.yaml -# - https://github.com/keboola/keboola-operator/blob/canary-operator/config/crd/bases/apps.keboola.com_appruns.yaml -# Save files locally as apps.keboola.com_appruns.yaml - -# Install on GCP CI cluster -kubectl --context="gke_kbc-ci-platform-services_us-central1_gcp-ci-ps" apply -f apps.keboola.com_apps.yaml -kubectl --context="gke_kbc-ci-platform-services_us-central1_gcp-ci-ps" apply -f apps.keboola.com_appruns.yaml - -# Install on AWS CI cluster -kubectl --context="arn:aws:eks:eu-central-1:480319613404:cluster/ci-ps-eu-central-1" apply -f apps.keboola.com_apps.yaml -kubectl --context="arn:aws:eks:eu-central-1:480319613404:cluster/ci-ps-eu-central-1" apply -f apps.keboola.com_appruns.yaml - -# Install on Azure CI cluster -kubectl --context="sandboxes-ci-2021-aks" apply -f apps.keboola.com_apps.yaml -kubectl --context="sandboxes-ci-2021-aks" apply -f apps.keboola.com_appruns.yaml -``` - -This is a one-time setup per cluster. The CRD defines the schema for AppRun resources used for billing and cost tracking. - - ## Implementing new API Only few K8S APIs we needed are implement so far. To implement new API, do following: * create API client wrapper in `Keboola\K8sClient\ApiClient` @@ -122,7 +152,7 @@ Only few K8S APIs we needed are implement so far. To implement new API, do follo * add the wrapper to `KubernetesApiClientFacade` * inject the `kubernetes/php-client` client through constructor * add support for the new resource to methods signatures -* update `GenericClientFacadeFactory` to provide new API class to `KubernetesApiClientFacade` +* update `KubernetesApiClientFacade::create()` to provide the new API class to the facade ## License diff --git a/libs/k8s-client/phpstan.neon b/libs/k8s-client/phpstan.neon index 83944f27b..1e2d58d2e 100644 --- a/libs/k8s-client/phpstan.neon +++ b/libs/k8s-client/phpstan.neon @@ -7,13 +7,5 @@ parameters: - tests/stubs/K8s.stub ignoreErrors: - identifier: missingType.iterableValue - - - # BaseApi: $this->client->request() returns mixed (Guzzle Client), but we know it returns ResponseInterface - message: '#Parameter \#1 \$response of method KubernetesRuntime\\AbstractAPI::parseResponse\(\) expects Psr\\Http\\Message\\ResponseInterface, mixed given\.#' - path: src/BaseApi/* - - - # BaseApi: parseResponse() returns mixed (external library), but actual types are defined by method return types - message: '#Method .+ should return .+ but returns mixed\.#' - path: src/BaseApi/* includes: - vendor/phpstan/phpstan-phpunit/extension.neon diff --git a/libs/k8s-client/src/ApiClient/ApiClientInterface.php b/libs/k8s-client/src/ApiClient/ApiClientInterface.php index 135f9d4c9..d3a98794f 100644 --- a/libs/k8s-client/src/ApiClient/ApiClientInterface.php +++ b/libs/k8s-client/src/ApiClient/ApiClientInterface.php @@ -27,6 +27,7 @@ public function get(string $name, array $queries = []): AbstractModel; /** * @param TItem $model + * @return TItem */ public function create(AbstractModel $model, array $queries = []): AbstractModel; diff --git a/libs/k8s-client/src/ApiClient/AppRunsApiClient.php b/libs/k8s-client/src/ApiClient/AppRunsApiClient.php deleted file mode 100644 index a42056c7e..000000000 --- a/libs/k8s-client/src/ApiClient/AppRunsApiClient.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ -class AppRunsApiClient extends BaseNamespaceApiClient -{ - public function __construct(KubernetesApiClient $apiClient) - { - parent::__construct( - $apiClient, - new AppRunsApi(), - AppRunList::class, - AppRun::class, - ); - } -} diff --git a/libs/k8s-client/src/ApiClient/AppsApiClient.php b/libs/k8s-client/src/ApiClient/AppsApiClient.php deleted file mode 100644 index fcd228993..000000000 --- a/libs/k8s-client/src/ApiClient/AppsApiClient.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ -class AppsApiClient extends BaseNamespaceApiClient -{ - public function __construct(KubernetesApiClient $apiClient) - { - parent::__construct( - $apiClient, - new AppsApi(), - AppList::class, - App::class, - ); - } -} diff --git a/libs/k8s-client/src/BaseApi/App.php b/libs/k8s-client/src/BaseApi/App.php deleted file mode 100644 index 808dd2b57..000000000 --- a/libs/k8s-client/src/BaseApi/App.php +++ /dev/null @@ -1,155 +0,0 @@ - [ - '200.' => AppList::class, - ], - 'readAppsKeboolaComV2NamespacedApp' => [ - '200.' => TheApp::class, - ], - 'createAppsKeboolaComV2NamespacedApp' => [ - '200.' => TheApp::class, - '201.' => TheApp::class, - '202.' => TheApp::class, - ], - 'patchAppsKeboolaComV2NamespacedApp' => [ - '200.' => TheApp::class, - '201.' => TheApp::class, - ], - 'deleteAppsKeboolaComV2NamespacedApp' => [ - '200.' => Status::class, - '202.' => Status::class, - ], - 'deleteAppsKeboolaComV2CollectionNamespacedApp' => [ - '200.' => Status::class, - ], - ]; - } - - /** - * List apps in a namespace - */ - public function list(string $namespace, array $queries = []): AppList|Status - { - return $this->parseResponse( - $this->client->request( - 'get', - "/apis/apps.keboola.com/v2/namespaces/$namespace/apps", - [ - 'query' => $queries, - ], - ), - 'listAppsKeboolaComV2NamespacedApp', - ); - } - - /** - * Read an app - */ - public function read(string $namespace, string $name, array $queries = []): TheApp|Status - { - return $this->parseResponse( - $this->client->request( - 'get', - "/apis/apps.keboola.com/v2/namespaces/$namespace/apps/$name", - [ - 'query' => $queries, - ], - ), - 'readAppsKeboolaComV2NamespacedApp', - ); - } - - /** - * Create an app - */ - public function create(string $namespace, TheApp $model, array $queries = []): TheApp|Status - { - return $this->parseResponse( - $this->client->request( - 'post', - "/apis/apps.keboola.com/v2/namespaces/$namespace/apps", - [ - 'json' => $model->getArrayCopy(), - 'query' => $queries, - ], - ), - 'createAppsKeboolaComV2NamespacedApp', - ); - } - - /** - * Patch an app - */ - public function patch(string $namespace, string $name, Patch $model, array $queries = []): TheApp|Status - { - return $this->parseResponse( - $this->client->request( - 'patch', - "/apis/apps.keboola.com/v2/namespaces/$namespace/apps/$name", - [ - 'json' => $model->getArrayCopy(), - 'query' => $queries, - ], - ), - 'patchAppsKeboolaComV2NamespacedApp', - ); - } - - /** - * Delete an app - */ - public function delete(string $namespace, string $name, DeleteOptions $options, array $queries = []): Status - { - return $this->parseResponse( - $this->client->request( - 'delete', - "/apis/apps.keboola.com/v2/namespaces/$namespace/apps/$name", - [ - 'json' => $options, - 'query' => $queries, - ], - ), - 'deleteAppsKeboolaComV2NamespacedApp', - ); - } - - /** - * Delete a collection of apps - */ - public function deleteCollection(string $namespace, DeleteOptions $options, array $queries = []): Status - { - return $this->parseResponse( - $this->client->request( - 'delete', - "/apis/apps.keboola.com/v2/namespaces/$namespace/apps", - [ - 'json' => $options, - 'query' => $queries, - ], - ), - 'deleteAppsKeboolaComV2CollectionNamespacedApp', - ); - } -} diff --git a/libs/k8s-client/src/BaseApi/AppRun.php b/libs/k8s-client/src/BaseApi/AppRun.php deleted file mode 100644 index a21aba0b1..000000000 --- a/libs/k8s-client/src/BaseApi/AppRun.php +++ /dev/null @@ -1,155 +0,0 @@ - [ - '200.' => AppRunList::class, - ], - 'readAppsKeboolaComV1NamespacedAppRun' => [ - '200.' => TheAppRun::class, - ], - 'createAppsKeboolaComV1NamespacedAppRun' => [ - '200.' => TheAppRun::class, - '201.' => TheAppRun::class, - '202.' => TheAppRun::class, - ], - 'patchAppsKeboolaComV1NamespacedAppRun' => [ - '200.' => TheAppRun::class, - '201.' => TheAppRun::class, - ], - 'deleteAppsKeboolaComV1NamespacedAppRun' => [ - '200.' => Status::class, - '202.' => Status::class, - ], - 'deleteAppsKeboolaComV1CollectionNamespacedAppRun' => [ - '200.' => Status::class, - ], - ]; - } - - /** - * List appruns in a namespace - */ - public function list(string $namespace, array $queries = []): AppRunList|Status - { - return $this->parseResponse( - $this->client->request( - 'get', - "/apis/apps.keboola.com/v1/namespaces/$namespace/appruns", - [ - 'query' => $queries, - ], - ), - 'listAppsKeboolaComV1NamespacedAppRun', - ); - } - - /** - * Read an apprun - */ - public function read(string $namespace, string $name, array $queries = []): TheAppRun|Status - { - return $this->parseResponse( - $this->client->request( - 'get', - "/apis/apps.keboola.com/v1/namespaces/$namespace/appruns/$name", - [ - 'query' => $queries, - ], - ), - 'readAppsKeboolaComV1NamespacedAppRun', - ); - } - - /** - * Create an apprun - */ - public function create(string $namespace, TheAppRun $model, array $queries = []): TheAppRun|Status - { - return $this->parseResponse( - $this->client->request( - 'post', - "/apis/apps.keboola.com/v1/namespaces/$namespace/appruns", - [ - 'json' => $model->getArrayCopy(), - 'query' => $queries, - ], - ), - 'createAppsKeboolaComV1NamespacedAppRun', - ); - } - - /** - * Patch an apprun - */ - public function patch(string $namespace, string $name, Patch $model, array $queries = []): TheAppRun|Status - { - return $this->parseResponse( - $this->client->request( - 'patch', - "/apis/apps.keboola.com/v1/namespaces/$namespace/appruns/$name", - [ - 'json' => $model->getArrayCopy(), - 'query' => $queries, - ], - ), - 'patchAppsKeboolaComV1NamespacedAppRun', - ); - } - - /** - * Delete an apprun - */ - public function delete(string $namespace, string $name, DeleteOptions $options, array $queries = []): Status - { - return $this->parseResponse( - $this->client->request( - 'delete', - "/apis/apps.keboola.com/v1/namespaces/$namespace/appruns/$name", - [ - 'json' => $options, - 'query' => $queries, - ], - ), - 'deleteAppsKeboolaComV1NamespacedAppRun', - ); - } - - /** - * Delete a collection of appruns - */ - public function deleteCollection(string $namespace, DeleteOptions $options, array $queries = []): Status - { - return $this->parseResponse( - $this->client->request( - 'delete', - "/apis/apps.keboola.com/v1/namespaces/$namespace/appruns", - [ - 'json' => $options, - 'query' => $queries, - ], - ), - 'deleteAppsKeboolaComV1CollectionNamespacedAppRun', - ); - } -} diff --git a/libs/k8s-client/src/ClientFacadeFactory/AutoDetectClientFacadeFactory.php b/libs/k8s-client/src/ClientFacadeFactory/AutoDetectClientFacadeFactory.php deleted file mode 100644 index f39fa8888..000000000 --- a/libs/k8s-client/src/ClientFacadeFactory/AutoDetectClientFacadeFactory.php +++ /dev/null @@ -1,48 +0,0 @@ -tryCreateClientFromEnv($namespace) ?? - $this->tryCreateClientFromInCluster($namespace) ?? - throw new ConfigurationException('No valid K8S client configuration found.') - ; - } - - private function tryCreateClientFromEnv(?string $namespace): ?KubernetesApiClientFacade - { - if (!$this->envVariablesFactory->isAvailable($namespace)) { - return null; - } - - $this->logger->debug('Using ENV variables configuration for K8S client.'); - return $this->envVariablesFactory->createClusterClient($namespace); - } - - private function tryCreateClientFromInCluster(?string $namespace): ?KubernetesApiClientFacade - { - if (!$this->inClusterFactory->isAvailable()) { - return null; - } - - $this->logger->debug('Using in-cluster configuration for K8S client.'); - return $this->inClusterFactory->createClusterClient($namespace); - } -} diff --git a/libs/k8s-client/src/ClientFacadeFactory/GenericClientFacadeFactory.php b/libs/k8s-client/src/ClientFacadeFactory/GenericClientFacadeFactory.php deleted file mode 100644 index 744f36a6c..000000000 --- a/libs/k8s-client/src/ClientFacadeFactory/GenericClientFacadeFactory.php +++ /dev/null @@ -1,59 +0,0 @@ -retryProxy = $retryProxy; - $this->logger = $logger; - } - - public function createClusterClient( - string $apiUrl, - TokenInterface|string $token, - string $caCertFile, - string $namespace, - ): KubernetesApiClientFacade { - ClientConfigurator::configureBaseClient($apiUrl, $caCertFile, $token); - $apiClient = new KubernetesApiClient($this->retryProxy, $namespace); - - // all K8S API clients created here will use the configuration above, even if the Client is reconfigured later - return new KubernetesApiClientFacade( - $this->logger, - new ConfigMapsApiClient($apiClient), - new EventsApiClient($apiClient), - new IngressesApiClient($apiClient), - new PersistentVolumeClaimsApiClient($apiClient), - new PersistentVolumesApiClient($apiClient), - new PodsApiClient($apiClient, new PodWithLogStream()), - new SecretsApiClient($apiClient), - new ServicesApiClient($apiClient), - new AppsApiClient($apiClient), - new AppRunsApiClient($apiClient), - ); - } -} diff --git a/libs/k8s-client/src/ClientFactory/AutoDetectKubernetesApiClientFactory.php b/libs/k8s-client/src/ClientFactory/AutoDetectKubernetesApiClientFactory.php new file mode 100644 index 000000000..444391460 --- /dev/null +++ b/libs/k8s-client/src/ClientFactory/AutoDetectKubernetesApiClientFactory.php @@ -0,0 +1,36 @@ +envVariablesFactory->isAvailable($namespace)) { + $this->logger->debug('Using ENV variables configuration for K8S client.'); + return $this->envVariablesFactory->createApiClient($namespace); + } + + // preserve original AutoDetect semantics: the in-cluster branch checks the namespace file too + // (unlike the env branch), so it is called without the caller-supplied namespace + if ($this->inClusterFactory->isAvailable()) { + $this->logger->debug('Using in-cluster configuration for K8S client.'); + return $this->inClusterFactory->createApiClient($namespace); + } + + throw new ConfigurationException('No valid K8S client configuration found.'); + } +} diff --git a/libs/k8s-client/src/ClientFacadeFactory/ClientConfigurator.php b/libs/k8s-client/src/ClientFactory/ClientConfigurator.php similarity index 92% rename from libs/k8s-client/src/ClientFacadeFactory/ClientConfigurator.php rename to libs/k8s-client/src/ClientFactory/ClientConfigurator.php index a59dc03db..a75bfbc61 100644 --- a/libs/k8s-client/src/ClientFacadeFactory/ClientConfigurator.php +++ b/libs/k8s-client/src/ClientFactory/ClientConfigurator.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Keboola\K8sClient\ClientFacadeFactory; +namespace Keboola\K8sClient\ClientFactory; use GuzzleHttp\HandlerStack; -use Keboola\K8sClient\ClientFacadeFactory\Token\TokenInterface; +use Keboola\K8sClient\ClientFactory\Token\TokenInterface; use Keboola\K8sClient\Exception\ConfigurationException; use Keboola\K8sClient\Guzzle\AuthMiddleware; use KubernetesRuntime\Client; diff --git a/libs/k8s-client/src/ClientFacadeFactory/EnvVariablesClientFacadeFactory.php b/libs/k8s-client/src/ClientFactory/EnvVariablesKubernetesApiClientFactory.php similarity index 71% rename from libs/k8s-client/src/ClientFacadeFactory/EnvVariablesClientFacadeFactory.php rename to libs/k8s-client/src/ClientFactory/EnvVariablesKubernetesApiClientFactory.php index e25888c5e..efe192596 100644 --- a/libs/k8s-client/src/ClientFacadeFactory/EnvVariablesClientFacadeFactory.php +++ b/libs/k8s-client/src/ClientFactory/EnvVariablesKubernetesApiClientFactory.php @@ -2,16 +2,17 @@ declare(strict_types=1); -namespace Keboola\K8sClient\ClientFacadeFactory; +namespace Keboola\K8sClient\ClientFactory; -use Keboola\K8sClient\ClientFacadeFactory\Token\StaticToken; -use Keboola\K8sClient\KubernetesApiClientFacade; +use Keboola\K8sClient\ClientFactory\Token\StaticToken; +use Keboola\K8sClient\KubernetesApiClient; +use Retry\RetryProxy; use RuntimeException; -class EnvVariablesClientFacadeFactory +class EnvVariablesKubernetesApiClientFactory implements KubernetesApiClientFactory { public function __construct( - private readonly GenericClientFacadeFactory $genericFactory, + private readonly RetryProxy $retryProxy, ) { } @@ -22,7 +23,7 @@ public function isAvailable(?string $namespace = null): bool return $k8sHost !== null && $k8sToken !== null && $k8sCaCertPath !== null && $k8sNamespace !== null; } - public function createClusterClient(?string $namespace = null): KubernetesApiClientFacade + public function createApiClient(?string $namespace = null): KubernetesApiClient { [$k8sHost, $k8sToken, $k8sCaCertPath, $k8sNamespace] = $this->loadEnvValues($namespace); if ($k8sHost === null || $k8sToken === null || $k8sCaCertPath === null || $k8sNamespace === null) { @@ -31,12 +32,9 @@ public function createClusterClient(?string $namespace = null): KubernetesApiCli ); } - return $this->genericFactory->createClusterClient( - $k8sHost, - new StaticToken($k8sToken), - $k8sCaCertPath, - $namespace ?? $k8sNamespace, - ); + ClientConfigurator::configureBaseClient($k8sHost, $k8sCaCertPath, new StaticToken($k8sToken)); + + return new KubernetesApiClient($this->retryProxy, $namespace ?? $k8sNamespace); } /** diff --git a/libs/k8s-client/src/ClientFacadeFactory/InClusterClientFacadeFactory.php b/libs/k8s-client/src/ClientFactory/InClusterKubernetesApiClientFactory.php similarity index 72% rename from libs/k8s-client/src/ClientFacadeFactory/InClusterClientFacadeFactory.php rename to libs/k8s-client/src/ClientFactory/InClusterKubernetesApiClientFactory.php index e99921c82..e316f9753 100644 --- a/libs/k8s-client/src/ClientFacadeFactory/InClusterClientFacadeFactory.php +++ b/libs/k8s-client/src/ClientFactory/InClusterKubernetesApiClientFactory.php @@ -2,26 +2,22 @@ declare(strict_types=1); -namespace Keboola\K8sClient\ClientFacadeFactory; +namespace Keboola\K8sClient\ClientFactory; -use Keboola\K8sClient\ClientFacadeFactory\Token\InClusterToken; +use Keboola\K8sClient\ClientFactory\Token\InClusterToken; use Keboola\K8sClient\Exception\ConfigurationException; -use Keboola\K8sClient\KubernetesApiClientFacade; +use Keboola\K8sClient\KubernetesApiClient; +use Retry\RetryProxy; -class InClusterClientFacadeFactory +class InClusterKubernetesApiClientFactory implements KubernetesApiClientFactory { private const IN_CLUSTER_AUTH_PATH = '/var/run/secrets/kubernetes.io/serviceaccount'; private const IN_CLUSTER_API_URL = 'https://kubernetes.default.svc'; - private GenericClientFacadeFactory $genericFactory; - private string $credentialsPath; - public function __construct( - GenericClientFacadeFactory $genericFactory, - string $credentialsPath = self::IN_CLUSTER_AUTH_PATH, + private readonly RetryProxy $retryProxy, + private readonly string $credentialsPath = self::IN_CLUSTER_AUTH_PATH, ) { - $this->genericFactory = $genericFactory; - $this->credentialsPath = $credentialsPath; } public function isAvailable(?string $namespace = null): bool @@ -37,12 +33,16 @@ public function isAvailable(?string $namespace = null): bool } } - public function createClusterClient(?string $namespace = null): KubernetesApiClientFacade + public function createApiClient(?string $namespace = null): KubernetesApiClient { - return $this->genericFactory->createClusterClient( + ClientConfigurator::configureBaseClient( self::IN_CLUSTER_API_URL, - new InClusterToken($this->findInClusterConfigFile('token')), $this->findInClusterConfigFile('ca.crt'), + new InClusterToken($this->findInClusterConfigFile('token')), + ); + + return new KubernetesApiClient( + $this->retryProxy, $namespace ?? $this->readInClusterConfigFile('namespace'), ); } diff --git a/libs/k8s-client/src/ClientFactory/KubernetesApiClientFactory.php b/libs/k8s-client/src/ClientFactory/KubernetesApiClientFactory.php new file mode 100644 index 000000000..62755158d --- /dev/null +++ b/libs/k8s-client/src/ClientFactory/KubernetesApiClientFactory.php @@ -0,0 +1,12 @@ +defaultNamespace; + + if ($namespace === null) { + throw new InvalidArgumentException( + 'Namespace must be provided either as an argument or configured as the default namespace.', + ); + } + + ClientConfigurator::configureBaseClient($this->apiUrl, $this->caCertFile, $this->token); + + return new KubernetesApiClient($this->retryProxy, $namespace); + } +} diff --git a/libs/k8s-client/src/ClientFacadeFactory/Token/InClusterToken.php b/libs/k8s-client/src/ClientFactory/Token/InClusterToken.php similarity index 96% rename from libs/k8s-client/src/ClientFacadeFactory/Token/InClusterToken.php rename to libs/k8s-client/src/ClientFactory/Token/InClusterToken.php index b80383aad..fcf6a033e 100644 --- a/libs/k8s-client/src/ClientFacadeFactory/Token/InClusterToken.php +++ b/libs/k8s-client/src/ClientFactory/Token/InClusterToken.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Keboola\K8sClient\ClientFacadeFactory\Token; +namespace Keboola\K8sClient\ClientFactory\Token; use Keboola\K8sClient\Exception\ConfigurationException; diff --git a/libs/k8s-client/src/ClientFacadeFactory/Token/StaticToken.php b/libs/k8s-client/src/ClientFactory/Token/StaticToken.php similarity index 81% rename from libs/k8s-client/src/ClientFacadeFactory/Token/StaticToken.php rename to libs/k8s-client/src/ClientFactory/Token/StaticToken.php index 1639d76df..b4fc427e1 100644 --- a/libs/k8s-client/src/ClientFacadeFactory/Token/StaticToken.php +++ b/libs/k8s-client/src/ClientFactory/Token/StaticToken.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Keboola\K8sClient\ClientFacadeFactory\Token; +namespace Keboola\K8sClient\ClientFactory\Token; readonly class StaticToken implements TokenInterface { diff --git a/libs/k8s-client/src/ClientFacadeFactory/Token/TokenInterface.php b/libs/k8s-client/src/ClientFactory/Token/TokenInterface.php similarity index 65% rename from libs/k8s-client/src/ClientFacadeFactory/Token/TokenInterface.php rename to libs/k8s-client/src/ClientFactory/Token/TokenInterface.php index 45736fda9..0c6699d97 100644 --- a/libs/k8s-client/src/ClientFacadeFactory/Token/TokenInterface.php +++ b/libs/k8s-client/src/ClientFactory/Token/TokenInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Keboola\K8sClient\ClientFacadeFactory\Token; +namespace Keboola\K8sClient\ClientFactory\Token; interface TokenInterface { diff --git a/libs/k8s-client/src/Guzzle/AuthMiddleware.php b/libs/k8s-client/src/Guzzle/AuthMiddleware.php index ba43ad991..eba62507e 100644 --- a/libs/k8s-client/src/Guzzle/AuthMiddleware.php +++ b/libs/k8s-client/src/Guzzle/AuthMiddleware.php @@ -4,7 +4,7 @@ namespace Keboola\K8sClient\Guzzle; -use Keboola\K8sClient\ClientFacadeFactory\Token\TokenInterface; +use Keboola\K8sClient\ClientFactory\Token\TokenInterface; use Psr\Http\Message\RequestInterface; readonly class AuthMiddleware diff --git a/libs/k8s-client/src/KubernetesApiClient.php b/libs/k8s-client/src/KubernetesApiClient.php index 49ea3920e..af7dffcf5 100644 --- a/libs/k8s-client/src/KubernetesApiClient.php +++ b/libs/k8s-client/src/KubernetesApiClient.php @@ -12,7 +12,9 @@ use Retry\RetryProxy; /** - * @internal + * Low-level, namespaced K8S API client with integrated retries. Produced by a + * {@see \Keboola\K8sClient\ClientFactory\KubernetesApiClientFactory} and handed to + * {@see KubernetesApiClientFacade::create()} (and to any consumer-defined CRD clients). */ class KubernetesApiClient { diff --git a/libs/k8s-client/src/KubernetesApiClientFacade.php b/libs/k8s-client/src/KubernetesApiClientFacade.php index a44c7be48..1f3fa1848 100644 --- a/libs/k8s-client/src/KubernetesApiClientFacade.php +++ b/libs/k8s-client/src/KubernetesApiClientFacade.php @@ -5,8 +5,7 @@ namespace Keboola\K8sClient; use InvalidArgumentException; -use Keboola\K8sClient\ApiClient\AppRunsApiClient; -use Keboola\K8sClient\ApiClient\AppsApiClient; +use Keboola\K8sClient\ApiClient\ApiClientInterface; use Keboola\K8sClient\ApiClient\ConfigMapsApiClient; use Keboola\K8sClient\ApiClient\EventsApiClient; use Keboola\K8sClient\ApiClient\IngressesApiClient; @@ -15,10 +14,9 @@ use Keboola\K8sClient\ApiClient\PodsApiClient; use Keboola\K8sClient\ApiClient\SecretsApiClient; use Keboola\K8sClient\ApiClient\ServicesApiClient; +use Keboola\K8sClient\BaseApi\PodWithLogStream; use Keboola\K8sClient\Exception\ResourceNotFoundException; use Keboola\K8sClient\Exception\TimeoutException; -use Keboola\K8sClient\Model\Io\Keboola\Apps\V1\AppRun; -use Keboola\K8sClient\Model\Io\Keboola\Apps\V2\App; use Kubernetes\Model\Io\K8s\Api\Core\V1\ConfigMap; use Kubernetes\Model\Io\K8s\Api\Core\V1\Event; use Kubernetes\Model\Io\K8s\Api\Core\V1\PersistentVolume; @@ -40,6 +38,7 @@ class KubernetesApiClientFacade { private const LIST_INTERNAL_PAGE_SIZE = 100; + /** @var array, ApiClientInterface> */ private readonly array $resourceTypeClientMap; public function __construct( @@ -52,8 +51,7 @@ public function __construct( private readonly PodsApiClient $podsApiClient, private readonly SecretsApiClient $secretsApiClient, private readonly ServicesApiClient $servicesApiClient, - private readonly AppsApiClient $appsApiClient, - private readonly AppRunsApiClient $appRunsApiClient, + array $extraClients = [], ) { $this->resourceTypeClientMap = [ ConfigMap::class => $this->configMapApiClient, @@ -64,11 +62,35 @@ public function __construct( Service::class => $this->servicesApiClient, Ingress::class => $this->ingressesApiClient, PersistentVolume::class => $this->persistentVolumesApiClient, - App::class => $this->appsApiClient, - AppRun::class => $this->appRunsApiClient, + ...$extraClients, ]; } + /** + * Named constructor: assemble the facade from a single configured {@see KubernetesApiClient} + * (build one with a {@see \Keboola\K8sClient\ClientFactory\KubernetesApiClientFactory}). + * + * @param array, ApiClientInterface> $extraClients + */ + public static function create( + KubernetesApiClient $apiClient, + LoggerInterface $logger, + array $extraClients = [], + ): self { + return new self( + $logger, + new ConfigMapsApiClient($apiClient), + new EventsApiClient($apiClient), + new IngressesApiClient($apiClient), + new PersistentVolumeClaimsApiClient($apiClient), + new PersistentVolumesApiClient($apiClient), + new PodsApiClient($apiClient, new PodWithLogStream()), + new SecretsApiClient($apiClient), + new ServicesApiClient($apiClient), + $extraClients, + ); + } + public function ingresses(): IngressesApiClient { return $this->ingressesApiClient; @@ -109,26 +131,23 @@ public function persistentVolumes(): PersistentVolumesApiClient return $this->persistentVolumesApiClient; } - public function apps(): AppsApiClient - { - return $this->appsApiClient; - } - - public function appRuns(): AppRunsApiClient + /** + * @template TItem of AbstractModel + * @param class-string $modelClass + * @return ApiClientInterface + */ + public function client(string $modelClass): ApiClientInterface { - return $this->appRunsApiClient; + return $this->getApiForResource($modelClass); } - // phpcs:disable Generic.Files.LineLength.MaxExceeded /** - * @phpstan-template T of ConfigMap|Event|PersistentVolumeClaim|Pod|Secret|Service|Ingress|PersistentVolume|App|AppRun + * @phpstan-template T of ConfigMap|Event|PersistentVolumeClaim|Pod|Secret|Service|Ingress|PersistentVolume * @phpstan-param class-string $resourceType * @phpstan-return T */ - // phpcs:enable Generic.Files.LineLength.MaxExceeded public function get(string $resourceType, string $name, array $queries = []) { - // @phpstan-ignore-next-line return $this->getApiForResource($resourceType)->get($name, $queries); } @@ -147,14 +166,10 @@ public function get(string $resourceType, string $name, array $queries = []) * new Service(...), * new Ingress(...), * new PersistentVolume(...), - * new App(...), - * new AppRun(...), * ]) * - * phpcs:disable Generic.Files.LineLength.MaxExceeded - * @param array $resources - * @return (ConfigMap|Event|PersistentVolumeClaim|Pod|Secret|Service|Ingress|PersistentVolume|App|AppRun)[] - * phpcs:enable Generic.Files.LineLength.MaxExceeded + * @param array $resources + * @return (ConfigMap|Event|PersistentVolumeClaim|Pod|Secret|Service|Ingress|PersistentVolume)[] */ public function createModels(array $resources, array $queries = []): array { @@ -179,13 +194,9 @@ public function createModels(array $resources, array $queries = []): array * new Service(...), * new Ingress(...), * new PersistentVolume(...), - * new App(...), - * new AppRun(...), * ]) * - * phpcs:disable Generic.Files.LineLength.MaxExceeded - * @param array $resources - * phpcs:enable Generic.Files.LineLength.MaxExceeded + * @param array $resources * @return Status[] */ public function deleteModels(array $resources, ?DeleteOptions $deleteOptions = null, array $queries = []): array @@ -204,10 +215,10 @@ public function deleteModels(array $resources, ?DeleteOptions $deleteOptions = n * Patch a resource using JSON merge-patch strategy. * * Example: - * $app = new App(['metadata' => ['name' => 'my-app'], 'spec' => ['replicas' => 3]]); - * $updatedApp = $apiFacade->mergePatch($app); + * $configMap = new ConfigMap(['metadata' => ['name' => 'my-config'], 'data' => ['key' => 'value']]); + * $updatedConfigMap = $apiFacade->mergePatch($configMap); * - * @template T of ConfigMap|Event|PersistentVolumeClaim|Pod|Secret|Service|Ingress|PersistentVolume|App|AppRun + * @template T of AbstractModel * @param T $resource The resource to patch (name extracted from metadata) * @return T The patched resource */ @@ -234,10 +245,10 @@ public function mergePatch( * if it doesn't exist. * * Example: - * $app = new App(['metadata' => ['name' => 'my-app'], 'spec' => ['replicas' => 3]]); - * $result = $apiFacade->createOrMergePatch($app); + * $configMap = new ConfigMap(['metadata' => ['name' => 'my-config'], 'data' => ['key' => 'value']]); + * $result = $apiFacade->createOrMergePatch($configMap); * - * @template T of ConfigMap|Event|PersistentVolumeClaim|Pod|Secret|Service|Ingress|PersistentVolume|App|AppRun + * @template T of ConfigMap|Event|PersistentVolumeClaim|Pod|Secret|Service|Ingress|PersistentVolume * @param T $resource The resource to create or patch * @return T The created/patched resource */ @@ -255,9 +266,7 @@ public function createOrMergePatch( } /** - * phpcs:disable Generic.Files.LineLength.MaxExceeded - * @param array $resources - * phpcs:enable Generic.Files.LineLength.MaxExceeded + * @param array $resources */ public function waitWhileExists(array $resources, float $timeout = INF): void { @@ -297,7 +306,7 @@ public function waitWhileExists(array $resources, float $timeout = INF): void } /** - * @template T of ConfigMap|Event|PersistentVolumeClaim|Pod|Secret|Service|Ingress|PersistentVolume|App|AppRun + * @template T of ConfigMap|Event|PersistentVolumeClaim|Pod|Secret|Service|Ingress|PersistentVolume * @param class-string $resourceType * @return iterable */ @@ -308,11 +317,13 @@ public function listMatching(string $resourceType, array $queries = []): iterabl do { $response = $api->list($queries); + // @phpstan-ignore-next-line foreach ($response->items as $item) { /** @var T $item */ yield $item; } + // @phpstan-ignore-next-line $queries['continue'] = $response->metadata?->continue; } while ($queries['continue']); } @@ -323,13 +334,13 @@ public function listMatching(string $resourceType, array $queries = []): iterabl * Resources are delete sequentially by API type. If some delete request fails, the error is logged and other APIs * are still called. Finally, the last exception is re-thrown. * - * @template T of ConfigMap|Event|PersistentVolumeClaim|Pod|Secret|Service|Ingress|PersistentVolume|App|AppRun + * @template T of ConfigMap|Event|PersistentVolumeClaim|Pod|Secret|Service|Ingress|PersistentVolume * @param array{ * resourceTypes?: class-string[] * } $queries * - resourceTypes: (optional) array of resource types to delete, by default is [ConfigMap::class, * Ingress::class, PersistentVolumeClaim::class, PersistentVolume::class, Pod::class, Secret::class, - * Service::class, App::class, AppRun::class] + * Service::class] * Other keys represent additional query parameters for the Kubernetes API's deleteCollection endpoint. * * Example: @@ -362,7 +373,7 @@ public function deleteAllMatching(?DeleteOptions $deleteOptions = null, array $q } /** - * @template T of ConfigMap|Event|PersistentVolumeClaim|Pod|Secret|Service|Ingress|PersistentVolume|App|AppRun + * @template T of ConfigMap|Event|PersistentVolumeClaim|Pod|Secret|Service|Ingress|PersistentVolume * @param class-string $resourceType */ public function checkResourceExists(string $resourceType, string $resourceName): bool @@ -377,32 +388,23 @@ public function checkResourceExists(string $resourceType, string $resourceName): } /** - * @param class-string $resourceType - * @return ($resourceType is class-string ? ConfigMapsApiClient : - * ($resourceType is class-string ? EventsApiClient : - * ($resourceType is class-string ? PersistentVolumeClaimsApiClient : - * ($resourceType is class-string ? PodsApiClient : - * ($resourceType is class-string ? SecretsApiClient : - * ($resourceType is class-string ? ServicesApiClient : - * ($resourceType is class-string ? IngressesApiClient : - * ($resourceType is class-string ? PersistentVolumesApiClient : - * ($resourceType is class-string ? AppsApiClient : - * ($resourceType is class-string ? AppRunsApiClient : - * never)))))))))) + * @template TItem of AbstractModel + * @param class-string $resourceType + * @return ApiClientInterface */ - // phpcs:ignore Generic.Files.LineLength.MaxExceeded - private function getApiForResource(string $resourceType): ConfigMapsApiClient|EventsApiClient|PersistentVolumeClaimsApiClient|PodsApiClient|SecretsApiClient|ServicesApiClient|IngressesApiClient|PersistentVolumesApiClient|AppsApiClient|AppRunsApiClient + private function getApiForResource(string $resourceType): ApiClientInterface { if (!array_key_exists($resourceType, $this->resourceTypeClientMap)) { - throw new RuntimeException(sprintf( - 'Unknown K8S resource type "%s"', - $resourceType, - )); + throw new RuntimeException(sprintf('Unknown K8S resource type "%s"', $resourceType)); } + // @phpstan-ignore-next-line return $this->resourceTypeClientMap[$resourceType]; } + /** + * @return array> + */ private function getDefaultResourceTypesForDeleteAll(): array { $resourceTypeClientMap = $this->resourceTypeClientMap; diff --git a/libs/k8s-client/src/Model/Io/Keboola/Apps/V1/AppReference.php b/libs/k8s-client/src/Model/Io/Keboola/Apps/V1/AppReference.php deleted file mode 100644 index 4e7085614..000000000 --- a/libs/k8s-client/src/Model/Io/Keboola/Apps/V1/AppReference.php +++ /dev/null @@ -1,34 +0,0 @@ -. ISO-8601 duration string (e.g. "1s", "30s", "5m"). - * Default 1s, min 1s, max 5m (enforced at admission via CRD CEL). - * - * @var string|null - */ - public $gitPollInterval = null; - - /** - * AutoRunSetupOnDepChange controls whether the in-pod git-watcher runs - * setup-dev.sh and restarts the app program when a tracked dependency - * file changes during a poll cycle. Default true. - * - * @var bool|null - */ - public $autoRunSetupOnDepChange = null; -} diff --git a/libs/k8s-client/src/Model/Io/Keboola/Apps/V2/AppFeatures.php b/libs/k8s-client/src/Model/Io/Keboola/Apps/V2/AppFeatures.php deleted file mode 100644 index 5998917f0..000000000 --- a/libs/k8s-client/src/Model/Io/Keboola/Apps/V2/AppFeatures.php +++ /dev/null @@ -1,57 +0,0 @@ -|null - */ - public $syncedFileHashes = null; - - /** - * TemplateBuildID is the E2B build ID returned by BuildTemplate. - * - * @var string|null - */ - public $templateBuildID = null; -} diff --git a/libs/k8s-client/src/Model/Io/Keboola/Apps/V2/ManagedGitCredentialStatus.php b/libs/k8s-client/src/Model/Io/Keboola/Apps/V2/ManagedGitCredentialStatus.php deleted file mode 100644 index e21180f95..000000000 --- a/libs/k8s-client/src/Model/Io/Keboola/Apps/V2/ManagedGitCredentialStatus.php +++ /dev/null @@ -1,30 +0,0 @@ -|null - */ - public $bucketPermissions = null; - - /** - * @var boolean|null - */ - public $canReadAllFileUploads = null; - - /** - * @var boolean|null - */ - public $canPurgeTrash = null; - - /** - * @var boolean|null - */ - public $canManageBuckets = null; - - /** - * @var SetEnvSpec[]|null - */ - public $setEnvs = null; - - /** - * @var MountPathSpec[]|null - */ - public $mountPaths = null; -} diff --git a/libs/k8s-client/src/Model/Io/Keboola/Apps/V2/WorkspaceSpec.php b/libs/k8s-client/src/Model/Io/Keboola/Apps/V2/WorkspaceSpec.php deleted file mode 100644 index 1ba5f5b64..000000000 --- a/libs/k8s-client/src/Model/Io/Keboola/Apps/V2/WorkspaceSpec.php +++ /dev/null @@ -1,91 +0,0 @@ - - */ - use BaseNamespaceApiClientTestCase; - - public function setUp(): void - { - parent::setUp(); - $this->setUpBaseNamespaceApiClientTest( - AppRunsApi::class, - AppRunsApiClient::class, - ); - } - - protected function createResource(array $metadata): AppRun - { - return new AppRun([ - 'metadata' => $metadata, - 'spec' => [ - 'podRef' => [ - 'name' => 'app-12345-deployment-abc123-xyz', - 'uid' => '550e8400-e29b-41d4-a716-446655440000', - ], - 'appRef' => [ - 'name' => 'app-12345', - 'appId' => 'app-123', - 'projectId' => 'project-456', - ], - 'createdAt' => '2025-01-15T12:00:00Z', - 'startedAt' => '2025-01-15T12:01:00Z', - 'state' => 'Running', - ], - ]); - } -} diff --git a/libs/k8s-client/tests/ApiClient/AppsApiClientFunctionalTest.php b/libs/k8s-client/tests/ApiClient/AppsApiClientFunctionalTest.php deleted file mode 100644 index 6fc363e92..000000000 --- a/libs/k8s-client/tests/ApiClient/AppsApiClientFunctionalTest.php +++ /dev/null @@ -1,85 +0,0 @@ - - */ - use BaseNamespaceApiClientTestCase; - - public function setUp(): void - { - parent::setUp(); - $this->setUpBaseNamespaceApiClientTest( - AppsApi::class, - AppsApiClient::class, - ); - } - - protected function createResource(array $metadata): App - { - return new App([ - 'metadata' => $metadata, - 'spec' => [ - 'appId' => 'app-123', - 'projectId' => 'project-456', - 'state' => 'Running', - 'replicas' => 1, - 'runtimeSize' => 'small', - 'features' => [ - 'storageToken' => [ - 'description' => 'test-token', - 'canManageBuckets' => true, - 'canReadAllFileUploads' => true, - 'canPurgeTrash' => false, - 'setEnvs' => [['envName' => 'KBC_TOKEN']], - ], - 'appsProxyIngress' => [ - 'targetPort' => 8080, - ], - 'dataDir' => [ - 'mount' => [[ - 'path' => '/data', - ]], - 'dataLoader' => [ - 'branchId' => 'main', - 'componentId' => 'component-1', - 'configId' => 'config-1', - ], - ], - 'mountConfig' => [ - 'branchId' => 'main', - 'componentId' => 'component-1', - 'configId' => 'config-1', - 'mount' => [[ - 'path' => '/config.json', - 'fields' => [['source' => '$.foo', 'target' => 'bar']], - ]], - ], - ], - 'containerSpec' => [ - 'image' => 'busybox', - 'env' => [['name' => 'FOO', 'value' => 'bar']], - 'startupProbe' => [ - 'httpGet' => ['path' => '/', 'port' => 8080], - 'periodSeconds' => 1, - 'failureThreshold' => 30, - ], - 'readinessProbe' => [ - 'httpGet' => ['path' => '/', 'port' => 8080], - 'periodSeconds' => 10, - ], - ], - ], - ]); - } -} diff --git a/libs/k8s-client/tests/ApiClient/BaseClusterApiClientTestCase.php b/libs/k8s-client/tests/ApiClient/BaseClusterApiClientTestCase.php index 696f589ff..f9e45cc47 100644 --- a/libs/k8s-client/tests/ApiClient/BaseClusterApiClientTestCase.php +++ b/libs/k8s-client/tests/ApiClient/BaseClusterApiClientTestCase.php @@ -5,7 +5,7 @@ namespace Keboola\K8sClient\Tests\ApiClient; use Keboola\K8sClient\ApiClient\BaseClusterApiClient; -use Keboola\K8sClient\ClientFacadeFactory\ClientConfigurator; +use Keboola\K8sClient\ClientFactory\ClientConfigurator; use Keboola\K8sClient\Exception\ResourceAlreadyExistsException; use Keboola\K8sClient\Exception\ResourceNotFoundException; use Keboola\K8sClient\KubernetesApiClient; diff --git a/libs/k8s-client/tests/ApiClient/BaseNamespaceApiClientTestCase.php b/libs/k8s-client/tests/ApiClient/BaseNamespaceApiClientTestCase.php index 4f316e425..95ffd1708 100644 --- a/libs/k8s-client/tests/ApiClient/BaseNamespaceApiClientTestCase.php +++ b/libs/k8s-client/tests/ApiClient/BaseNamespaceApiClientTestCase.php @@ -5,7 +5,7 @@ namespace Keboola\K8sClient\Tests\ApiClient; use Keboola\K8sClient\ApiClient\BaseNamespaceApiClient; -use Keboola\K8sClient\ClientFacadeFactory\ClientConfigurator; +use Keboola\K8sClient\ClientFactory\ClientConfigurator; use Keboola\K8sClient\Exception\ResourceAlreadyExistsException; use Keboola\K8sClient\Exception\ResourceNotFoundException; use Keboola\K8sClient\KubernetesApiClient; diff --git a/libs/k8s-client/tests/ApiClient/EventsApiClientTest.php b/libs/k8s-client/tests/ApiClient/EventsApiClientTest.php index 214cb0d30..06c0d19be 100644 --- a/libs/k8s-client/tests/ApiClient/EventsApiClientTest.php +++ b/libs/k8s-client/tests/ApiClient/EventsApiClientTest.php @@ -5,7 +5,7 @@ namespace Keboola\K8sClient\Tests\ApiClient; use Keboola\K8sClient\ApiClient\EventsApiClient; -use Keboola\K8sClient\ClientFacadeFactory\ClientConfigurator; +use Keboola\K8sClient\ClientFactory\ClientConfigurator; use Keboola\K8sClient\KubernetesApiClient; use Kubernetes\API\Event as EventsApi; use Kubernetes\Model\Io\K8s\Api\Core\V1\EventList; diff --git a/libs/k8s-client/tests/BaseApi/AppRunTest.php b/libs/k8s-client/tests/BaseApi/AppRunTest.php deleted file mode 100644 index 42a9e6a6e..000000000 --- a/libs/k8s-client/tests/BaseApi/AppRunTest.php +++ /dev/null @@ -1,223 +0,0 @@ - 'app=test']; - - $clientMock = $this->createMock(Client::class); - $clientMock->expects($this->once()) - ->method('request') - ->with( - 'get', - "/apis/apps.keboola.com/v1/namespaces/$namespace/appruns", - [ - 'query' => $queries, - ], - ) - ->willReturn(new Response(200)); - - $appRunApi = $this->getMockBuilder(AppRun::class) - ->disableOriginalConstructor() - ->onlyMethods(['parseResponse']) - ->getMock(); - - $appRunApi->expects($this->once()) - ->method('parseResponse') - ->willReturn(new AppRunList()); - - self::setPrivatePropertyValue($appRunApi, 'client', $clientMock); - - $appRunApi->list($namespace, $queries); - } - - public function testRead(): void - { - $namespace = 'default'; - $name = 'apprun-123'; - $queries = []; - - $clientMock = $this->createMock(Client::class); - $clientMock->expects($this->once()) - ->method('request') - ->with( - 'get', - "/apis/apps.keboola.com/v1/namespaces/$namespace/appruns/$name", - [ - 'query' => $queries, - ], - ) - ->willReturn(new Response(200)); - - $appRunApi = $this->getMockBuilder(AppRun::class) - ->disableOriginalConstructor() - ->onlyMethods(['parseResponse']) - ->getMock(); - - $appRunApi->expects($this->once()) - ->method('parseResponse') - ->with($this->anything(), 'readAppsKeboolaComV1NamespacedAppRun') - ->willReturn(new TheAppRun()); - - self::setPrivatePropertyValue($appRunApi, 'client', $clientMock); - - $appRunApi->read($namespace, $name, $queries); - } - - public function testCreate(): void - { - $namespace = 'default'; - $appRun = new TheAppRun(['metadata' => ['name' => 'apprun-123']]); - $queries = []; - - $clientMock = $this->createMock(Client::class); - $clientMock->expects($this->once()) - ->method('request') - ->with( - 'post', - "/apis/apps.keboola.com/v1/namespaces/$namespace/appruns", - [ - 'json' => $appRun->getArrayCopy(), - 'query' => $queries, - ], - ) - ->willReturn(new Response(201)); - - $appRunApi = $this->getMockBuilder(AppRun::class) - ->disableOriginalConstructor() - ->onlyMethods(['parseResponse']) - ->getMock(); - - $appRunApi->expects($this->once()) - ->method('parseResponse') - ->with($this->anything(), 'createAppsKeboolaComV1NamespacedAppRun') - ->willReturn(new TheAppRun()); - - self::setPrivatePropertyValue($appRunApi, 'client', $clientMock); - - $appRunApi->create($namespace, $appRun, $queries); - } - - public function testPatch(): void - { - $namespace = 'default'; - $name = 'apprun-123'; - $patch = new Patch(['spec' => ['state' => 'Running']]); - $queries = []; - - $clientMock = $this->createMock(Client::class); - $clientMock->expects($this->once()) - ->method('request') - ->with( - 'patch', - "/apis/apps.keboola.com/v1/namespaces/$namespace/appruns/$name", - [ - 'json' => $patch->getArrayCopy(), - 'query' => $queries, - ], - ) - ->willReturn(new Response(200)); - - $appRunApi = $this->getMockBuilder(AppRun::class) - ->disableOriginalConstructor() - ->onlyMethods(['parseResponse']) - ->getMock(); - - $appRunApi->expects($this->once()) - ->method('parseResponse') - ->with($this->anything(), 'patchAppsKeboolaComV1NamespacedAppRun') - ->willReturn(new TheAppRun()); - - self::setPrivatePropertyValue($appRunApi, 'client', $clientMock); - - $appRunApi->patch($namespace, $name, $patch, $queries); - } - - public function testDelete(): void - { - $namespace = 'default'; - $name = 'apprun-123'; - $deleteOptions = new DeleteOptions(); - $queries = []; - - $clientMock = $this->createMock(Client::class); - $clientMock->expects($this->once()) - ->method('request') - ->with( - 'delete', - "/apis/apps.keboola.com/v1/namespaces/$namespace/appruns/$name", - [ - 'json' => $deleteOptions, - 'query' => $queries, - ], - ) - ->willReturn(new Response(200)); - - $appRunApi = $this->getMockBuilder(AppRun::class) - ->disableOriginalConstructor() - ->onlyMethods(['parseResponse']) - ->getMock(); - - $appRunApi->expects($this->once()) - ->method('parseResponse') - ->with($this->anything(), 'deleteAppsKeboolaComV1NamespacedAppRun') - ->willReturn(new Status()); - - self::setPrivatePropertyValue($appRunApi, 'client', $clientMock); - - $appRunApi->delete($namespace, $name, $deleteOptions, $queries); - } - - public function testDeleteCollection(): void - { - $namespace = 'default'; - $deleteOptions = new DeleteOptions(); - $queries = ['labelSelector' => 'app=test']; - - $clientMock = $this->createMock(Client::class); - $clientMock->expects($this->once()) - ->method('request') - ->with( - 'delete', - "/apis/apps.keboola.com/v1/namespaces/$namespace/appruns", - [ - 'json' => $deleteOptions, - 'query' => $queries, - ], - ) - ->willReturn(new Response(200)); - - $appRunApi = $this->getMockBuilder(AppRun::class) - ->disableOriginalConstructor() - ->onlyMethods(['parseResponse']) - ->getMock(); - - $appRunApi->expects($this->once()) - ->method('parseResponse') - ->with($this->anything(), 'deleteAppsKeboolaComV1CollectionNamespacedAppRun') - ->willReturn(new Status()); - - self::setPrivatePropertyValue($appRunApi, 'client', $clientMock); - - $appRunApi->deleteCollection($namespace, $deleteOptions, $queries); - } -} diff --git a/libs/k8s-client/tests/BaseApi/AppTest.php b/libs/k8s-client/tests/BaseApi/AppTest.php deleted file mode 100644 index a185377a1..000000000 --- a/libs/k8s-client/tests/BaseApi/AppTest.php +++ /dev/null @@ -1,223 +0,0 @@ - 'app=test']; - - $clientMock = $this->createMock(Client::class); - $clientMock->expects($this->once()) - ->method('request') - ->with( - 'get', - "/apis/apps.keboola.com/v2/namespaces/$namespace/apps", - [ - 'query' => $queries, - ], - ) - ->willReturn(new Response(200)); - - $appApi = $this->getMockBuilder(App::class) - ->disableOriginalConstructor() - ->onlyMethods(['parseResponse']) - ->getMock(); - - $appApi->expects($this->once()) - ->method('parseResponse') - ->willReturn(new AppList()); - - self::setPrivatePropertyValue($appApi, 'client', $clientMock); - - $appApi->list($namespace, $queries); - } - - public function testRead(): void - { - $namespace = 'default'; - $name = 'app-123'; - $queries = []; - - $clientMock = $this->createMock(Client::class); - $clientMock->expects($this->once()) - ->method('request') - ->with( - 'get', - "/apis/apps.keboola.com/v2/namespaces/$namespace/apps/$name", - [ - 'query' => $queries, - ], - ) - ->willReturn(new Response(200)); - - $appApi = $this->getMockBuilder(App::class) - ->disableOriginalConstructor() - ->onlyMethods(['parseResponse']) - ->getMock(); - - $appApi->expects($this->once()) - ->method('parseResponse') - ->with($this->anything(), 'readAppsKeboolaComV2NamespacedApp') - ->willReturn(new TheApp()); - - self::setPrivatePropertyValue($appApi, 'client', $clientMock); - - $appApi->read($namespace, $name, $queries); - } - - public function testCreate(): void - { - $namespace = 'default'; - $app = new TheApp(['metadata' => ['name' => 'app-123']]); - $queries = []; - - $clientMock = $this->createMock(Client::class); - $clientMock->expects($this->once()) - ->method('request') - ->with( - 'post', - "/apis/apps.keboola.com/v2/namespaces/$namespace/apps", - [ - 'json' => $app->getArrayCopy(), - 'query' => $queries, - ], - ) - ->willReturn(new Response(201)); - - $appApi = $this->getMockBuilder(App::class) - ->disableOriginalConstructor() - ->onlyMethods(['parseResponse']) - ->getMock(); - - $appApi->expects($this->once()) - ->method('parseResponse') - ->with($this->anything(), 'createAppsKeboolaComV2NamespacedApp') - ->willReturn(new TheApp()); - - self::setPrivatePropertyValue($appApi, 'client', $clientMock); - - $appApi->create($namespace, $app, $queries); - } - - public function testPatch(): void - { - $namespace = 'default'; - $name = 'app-123'; - $patch = new Patch(['spec' => ['state' => 'Running']]); - $queries = []; - - $clientMock = $this->createMock(Client::class); - $clientMock->expects($this->once()) - ->method('request') - ->with( - 'patch', - "/apis/apps.keboola.com/v2/namespaces/$namespace/apps/$name", - [ - 'json' => $patch->getArrayCopy(), - 'query' => $queries, - ], - ) - ->willReturn(new Response(200)); - - $appApi = $this->getMockBuilder(App::class) - ->disableOriginalConstructor() - ->onlyMethods(['parseResponse']) - ->getMock(); - - $appApi->expects($this->once()) - ->method('parseResponse') - ->with($this->anything(), 'patchAppsKeboolaComV2NamespacedApp') - ->willReturn(new TheApp()); - - self::setPrivatePropertyValue($appApi, 'client', $clientMock); - - $appApi->patch($namespace, $name, $patch, $queries); - } - - public function testDelete(): void - { - $namespace = 'default'; - $name = 'app-123'; - $deleteOptions = new DeleteOptions(); - $queries = []; - - $clientMock = $this->createMock(Client::class); - $clientMock->expects($this->once()) - ->method('request') - ->with( - 'delete', - "/apis/apps.keboola.com/v2/namespaces/$namespace/apps/$name", - [ - 'json' => $deleteOptions, - 'query' => $queries, - ], - ) - ->willReturn(new Response(200)); - - $appApi = $this->getMockBuilder(App::class) - ->disableOriginalConstructor() - ->onlyMethods(['parseResponse']) - ->getMock(); - - $appApi->expects($this->once()) - ->method('parseResponse') - ->with($this->anything(), 'deleteAppsKeboolaComV2NamespacedApp') - ->willReturn(new Status()); - - self::setPrivatePropertyValue($appApi, 'client', $clientMock); - - $appApi->delete($namespace, $name, $deleteOptions, $queries); - } - - public function testDeleteCollection(): void - { - $namespace = 'default'; - $deleteOptions = new DeleteOptions(); - $queries = ['labelSelector' => 'app=test']; - - $clientMock = $this->createMock(Client::class); - $clientMock->expects($this->once()) - ->method('request') - ->with( - 'delete', - "/apis/apps.keboola.com/v2/namespaces/$namespace/apps", - [ - 'json' => $deleteOptions, - 'query' => $queries, - ], - ) - ->willReturn(new Response(200)); - - $appApi = $this->getMockBuilder(App::class) - ->disableOriginalConstructor() - ->onlyMethods(['parseResponse']) - ->getMock(); - - $appApi->expects($this->once()) - ->method('parseResponse') - ->with($this->anything(), 'deleteAppsKeboolaComV2CollectionNamespacedApp') - ->willReturn(new Status()); - - self::setPrivatePropertyValue($appApi, 'client', $clientMock); - - $appApi->deleteCollection($namespace, $deleteOptions, $queries); - } -} diff --git a/libs/k8s-client/tests/ClientFacadeFactory/AutoDetectClientFacadeFactoryTest.php b/libs/k8s-client/tests/ClientFacadeFactory/AutoDetectClientFacadeFactoryTest.php deleted file mode 100644 index faa2794c6..000000000 --- a/libs/k8s-client/tests/ClientFacadeFactory/AutoDetectClientFacadeFactoryTest.php +++ /dev/null @@ -1,116 +0,0 @@ -logsHandler = new TestHandler(); - $this->logger = new Logger('test', [$this->logsHandler]); - } - - public static function provideCustomNamespaceValue(): iterable - { - yield 'default namespace' => [ - 'customNamespace' => null, - ]; - - yield 'custom namespace' => [ - 'customNamespace' => 'custom-namespace', - ]; - } - - /** @dataProvider provideCustomNamespaceValue */ - public function testCreateClientWithEnvVars(?string $customNamespace): void - { - $createdClient = $this->createMock(KubernetesApiClientFacade::class); - - $envVariablesFactory = $this->createMock(EnvVariablesClientFacadeFactory::class); - $envVariablesFactory->expects(self::once())->method('isAvailable')->willReturn(true); - $envVariablesFactory->expects(self::once()) - ->method('createClusterClient') - ->with($customNamespace) - ->willReturn($createdClient); - - $inClusterFactory = $this->createMock(InClusterClientFacadeFactory::class); - $inClusterFactory->expects(self::never())->method('isAvailable'); - $inClusterFactory->expects(self::never())->method('createClusterClient'); - - $factory = new AutoDetectClientFacadeFactory( - $envVariablesFactory, - $inClusterFactory, - $this->logger, - ); - $result = $factory->createClusterClient($customNamespace); - - self::assertSame($createdClient, $result); - self::assertTrue($this->logsHandler->hasDebugThatContains('Using ENV variables configuration for K8S client.')); - } - - /** @dataProvider provideCustomNamespaceValue */ - public function testCreateClientWithInClusterAuth(?string $customNamespace): void - { - $createdClient = $this->createMock(KubernetesApiClientFacade::class); - - $envVariablesFactory = $this->createMock(EnvVariablesClientFacadeFactory::class); - $envVariablesFactory->expects(self::once())->method('isAvailable')->willReturn(false); - $envVariablesFactory->expects(self::never())->method('createClusterClient'); - - $inClusterFactory = $this->createMock(InClusterClientFacadeFactory::class); - $inClusterFactory->expects(self::once())->method('isAvailable')->willReturn(true); - $inClusterFactory->expects(self::once()) - ->method('createClusterClient') - ->with($customNamespace) - ->willReturn($createdClient); - - $factory = new AutoDetectClientFacadeFactory( - $envVariablesFactory, - $inClusterFactory, - $this->logger, - ); - $result = $factory->createClusterClient($customNamespace); - - self::assertSame($createdClient, $result); - self::assertTrue($this->logsHandler->hasDebugThatContains('Using in-cluster configuration for K8S client.')); - } - - public function testCreateClusterClientWithNoCredentialsFound(): void - { - $envVariablesFactory = $this->createMock(EnvVariablesClientFacadeFactory::class); - $envVariablesFactory->expects(self::once())->method('isAvailable')->willReturn(false); - $envVariablesFactory->expects(self::never())->method('createClusterClient'); - - $inClusterFactory = $this->createMock(InClusterClientFacadeFactory::class); - $inClusterFactory->expects(self::once())->method('isAvailable')->willReturn(false); - $inClusterFactory->expects(self::never())->method('createClusterClient'); - - $factory = new AutoDetectClientFacadeFactory( - $envVariablesFactory, - $inClusterFactory, - $this->logger, - ); - - $this->expectException(ConfigurationException::class); - $this->expectExceptionMessage('No valid K8S client configuration found.'); - - $factory->createClusterClient(); - } -} diff --git a/libs/k8s-client/tests/ClientFacadeFactory/InClusterClientFacadeFactoryTest.php b/libs/k8s-client/tests/ClientFacadeFactory/InClusterClientFacadeFactoryTest.php deleted file mode 100644 index 175415428..000000000 --- a/libs/k8s-client/tests/ClientFacadeFactory/InClusterClientFacadeFactoryTest.php +++ /dev/null @@ -1,268 +0,0 @@ - 'test-token', - 'ca.crt' => 'test-cert', - 'namespace' => 'test-namespace', - ]; - - private readonly string $credentialsPath; - - protected function setUp(): void - { - parent::setUp(); - - $this->credentialsPath = sys_get_temp_dir().'/k8s-creds-test'; - - $filesystem = new Filesystem(); - $filesystem->remove($this->credentialsPath); - } - - protected function tearDown(): void - { - $filesystem = new Filesystem(); - $filesystem->remove($this->credentialsPath); - - parent::tearDown(); - } - - public static function provideIsAvailableTestData(): iterable - { - yield 'no files' => [ - 'existingFiles' => [], - 'customNamespace' => null, - 'expectedResult' => false, - ]; - - yield 'missing token file' => [ - 'existingFiles' => [ - 'ca.crt' => 'test-cert', - 'namespace' => 'test-namespace', - ], - 'customNamespace' => null, - 'expectedResult' => false, - ]; - - yield 'missing cert file' => [ - 'existingFiles' => [ - 'token' => 'test-token', - 'namespace' => 'test-namespace', - ], - 'customNamespace' => null, - 'expectedResult' => false, - ]; - - yield 'missing namespace file' => [ - 'existingFiles' => [ - 'token' => 'test-token', - 'ca.crt' => 'test-cert', - ], - 'customNamespace' => null, - 'expectedResult' => false, - ]; - - yield 'missing namespace file with custom namespace' => [ - 'existingFiles' => [ - 'token' => 'test-token', - 'ca.crt' => 'test-cert', - ], - 'customNamespace' => 'custom-namespace', - 'expectedResult' => true, - ]; - - yield 'all files' => [ - 'existingFiles' => self::FILES, - 'customNamespace' => null, - 'expectedResult' => true, - ]; - } - - /** @dataProvider provideIsAvailableTestData */ - public function testIsAvailable(array $existingFiles, ?string $customNamespace, bool $expectedResult): void - { - foreach ($existingFiles as $file => $contents) { - $filesystem = new Filesystem(); - $filesystem->dumpFile(Path::join($this->credentialsPath, $file), $contents); - } - - $genericFactory = $this->createMock(GenericClientFacadeFactory::class); - $genericFactory->expects(self::never())->method('createClusterClient'); - - $factory = new InClusterClientFacadeFactory($genericFactory, $this->credentialsPath); - $result = $factory->isAvailable($customNamespace); - - self::assertSame($expectedResult, $result); - } - - public function testCreateClusterClient(): void - { - foreach (self::FILES as $file => $contents) { - $filesystem = new Filesystem(); - $filesystem->dumpFile(Path::join($this->credentialsPath, $file), $contents); - } - - $createdClient = $this->createMock(KubernetesApiClientFacade::class); - - $genericFactory = $this->createMock(GenericClientFacadeFactory::class); - $genericFactory->expects(self::once()) - ->method('createClusterClient') - ->with( - 'https://kubernetes.default.svc', - new InClusterToken($this->credentialsPath . '/token'), - $this->credentialsPath . '/ca.crt', - 'test-namespace', - ) - ->willReturn($createdClient) - ; - - $factory = new InClusterClientFacadeFactory( - $genericFactory, - $this->credentialsPath, - ); - - $result = $factory->createClusterClient(); - self::assertSame($createdClient, $result); - } - - public function testCreateClusterClientWithCustomNamespace(): void - { - foreach (self::FILES as $file => $contents) { - $filesystem = new Filesystem(); - $filesystem->dumpFile(Path::join($this->credentialsPath, $file), $contents); - } - - $createdClient = $this->createMock(KubernetesApiClientFacade::class); - - $genericFactory = $this->createMock(GenericClientFacadeFactory::class); - $genericFactory->expects(self::once()) - ->method('createClusterClient') - ->with( - 'https://kubernetes.default.svc', - new InClusterToken($this->credentialsPath . '/token'), - $this->credentialsPath . '/ca.crt', - 'custom-namespace', - ) - ->willReturn($createdClient) - ; - - $factory = new InClusterClientFacadeFactory( - $genericFactory, - $this->credentialsPath, - ); - - $result = $factory->createClusterClient('custom-namespace'); - self::assertSame($createdClient, $result); - } - - public function testCreateClusterClientWithCustomNamespaceAndMissingNamespaceFile(): void - { - foreach (self::FILES as $file => $contents) { - $filesystem = new Filesystem(); - $filesystem->dumpFile(Path::join($this->credentialsPath, $file), $contents); - } - unlink($this->credentialsPath . '/namespace'); - - $createdClient = $this->createMock(KubernetesApiClientFacade::class); - - $genericFactory = $this->createMock(GenericClientFacadeFactory::class); - $genericFactory->expects(self::once()) - ->method('createClusterClient') - ->with( - 'https://kubernetes.default.svc', - new InClusterToken($this->credentialsPath . '/token'), - $this->credentialsPath . '/ca.crt', - 'custom-namespace', - ) - ->willReturn($createdClient) - ; - - $factory = new InClusterClientFacadeFactory( - $genericFactory, - $this->credentialsPath, - ); - - $result = $factory->createClusterClient('custom-namespace'); - self::assertSame($createdClient, $result); - } - - public function testCreateClusterClientWithMissingTokenFile(): void - { - foreach (self::FILES as $file => $contents) { - $filesystem = new Filesystem(); - $filesystem->dumpFile(Path::join($this->credentialsPath, $file), $contents); - } - unlink($this->credentialsPath . '/token'); - - $genericFactory = $this->createMock(GenericClientFacadeFactory::class); - $genericFactory->expects(self::never())->method('createClusterClient'); - - $factory = new InClusterClientFacadeFactory( - $genericFactory, - $this->credentialsPath, - ); - - $this->expectException(ConfigurationException::class); - $this->expectExceptionMessage('In-cluster configuration file "/tmp/k8s-creds-test/token" does not exist'); - - $factory->createClusterClient(); - } - - public function testCreateClusterClientWithMissingCertFile(): void - { - foreach (self::FILES as $file => $contents) { - $filesystem = new Filesystem(); - $filesystem->dumpFile(Path::join($this->credentialsPath, $file), $contents); - } - unlink($this->credentialsPath . '/ca.crt'); - - $genericFactory = $this->createMock(GenericClientFacadeFactory::class); - $genericFactory->expects(self::never())->method('createClusterClient'); - - $factory = new InClusterClientFacadeFactory( - $genericFactory, - $this->credentialsPath, - ); - - $this->expectException(ConfigurationException::class); - $this->expectExceptionMessage('In-cluster configuration file "/tmp/k8s-creds-test/ca.crt" does not exist'); - - $factory->createClusterClient(); - } - - public function testCreateClusterClientWithMissingNamespaceFile(): void - { - foreach (self::FILES as $file => $contents) { - $filesystem = new Filesystem(); - $filesystem->dumpFile(Path::join($this->credentialsPath, $file), $contents); - } - unlink($this->credentialsPath . '/namespace'); - - $genericFactory = $this->createMock(GenericClientFacadeFactory::class); - $genericFactory->expects(self::never())->method('createClusterClient'); - - $factory = new InClusterClientFacadeFactory( - $genericFactory, - $this->credentialsPath, - ); - - $this->expectException(ConfigurationException::class); - $this->expectExceptionMessage('In-cluster configuration file "/tmp/k8s-creds-test/namespace" does not exist'); - - $factory->createClusterClient(); - } -} diff --git a/libs/k8s-client/tests/ClientFactory/AutoDetectKubernetesApiClientFactoryTest.php b/libs/k8s-client/tests/ClientFactory/AutoDetectKubernetesApiClientFactoryTest.php new file mode 100644 index 000000000..e9f034fae --- /dev/null +++ b/libs/k8s-client/tests/ClientFactory/AutoDetectKubernetesApiClientFactoryTest.php @@ -0,0 +1,131 @@ +logsHandler = new TestHandler(); + $this->logger = new Logger('test', [$this->logsHandler]); + } + + public static function provideCustomNamespaceValue(): iterable + { + yield 'default namespace' => [ + 'customNamespace' => null, + ]; + + yield 'custom namespace' => [ + 'customNamespace' => 'custom-namespace', + ]; + } + + /** @dataProvider provideCustomNamespaceValue */ + public function testCreateApiClientWithEnvVars(?string $customNamespace): void + { + $createdClient = $this->createMock(KubernetesApiClient::class); + + $envVariablesFactory = $this->createMock(EnvVariablesKubernetesApiClientFactory::class); + $envVariablesFactory->expects(self::once()) + ->method('isAvailable') + ->with($customNamespace) + ->willReturn(true); + $envVariablesFactory->expects(self::once()) + ->method('createApiClient') + ->with($customNamespace) + ->willReturn($createdClient); + + $inClusterFactory = $this->createMock(InClusterKubernetesApiClientFactory::class); + $inClusterFactory->expects(self::never())->method('isAvailable'); + $inClusterFactory->expects(self::never())->method('createApiClient'); + + $factory = new AutoDetectKubernetesApiClientFactory( + $envVariablesFactory, + $inClusterFactory, + $this->logger, + ); + $result = $factory->createApiClient($customNamespace); + + self::assertSame($createdClient, $result); + self::assertTrue( + $this->logsHandler->hasDebugThatContains('Using ENV variables configuration for K8S client.'), + ); + } + + /** @dataProvider provideCustomNamespaceValue */ + public function testCreateApiClientWithInClusterAuth(?string $customNamespace): void + { + $createdClient = $this->createMock(KubernetesApiClient::class); + + $envVariablesFactory = $this->createMock(EnvVariablesKubernetesApiClientFactory::class); + $envVariablesFactory->expects(self::once()) + ->method('isAvailable') + ->with($customNamespace) + ->willReturn(false); + $envVariablesFactory->expects(self::never())->method('createApiClient'); + + $inClusterFactory = $this->createMock(InClusterKubernetesApiClientFactory::class); + // in-cluster availability is probed WITHOUT the caller namespace (preserves original AutoDetect + // semantics: the in-cluster branch also requires the namespace file to exist) + $inClusterFactory->expects(self::once()) + ->method('isAvailable') + ->with() + ->willReturn(true); + $inClusterFactory->expects(self::once()) + ->method('createApiClient') + ->with($customNamespace) + ->willReturn($createdClient); + + $factory = new AutoDetectKubernetesApiClientFactory( + $envVariablesFactory, + $inClusterFactory, + $this->logger, + ); + $result = $factory->createApiClient($customNamespace); + + self::assertSame($createdClient, $result); + self::assertTrue( + $this->logsHandler->hasDebugThatContains('Using in-cluster configuration for K8S client.'), + ); + } + + public function testCreateApiClientWithNoCredentialsFound(): void + { + $envVariablesFactory = $this->createMock(EnvVariablesKubernetesApiClientFactory::class); + $envVariablesFactory->expects(self::once())->method('isAvailable')->willReturn(false); + $envVariablesFactory->expects(self::never())->method('createApiClient'); + + $inClusterFactory = $this->createMock(InClusterKubernetesApiClientFactory::class); + $inClusterFactory->expects(self::once())->method('isAvailable')->willReturn(false); + $inClusterFactory->expects(self::never())->method('createApiClient'); + + $factory = new AutoDetectKubernetesApiClientFactory( + $envVariablesFactory, + $inClusterFactory, + $this->logger, + ); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('No valid K8S client configuration found.'); + + $factory->createApiClient(); + } +} diff --git a/libs/k8s-client/tests/ClientFacadeFactory/ClientConfiguratorTest.php b/libs/k8s-client/tests/ClientFactory/ClientConfiguratorTest.php similarity index 95% rename from libs/k8s-client/tests/ClientFacadeFactory/ClientConfiguratorTest.php rename to libs/k8s-client/tests/ClientFactory/ClientConfiguratorTest.php index a16a39a27..e9af06d44 100644 --- a/libs/k8s-client/tests/ClientFacadeFactory/ClientConfiguratorTest.php +++ b/libs/k8s-client/tests/ClientFactory/ClientConfiguratorTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Keboola\K8sClient\Tests\ClientFacadeFactory; +namespace Keboola\K8sClient\Tests\ClientFactory; -use Keboola\K8sClient\ClientFacadeFactory\ClientConfigurator; +use Keboola\K8sClient\ClientFactory\ClientConfigurator; use Keboola\K8sClient\Exception\ConfigurationException; use KubernetesRuntime\Client; use PHPUnit\Framework\TestCase; diff --git a/libs/k8s-client/tests/ClientFacadeFactory/EnvVariablesClientFacadeFactoryTest.php b/libs/k8s-client/tests/ClientFactory/EnvVariablesKubernetesApiClientFactoryTest.php similarity index 56% rename from libs/k8s-client/tests/ClientFacadeFactory/EnvVariablesClientFacadeFactoryTest.php rename to libs/k8s-client/tests/ClientFactory/EnvVariablesKubernetesApiClientFactoryTest.php index 482f63e54..8ed0d46f1 100644 --- a/libs/k8s-client/tests/ClientFacadeFactory/EnvVariablesClientFacadeFactoryTest.php +++ b/libs/k8s-client/tests/ClientFactory/EnvVariablesKubernetesApiClientFactoryTest.php @@ -2,17 +2,16 @@ declare(strict_types=1); -namespace Keboola\K8sClient\Tests\ClientFacadeFactory; +namespace Keboola\K8sClient\Tests\ClientFactory; -use Keboola\K8sClient\ClientFacadeFactory\EnvVariablesClientFacadeFactory; -use Keboola\K8sClient\ClientFacadeFactory\GenericClientFacadeFactory; -use Keboola\K8sClient\ClientFacadeFactory\Token\StaticToken; -use Keboola\K8sClient\KubernetesApiClientFacade; +use Keboola\K8sClient\ClientFactory\EnvVariablesKubernetesApiClientFactory; +use Keboola\K8sClient\KubernetesApiClient; use PHPUnit\Framework\TestCase; +use Retry\RetryProxy; use RuntimeException; /** @runTestsInSeparateProcesses */ -class EnvVariablesClientFacadeFactoryTest extends TestCase +class EnvVariablesKubernetesApiClientFactoryTest extends TestCase { private const ENV_VARS = [ 'K8S_HOST' => 'https://k8s.example.com', @@ -96,83 +95,56 @@ public function testIsAvailable(array $envs, ?string $customNamespace, bool $exp putenv(sprintf('%s=%s', $key, $value)); } - $genericFactory = $this->createMock(GenericClientFacadeFactory::class); - $genericFactory->expects(self::never())->method('createClusterClient'); - - $factory = new EnvVariablesClientFacadeFactory($genericFactory); + $factory = new EnvVariablesKubernetesApiClientFactory(new RetryProxy()); $result = $factory->isAvailable($customNamespace); self::assertSame($expectedResult, $result); } - public function testCreateClusterClient(): void + public function testCreateApiClient(): void { - foreach (self::ENV_VARS as $key => $value) { + $envs = self::ENV_VARS; + $envs['K8S_CA_CERT_PATH'] = __DIR__ . '/../fixtures/ca.crt'; + + foreach ($envs as $key => $value) { putenv(sprintf('%s=%s', $key, $value)); } - $createdClient = $this->createMock(KubernetesApiClientFacade::class); - - $genericFactory = $this->createMock(GenericClientFacadeFactory::class); - $genericFactory->expects(self::once()) - ->method('createClusterClient') - ->with( - self::ENV_VARS['K8S_HOST'], - new StaticToken(self::ENV_VARS['K8S_TOKEN']), - self::ENV_VARS['K8S_CA_CERT_PATH'], - self::ENV_VARS['K8S_NAMESPACE'], - ) - ->willReturn($createdClient) - ; - - $factory = new EnvVariablesClientFacadeFactory($genericFactory); - $client = $factory->createClusterClient(); + $factory = new EnvVariablesKubernetesApiClientFactory(new RetryProxy()); + $client = $factory->createApiClient(); - self::assertSame($createdClient, $client); + self::assertInstanceOf(KubernetesApiClient::class, $client); + self::assertSame(self::ENV_VARS['K8S_NAMESPACE'], $client->getK8sNamespace()); } - public function testCreateClusterClientWithCustomNamespace(): void + public function testCreateApiClientWithCustomNamespace(): void { - foreach (self::ENV_VARS as $key => $value) { + $envs = self::ENV_VARS; + $envs['K8S_CA_CERT_PATH'] = __DIR__ . '/../fixtures/ca.crt'; + + foreach ($envs as $key => $value) { putenv(sprintf('%s=%s', $key, $value)); } - $createdClient = $this->createMock(KubernetesApiClientFacade::class); + $factory = new EnvVariablesKubernetesApiClientFactory(new RetryProxy()); + $client = $factory->createApiClient('custom-namespace'); - $genericFactory = $this->createMock(GenericClientFacadeFactory::class); - $genericFactory->expects(self::once()) - ->method('createClusterClient') - ->with( - self::ENV_VARS['K8S_HOST'], - new StaticToken(self::ENV_VARS['K8S_TOKEN']), - self::ENV_VARS['K8S_CA_CERT_PATH'], - 'custom-namespace', - ) - ->willReturn($createdClient) - ; - - $factory = new EnvVariablesClientFacadeFactory($genericFactory); - $client = $factory->createClusterClient('custom-namespace'); - - self::assertSame($createdClient, $client); + self::assertSame('custom-namespace', $client->getK8sNamespace()); } - public function testCreateClientWithInvalidConfig(): void + public function testCreateApiClientWithInvalidConfig(): void { foreach (array_keys(self::ENV_VARS) as $key) { putenv($key); } - $genericFactory = $this->createMock(GenericClientFacadeFactory::class); - $genericFactory->expects(self::never())->method('createClusterClient'); - - $factory = new EnvVariablesClientFacadeFactory($genericFactory); + $factory = new EnvVariablesKubernetesApiClientFactory(new RetryProxy()); $this->expectException(RuntimeException::class); $this->expectExceptionMessage( 'Configuration is not complete. Use isAvailable() to check if the factory can be used.', ); - $factory->createClusterClient(); + $factory->createApiClient(); } } diff --git a/libs/k8s-client/tests/ClientFactory/InClusterKubernetesApiClientFactoryTest.php b/libs/k8s-client/tests/ClientFactory/InClusterKubernetesApiClientFactoryTest.php new file mode 100644 index 000000000..1c151fb3d --- /dev/null +++ b/libs/k8s-client/tests/ClientFactory/InClusterKubernetesApiClientFactoryTest.php @@ -0,0 +1,207 @@ + 'test-token', + 'ca.crt' => 'test-cert', + 'namespace' => 'test-namespace', + ]; + + private readonly string $credentialsPath; + + protected function setUp(): void + { + parent::setUp(); + + $this->credentialsPath = sys_get_temp_dir().'/k8s-creds-test'; + + $filesystem = new Filesystem(); + $filesystem->remove($this->credentialsPath); + } + + protected function tearDown(): void + { + $filesystem = new Filesystem(); + $filesystem->remove($this->credentialsPath); + + parent::tearDown(); + } + + public static function provideIsAvailableTestData(): iterable + { + yield 'no files' => [ + 'existingFiles' => [], + 'customNamespace' => null, + 'expectedResult' => false, + ]; + + yield 'missing token file' => [ + 'existingFiles' => [ + 'ca.crt' => 'test-cert', + 'namespace' => 'test-namespace', + ], + 'customNamespace' => null, + 'expectedResult' => false, + ]; + + yield 'missing cert file' => [ + 'existingFiles' => [ + 'token' => 'test-token', + 'namespace' => 'test-namespace', + ], + 'customNamespace' => null, + 'expectedResult' => false, + ]; + + yield 'missing namespace file' => [ + 'existingFiles' => [ + 'token' => 'test-token', + 'ca.crt' => 'test-cert', + ], + 'customNamespace' => null, + 'expectedResult' => false, + ]; + + yield 'missing namespace file with custom namespace' => [ + 'existingFiles' => [ + 'token' => 'test-token', + 'ca.crt' => 'test-cert', + ], + 'customNamespace' => 'custom-namespace', + 'expectedResult' => true, + ]; + + yield 'all files' => [ + 'existingFiles' => self::FILES, + 'customNamespace' => null, + 'expectedResult' => true, + ]; + } + + /** @dataProvider provideIsAvailableTestData */ + public function testIsAvailable(array $existingFiles, ?string $customNamespace, bool $expectedResult): void + { + foreach ($existingFiles as $file => $contents) { + $filesystem = new Filesystem(); + $filesystem->dumpFile(Path::join($this->credentialsPath, $file), $contents); + } + + $factory = new InClusterKubernetesApiClientFactory(new RetryProxy(), $this->credentialsPath); + $result = $factory->isAvailable($customNamespace); + + self::assertSame($expectedResult, $result); + } + + public function testCreateApiClient(): void + { + foreach (self::FILES as $file => $contents) { + $filesystem = new Filesystem(); + $filesystem->dumpFile(Path::join($this->credentialsPath, $file), $contents); + } + + $factory = new InClusterKubernetesApiClientFactory(new RetryProxy(), $this->credentialsPath); + + $result = $factory->createApiClient(); + + self::assertInstanceOf(KubernetesApiClient::class, $result); + self::assertSame('test-namespace', $result->getK8sNamespace()); + } + + public function testCreateApiClientWithCustomNamespace(): void + { + foreach (self::FILES as $file => $contents) { + $filesystem = new Filesystem(); + $filesystem->dumpFile(Path::join($this->credentialsPath, $file), $contents); + } + + $factory = new InClusterKubernetesApiClientFactory(new RetryProxy(), $this->credentialsPath); + + $result = $factory->createApiClient('custom-namespace'); + + self::assertSame('custom-namespace', $result->getK8sNamespace()); + } + + public function testCreateApiClientWithCustomNamespaceAndMissingNamespaceFile(): void + { + foreach (self::FILES as $file => $contents) { + $filesystem = new Filesystem(); + $filesystem->dumpFile(Path::join($this->credentialsPath, $file), $contents); + } + unlink($this->credentialsPath . '/namespace'); + + $factory = new InClusterKubernetesApiClientFactory(new RetryProxy(), $this->credentialsPath); + + $result = $factory->createApiClient('custom-namespace'); + self::assertSame('custom-namespace', $result->getK8sNamespace()); + } + + public function testCreateApiClientWithMissingTokenFile(): void + { + foreach (self::FILES as $file => $contents) { + $filesystem = new Filesystem(); + $filesystem->dumpFile(Path::join($this->credentialsPath, $file), $contents); + } + unlink($this->credentialsPath . '/token'); + + $factory = new InClusterKubernetesApiClientFactory(new RetryProxy(), $this->credentialsPath); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage(sprintf( + 'In-cluster configuration file "%s/token" does not exist', + $this->credentialsPath, + )); + + $factory->createApiClient(); + } + + public function testCreateApiClientWithMissingCertFile(): void + { + foreach (self::FILES as $file => $contents) { + $filesystem = new Filesystem(); + $filesystem->dumpFile(Path::join($this->credentialsPath, $file), $contents); + } + unlink($this->credentialsPath . '/ca.crt'); + + $factory = new InClusterKubernetesApiClientFactory(new RetryProxy(), $this->credentialsPath); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage(sprintf( + 'In-cluster configuration file "%s/ca.crt" does not exist', + $this->credentialsPath, + )); + + $factory->createApiClient(); + } + + public function testCreateApiClientWithMissingNamespaceFile(): void + { + foreach (self::FILES as $file => $contents) { + $filesystem = new Filesystem(); + $filesystem->dumpFile(Path::join($this->credentialsPath, $file), $contents); + } + unlink($this->credentialsPath . '/namespace'); + + $factory = new InClusterKubernetesApiClientFactory(new RetryProxy(), $this->credentialsPath); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage(sprintf( + 'In-cluster configuration file "%s/namespace" does not exist', + $this->credentialsPath, + )); + + $factory->createApiClient(); + } +} diff --git a/libs/k8s-client/tests/ClientFactory/StaticKubernetesApiClientFactoryTest.php b/libs/k8s-client/tests/ClientFactory/StaticKubernetesApiClientFactoryTest.php new file mode 100644 index 000000000..a2885893b --- /dev/null +++ b/libs/k8s-client/tests/ClientFactory/StaticKubernetesApiClientFactoryTest.php @@ -0,0 +1,90 @@ +createApiClient('my-namespace'); + + self::assertInstanceOf(KubernetesApiClient::class, $apiClient); + self::assertSame('my-namespace', $apiClient->getK8sNamespace()); + } + + public function testCreateApiClientUsesDefaultNamespaceWhenNoneProvided(): void + { + $factory = new StaticKubernetesApiClientFactory( + new RetryProxy(), + 'https://example.test', + new StaticToken('token'), + __DIR__ . '/../fixtures/ca.crt', + 'default-namespace', + ); + + $apiClient = $factory->createApiClient(); + + self::assertSame('default-namespace', $apiClient->getK8sNamespace()); + } + + public function testCreateApiClientArgumentOverridesDefaultNamespace(): void + { + $factory = new StaticKubernetesApiClientFactory( + new RetryProxy(), + 'https://example.test', + new StaticToken('token'), + __DIR__ . '/../fixtures/ca.crt', + 'default-namespace', + ); + + $apiClient = $factory->createApiClient('explicit-namespace'); + + self::assertSame('explicit-namespace', $apiClient->getK8sNamespace()); + } + + public function testCreateApiClientAcceptsRawStringToken(): void + { + $factory = new StaticKubernetesApiClientFactory( + new RetryProxy(), + 'https://example.test', + 'raw-token', + __DIR__ . '/../fixtures/ca.crt', + 'my-namespace', + ); + + $apiClient = $factory->createApiClient(); + + self::assertInstanceOf(KubernetesApiClient::class, $apiClient); + self::assertSame('my-namespace', $apiClient->getK8sNamespace()); + } + + public function testCreateApiClientThrowsWhenNoNamespaceIsAvailable(): void + { + $factory = new StaticKubernetesApiClientFactory( + new RetryProxy(), + 'https://example.test', + new StaticToken('token'), + __DIR__ . '/../fixtures/ca.crt', + ); + + $this->expectException(InvalidArgumentException::class); + + $factory->createApiClient(); + } +} diff --git a/libs/k8s-client/tests/ClientFacadeFactory/Token/InClusterTokenTest.php b/libs/k8s-client/tests/ClientFactory/Token/InClusterTokenTest.php similarity index 93% rename from libs/k8s-client/tests/ClientFacadeFactory/Token/InClusterTokenTest.php rename to libs/k8s-client/tests/ClientFactory/Token/InClusterTokenTest.php index fde6c0973..1dd9020bc 100644 --- a/libs/k8s-client/tests/ClientFacadeFactory/Token/InClusterTokenTest.php +++ b/libs/k8s-client/tests/ClientFactory/Token/InClusterTokenTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Keboola\K8sClient\Tests\ClientFacadeFactory\Token; +namespace Keboola\K8sClient\Tests\ClientFactory\Token; -use Keboola\K8sClient\ClientFacadeFactory\Token\InClusterToken; +use Keboola\K8sClient\ClientFactory\Token\InClusterToken; use PHPUnit\Framework\TestCase; class InClusterTokenTest extends TestCase diff --git a/libs/k8s-client/tests/ClientFacadeFactory/Token/StaticTokenTest.php b/libs/k8s-client/tests/ClientFactory/Token/StaticTokenTest.php similarity index 68% rename from libs/k8s-client/tests/ClientFacadeFactory/Token/StaticTokenTest.php rename to libs/k8s-client/tests/ClientFactory/Token/StaticTokenTest.php index 2f37b7bb5..b2a86448f 100644 --- a/libs/k8s-client/tests/ClientFacadeFactory/Token/StaticTokenTest.php +++ b/libs/k8s-client/tests/ClientFactory/Token/StaticTokenTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Keboola\K8sClient\Tests\ClientFacadeFactory\Token; +namespace Keboola\K8sClient\Tests\ClientFactory\Token; -use Keboola\K8sClient\ClientFacadeFactory\Token\StaticToken; +use Keboola\K8sClient\ClientFactory\Token\StaticToken; use PHPUnit\Framework\TestCase; class StaticTokenTest extends TestCase diff --git a/libs/k8s-client/tests/FakeCrdModel.php b/libs/k8s-client/tests/FakeCrdModel.php new file mode 100644 index 000000000..7eb669975 --- /dev/null +++ b/libs/k8s-client/tests/FakeCrdModel.php @@ -0,0 +1,29 @@ +|null */ + public $spec = null; + + /** @var array|null */ + public $status = null; +} diff --git a/libs/k8s-client/tests/Guzzle/AuthMiddlewareTest.php b/libs/k8s-client/tests/Guzzle/AuthMiddlewareTest.php index a4524d3f6..fd53a8d32 100644 --- a/libs/k8s-client/tests/Guzzle/AuthMiddlewareTest.php +++ b/libs/k8s-client/tests/Guzzle/AuthMiddlewareTest.php @@ -6,7 +6,7 @@ use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; -use Keboola\K8sClient\ClientFacadeFactory\Token\StaticToken; +use Keboola\K8sClient\ClientFactory\Token\StaticToken; use Keboola\K8sClient\Guzzle\AuthMiddleware; use PHPUnit\Framework\TestCase; use Psr\Http\Message\RequestInterface; diff --git a/libs/k8s-client/tests/KubernetesApiClientFacadeCreateTest.php b/libs/k8s-client/tests/KubernetesApiClientFacadeCreateTest.php new file mode 100644 index 000000000..05c855bf2 --- /dev/null +++ b/libs/k8s-client/tests/KubernetesApiClientFacadeCreateTest.php @@ -0,0 +1,76 @@ +apiClient(), new Logger('test')); + + self::assertInstanceOf(KubernetesApiClientFacade::class, $facade); + } + + public function testCreateThreadsExtraClientsThroughToFacade(): void + { + $extraClient = $this->createMock(ApiClientInterface::class); + + $facade = KubernetesApiClientFacade::create( + $this->apiClient(), + new Logger('test'), + [FakeCrdModel::class => $extraClient], + ); + + self::assertSame($extraClient, $facade->client(FakeCrdModel::class)); + } + + public function testCreateThreadsExtraClientMergePatchRouting(): void + { + $model = new FakeCrdModel(['metadata' => ['name' => 'thing-1'], 'spec' => ['size' => 2]]); + + $extraClient = $this->createMock(ApiClientInterface::class); + $extraClient->expects(self::once()) + ->method('patch') + ->willReturnCallback(function (string $name, Patch $patch) use ($model) { + self::assertSame('thing-1', $name); + $data = $patch->getArrayCopy(); + self::assertSame('merge-patch', $data['patchOperation']); + self::assertSame(2, $data['spec']['size']); + return $model; + }); + + $facade = KubernetesApiClientFacade::create( + $this->apiClient(), + new Logger('test'), + [FakeCrdModel::class => $extraClient], + ); + + self::assertSame($model, $facade->mergePatch($model)); + } + + /** + * Build a client through a real factory so the global KubernetesRuntime\Client is configured — + * constructing the facade's core API clients requires it. + */ + private function apiClient(): KubernetesApiClient + { + return (new StaticKubernetesApiClientFactory( + new RetryProxy(), + 'https://example.test', + 'token', + __DIR__ . '/fixtures/ca.crt', + 'my-namespace', + ))->createApiClient(); + } +} diff --git a/libs/k8s-client/tests/KubernetesApiClientFacadeFunctionalTest.php b/libs/k8s-client/tests/KubernetesApiClientFacadeFunctionalTest.php index 63ff85001..2b9510568 100644 --- a/libs/k8s-client/tests/KubernetesApiClientFacadeFunctionalTest.php +++ b/libs/k8s-client/tests/KubernetesApiClientFacadeFunctionalTest.php @@ -4,10 +4,8 @@ namespace Keboola\K8sClient\Tests; -use Keboola\K8sClient\ClientFacadeFactory\GenericClientFacadeFactory; +use Keboola\K8sClient\ClientFactory\StaticKubernetesApiClientFactory; use Keboola\K8sClient\KubernetesApiClientFacade; -use Keboola\K8sClient\Model\Io\Keboola\Apps\V1\AppRun; -use Keboola\K8sClient\Model\Io\Keboola\Apps\V2\App; use Keboola\K8sClient\RetryProxyFactory; use Kubernetes\Model\Io\K8s\Api\Core\V1\Pod; use Kubernetes\Model\Io\K8s\Apimachinery\Pkg\Apis\Meta\V1\DeleteOptions; @@ -24,15 +22,15 @@ public function setUp(): void $logger = new Logger('test'); - $this->apiClient = (new GenericClientFacadeFactory( + $apiClient = (new StaticKubernetesApiClientFactory( (new RetryProxyFactory($logger))->createRetryProxy(), - $logger, - ))->createClusterClient( (string) getenv('K8S_HOST'), (string) getenv('K8S_TOKEN'), (string) getenv('K8S_CA_CERT_PATH'), (string) getenv('K8S_NAMESPACE'), - ); + ))->createApiClient(); + + $this->apiClient = KubernetesApiClientFacade::create($apiClient, $logger); $this->cleanupCluster(); } @@ -111,114 +109,4 @@ public function testListMatching(): void $noPods = [...$this->apiClient->listMatching(Pod::class, ['labelSelector' => 'foo=bar'])]; self::assertCount(0, $noPods); } - - public function testMergePatch(): void - { - // Create initial app - $app = new App([ - 'metadata' => [ - 'name' => 'test-patch-app', - 'labels' => ['app' => 'KubernetesApiClientFacadeFunctionalTest'], - ], - 'spec' => [ - 'appId' => 'app-123', - 'projectId' => 'project-456', - 'state' => 'Running', - 'replicas' => 1, - 'runtimeSize' => 'small', - 'containerSpec' => [ - 'image' => 'busybox', - ], - ], - ]); - $created = $this->apiClient->apps()->create($app); - - // Verify initial state - self::assertNotNull($created->spec); - self::assertSame('Running', $created->spec->state); - self::assertSame(1, $created->spec->replicas); - - // Update via patch - self::assertNotNull($app->spec); - $app->spec->replicas = 3; - $app->spec->state = 'Stopped'; - - $patched = $this->apiClient->mergePatch($app); - - self::assertNotNull($patched->metadata); - self::assertSame('test-patch-app', $patched->metadata->name); - self::assertNotNull($patched->spec); - self::assertSame(3, $patched->spec->replicas); - self::assertSame('Stopped', $patched->spec->state); - } - - public function testCreateOrMergePatchCreatesNewResource(): void - { - $appRun = new AppRun([ - 'metadata' => [ - 'name' => 'test-create-apprun', - 'labels' => ['app' => 'KubernetesApiClientFacadeFunctionalTest'], - ], - 'spec' => [ - 'podRef' => [ - 'name' => 'test-pod', - 'uid' => '550e8400-e29b-41d4-a716-446655440000', - ], - 'appRef' => [ - 'name' => 'test-app', - 'appId' => 'app-123', - 'projectId' => 'project-456', - ], - 'createdAt' => '2025-01-15T12:00:00Z', - 'state' => 'Running', - ], - ]); - - $result = $this->apiClient->createOrMergePatch($appRun); - - self::assertNotNull($result->metadata); - self::assertSame('test-create-apprun', $result->metadata->name); - self::assertNotNull($result->spec); - self::assertSame('Running', $result->spec->state); - } - - public function testCreateOrMergePatchUpdatesExistingResource(): void - { - // Create initial app - $app = new App([ - 'metadata' => [ - 'name' => 'test-createorpatch-app', - 'labels' => ['app' => 'KubernetesApiClientFacadeFunctionalTest'], - ], - 'spec' => [ - 'appId' => 'app-123', - 'projectId' => 'project-456', - 'state' => 'Running', - 'replicas' => 1, - 'runtimeSize' => 'small', - 'containerSpec' => [ - 'image' => 'busybox', - ], - ], - ]); - $created = $this->apiClient->apps()->create($app); - - // Verify initial state - self::assertNotNull($created->spec); - self::assertSame('Running', $created->spec->state); - self::assertSame(1, $created->spec->replicas); - - // Update via createOrMergePatch (should patch since it exists) - self::assertNotNull($app->spec); - $app->spec->replicas = 5; - $app->spec->state = 'Stopped'; - - $result = $this->apiClient->createOrMergePatch($app); - - self::assertNotNull($result->metadata); - self::assertSame('test-createorpatch-app', $result->metadata->name); - self::assertNotNull($result->spec); - self::assertSame(5, $result->spec->replicas); - self::assertSame('Stopped', $result->spec->state); - } } diff --git a/libs/k8s-client/tests/KubernetesApiClientFacadeTest.php b/libs/k8s-client/tests/KubernetesApiClientFacadeTest.php index ffd12bf3f..fbc8af522 100644 --- a/libs/k8s-client/tests/KubernetesApiClientFacadeTest.php +++ b/libs/k8s-client/tests/KubernetesApiClientFacadeTest.php @@ -4,8 +4,7 @@ namespace Keboola\K8sClient\Tests; -use Keboola\K8sClient\ApiClient\AppRunsApiClient; -use Keboola\K8sClient\ApiClient\AppsApiClient; +use Keboola\K8sClient\ApiClient\ApiClientInterface; use Keboola\K8sClient\ApiClient\ConfigMapsApiClient; use Keboola\K8sClient\ApiClient\EventsApiClient; use Keboola\K8sClient\ApiClient\IngressesApiClient; @@ -17,7 +16,6 @@ use Keboola\K8sClient\Exception\ResourceNotFoundException; use Keboola\K8sClient\Exception\TimeoutException; use Keboola\K8sClient\KubernetesApiClientFacade; -use Keboola\K8sClient\Model\Io\Keboola\Apps\V2\App; use Kubernetes\Model\Io\K8s\Api\Core\V1\Event; use Kubernetes\Model\Io\K8s\Api\Core\V1\PersistentVolume; use Kubernetes\Model\Io\K8s\Api\Core\V1\Pod; @@ -28,6 +26,7 @@ use Kubernetes\Model\Io\K8s\Apimachinery\Pkg\Apis\Meta\V1\DeleteOptions; use Kubernetes\Model\Io\K8s\Apimachinery\Pkg\Apis\Meta\V1\Patch; use Kubernetes\Model\Io\K8s\Apimachinery\Pkg\Apis\Meta\V1\Status; +use KubernetesRuntime\AbstractModel; use Monolog\Handler\TestHandler; use Monolog\Logger; use PHPUnit\Framework\TestCase; @@ -59,9 +58,6 @@ public function testApisAccessors(): void $ingressesApiClient = $this->createMock(IngressesApiClient::class); $persistentVolumeClient = $this->createMock(PersistentVolumesApiClient::class); - $appsApiClient = $this->createMock(AppsApiClient::class); - $appRunsApiClient = $this->createMock(AppRunsApiClient::class); - $facade = new KubernetesApiClientFacade( $this->logger, $configMapsApiClient, @@ -72,8 +68,6 @@ public function testApisAccessors(): void $podsApiClient, $secretsApiClient, $servicesApiClient, - $appsApiClient, - $appRunsApiClient, ); self::assertSame($configMapsApiClient, $facade->configMaps()); @@ -83,8 +77,6 @@ public function testApisAccessors(): void self::assertSame($secretsApiClient, $facade->secrets()); self::assertSame($ingressesApiClient, $facade->ingresses()); self::assertSame($persistentVolumeClient, $facade->persistentVolumes()); - self::assertSame($appsApiClient, $facade->apps()); - self::assertSame($appRunsApiClient, $facade->appRuns()); } public function testGetPod(): void @@ -130,8 +122,6 @@ public function testGetPod(): void $podsApiClient, $secretsApiClient, $servicesApiClient, - $this->createMock(AppsApiClient::class), - $this->createMock(AppRunsApiClient::class), ); $result = $facade->get(Pod::class, 'pod-name', ['labelSelector' => 'app=pod-name']); @@ -181,8 +171,6 @@ public function testGetSecret(): void $podsApiClient, $secretsApiClient, $servicesApiClient, - $this->createMock(AppsApiClient::class), - $this->createMock(AppRunsApiClient::class), ); $result = $facade->get(Secret::class, 'secret-name', ['labelSelector' => 'app=secret-name']); @@ -232,8 +220,6 @@ public function testGetEvent(): void $podsApiClient, $secretsApiClient, $servicesApiClient, - $this->createMock(AppsApiClient::class), - $this->createMock(AppRunsApiClient::class), ); $result = $facade->get(Event::class, 'event-name', ['labelSelector' => 'app=event-name']); @@ -318,8 +304,6 @@ public function testCreateModels(): void $podsApiClient, $secretsApiClient, $servicesApiClient, - $this->createMock(AppsApiClient::class), - $this->createMock(AppRunsApiClient::class), ); $result = $facade->createModels([ @@ -387,8 +371,6 @@ public function testCreateModelsErrorHandling(): void $podsApiClient, $secretsApiClient, $servicesApiClient, - $this->createMock(AppsApiClient::class), - $this->createMock(AppRunsApiClient::class), ); $this->expectException(RuntimeException::class); @@ -476,8 +458,6 @@ public function testDeleteModels(): void $podsApiClient, $secretsApiClient, $servicesApiClient, - $this->createMock(AppsApiClient::class), - $this->createMock(AppRunsApiClient::class), ); $result = $facade->deleteModels([ @@ -547,8 +527,6 @@ public function testDeleteModelsErrorHandling(): void $podsApiClient, $secretsApiClient, $servicesApiClient, - $this->createMock(AppsApiClient::class), - $this->createMock(AppRunsApiClient::class), ); $this->expectException(RuntimeException::class); @@ -604,8 +582,6 @@ public function testWaitWhileExists(): void $podsApiClient, $secretsApiClient, $servicesApiClient, - $this->createMock(AppsApiClient::class), - $this->createMock(AppRunsApiClient::class), ); $facade->waitWhileExists([ @@ -647,8 +623,6 @@ public function testWaitWhileExistsTimeout(): void $podsApiClient, $secretsApiClient, $servicesApiClient, - $this->createMock(AppsApiClient::class), - $this->createMock(AppRunsApiClient::class), ); $startTime = microtime(true); @@ -716,8 +690,6 @@ public function testListMatching(): void $podsApiClient, $secretsApiClient, $servicesApiClient, - $this->createMock(AppsApiClient::class), - $this->createMock(AppRunsApiClient::class), ); $result = $facade->listMatching(Pod::class, ['labelSelector' => 'app=my']); @@ -775,8 +747,6 @@ public function testListMatchingWithCustomPageSize(): void $podsApiClient, $secretsApiClient, $servicesApiClient, - $this->createMock(AppsApiClient::class), - $this->createMock(AppRunsApiClient::class), ); $result = $facade->listMatching(Pod::class, ['labelSelector' => 'app=my', 'limit' => 5]); @@ -833,18 +803,6 @@ public function testDeleteAllMatching(): void ->with($deleteOptions, $deleteQuery) ; - $appsApiClient = $this->createMock(AppsApiClient::class); - $appsApiClient->expects(self::once()) - ->method('deleteCollection') - ->with($deleteOptions, $deleteQuery) - ; - - $appRunsApiClient = $this->createMock(AppRunsApiClient::class); - $appRunsApiClient->expects(self::once()) - ->method('deleteCollection') - ->with($deleteOptions, $deleteQuery) - ; - $facade = new KubernetesApiClientFacade( $this->logger, $configMapsApiClient, @@ -855,8 +813,6 @@ public function testDeleteAllMatching(): void $podsApiClient, $secretsApiClient, $servicesApiClient, - $appsApiClient, - $appRunsApiClient, ); $facade->deleteAllMatching($deleteOptions, $deleteQuery); @@ -904,8 +860,6 @@ public function testDeleteAllMatchingWithResourceTypesFilter(): void $podsApiClient, $secretsApiClient, $servicesApiClient, - $this->createMock(AppsApiClient::class), - $this->createMock(AppRunsApiClient::class), ); $facade->deleteAllMatching($deleteOptions, ['resourceTypes' => [Secret::class], ...$deleteQuery]); @@ -962,18 +916,6 @@ public function testDeleteAllMatchingErrorHandling(): void ->with($deleteOptions, $deleteQuery) ; - $appsApiClient = $this->createMock(AppsApiClient::class); - $appsApiClient->expects(self::once()) - ->method('deleteCollection') - ->with($deleteOptions, $deleteQuery) - ; - - $appRunsApiClient = $this->createMock(AppRunsApiClient::class); - $appRunsApiClient->expects(self::once()) - ->method('deleteCollection') - ->with($deleteOptions, $deleteQuery) - ; - $facade = new KubernetesApiClientFacade( $this->logger, $configMapsApiClient, @@ -984,8 +926,6 @@ public function testDeleteAllMatchingErrorHandling(): void $podsApiClient, $secretsApiClient, $servicesApiClient, - $appsApiClient, - $appRunsApiClient, ); try { @@ -1041,33 +981,45 @@ public function testCheckResourceExists(): void $podsApiClient, $secretsApiClient, $this->createMock(ServicesApiClient::class), - $this->createMock(AppsApiClient::class), - $this->createMock(AppRunsApiClient::class), ); self::assertFalse($facade->checkResourceExists(Secret::class, 'secret-name')); self::assertTrue($facade->checkResourceExists(Pod::class, 'pod-name')); } - public function testMergePatch(): void + public function testClientReturnsRegisteredExtraClient(): void { - $app = new App([ - 'metadata' => ['name' => 'test-app'], - 'spec' => ['replicas' => 3], - ]); + $extraClient = $this->createMock(ApiClientInterface::class); - $appsApiClient = $this->createMock(AppsApiClient::class); - $appsApiClient->expects(self::once()) + $facade = new KubernetesApiClientFacade( + $this->logger, + $this->createMock(ConfigMapsApiClient::class), + $this->createMock(EventsApiClient::class), + $this->createMock(IngressesApiClient::class), + $this->createMock(PersistentVolumeClaimsApiClient::class), + $this->createMock(PersistentVolumesApiClient::class), + $this->createMock(PodsApiClient::class), + $this->createMock(SecretsApiClient::class), + $this->createMock(ServicesApiClient::class), + [FakeCrdModel::class => $extraClient], + ); + + self::assertSame($extraClient, $facade->client(FakeCrdModel::class)); + } + + public function testMergePatchRoutesToRegisteredExtraClient(): void + { + $model = new FakeCrdModel(['metadata' => ['name' => 'thing-1'], 'spec' => ['size' => 2]]); + + $extraClient = $this->createMock(ApiClientInterface::class); + $extraClient->expects(self::once()) ->method('patch') - ->willReturnCallback(function ($name, $patch) use ($app) { - self::assertSame('test-app', $name); - self::assertInstanceOf(Patch::class, $patch); + ->willReturnCallback(function (string $name, Patch $patch) use ($model) { + self::assertSame('thing-1', $name); $data = $patch->getArrayCopy(); - self::assertArrayHasKey('patchOperation', $data); self::assertSame('merge-patch', $data['patchOperation']); - self::assertArrayHasKey('spec', $data); - self::assertSame(3, $data['spec']['replicas']); - return $app; + self::assertSame(2, $data['spec']['size']); + return $model; }); $facade = new KubernetesApiClientFacade( @@ -1080,12 +1032,28 @@ public function testMergePatch(): void $this->createMock(PodsApiClient::class), $this->createMock(SecretsApiClient::class), $this->createMock(ServicesApiClient::class), - $appsApiClient, - $this->createMock(AppRunsApiClient::class), + [FakeCrdModel::class => $extraClient], ); - $result = $facade->mergePatch($app); + self::assertSame($model, $facade->mergePatch($model)); + } + + public function testClientThrowsOnUnknownType(): void + { + $facade = new KubernetesApiClientFacade( + $this->logger, + $this->createMock(ConfigMapsApiClient::class), + $this->createMock(EventsApiClient::class), + $this->createMock(IngressesApiClient::class), + $this->createMock(PersistentVolumeClaimsApiClient::class), + $this->createMock(PersistentVolumesApiClient::class), + $this->createMock(PodsApiClient::class), + $this->createMock(SecretsApiClient::class), + $this->createMock(ServicesApiClient::class), + ); - self::assertSame($app, $result); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unknown K8S resource type'); + $facade->client(FakeCrdModel::class); } } diff --git a/libs/k8s-client/tests/Model/V1/AppRunModelTest.php b/libs/k8s-client/tests/Model/V1/AppRunModelTest.php deleted file mode 100644 index 1c4962693..000000000 --- a/libs/k8s-client/tests/Model/V1/AppRunModelTest.php +++ /dev/null @@ -1,157 +0,0 @@ - 'apps.keboola.com/v1', - 'kind' => 'AppRun', - 'metadata' => [ - 'name' => 'apprun-12345', - 'labels' => [ - 'app.kubernetes.io/name' => 'keboola-operator', - 'app.kubernetes.io/managed-by' => 'kustomize', - ], - ], - 'spec' => [ - 'podRef' => [ - 'name' => 'app-12345-deployment-abc123-xyz', - 'uid' => '550e8400-e29b-41d4-a716-446655440000', - ], - 'appRef' => [ - 'name' => 'app-12345', - 'appId' => 'app-123', - 'projectId' => 'project-456', - ], - 'createdAt' => '2025-01-15T12:00:00Z', - 'startedAt' => '2025-01-15T12:01:00Z', - 'stoppedAt' => '2025-01-15T13:00:00Z', - 'state' => 'Finished', - 'startupLogs' => "foo\nbar\n", - 'runtimeSize' => 'small', - 'configVersion' => '1', - 'devMode' => true, - ], - 'status' => [ - 'syncedAt' => '2025-01-15T13:05:00Z', - 'conditions' => [ - [ - 'type' => 'Ready', - 'status' => 'True', - 'lastTransitionTime' => '2025-01-15T12:01:00Z', - 'reason' => 'PodRunning', - 'message' => 'Pod is running', - ], - ], - ], - ]; - } - - public function testAppRunModelHydration(): void - { - $data = self::getAppRunTestData(); - $appRun = new AppRun($data); - - // Basic metadata - self::assertNotNull($appRun->metadata); - self::assertSame('apprun-12345', $appRun->metadata->name); - - // Spec basics - self::assertNotNull($appRun->spec); - self::assertInstanceOf(AppRunSpec::class, $appRun->spec); - self::assertSame('2025-01-15T12:00:00Z', $appRun->spec->createdAt); - self::assertSame('2025-01-15T12:01:00Z', $appRun->spec->startedAt); - self::assertSame('2025-01-15T13:00:00Z', $appRun->spec->stoppedAt); - self::assertSame('Finished', $appRun->spec->state); - self::assertSame("foo\nbar\n", $appRun->spec->startupLogs); - self::assertSame('small', $appRun->spec->runtimeSize); - self::assertSame('1', $appRun->spec->configVersion); - self::assertTrue($appRun->spec->devMode); - - // PodRef - self::assertNotNull($appRun->spec->podRef); - self::assertInstanceOf(PodReference::class, $appRun->spec->podRef); - self::assertSame('app-12345-deployment-abc123-xyz', $appRun->spec->podRef->name); - self::assertSame('550e8400-e29b-41d4-a716-446655440000', $appRun->spec->podRef->uid); - - // AppRef - self::assertNotNull($appRun->spec->appRef); - self::assertInstanceOf(AppReference::class, $appRun->spec->appRef); - self::assertSame('app-12345', $appRun->spec->appRef->name); - self::assertSame('app-123', $appRun->spec->appRef->appId); - self::assertSame('project-456', $appRun->spec->appRef->projectId); - - // Status - self::assertNotNull($appRun->status); - self::assertInstanceOf(AppRunStatus::class, $appRun->status); - self::assertSame('2025-01-15T13:05:00Z', $appRun->status->syncedAt); - self::assertNotNull($appRun->status->conditions); - self::assertCount(1, $appRun->status->conditions); - self::assertSame('Ready', $appRun->status->conditions[0]->type); - self::assertSame('True', $appRun->status->conditions[0]->status); - } - - public function testAppRunFailureReasonHydratesAndSerializes(): void - { - $data = self::getAppRunTestData(); - $data['spec']['state'] = 'Failed'; - $data['spec']['failureReason'] = [ - 'reason' => 'OutOfMemory', - 'message' => 'The app ran out of memory.', - ]; - - $appRun = new AppRun($data); - - self::assertNotNull($appRun->spec->failureReason); - self::assertInstanceOf(AppRunFailureReason::class, $appRun->spec->failureReason); - self::assertSame('OutOfMemory', $appRun->spec->failureReason->reason); - self::assertSame('The app ran out of memory.', $appRun->spec->failureReason->message); - - $serialized = $appRun->getArrayCopy(); - self::assertSame('OutOfMemory', $serialized['spec']['failureReason']['reason']); - self::assertSame('The app ran out of memory.', $serialized['spec']['failureReason']['message']); - } - - public function testAppRunWithoutFailureReasonHydratesToNull(): void - { - // Legacy AppRuns predating the failureReason field, and any non-Failed run, - // carry no failureReason and must hydrate to null (never serialized back). - $appRun = new AppRun(self::getAppRunTestData()); - - self::assertNull($appRun->spec->failureReason); - self::assertArrayNotHasKey('failureReason', $appRun->getArrayCopy()['spec']); - } - - public function testAppRunModelSerialization(): void - { - $data = self::getAppRunTestData(); - $appRun = new AppRun($data); - - $serialized = $appRun->getArrayCopy(); - - self::assertIsArray($serialized); - self::assertArrayHasKey('metadata', $serialized); - self::assertArrayHasKey('spec', $serialized); - - // Verify key nested values survive round-trip - self::assertSame('app-12345-deployment-abc123-xyz', $serialized['spec']['podRef']['name']); - self::assertSame('app-123', $serialized['spec']['appRef']['appId']); - self::assertSame('Finished', $serialized['spec']['state']); - self::assertSame('small', $serialized['spec']['runtimeSize']); - self::assertSame('1', $serialized['spec']['configVersion']); - self::assertTrue($serialized['spec']['devMode']); - } -} diff --git a/libs/k8s-client/tests/Model/V2/AppModelTest.php b/libs/k8s-client/tests/Model/V2/AppModelTest.php deleted file mode 100644 index 9db6702e9..000000000 --- a/libs/k8s-client/tests/Model/V2/AppModelTest.php +++ /dev/null @@ -1,585 +0,0 @@ - 'apps.keboola.com/v2', - 'kind' => 'App', - 'metadata' => [ - 'name' => 'app-12345', - ], - 'spec' => [ - 'appId' => '12345', - 'projectId' => 'project-789', - 'state' => 'Running', - 'replicas' => 1, - 'autoRestartEnabled' => false, - 'restartRequestedAt' => '2024-01-15T10:30:00Z', - 'runtimeSize' => 'small', - 'runtime' => [ - 'size' => 'small', - 'backend' => [ - 'type' => 'e2bSandbox', - ], - ], - 'devMode' => [ - 'enabled' => true, - 'gitPollInterval' => '5s', - 'autoRunSetupOnDepChange' => false, - ], - 'features' => [ - 'managedGitRepo' => [ - 'repoId' => 'repo-abc-123', - 'credentialType' => 'ssh_key', - ], - 'storageToken' => [ - 'description' => '[_internal][app] App 12345', - 'expiresIn' => 86400, - 'componentAccess' => ['keboola.streamlit'], - 'bucketPermissions' => ['in.c-main' => 'read'], - 'canManageBuckets' => true, - 'canReadAllFileUploads' => true, - 'canPurgeTrash' => false, - 'setEnvs' => [ - [ - 'container' => 'app', - 'envName' => 'KBC_TOKEN', - ], - ], - 'mountPaths' => [ - [ - 'container' => 'app', - 'path' => '/tmp/token', - ], - ], - ], - 'appsProxyIngress' => [ - 'container' => 'app', - 'targetPort' => 8888, - ], - 'dataDir' => [ - 'mount' => [ - [ - 'container' => 'app', - 'path' => '/data', - ], - ], - 'dataLoader' => [ - 'branchId' => 'main', - 'componentId' => 'keboola.streamlit', - 'configId' => 'config-456', - 'port' => 8080, - ], - ], - 'mountConfig' => [ - 'branchId' => 'main', - 'componentId' => 'keboola.streamlit', - 'configId' => 'config-456', - 'configVersion' => '3', - 'mount' => [ - [ - 'container' => 'app', - 'path' => '/data/config.json', - 'fields' => [ - [ - 'source' => '$.parameters.packages', - 'target' => 'packages', - 'strategy' => 'replace', - ], - [ - 'source' => '$.parameters.script', - 'target' => 'script', - ], - [ - 'target' => 'staticValue', - 'value' => 'hello', - 'strategy' => 'fallback', - ], - [ - 'source' => '$.storage.input', - 'target' => 'input', - ], - ], - ], - ], - ], - 'workspace' => [ - 'branchId' => 'main', - 'componentId' => 'keboola.streamlit', - 'configId' => 'config-456', - 'backend' => 'snowflake', - 'backendSize' => 'small', - 'publicKey' => 'ssh-rsa AAAAB3...', - 'readOnlyStorageAccess' => true, - 'useCase' => 'analytics', - ], - ], - 'containerSpec' => [ - 'image' => 'keboola.azurecr.io/docker-python-streamlit:1.2.3', - 'command' => ['/bin/sh', '-c', 'streamlit run app.py'], - 'env' => [ - ['name' => 'KBC_URL', 'value' => 'https://connection.keboola.com'], - ['name' => 'SANDBOX_ID', 'value' => '12345'], - ['name' => 'PROJECT_ID', 'value' => 'project-789'], - ['name' => 'ROOT_DIR', 'value' => ''], - ['name' => 'DATA_LOADER_API_URL', 'value' => 'localhost:8080'], - ['name' => 'IS_OPERATOR', 'value' => 'true'], - ], - 'startupProbe' => [ - 'httpGet' => [ - 'path' => '/', - 'port' => 8888, - ], - 'initialDelaySeconds' => 1, - 'periodSeconds' => 1, - 'failureThreshold' => 120, - ], - 'readinessProbe' => [ - 'httpGet' => [ - 'path' => '/', - 'port' => 8888, - ], - 'initialDelaySeconds' => 0, - 'periodSeconds' => 10, - ], - 'livenessProbe' => [ - 'httpGet' => [ - 'path' => '/health', - 'port' => 8888, - ], - 'initialDelaySeconds' => 0, - 'periodSeconds' => 10, - ], - ], - ], - 'status' => [ - 'observedGeneration' => 5, - 'currentState' => 'Running', - 'readyReplicas' => 1, - 'updatedReplicas' => 1, - 'lastStartedTime' => '2024-01-15T10:31:00Z', - 'runStartRequestedAt' => '2024-01-15T10:30:00Z', - 'appsProxyServiceRef' => [ - 'name' => 'app-12345-proxy', - ], - 'appsProxy' => [ - 'serviceRef' => [ - 'name' => 'app-12345-proxy-svc', - ], - 'upstreamUrl' => 'http://app-12345.ns.svc:8888', - ], - 'e2bSandbox' => [ - 'name' => 'e2b-sandbox-12345', - 'sandboxID' => 'sb-abc-123', - 'startupLaunchedAt' => '2024-01-15T10:30:30Z', - 'startupProbeFailures' => 0, - 'syncedFileHashes' => ['app.py' => 'abc123'], - 'templateBuildID' => 'build-xyz', - ], - 'managedGitCredential' => [ - 'id' => 'cred-xyz-789', - 'repoId' => 'repo-abc-123', - ], - 'conditions' => [ - [ - 'type' => 'Ready', - 'status' => 'True', - 'lastTransitionTime' => '2024-01-15T10:31:00Z', - 'reason' => 'AppRunning', - 'message' => 'App is running', - ], - ], - ], - ]; - } - - public function testStreamlitAppModelHydration(): void - { - $data = self::getAppTestData(); - $app = new App($data); - - // Basic metadata - self::assertNotNull($app->metadata); - self::assertSame('app-12345', $app->metadata->name); - - // API version - self::assertSame('apps.keboola.com/v2', $app->apiVersion); - self::assertSame('App', $app->kind); - - // Spec basics - self::assertNotNull($app->spec); - self::assertInstanceOf(AppSpec::class, $app->spec); - self::assertSame('12345', $app->spec->appId); - self::assertSame('project-789', $app->spec->projectId); - self::assertSame('Running', $app->spec->state); - self::assertSame(1, $app->spec->replicas); - self::assertFalse($app->spec->autoRestartEnabled); - self::assertSame('2024-01-15T10:30:00Z', $app->spec->restartRequestedAt); - self::assertSame('small', $app->spec->runtimeSize); - self::assertNotNull($app->spec->runtime); - self::assertInstanceOf(AppRuntime::class, $app->spec->runtime); - self::assertSame('small', $app->spec->runtime->size); - self::assertNotNull($app->spec->runtime->backend); - self::assertInstanceOf(Backend::class, $app->spec->runtime->backend); - self::assertSame('e2bSandbox', $app->spec->runtime->backend->type); - - // DevMode - self::assertNotNull($app->spec->devMode); - self::assertInstanceOf(AppDevModeSpec::class, $app->spec->devMode); - self::assertTrue($app->spec->devMode->enabled); - self::assertSame('5s', $app->spec->devMode->gitPollInterval); - self::assertFalse($app->spec->devMode->autoRunSetupOnDepChange); - - // ManagedGitRepo (under features) - self::assertInstanceOf(AppFeatures::class, $app->spec->features); - self::assertNotNull($app->spec->features->managedGitRepo); - self::assertInstanceOf(ManagedGitRepoSpec::class, $app->spec->features->managedGitRepo); - self::assertSame('repo-abc-123', $app->spec->features->managedGitRepo->repoId); - self::assertSame('ssh_key', $app->spec->features->managedGitRepo->credentialType); - - // Features - self::assertNotNull($app->spec->features); - self::assertInstanceOf(AppFeatures::class, $app->spec->features); - $this->assertStorageTokenFeature($app->spec->features->storageToken); - $this->assertAppsProxyIngressFeature($app->spec->features->appsProxyIngress); - $this->assertDataDirFeature($app->spec->features->dataDir); - $this->assertMountConfigFeature($app->spec->features->mountConfig); - $this->assertWorkspaceFeature($app->spec->features->workspace); - - // ContainerSpec - self::assertNotNull($app->spec->containerSpec); - self::assertInstanceOf(ContainerSpec::class, $app->spec->containerSpec); - $this->assertContainerSpec($app->spec->containerSpec); - - // Status - self::assertNotNull($app->status); - self::assertInstanceOf(AppStatus::class, $app->status); - self::assertSame(5, $app->status->observedGeneration); - self::assertSame('Running', $app->status->currentState); - self::assertSame(1, $app->status->readyReplicas); - self::assertSame(1, $app->status->updatedReplicas); - self::assertSame('2024-01-15T10:31:00Z', $app->status->lastStartedTime); - self::assertSame('2024-01-15T10:30:00Z', $app->status->runStartRequestedAt); - - // Status - appsProxyServiceRef (deprecated) - self::assertNotNull($app->status->appsProxyServiceRef); - self::assertInstanceOf(LocalObjectReference::class, $app->status->appsProxyServiceRef); - self::assertSame('app-12345-proxy', $app->status->appsProxyServiceRef->name); - - // Status - appsProxy - self::assertNotNull($app->status->appsProxy); - self::assertInstanceOf(AppsProxyStatus::class, $app->status->appsProxy); - self::assertSame('http://app-12345.ns.svc:8888', $app->status->appsProxy->upstreamUrl); - self::assertNotNull($app->status->appsProxy->serviceRef); - self::assertInstanceOf(LocalObjectReference::class, $app->status->appsProxy->serviceRef); - self::assertSame('app-12345-proxy-svc', $app->status->appsProxy->serviceRef->name); - - // Status - e2bSandbox - self::assertNotNull($app->status->e2bSandbox); - self::assertInstanceOf(E2bSandboxStatus::class, $app->status->e2bSandbox); - self::assertSame('e2b-sandbox-12345', $app->status->e2bSandbox->name); - self::assertSame('sb-abc-123', $app->status->e2bSandbox->sandboxID); - self::assertSame('2024-01-15T10:30:30Z', $app->status->e2bSandbox->startupLaunchedAt); - self::assertSame(0, $app->status->e2bSandbox->startupProbeFailures); - self::assertSame(['app.py' => 'abc123'], $app->status->e2bSandbox->syncedFileHashes); - self::assertSame('build-xyz', $app->status->e2bSandbox->templateBuildID); - - // Status - managedGitCredential - self::assertNotNull($app->status->managedGitCredential); - self::assertInstanceOf(ManagedGitCredentialStatus::class, $app->status->managedGitCredential); - self::assertSame('cred-xyz-789', $app->status->managedGitCredential->id); - self::assertSame('repo-abc-123', $app->status->managedGitCredential->repoId); - - // Status - conditions - self::assertNotNull($app->status->conditions); - self::assertCount(1, $app->status->conditions); - self::assertSame('Ready', $app->status->conditions[0]->type); - self::assertSame('True', $app->status->conditions[0]->status); - } - - private function assertStorageTokenFeature(?StorageTokenSpec $storageToken): void - { - self::assertNotNull($storageToken); - self::assertInstanceOf(StorageTokenSpec::class, $storageToken); - self::assertSame('[_internal][app] App 12345', $storageToken->description); - self::assertSame(86400, $storageToken->expiresIn); - self::assertSame(['keboola.streamlit'], $storageToken->componentAccess); - self::assertSame(['in.c-main' => 'read'], $storageToken->bucketPermissions); - self::assertTrue($storageToken->canManageBuckets); - self::assertTrue($storageToken->canReadAllFileUploads); - self::assertFalse($storageToken->canPurgeTrash); - - // setEnvs - self::assertNotNull($storageToken->setEnvs); - self::assertCount(1, $storageToken->setEnvs); - self::assertInstanceOf(SetEnvSpec::class, $storageToken->setEnvs[0]); - self::assertSame('app', $storageToken->setEnvs[0]->container); - self::assertSame('KBC_TOKEN', $storageToken->setEnvs[0]->envName); - - // mountPaths - self::assertNotNull($storageToken->mountPaths); - self::assertCount(1, $storageToken->mountPaths); - self::assertInstanceOf(MountPathSpec::class, $storageToken->mountPaths[0]); - self::assertSame('app', $storageToken->mountPaths[0]->container); - self::assertSame('/tmp/token', $storageToken->mountPaths[0]->path); - } - - private function assertAppsProxyIngressFeature(?AppsProxyIngressSpec $appsProxyIngress): void - { - self::assertNotNull($appsProxyIngress); - self::assertInstanceOf(AppsProxyIngressSpec::class, $appsProxyIngress); - self::assertSame('app', $appsProxyIngress->container); - self::assertSame(8888, $appsProxyIngress->targetPort); - } - - private function assertDataDirFeature(?DataDirSpec $dataDir): void - { - self::assertNotNull($dataDir); - self::assertInstanceOf(DataDirSpec::class, $dataDir); - - // mount - self::assertNotNull($dataDir->mount); - self::assertCount(1, $dataDir->mount); - self::assertInstanceOf(DataDirMountSpec::class, $dataDir->mount[0]); - self::assertSame('app', $dataDir->mount[0]->container); - self::assertSame('/data', $dataDir->mount[0]->path); - - // dataLoader - self::assertNotNull($dataDir->dataLoader); - self::assertInstanceOf(DataLoaderSpec::class, $dataDir->dataLoader); - self::assertSame('main', $dataDir->dataLoader->branchId); - self::assertSame('keboola.streamlit', $dataDir->dataLoader->componentId); - self::assertSame('config-456', $dataDir->dataLoader->configId); - self::assertSame(8080, $dataDir->dataLoader->port); - } - - private function assertMountConfigFeature(?ConfigMountSpec $mountConfig): void - { - self::assertNotNull($mountConfig); - self::assertInstanceOf(ConfigMountSpec::class, $mountConfig); - self::assertSame('main', $mountConfig->branchId); - self::assertSame('keboola.streamlit', $mountConfig->componentId); - self::assertSame('config-456', $mountConfig->configId); - self::assertSame('3', $mountConfig->configVersion); - - // mount - self::assertNotNull($mountConfig->mount); - self::assertCount(1, $mountConfig->mount); - self::assertInstanceOf(ConfigMountItemSpec::class, $mountConfig->mount[0]); - self::assertSame('app', $mountConfig->mount[0]->container); - self::assertSame('/data/config.json', $mountConfig->mount[0]->path); - - // fields - self::assertNotNull($mountConfig->mount[0]->fields); - self::assertCount(4, $mountConfig->mount[0]->fields); - - // First field - with source and strategy - self::assertInstanceOf(MountConfigField::class, $mountConfig->mount[0]->fields[0]); - self::assertSame('$.parameters.packages', $mountConfig->mount[0]->fields[0]->source); - self::assertSame('packages', $mountConfig->mount[0]->fields[0]->target); - self::assertSame('replace', $mountConfig->mount[0]->fields[0]->strategy); - - // Third field - with static value and fallback strategy - self::assertInstanceOf(MountConfigField::class, $mountConfig->mount[0]->fields[2]); - self::assertNull($mountConfig->mount[0]->fields[2]->source); - self::assertSame('staticValue', $mountConfig->mount[0]->fields[2]->target); - self::assertSame('hello', $mountConfig->mount[0]->fields[2]->value); - self::assertSame('fallback', $mountConfig->mount[0]->fields[2]->strategy); - } - - private function assertWorkspaceFeature(?WorkspaceSpec $workspace): void - { - self::assertNotNull($workspace); - self::assertInstanceOf(WorkspaceSpec::class, $workspace); - self::assertSame('main', $workspace->branchId); - self::assertSame('keboola.streamlit', $workspace->componentId); - self::assertSame('config-456', $workspace->configId); - self::assertSame('snowflake', $workspace->backend); - self::assertSame('small', $workspace->backendSize); - self::assertSame('ssh-rsa AAAAB3...', $workspace->publicKey); - self::assertTrue($workspace->readOnlyStorageAccess); - self::assertSame('analytics', $workspace->useCase); - } - - private function assertContainerSpec(ContainerSpec $containerSpec): void - { - self::assertSame('keboola.azurecr.io/docker-python-streamlit:1.2.3', $containerSpec->image); - - // command - self::assertNotNull($containerSpec->command); - self::assertIsArray($containerSpec->command); - self::assertCount(3, $containerSpec->command); - self::assertSame(['/bin/sh', '-c', 'streamlit run app.py'], $containerSpec->command); - - // env vars - self::assertNotNull($containerSpec->env); - self::assertCount(6, $containerSpec->env); - self::assertInstanceOf(EnvVar::class, $containerSpec->env[0]); - self::assertSame('KBC_URL', $containerSpec->env[0]->name); - self::assertSame('https://connection.keboola.com', $containerSpec->env[0]->value); - - // probes - self::assertNotNull($containerSpec->startupProbe); - self::assertInstanceOf(Probe::class, $containerSpec->startupProbe); - self::assertNotNull($containerSpec->startupProbe->httpGet); - self::assertInstanceOf(HTTPGetAction::class, $containerSpec->startupProbe->httpGet); - self::assertSame('/', $containerSpec->startupProbe->httpGet->path); - self::assertEquals(8888, $containerSpec->startupProbe->httpGet->port); - self::assertSame(1, $containerSpec->startupProbe->initialDelaySeconds); - self::assertSame(1, $containerSpec->startupProbe->periodSeconds); - self::assertSame(120, $containerSpec->startupProbe->failureThreshold); - - self::assertNotNull($containerSpec->readinessProbe); - self::assertInstanceOf(Probe::class, $containerSpec->readinessProbe); - self::assertSame(0, $containerSpec->readinessProbe->initialDelaySeconds); - self::assertSame(10, $containerSpec->readinessProbe->periodSeconds); - - self::assertNotNull($containerSpec->livenessProbe); - self::assertInstanceOf(Probe::class, $containerSpec->livenessProbe); - self::assertNotNull($containerSpec->livenessProbe->httpGet); - self::assertSame('/health', $containerSpec->livenessProbe->httpGet->path); - } - - public function testAppModelSerialization(): void - { - $data = self::getAppTestData(); - $app = new App($data); - - // Test that the model can be serialized back to array - $serialized = $app->getArrayCopy(); - - self::assertIsArray($serialized); - self::assertArrayHasKey('apiVersion', $serialized); - self::assertArrayHasKey('kind', $serialized); - self::assertArrayHasKey('metadata', $serialized); - self::assertArrayHasKey('spec', $serialized); - - // Verify API version - self::assertSame('apps.keboola.com/v2', $serialized['apiVersion']); - self::assertSame('App', $serialized['kind']); - - // Verify key nested values survive round-trip - self::assertSame('12345', $serialized['spec']['appId']); - self::assertSame('project-789', $serialized['spec']['projectId']); - self::assertSame('Running', $serialized['spec']['state']); - self::assertArrayHasKey('runtime', $serialized['spec']); - self::assertSame('small', $serialized['spec']['runtime']['size']); - self::assertSame('e2bSandbox', $serialized['spec']['runtime']['backend']['type']); - self::assertSame('small', $serialized['spec']['runtimeSize']); - - // Verify devMode survives round-trip - self::assertArrayHasKey('devMode', $serialized['spec']); - self::assertTrue($serialized['spec']['devMode']['enabled']); - self::assertSame('5s', $serialized['spec']['devMode']['gitPollInterval']); - self::assertFalse($serialized['spec']['devMode']['autoRunSetupOnDepChange']); - - // Verify managedGitRepo survives round-trip (under features) - self::assertArrayHasKey('managedGitRepo', $serialized['spec']['features']); - self::assertSame('repo-abc-123', $serialized['spec']['features']['managedGitRepo']['repoId']); - self::assertSame('ssh_key', $serialized['spec']['features']['managedGitRepo']['credentialType']); - - // Verify containerSpec is present and podSpec is not - self::assertArrayHasKey('containerSpec', $serialized['spec']); - self::assertArrayNotHasKey('podSpec', $serialized['spec']); - - // Verify containerSpec structure - self::assertSame( - 'keboola.azurecr.io/docker-python-streamlit:1.2.3', - $serialized['spec']['containerSpec']['image'], - ); - self::assertIsArray($serialized['spec']['containerSpec']['command']); - self::assertArrayNotHasKey('name', $serialized['spec']['containerSpec']); - self::assertArrayNotHasKey('resources', $serialized['spec']['containerSpec']); - - // Verify status - self::assertArrayHasKey('status', $serialized); - self::assertSame('Running', $serialized['status']['currentState']); - self::assertArrayHasKey('appsProxy', $serialized['status']); - self::assertArrayHasKey('e2bSandbox', $serialized['status']); - - // Verify managedGitCredential survives round-trip - self::assertArrayHasKey('managedGitCredential', $serialized['status']); - self::assertSame('cred-xyz-789', $serialized['status']['managedGitCredential']['id']); - self::assertSame('repo-abc-123', $serialized['status']['managedGitCredential']['repoId']); - } - - public function testCreateAppWithNestedObjects(): void - { - $data = self::getAppTestData(); - $app = new App($data); - - // Test containerSpec - self::assertNotNull($app->spec); - self::assertInstanceOf(ContainerSpec::class, $app->spec->containerSpec); - self::assertNotNull($app->spec->containerSpec); - self::assertNotNull($app->spec->containerSpec->env); - self::assertCount(6, $app->spec->containerSpec->env); - self::assertInstanceOf(EnvVar::class, $app->spec->containerSpec->env[0]); - - // Test probes - self::assertInstanceOf(Probe::class, $app->spec->containerSpec->startupProbe); - self::assertNotNull($app->spec->containerSpec->startupProbe); - self::assertInstanceOf(HTTPGetAction::class, $app->spec->containerSpec->startupProbe->httpGet); - self::assertInstanceOf(Probe::class, $app->spec->containerSpec->readinessProbe); - self::assertInstanceOf(Probe::class, $app->spec->containerSpec->livenessProbe); - - // Test features - self::assertNotNull($app->spec->features); - self::assertInstanceOf(AppFeatures::class, $app->spec->features); - self::assertInstanceOf(StorageTokenSpec::class, $app->spec->features->storageToken); - self::assertInstanceOf(DataDirSpec::class, $app->spec->features->dataDir); - self::assertInstanceOf(ConfigMountSpec::class, $app->spec->features->mountConfig); - self::assertInstanceOf(WorkspaceSpec::class, $app->spec->features->workspace); - - // Test status - self::assertNotNull($app->status); - self::assertInstanceOf(AppStatus::class, $app->status); - self::assertInstanceOf(AppsProxyStatus::class, $app->status->appsProxy); - self::assertInstanceOf(E2bSandboxStatus::class, $app->status->e2bSandbox); - self::assertInstanceOf(LocalObjectReference::class, $app->status->appsProxyServiceRef); - } -} diff --git a/libs/k8s-client/tests/fixtures/ca.crt b/libs/k8s-client/tests/fixtures/ca.crt new file mode 100644 index 000000000..d82960018 --- /dev/null +++ b/libs/k8s-client/tests/fixtures/ca.crt @@ -0,0 +1,3 @@ +-----BEGIN CERTIFICATE----- +MIIB +-----END CERTIFICATE-----