Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/Exceptions/KubernetesAPIException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,22 @@

namespace RenokiCo\PhpK8s\Exceptions;

use GuzzleHttp\Exception\BadResponseException;

class KubernetesAPIException extends PhpK8sException
{
//
public static function from(BadResponseException $exception): self
{
$response = $exception->getResponse();
$body = json_decode((string) $response->getBody(), true);
$payload = is_array($body) ? $body : null;
$code = $payload['code'] ?? null;

return new self(
$exception->getMessage(),
is_int($code) && $code > 0 ? $code : $response->getStatusCode(),
$payload,
$exception,
);
}
}
5 changes: 3 additions & 2 deletions src/Exceptions/PhpK8sException.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@
namespace RenokiCo\PhpK8s\Exceptions;

use Exception;
use Throwable;

class PhpK8sException extends Exception
{
/**
* Initialize the exception.
*/
public function __construct(?string $message = null, int $code = 0, protected ?array $payload = null)
public function __construct(?string $message = null, int $code = 0, protected ?array $payload = null, ?Throwable $previous = null)
{
parent::__construct($message, $code);
parent::__construct($message ?? '', $code, $previous);
}

/**
Expand Down
10 changes: 2 additions & 8 deletions src/Traits/Cluster/ChecksClusterVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,8 @@ protected function loadClusterVersion(): void

try {
$response = $this->getClient()->request('GET', $callableUrl);
} catch (ClientException $e) {
$payload = json_decode((string) $e->getResponse()->getBody(), true);

throw new KubernetesAPIException(
$e->getMessage(),
$payload['code'] ?? 0,
$payload
);
} catch (ClientException $exception) {
throw KubernetesAPIException::from($exception);
}

$json = json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR);
Expand Down
10 changes: 2 additions & 8 deletions src/Traits/Cluster/MakesHttpCalls.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,8 @@ public function call(string $method, string $path, string $payload = '', array $
}

$response = $this->getClient()->request($method, $this->getCallableUrl($path, $query), $requestOptions);
} catch (ClientException $e) {
$errorPayload = json_decode((string) $e->getResponse()->getBody(), true);

throw new KubernetesAPIException(
$e->getMessage(),
$errorPayload['code'] ?? 0,
$errorPayload
);
} catch (ClientException $exception) {
throw KubernetesAPIException::from($exception);
}

return $response;
Expand Down
20 changes: 20 additions & 0 deletions tests/ChecksClusterVersionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

namespace RenokiCo\PhpK8s\Test;

use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Psr7\Response;
use RenokiCo\PhpK8s\Exceptions\KubernetesAPIException;

class ChecksClusterVersionTest extends TestCase
{
public function test_check_cluster_version(): void
Expand All @@ -11,4 +15,20 @@ public function test_check_cluster_version(): void
$this->assertFalse($this->cluster->newerThan('2.0.0'));
$this->assertTrue($this->cluster->olderThan('2.0.0'));
}

public function test_plain_text_version_error_keeps_http_status_and_chains_guzzle_exception(): void
{
$cluster = $this->clusterRespondingWith(
new Response(403, ['Content-Type' => 'text/plain; charset=utf-8'], "Forbidden\n")
);

try {
$cluster->newerThan('1.18.0');
$this->fail('Expected a KubernetesAPIException.');
} catch (KubernetesAPIException $exception) {
$this->assertSame(403, $exception->getCode());
$this->assertNull($exception->getPayload());
$this->assertInstanceOf(ClientException::class, $exception->getPrevious());
}
}
}
40 changes: 40 additions & 0 deletions tests/MakesHttpCallsIntegrationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace RenokiCo\PhpK8s\Test;

use Exception;
use RenokiCo\PhpK8s\Exceptions\KubernetesAPIException;

class MakesHttpCallsIntegrationTest extends TestCase
{
#[\Override]
protected function setUp(): void
{
parent::setUp();

if (! getenv('CI') && ! $this->isClusterAvailable()) {
$this->markTestSkipped('Integration tests require a live Kubernetes cluster');
}
}

private function isClusterAvailable(): bool
{
try {
$this->cluster->getAllNamespaces();

return true;
} catch (Exception) {
return false;
}
}

public function test_unserved_api_group_answers_not_found(): void
{
try {
$this->cluster->call('GET', '/apis/unserved.php-k8s.invalid/v1/namespaces/default/widgets/missing');
$this->fail('Expected a KubernetesAPIException for an API group the cluster does not serve.');
} catch (KubernetesAPIException $exception) {
$this->assertSame(404, $exception->getCode());
}
}
}
59 changes: 59 additions & 0 deletions tests/MakesHttpCallsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace RenokiCo\PhpK8s\Test;

use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use RenokiCo\PhpK8s\Exceptions\KubernetesAPIException;

class MakesHttpCallsTest extends TestCase
{
public function test_plain_text_error_keeps_http_status_and_chains_guzzle_exception(): void
{
$exception = $this->failedCall(
new Response(404, ['Content-Type' => 'text/plain; charset=utf-8'], "404 page not found\n")
);

$this->assertSame(404, $exception->getCode());
$this->assertNull($exception->getPayload());
$this->assertInstanceOf(ClientException::class, $exception->getPrevious());
}

public function test_status_body_code_wins_over_http_status(): void
{
$exception = $this->failedCall(
new Response(400, ['Content-Type' => 'application/json'], '{"kind":"Status","apiVersion":"v1","status":"Failure","reason":"Conflict","code":409}')
);

$this->assertSame(409, $exception->getCode());
$this->assertSame(
['kind' => 'Status', 'apiVersion' => 'v1', 'status' => 'Failure', 'reason' => 'Conflict', 'code' => 409],
$exception->getPayload()
);
}

public function test_zero_status_body_code_falls_back_to_http_status(): void
{
$exception = $this->failedCall(
new Response(404, ['Content-Type' => 'application/json'], '{"kind":"Status","apiVersion":"v1","status":"Failure","reason":"NotFound","code":0}')
);

$this->assertSame(404, $exception->getCode());
$this->assertSame(
['kind' => 'Status', 'apiVersion' => 'v1', 'status' => 'Failure', 'reason' => 'NotFound', 'code' => 0],
$exception->getPayload()
);
}

private function failedCall(ResponseInterface $response): KubernetesAPIException
{
try {
$this->clusterRespondingWith($response)->call('GET', '/apis/snapshot.storage.k8s.io/v1/namespaces/default/volumesnapshots/branch');
} catch (KubernetesAPIException $exception) {
return $exception;
}

$this->fail('Expected a KubernetesAPIException.');
}
}
21 changes: 21 additions & 0 deletions tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

namespace RenokiCo\PhpK8s\Test;

use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use Orchestra\Testbench\TestCase as Orchestra;
use Psr\Http\Message\ResponseInterface;
use RenokiCo\PhpK8s\Exceptions\PhpK8sException;
use RenokiCo\PhpK8s\Instances\Container;
use RenokiCo\PhpK8s\K8s;
Expand Down Expand Up @@ -191,4 +195,21 @@ protected function createPerlPod(array $options = []): K8sPod

return $pod;
}

protected function clusterRespondingWith(ResponseInterface $response): KubernetesCluster
{
return new class(HandlerStack::create(new MockHandler([$response]))) extends KubernetesCluster
{
public function __construct(private readonly HandlerStack $handler)
{
parent::__construct('https://kubernetes.test');
}

#[\Override]
public function getClient(): Client
{
return new Client(['handler' => $this->handler]);
}
};
}
}
Loading