diff --git a/README.md b/README.md index 0c887bf..558e47f 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,18 @@ # Orchestrator PHP Client -PHP SDK for the Open Runtimes orchestrator Jobs API. +PHP SDK for the Open Runtimes orchestrator: jobs, deployments, sandboxes, and pools. Server: https://github.com/open-runtimes/orchestrator +Each service is its own client over one configured `Utopia\Client`: + +```php +$jobs = new Jobs($http); +$deployments = new Deployments($http); +$sandboxes = new Sandboxes($http); +$pools = new DeploymentPools($http); +``` + ```php use OpenRuntimes\Orchestrator\Enum\CallbackEvent; use OpenRuntimes\Orchestrator\Jobs; @@ -47,6 +56,155 @@ $list = $jobs->list(); $jobs->delete('build-001'); ``` +## Deployments + +A deployment is a container serving HTTP behind the orchestrator's gateway, kept +running, routable, and scaled — including down to zero. `apply()` is declarative: +applying a changed spec for an existing id rolls out a new revision, and applying +an identical one is a no-op. + +```php +use OpenRuntimes\Orchestrator\Deployments; +use OpenRuntimes\Orchestrator\Model\Autoscaling; +use OpenRuntimes\Orchestrator\Model\Probe; +use OpenRuntimes\Orchestrator\Model\Probes; + +$deployments = new Deployments($http); + +$web = $deployments->apply( + id: 'web', + image: 'ghcr.io/acme/web:v3', + port: 8080, + hosts: ['acme.com', 'www.acme.com'], + autoscaling: new Autoscaling(minReplicas: 0, maxReplicas: 10, target: 100), + probes: new Probes(readiness: new Probe(path: '/healthz', periodMillis: 500)), +); + +echo $web->url; // primary host +echo $web->status->value; // pending|ready|idle|degraded|failed|deleting + +$deployments->get('web'); +$deployments->list(); +$deployments->delete('web'); +``` + +### Traffic + +Every spec change mints an immutable revision, which makes canaries and rollbacks +cheap. Pinning any split switches the deployment to manual mode; `release()` hands +traffic back to auto. + +```php +use OpenRuntimes\Orchestrator\Model\TrafficTarget; + +$revisions = $deployments->revisions('web'); + +// Canary: 90% stable, 10% new. +$deployments->setTraffic('web', [ + new TrafficTarget('web-00001', 90), + new TrafficTarget('web-00002', 10), +]); + +// Rollback is just a split. +$deployments->setTraffic('web', [new TrafficTarget('web-00001', 100)]); + +// Back to auto: 100% on the latest revision, auto-cut re-armed. +$deployments->release('web'); +``` + +## Sandboxes + +A sandbox is a live, isolated workspace you drive from the outside. Name a `pool` +to claim an already-running pod (sub-second), or an `image` to have one built for +this request (a cold start, but nothing to configure ahead of time and per-sandbox +control over `cpu`, `memory`, `runtimeClass`, and `volumes`). + +```php +use OpenRuntimes\Orchestrator\Enum\RuntimeClass; +use OpenRuntimes\Orchestrator\Sandboxes; + +$sandboxes = new Sandboxes($http); + +$sandbox = $sandboxes->create( + pool: 'py', + id: 'agent-run-42', + ports: [5173], // extra ports, each at its own hostname + timeoutSeconds: 0, // no per-request bound, for long-lived sessions + idleTimeoutSeconds: 900, + artifacts: [ + new DownloadArtifact('code', 'https://acme.test/app.tar.gz', 'app.tar.gz'), + new UnarchiveArtifact('unpack', 'app.tar.gz', '.', depends: 'code'), + ], +); + +// Without a pool — a port is required, since nothing else declares one. +$sandboxes->create(image: 'python:3.12-slim', port: 3000, runtimeClass: RuntimeClass::Gvisor); + +$sandboxes->get('agent-run-42'); +$sandboxes->list(); +$sandboxes->delete('agent-run-42'); // invalidates the URL immediately + +$sandboxes->pools(); // read-only: pools are operator config +``` + +Running commands and moving files are **not** part of this API. They are an HTTP +contract (`POST /execute`, `GET|PUT|DELETE /files/{path}`) served *inside* the +sandbox, at the address in `$sandbox->url` — read secondary ports out of +`$sandbox->urls` rather than building them. + +**Treat those URLs as secrets.** Reaching one is sufficient to run commands in the +sandbox, which is why the hostname carries an unguessable token instead of the id. + +A sandbox that fails to materialize is not an error response: `create()` returns a +status with `SandboxState::Failed` and an `error`, because the sandbox exists as a +record you can read and delete. + +## Deployment pools + +A pool is standing warm capacity; an activation claims one warm pod and late-binds +your payload onto it. Pools are operator configuration, so the API over them is +read plus activate. + +```php +use OpenRuntimes\Orchestrator\DeploymentPools; + +$pools = new DeploymentPools($http); + +$pools->list(); +$pools->get('node'); + +$activation = $pools->activate( + poolId: 'node', + command: 'node server.js', + id: 'preview-7', // choosing one buys idempotency + idleTimeoutSeconds: 600, +); + +echo $activation->url; + +$pools->activations('node'); +$pools->activation('node', 'preview-7'); +$pools->deactivate('node', 'preview-7'); +``` + +Pass `async: true` to get an accepted activation back immediately, with the result +delivered to your callback as an `orchestrator.pool.activation.result` event. It +requires a callback — nothing is stored to poll in the meantime — and the returned +activation has no `id` yet. + +```php +$pools->activate( + poolId: 'node', + command: 'node server.js', + callback: new Callback( + url: 'https://acme.test/hook', + events: [CallbackEvent::PoolActivationResult], + key: 'signing-secret', + ), + async: true, +); +``` + ## Errors API responses with status `>= 400` throw `ApiException` with `statusCode`, raw `body`, and decoded JSON when available. diff --git a/composer.json b/composer.json index 7af356a..df83221 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "open-runtimes/sdk-for-php", - "description": "PHP SDK for the Open Runtimes orchestrator Jobs API.", + "description": "PHP SDK for the Open Runtimes orchestrator: jobs, deployments, sandboxes, and pools.", "type": "library", "license": "MIT", "require": { diff --git a/src/DeploymentPools.php b/src/DeploymentPools.php new file mode 100644 index 0000000..380f93a --- /dev/null +++ b/src/DeploymentPools.php @@ -0,0 +1,146 @@ +transport = new Transport($client); + } + + public function list(): PoolList + { + return PoolList::fromArray($this->transport->json(Method::GET, '/v1/deployment-pools')); + } + + public function get(string $poolId): Pool + { + return Pool::fromArray($this->transport->json(Method::GET, $this->path($poolId))); + } + + /** + * Claim a warm pod and run a command on it, returning once the workload is + * serving on its URL. + * + * Set `async` to get an accepted activation back immediately instead; the + * result then arrives at your callback as an + * `orchestrator.pool.activation.result` event, which is why async requires + * one — nothing is stored to poll in the meantime. + * + * @param string|null $id Choosing one buys idempotency: re-activating a live id is a 409. + * @param string|null $host Defaults to `{id}.{pool domain}`. + * @param array $environment + * @param list $artifacts + * @param int|null $idleTimeoutSeconds Tear down after this long with no traffic; 0 = until deactivate(). + */ + public function activate( + string $poolId, + string $command, + ?string $id = null, + ?string $host = null, + array $environment = [], + array $artifacts = [], + ?int $timeoutSeconds = null, + ?int $idleTimeoutSeconds = null, + ?Callback $callback = null, + bool $async = false, + ): Activation { + if ($async && ! $callback instanceof Callback) { + throw new ClientException('An async activation requires a callback to deliver its result to.'); + } + + $payload = ['command' => $command]; + + if ($id !== null && $id !== '') { + $payload['id'] = $id; + } + + if ($host !== null && $host !== '') { + $payload['host'] = $host; + } + + if ($environment !== []) { + $payload['environment'] = $environment; + } + + if ($artifacts !== []) { + $payload['artifacts'] = \array_map(static fn (Artifact $artifact): array => $artifact->toArray(), $artifacts); + } + + if ($timeoutSeconds !== null) { + $payload['timeoutSeconds'] = $timeoutSeconds; + } + + if ($idleTimeoutSeconds !== null) { + $payload['idleTimeoutSeconds'] = $idleTimeoutSeconds; + } + + if ($callback instanceof Callback) { + $payload['callback'] = $callback->toArray(); + } + + return Activation::fromArray($this->transport->json( + Method::POST, + $this->activationsPath($poolId), + $payload, + $async ? ['Prefer' => 'respond-async'] : [], + )); + } + + public function activations(string $poolId): ActivationList + { + return ActivationList::fromArray($this->transport->json(Method::GET, $this->activationsPath($poolId))); + } + + public function activation(string $poolId, string $activationId): Activation + { + return Activation::fromArray($this->transport->json(Method::GET, $this->activationPath($poolId, $activationId))); + } + + /** + * Tear an activation down. The pod is discarded rather than reused, and the + * pool replenishes with a fresh one. + */ + public function deactivate(string $poolId, string $activationId): void + { + $this->transport->discard(Method::DELETE, $this->activationPath($poolId, $activationId)); + } + + private function path(string $poolId): string + { + return '/v1/deployment-pools/'.\rawurlencode($poolId); + } + + private function activationsPath(string $poolId): string + { + return $this->path($poolId).'/activations'; + } + + private function activationPath(string $poolId, string $activationId): string + { + return $this->activationsPath($poolId).'/'.\rawurlencode($activationId); + } +} diff --git a/src/Deployments.php b/src/Deployments.php new file mode 100644 index 0000000..d6feec0 --- /dev/null +++ b/src/Deployments.php @@ -0,0 +1,201 @@ +transport = new Transport($client); + } + + /** + * Declaratively create or update a deployment. + * + * Applying a changed spec for an existing id rolls out a new revision, which + * takes traffic once it reports ready; re-applying an identical spec is a + * no-op. + * + * @param int $port The container port serving HTTP. + * @param list $hosts `hosts[0]` is the primary; defaults to `{id}.{domain}`. + * @param array $meta + * @param array $environment + * @param list $artifacts + * @param list $volumes + * @param int|null $concurrency Hard per-replica in-flight cap; 0 = unlimited. + */ + public function apply( + string $id, + string $image, + int $port, + ?string $command = null, + float $cpu = 1.0, + int $memory = 512, + string $workspace = '/workspace', + array $hosts = [], + array $meta = [], + array $environment = [], + array $artifacts = [], + array $volumes = [], + ?int $replicas = null, + ?int $concurrency = null, + ?Autoscaling $autoscaling = null, + ?Probes $probes = null, + ?Callback $callback = null, + ?RuntimeClass $runtimeClass = null, + ?int $timeoutSeconds = null, + ?int $startTimeoutSeconds = null, + ?int $readyTimeoutSeconds = null, + ): DeploymentStatus { + $payload = [ + 'id' => $id, + 'image' => $image, + 'port' => $port, + 'cpu' => $cpu, + 'memory' => $memory, + 'workspace' => $workspace, + ]; + + if ($command !== null && $command !== '') { + $payload['command'] = $command; + } + + if ($hosts !== []) { + $payload['hosts'] = $hosts; + } + + if ($meta !== []) { + $payload['meta'] = $meta; + } + + if ($environment !== []) { + $payload['environment'] = $environment; + } + + if ($artifacts !== []) { + $payload['artifacts'] = \array_map(static fn (Artifact $artifact): array => $artifact->toArray(), $artifacts); + } + + if ($volumes !== []) { + $payload['volumes'] = \array_map(static fn (Volume $volume): array => $volume->toArray(), $volumes); + } + + if ($replicas !== null) { + $payload['replicas'] = $replicas; + } + + if ($concurrency !== null) { + $payload['concurrency'] = $concurrency; + } + + if ($autoscaling instanceof Autoscaling) { + $payload['autoscaling'] = $autoscaling->toArray(); + } + + if ($probes instanceof Probes) { + $payload['probes'] = $probes->toArray(); + } + + if ($callback instanceof Callback) { + $payload['callback'] = $callback->toArray(); + } + + if ($runtimeClass instanceof RuntimeClass) { + $payload['runtimeClass'] = $runtimeClass->value; + } + + if ($timeoutSeconds !== null) { + $payload['timeoutSeconds'] = $timeoutSeconds; + } + + if ($startTimeoutSeconds !== null) { + $payload['startTimeoutSeconds'] = $startTimeoutSeconds; + } + + if ($readyTimeoutSeconds !== null) { + $payload['readyTimeoutSeconds'] = $readyTimeoutSeconds; + } + + return DeploymentStatus::fromArray($this->transport->json(Method::POST, '/v1/deployments', $payload)); + } + + public function get(string $deploymentId): DeploymentStatus + { + return DeploymentStatus::fromArray($this->transport->json(Method::GET, $this->path($deploymentId))); + } + + public function list(): DeploymentList + { + return DeploymentList::fromArray($this->transport->json(Method::GET, '/v1/deployments')); + } + + public function delete(string $deploymentId): void + { + $this->transport->discard(Method::DELETE, $this->path($deploymentId)); + } + + /** + * List a deployment's revisions, newest first, with the traffic table + * currently in force. + */ + public function revisions(string $deploymentId): RevisionList + { + return RevisionList::fromArray($this->transport->json(Method::GET, $this->path($deploymentId).'/revisions')); + } + + /** + * Pin an explicit traffic split across existing revisions — a canary, a + * blue-green cut, or a rollback. Percents must sum to 100. + * + * Setting a split switches the deployment to manual mode: new revisions + * still roll out, but traffic stays where you put it until you release(). + * + * @param list $targets + */ + public function setTraffic(string $deploymentId, array $targets): DeploymentStatus + { + return DeploymentStatus::fromArray($this->transport->json( + Method::POST, + $this->path($deploymentId).'/traffic', + ['targets' => \array_map(static fn (TrafficTarget $target): array => $target->toArray(), $targets)], + )); + } + + /** + * Release traffic back to auto mode: 100% on the latest revision, with + * auto-cut on new revisions re-armed. + */ + public function release(string $deploymentId): DeploymentStatus + { + return $this->setTraffic($deploymentId, []); + } + + private function path(string $deploymentId): string + { + return '/v1/deployments/'.\rawurlencode($deploymentId); + } +} diff --git a/src/Enum/ActivationState.php b/src/Enum/ActivationState.php new file mode 100644 index 0000000..5d814fc --- /dev/null +++ b/src/Enum/ActivationState.php @@ -0,0 +1,13 @@ +factory = new RequestFactory; + $this->transport = new Transport($client); } /** @@ -83,97 +75,21 @@ public function create( $payload['callback'] = $callback->toArray(); } - return JobCreated::fromArray($this->json(Method::POST, '/v1/jobs', $payload)); + return JobCreated::fromArray($this->transport->json(Method::POST, '/v1/jobs', $payload)); } public function get(string $jobId): JobStatus { - return JobStatus::fromArray($this->json(Method::GET, '/v1/jobs/'.\rawurlencode($jobId))); + return JobStatus::fromArray($this->transport->json(Method::GET, '/v1/jobs/'.\rawurlencode($jobId))); } public function list(): JobList { - return JobList::fromArray($this->json(Method::GET, '/v1/jobs')); + return JobList::fromArray($this->transport->json(Method::GET, '/v1/jobs')); } public function delete(string $jobId): void { - $this->assertSuccess($this->send(Method::DELETE, '/v1/jobs/'.\rawurlencode($jobId))); - } - - /** - * @param array|null $payload - * @return array - */ - private function json(string $method, string $path, ?array $payload = null): array - { - $response = $this->send($method, $path, $payload); - $this->assertSuccess($response); - - $body = (string) $response->getBody(); - if ($body === '') { - return []; - } - - try { - $decoded = \json_decode($body, true, flags: JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new ClientException("Failed to decode orchestrator response: {$e->getMessage()}", $response->getStatusCode()); - } - - if (! \is_array($decoded)) { - throw new ClientException('Orchestrator response was not a JSON object.', $response->getStatusCode()); - } - - /** @var array $decoded */ - return $decoded; - } - - /** - * @param array|null $payload - */ - private function send(string $method, string $path, ?array $payload = null): ResponseInterface - { - try { - $request = $payload === null - ? $this->factory->createRequest($method, $path) - : $this->factory->json($method, $path, $payload); - } catch (JsonException $e) { - throw new ClientException("Failed to encode orchestrator request: {$e->getMessage()}"); - } - - try { - return $this->client->sendRequest($request); - } catch (TransportTimeoutException $e) { - throw new TimeoutException(previous: $e); - } catch (ClientExceptionInterface $e) { - throw new ClientException($e->getMessage(), previous: $e); - } - } - - private function assertSuccess(ResponseInterface $response): void - { - $status = $response->getStatusCode(); - if ($status >= 200 && $status < 300) { - return; - } - - $body = (string) $response->getBody(); - $message = $body === '' ? 'Orchestrator request failed.' : $body; - $decoded = null; - - try { - $candidate = \json_decode($body, true, flags: JSON_THROW_ON_ERROR); - if (\is_array($candidate)) { - /** @var array $candidate */ - $decoded = $candidate; - if (isset($candidate['error']) && \is_string($candidate['error'])) { - $message = $candidate['error']; - } - } - } catch (JsonException) { - } - - throw new ApiException($message, $status, $body, $decoded); + $this->transport->discard(Method::DELETE, '/v1/jobs/'.\rawurlencode($jobId)); } } diff --git a/src/Model/Activation.php b/src/Model/Activation.php new file mode 100644 index 0000000..099a0c3 --- /dev/null +++ b/src/Model/Activation.php @@ -0,0 +1,41 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + poolId: Data::string($data, 'poolId', 'activation'), + status: Data::enum($data, 'status', ActivationState::class, 'activation'), + id: Data::optionalString($data, 'id', 'activation'), + url: Data::optionalString($data, 'url', 'activation'), + podId: Data::optionalString($data, 'podId', 'activation'), + error: Data::optionalString($data, 'error', 'activation'), + ); + } +} diff --git a/src/Model/ActivationList.php b/src/Model/ActivationList.php new file mode 100644 index 0000000..5e74cc7 --- /dev/null +++ b/src/Model/ActivationList.php @@ -0,0 +1,24 @@ + $activations + */ + public function __construct(public array $activations) {} + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self(\array_map( + Activation::fromArray(...), + Data::objects($data, 'activations', 'activation list'), + )); + } +} diff --git a/src/Model/Artifact/MountArtifact.php b/src/Model/Artifact/MountArtifact.php index f0f73f7..1f981cd 100644 --- a/src/Model/Artifact/MountArtifact.php +++ b/src/Model/Artifact/MountArtifact.php @@ -17,6 +17,8 @@ public function __construct( public bool $writable = false, public ?int $size = null, public ?string $depends = null, + public ?string $sync = null, + public ?int $syncIntervalSeconds = null, ) {} public function type(): ArtifactType @@ -39,6 +41,14 @@ public function toArray(): array $data['size'] = $this->size; } + if ($this->sync !== null && $this->sync !== '') { + $data['sync'] = $this->sync; + } + + if ($this->syncIntervalSeconds !== null) { + $data['syncIntervalSeconds'] = $this->syncIntervalSeconds; + } + return $data; } } diff --git a/src/Model/Autoscaling.php b/src/Model/Autoscaling.php new file mode 100644 index 0000000..7fbf216 --- /dev/null +++ b/src/Model/Autoscaling.php @@ -0,0 +1,33 @@ + $this->minReplicas]; + + if ($this->maxReplicas !== null) { + $data['maxReplicas'] = $this->maxReplicas; + } + + if ($this->target !== null) { + $data['target'] = $this->target; + } + + return $data; + } +} diff --git a/src/Model/Data.php b/src/Model/Data.php new file mode 100644 index 0000000..3965bf1 --- /dev/null +++ b/src/Model/Data.php @@ -0,0 +1,153 @@ + $data + */ + public static function string(array $data, string $key, string $context): string + { + $value = $data[$key] ?? null; + if (! \is_string($value) || $value === '') { + throw new ClientException("Invalid {$context}: missing string {$key}."); + } + + return $value; + } + + /** + * @param array $data + */ + public static function optionalString(array $data, string $key, string $context): ?string + { + $value = $data[$key] ?? null; + if ($value === null) { + return null; + } + + if (! \is_string($value)) { + throw new ClientException("Invalid {$context}: {$key} must be a string."); + } + + return $value === '' ? null : $value; + } + + /** + * @param array $data + */ + public static function int(array $data, string $key, string $context, int $default = 0): int + { + return self::optionalInt($data, $key, $context) ?? $default; + } + + /** + * @param array $data + */ + public static function optionalInt(array $data, string $key, string $context): ?int + { + $value = $data[$key] ?? null; + if ($value === null) { + return null; + } + + if (! \is_int($value)) { + throw new ClientException("Invalid {$context}: {$key} must be an integer."); + } + + return $value; + } + + /** + * @param array $data + * @return list + */ + public static function strings(array $data, string $key, string $context): array + { + $value = $data[$key] ?? []; + if (! \is_array($value)) { + throw new ClientException("Invalid {$context}: {$key} must be an array of strings."); + } + + foreach ($value as $item) { + if (! \is_string($item)) { + throw new ClientException("Invalid {$context}: {$key} must be an array of strings."); + } + } + + /** @var list */ + return \array_values($value); + } + + /** + * @param array $data + * @return array + */ + public static function stringMap(array $data, string $key, string $context): array + { + $value = $data[$key] ?? []; + if (! \is_array($value)) { + throw new ClientException("Invalid {$context}: {$key} must be an object of strings."); + } + + foreach ($value as $item) { + if (! \is_string($item)) { + throw new ClientException("Invalid {$context}: {$key} must be an object of strings."); + } + } + + return $value; + } + + /** + * @param array $data + * @return list> + */ + public static function objects(array $data, string $key, string $context): array + { + $value = $data[$key] ?? []; + if (! \is_array($value)) { + throw new ClientException("Invalid {$context}: {$key} must be an array of objects."); + } + + foreach ($value as $item) { + if (! \is_array($item)) { + throw new ClientException("Invalid {$context}: each entry of {$key} must be an object."); + } + } + + /** @var list> */ + return \array_values($value); + } + + /** + * @template T of BackedEnum + * + * @param array $data + * @param class-string $enum + * @return T + */ + public static function enum(array $data, string $key, string $enum, string $context): BackedEnum + { + $value = self::string($data, $key, $context); + $case = $enum::tryFrom($value); + + if ($case === null) { + throw new ClientException("Invalid {$context}: unknown {$key} \"{$value}\"."); + } + + return $case; + } +} diff --git a/src/Model/DeploymentList.php b/src/Model/DeploymentList.php new file mode 100644 index 0000000..fad40dc --- /dev/null +++ b/src/Model/DeploymentList.php @@ -0,0 +1,24 @@ + $deployments + */ + public function __construct(public array $deployments) {} + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self(\array_map( + DeploymentStatus::fromArray(...), + Data::objects($data, 'deployments', 'deployment list'), + )); + } +} diff --git a/src/Model/DeploymentStatus.php b/src/Model/DeploymentStatus.php new file mode 100644 index 0000000..723ea62 --- /dev/null +++ b/src/Model/DeploymentStatus.php @@ -0,0 +1,47 @@ + $revisions Newest first; empty on the Docker backend, which is single-revision. + * @param list $traffic + */ + public function __construct( + public string $id, + public DeploymentState $status, + public string $url, + public int $desiredReplicas = 0, + public int $availableReplicas = 0, + public array $revisions = [], + public array $traffic = [], + public ?TrafficMode $mode = null, + public ?string $error = null, + ) {} + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $mode = Data::optionalString($data, 'mode', 'deployment status'); + + return new self( + id: Data::string($data, 'id', 'deployment status'), + status: Data::enum($data, 'status', DeploymentState::class, 'deployment status'), + url: Data::optionalString($data, 'url', 'deployment status') ?? '', + desiredReplicas: Data::int($data, 'desiredReplicas', 'deployment status'), + availableReplicas: Data::int($data, 'availableReplicas', 'deployment status'), + revisions: Data::strings($data, 'revisions', 'deployment status'), + traffic: \array_map(TrafficTarget::fromArray(...), Data::objects($data, 'traffic', 'deployment status')), + mode: $mode === null ? null : TrafficMode::tryFrom($mode), + error: Data::optionalString($data, 'error', 'deployment status'), + ); + } +} diff --git a/src/Model/Pool.php b/src/Model/Pool.php new file mode 100644 index 0000000..02488af --- /dev/null +++ b/src/Model/Pool.php @@ -0,0 +1,39 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + id: Data::string($data, 'id', 'pool'), + image: Data::string($data, 'image', 'pool'), + size: Data::int($data, 'size', 'pool'), + warm: Data::int($data, 'warm', 'pool'), + claimed: Data::int($data, 'claimed', 'pool'), + ); + } +} diff --git a/src/Model/PoolList.php b/src/Model/PoolList.php new file mode 100644 index 0000000..8ed8dfb --- /dev/null +++ b/src/Model/PoolList.php @@ -0,0 +1,21 @@ + $pools + */ + public function __construct(public array $pools) {} + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self(\array_map(Pool::fromArray(...), Data::objects($data, 'pools', 'pool list'))); + } +} diff --git a/src/Model/Probe.php b/src/Model/Probe.php new file mode 100644 index 0000000..d27f49f --- /dev/null +++ b/src/Model/Probe.php @@ -0,0 +1,42 @@ +path !== null && $this->path !== '') { + $data['path'] = $this->path; + } + + if ($this->periodMillis !== null) { + $data['periodMillis'] = $this->periodMillis; + } + + if ($this->timeoutMillis !== null) { + $data['timeoutMillis'] = $this->timeoutMillis; + } + + if ($this->failureThreshold !== null) { + $data['failureThreshold'] = $this->failureThreshold; + } + + return $data; + } +} diff --git a/src/Model/Probes.php b/src/Model/Probes.php new file mode 100644 index 0000000..b131d6f --- /dev/null +++ b/src/Model/Probes.php @@ -0,0 +1,38 @@ +readiness instanceof Probe) { + $data['readiness'] = $this->readiness->toArray(); + } + + if ($this->liveness instanceof Probe) { + $data['liveness'] = $this->liveness->toArray(); + } + + if ($this->startup instanceof Probe) { + $data['startup'] = $this->startup->toArray(); + } + + return $data; + } +} diff --git a/src/Model/RevisionList.php b/src/Model/RevisionList.php new file mode 100644 index 0000000..4b1606a --- /dev/null +++ b/src/Model/RevisionList.php @@ -0,0 +1,32 @@ + $revisions + * @param list $traffic + */ + public function __construct( + public array $revisions, + public array $traffic = [], + ) {} + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + revisions: Data::strings($data, 'revisions', 'revision list'), + traffic: \array_map(TrafficTarget::fromArray(...), Data::objects($data, 'traffic', 'revision list')), + ); + } +} diff --git a/src/Model/SandboxList.php b/src/Model/SandboxList.php new file mode 100644 index 0000000..379fd8b --- /dev/null +++ b/src/Model/SandboxList.php @@ -0,0 +1,24 @@ + $sandboxes + */ + public function __construct(public array $sandboxes) {} + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self(\array_map( + SandboxStatus::fromArray(...), + Data::objects($data, 'sandboxes', 'sandbox list'), + )); + } +} diff --git a/src/Model/SandboxStatus.php b/src/Model/SandboxStatus.php new file mode 100644 index 0000000..66a205a --- /dev/null +++ b/src/Model/SandboxStatus.php @@ -0,0 +1,42 @@ + $urls Every port the sandbox serves, keyed by port number. + */ + public function __construct( + public string $id, + public SandboxState $status, + public ?string $poolId = null, + public ?string $url = null, + public array $urls = [], + public ?string $error = null, + ) {} + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + id: Data::string($data, 'id', 'sandbox status'), + status: Data::enum($data, 'status', SandboxState::class, 'sandbox status'), + poolId: Data::optionalString($data, 'poolId', 'sandbox status'), + url: Data::optionalString($data, 'url', 'sandbox status'), + urls: Data::stringMap($data, 'urls', 'sandbox status'), + error: Data::optionalString($data, 'error', 'sandbox status'), + ); + } +} diff --git a/src/Model/TrafficTarget.php b/src/Model/TrafficTarget.php new file mode 100644 index 0000000..1860e2c --- /dev/null +++ b/src/Model/TrafficTarget.php @@ -0,0 +1,36 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + revisionName: Data::string($data, 'revisionName', 'traffic target'), + percent: Data::int($data, 'percent', 'traffic target'), + ); + } + + public function toArray(): array + { + return [ + 'revisionName' => $this->revisionName, + 'percent' => $this->percent, + ]; + } +} diff --git a/src/Sandboxes.php b/src/Sandboxes.php new file mode 100644 index 0000000..b73d725 --- /dev/null +++ b/src/Sandboxes.php @@ -0,0 +1,171 @@ +transport = new Transport($client); + } + + /** + * Create a sandbox, returning once it is ready or failed. + * + * Pass exactly one of `pool` or `image`. Naming a pool claims an already + * running pod, so the create is sub-second; naming an image builds a pod for + * this request instead — no standing capacity to configure, at the cost of a + * cold start, and `cpu`, `memory`, `runtimeClass` and `volumes` become + * yours to set rather than the pool's. + * + * @param int|null $port Where the contract is served. Required with `image`. + * @param list $ports Extra ports to expose, each at its own hostname. + * @param array $environment + * @param list $artifacts Materialized into the workspace before the sandbox reports ready. + * @param list $volumes Poolless sandboxes only; on a pool, volumes are a pool dimension. + * @param int|null $timeoutSeconds Bounds each request to the sandbox; 0 removes the bound, which + * long-lived sessions such as terminals and LSP need. + * @param int|null $idleTimeoutSeconds Tear down after this long with no traffic; 0 = until delete(). + */ + public function create( + ?string $pool = null, + ?string $image = null, + ?int $port = null, + ?string $id = null, + ?string $command = null, + array $environment = [], + array $ports = [], + array $artifacts = [], + array $volumes = [], + ?float $cpu = null, + ?int $memory = null, + ?RuntimeClass $runtimeClass = null, + ?int $timeoutSeconds = null, + ?int $idleTimeoutSeconds = null, + ): SandboxStatus { + if (($pool === null) === ($image === null)) { + throw new ClientException('Creating a sandbox takes exactly one of pool or image.'); + } + + if ($image !== null && $port === null) { + throw new ClientException('Creating a sandbox from an image requires a port.'); + } + + $payload = []; + + if ($pool !== null) { + $payload['pool'] = $pool; + } + + if ($image !== null) { + $payload['image'] = $image; + } + + if ($port !== null) { + $payload['port'] = $port; + } + + if ($id !== null && $id !== '') { + $payload['id'] = $id; + } + + if ($command !== null && $command !== '') { + $payload['command'] = $command; + } + + if ($environment !== []) { + $payload['environment'] = $environment; + } + + if ($ports !== []) { + $payload['ports'] = $ports; + } + + if ($artifacts !== []) { + $payload['artifacts'] = \array_map(static fn (Artifact $artifact): array => $artifact->toArray(), $artifacts); + } + + if ($volumes !== []) { + $payload['volumes'] = \array_map(static fn (Volume $volume): array => $volume->toArray(), $volumes); + } + + if ($cpu !== null) { + $payload['cpu'] = $cpu; + } + + if ($memory !== null) { + $payload['memory'] = $memory; + } + + if ($runtimeClass instanceof RuntimeClass) { + $payload['runtimeClass'] = $runtimeClass->value; + } + + if ($timeoutSeconds !== null) { + $payload['timeoutSeconds'] = $timeoutSeconds; + } + + if ($idleTimeoutSeconds !== null) { + $payload['idleTimeoutSeconds'] = $idleTimeoutSeconds; + } + + return SandboxStatus::fromArray($this->transport->json(Method::POST, '/v1/sandbox', $payload)); + } + + public function get(string $sandboxId): SandboxStatus + { + return SandboxStatus::fromArray($this->transport->json(Method::GET, '/v1/sandbox/'.\rawurlencode($sandboxId))); + } + + public function list(): SandboxList + { + return SandboxList::fromArray($this->transport->json(Method::GET, '/v1/sandbox')); + } + + /** + * Tear a sandbox down. This invalidates its URL immediately, before the pod + * has finished terminating. + */ + public function delete(string $sandboxId): void + { + $this->transport->discard(Method::DELETE, '/v1/sandbox/'.\rawurlencode($sandboxId)); + } + + /** + * List the configured sandbox pools with their live warm and claimed counts. + */ + public function pools(): PoolList + { + return PoolList::fromArray($this->transport->json(Method::GET, '/v1/sandbox-pool')); + } + + public function pool(string $poolId): Pool + { + return Pool::fromArray($this->transport->json(Method::GET, '/v1/sandbox-pool/'.\rawurlencode($poolId))); + } +} diff --git a/src/Transport.php b/src/Transport.php new file mode 100644 index 0000000..55b2443 --- /dev/null +++ b/src/Transport.php @@ -0,0 +1,122 @@ +factory = new RequestFactory; + } + + /** + * @param array|null $payload + * @param array $headers + * @return array + */ + public function json(string $method, string $path, ?array $payload = null, array $headers = []): array + { + $response = $this->send($method, $path, $payload, $headers); + $this->assertSuccess($response); + + $body = (string) $response->getBody(); + if ($body === '') { + return []; + } + + try { + $decoded = \json_decode($body, true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new ClientException("Failed to decode orchestrator response: {$e->getMessage()}", $response->getStatusCode()); + } + + if (! \is_array($decoded)) { + throw new ClientException('Orchestrator response was not a JSON object.', $response->getStatusCode()); + } + + /** @var array $decoded */ + return $decoded; + } + + /** + * Send a request whose response body carries nothing worth decoding. + */ + public function discard(string $method, string $path): void + { + $this->assertSuccess($this->send($method, $path)); + } + + /** + * @param array|null $payload + * @param array $headers + */ + private function send(string $method, string $path, ?array $payload = null, array $headers = []): ResponseInterface + { + try { + $request = $payload === null + ? $this->factory->createRequest($method, $path) + : $this->factory->json($method, $path, $payload); + } catch (JsonException $e) { + throw new ClientException("Failed to encode orchestrator request: {$e->getMessage()}"); + } + + foreach ($headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + try { + return $this->client->sendRequest($request); + } catch (TransportTimeoutException $e) { + throw new TimeoutException(previous: $e); + } catch (ClientExceptionInterface $e) { + throw new ClientException($e->getMessage(), previous: $e); + } + } + + private function assertSuccess(ResponseInterface $response): void + { + $status = $response->getStatusCode(); + if ($status >= 200 && $status < 300) { + return; + } + + $body = (string) $response->getBody(); + $message = $body === '' ? 'Orchestrator request failed.' : $body; + $decoded = null; + + try { + $candidate = \json_decode($body, true, flags: JSON_THROW_ON_ERROR); + if (\is_array($candidate)) { + /** @var array $candidate */ + $decoded = $candidate; + if (isset($candidate['error']) && \is_string($candidate['error'])) { + $message = $candidate['error']; + } + } + } catch (JsonException) { + } + + throw new ApiException($message, $status, $body, $decoded); + } +} diff --git a/tests/DeploymentPoolsTest.php b/tests/DeploymentPoolsTest.php new file mode 100644 index 0000000..4a684a4 --- /dev/null +++ b/tests/DeploymentPoolsTest.php @@ -0,0 +1,122 @@ +assertSame('node', $pools->list()->pools[0]->id); + $this->assertSame(2, $pools->get('node')->claimed); + $this->assertSame('/v1/deployment-pools', (string) $http->requests[0]->getUri()); + $this->assertSame('/v1/deployment-pools/node', (string) $http->requests[1]->getUri()); + } + + public function test_activate_claims_a_warm_pod(): void + { + $http = new Client([new Response(201, body: new Stream( + '{"id":"preview-7","poolId":"node","status":"ready","url":"http://preview-7.pools.test","podId":"pod-abc"}' + ))]); + + $activation = new DeploymentPools($http)->activate( + poolId: 'node', + command: 'node server.js', + id: 'preview-7', + environment: ['NODE_ENV' => 'production'], + artifacts: [new DownloadArtifact('code', 'https://acme.test/b.tar.gz', 'b.tar.gz')], + idleTimeoutSeconds: 600, + ); + + $this->assertSame('preview-7', $activation->id); + $this->assertSame('node', $activation->poolId); + $this->assertSame(ActivationState::Ready, $activation->status); + $this->assertSame('http://preview-7.pools.test', $activation->url); + $this->assertSame('pod-abc', $activation->podId); + + $request = $http->requests[0]; + $this->assertSame('/v1/deployment-pools/node/activations', (string) $request->getUri()); + $this->assertFalse($request->hasHeader('Prefer')); + $this->assertJsonStringEqualsJsonString( + '{"command":"node server.js","id":"preview-7","environment":{"NODE_ENV":"production"},' + .'"artifacts":[{"id":"code","type":"download","in":"https://acme.test/b.tar.gz","out":"b.tar.gz"}],' + .'"idleTimeoutSeconds":600}', + (string) $request->getBody(), + ); + } + + public function test_async_activation_sends_prefer_and_accepts_a_status_without_an_id(): void + { + $http = new Client([new Response(202, body: new Stream('{"poolId":"node","status":"activating"}'))]); + + $activation = new DeploymentPools($http)->activate( + poolId: 'node', + command: 'node server.js', + callback: new Callback( + url: 'https://acme.test/hook', + events: [CallbackEvent::PoolActivationResult], + key: 'secret', + ), + async: true, + ); + + $this->assertNull($activation->id); + $this->assertSame(ActivationState::Activating, $activation->status); + $this->assertSame('respond-async', $http->requests[0]->getHeaderLine('Prefer')); + $this->assertJsonStringEqualsJsonString( + '{"command":"node server.js","callback":{"url":"https://acme.test/hook",' + .'"events":["orchestrator.pool.activation.result"],"key":"secret"}}', + (string) $http->requests[0]->getBody(), + ); + } + + public function test_async_activation_requires_a_callback(): void + { + $http = new Client; + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('async activation requires a callback'); + + new DeploymentPools($http)->activate(poolId: 'node', command: 'node server.js', async: true); + } + + public function test_read_and_deactivate_activations(): void + { + $http = new Client([ + new Response(200, body: new Stream('{"activations":[{"id":"preview-7","poolId":"node","status":"ready","url":"http://preview-7.pools.test"}]}')), + new Response(200, body: new Stream('{"id":"preview-7","poolId":"node","status":"failed","error":"workload exited"}')), + new Response(204), + ]); + $pools = new DeploymentPools($http); + + $this->assertCount(1, $pools->activations('node')->activations); + + $activation = $pools->activation('node', 'preview-7'); + $this->assertSame(ActivationState::Failed, $activation->status); + $this->assertSame('workload exited', $activation->error); + $this->assertNull($activation->url); + + $pools->deactivate('node', 'preview-7'); + + $this->assertSame('/v1/deployment-pools/node/activations', (string) $http->requests[0]->getUri()); + $this->assertSame('/v1/deployment-pools/node/activations/preview-7', (string) $http->requests[1]->getUri()); + $this->assertSame('DELETE', $http->requests[2]->getMethod()); + } +} diff --git a/tests/DeploymentsTest.php b/tests/DeploymentsTest.php new file mode 100644 index 0000000..892b165 --- /dev/null +++ b/tests/DeploymentsTest.php @@ -0,0 +1,166 @@ +apply(id: 'web', image: 'traefik/whoami', port: 80); + + $this->assertSame('web', $status->id); + $this->assertSame(DeploymentState::Pending, $status->status); + $this->assertSame('http://web.localhost', $status->url); + $this->assertSame(['web-00001'], $status->revisions); + $this->assertSame(TrafficMode::Auto, $status->mode); + $this->assertSame(1, $status->desiredReplicas); + $this->assertSame(0, $status->availableReplicas); + $this->assertCount(1, $status->traffic); + $this->assertSame('web-00001', $status->traffic[0]->revisionName); + $this->assertSame(100, $status->traffic[0]->percent); + + $request = $http->requests[0]; + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('/v1/deployments', (string) $request->getUri()); + $this->assertJsonStringEqualsJsonString( + '{"id":"web","image":"traefik/whoami","port":80,"cpu":1,"memory":512,"workspace":"/workspace"}', + (string) $request->getBody(), + ); + } + + public function test_apply_serializes_the_full_spec(): void + { + $http = new Client([new Response(200, body: new Stream('{"id":"api","status":"ready","url":"http://api.test"}'))]); + + new Deployments($http)->apply( + id: 'api', + image: 'ghcr.io/acme/api:v3', + port: 8080, + command: 'server --flag', + cpu: 0.5, + memory: 1024, + hosts: ['acme.com', 'www.acme.com'], + meta: ['team' => 'core'], + environment: ['LOG' => 'debug'], + artifacts: [new DownloadArtifact('cfg', 'https://acme.test/c.yaml', 'config.yaml')], + replicas: 2, + concurrency: 50, + autoscaling: new Autoscaling(minReplicas: 0, maxReplicas: 10, target: 100), + probes: new Probes(readiness: new Probe(path: '/healthz', periodMillis: 500)), + callback: new Callback(url: 'https://acme.test/hook', key: 'secret'), + runtimeClass: RuntimeClass::Gvisor, + timeoutSeconds: 300, + startTimeoutSeconds: 120, + readyTimeoutSeconds: 600, + ); + + $this->assertJsonStringEqualsJsonString( + '{"id":"api","image":"ghcr.io/acme/api:v3","port":8080,"cpu":0.5,"memory":1024,' + .'"workspace":"/workspace","command":"server --flag","hosts":["acme.com","www.acme.com"],' + .'"meta":{"team":"core"},"environment":{"LOG":"debug"},' + .'"artifacts":[{"id":"cfg","type":"download","in":"https://acme.test/c.yaml","out":"config.yaml"}],' + .'"replicas":2,"concurrency":50,"autoscaling":{"minReplicas":0,"maxReplicas":10,"target":100},' + .'"probes":{"readiness":{"path":"/healthz","periodMillis":500}},' + .'"callback":{"url":"https://acme.test/hook","events":[],"key":"secret"},' + .'"runtimeClass":"gvisor","timeoutSeconds":300,"startTimeoutSeconds":120,"readyTimeoutSeconds":600}', + (string) $http->requests[0]->getBody(), + ); + } + + public function test_get_list_and_delete(): void + { + $http = new Client([ + new Response(200, body: new Stream('{"id":"web","status":"idle","url":"http://web.localhost","desiredReplicas":0,"availableReplicas":0}')), + new Response(200, body: new Stream('{"deployments":[{"id":"web","status":"ready","url":"http://web.localhost"}]}')), + new Response(204), + ]); + $deployments = new Deployments($http); + + $status = $deployments->get('web'); + $this->assertSame(DeploymentState::Idle, $status->status); + $this->assertNotInstanceOf(TrafficMode::class, $status->mode); + + $list = $deployments->list(); + $this->assertCount(1, $list->deployments); + $this->assertSame(DeploymentState::Ready, $list->deployments[0]->status); + + $deployments->delete('web'); + + $this->assertSame('/v1/deployments/web', (string) $http->requests[0]->getUri()); + $this->assertSame('/v1/deployments', (string) $http->requests[1]->getUri()); + $this->assertSame('DELETE', $http->requests[2]->getMethod()); + } + + public function test_revisions_returns_history_and_traffic(): void + { + $http = new Client([new Response(200, body: new Stream( + '{"revisions":["web-00002","web-00001"],"traffic":[{"revisionName":"web-00002","percent":100}]}' + ))]); + + $revisions = new Deployments($http)->revisions('web'); + + $this->assertSame(['web-00002', 'web-00001'], $revisions->revisions); + $this->assertSame('web-00002', $revisions->traffic[0]->revisionName); + $this->assertSame('/v1/deployments/web/revisions', (string) $http->requests[0]->getUri()); + } + + public function test_set_traffic_pins_a_canary_split(): void + { + $http = new Client([new Response(200, body: new Stream( + '{"id":"web","status":"ready","url":"http://web.localhost","mode":"manual"}' + ))]); + + $status = new Deployments($http)->setTraffic('web', [ + new TrafficTarget('web-00001', 90), + new TrafficTarget('web-00002', 10), + ]); + + $this->assertSame(TrafficMode::Manual, $status->mode); + $this->assertSame('/v1/deployments/web/traffic', (string) $http->requests[0]->getUri()); + $this->assertJsonStringEqualsJsonString( + '{"targets":[{"revisionName":"web-00001","percent":90},{"revisionName":"web-00002","percent":10}]}', + (string) $http->requests[0]->getBody(), + ); + } + + public function test_release_posts_an_empty_target_list(): void + { + $http = new Client([new Response(200, body: new Stream('{"id":"web","status":"ready","url":"http://web.localhost","mode":"auto"}'))]); + + $this->assertSame(TrafficMode::Auto, new Deployments($http)->release('web')->mode); + $this->assertJsonStringEqualsJsonString('{"targets":[]}', (string) $http->requests[0]->getBody()); + } + + public function test_host_conflict_raises_an_api_exception(): void + { + $http = new Client([new Response(409, body: new Stream('{"error":"host acme.com is owned by deployment other"}'))]); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('host acme.com is owned by deployment other'); + + new Deployments($http)->apply(id: 'web', image: 'nginx', port: 80, hosts: ['acme.com']); + } +} diff --git a/tests/SandboxesTest.php b/tests/SandboxesTest.php new file mode 100644 index 0000000..e391b52 --- /dev/null +++ b/tests/SandboxesTest.php @@ -0,0 +1,158 @@ +create( + pool: 'py', + ports: [5173], + artifacts: [ + new DownloadArtifact('code', 'https://acme.test/app.tar.gz', 'app.tar.gz'), + new UnarchiveArtifact('unpack', 'app.tar.gz', '.', depends: 'code'), + ], + timeoutSeconds: 0, + idleTimeoutSeconds: 900, + ); + + $this->assertSame('py-3f9c1a02', $sandbox->id); + $this->assertSame('py', $sandbox->poolId); + $this->assertSame(SandboxState::Ready, $sandbox->status); + $this->assertSame('http://s-abc.sandboxes.test', $sandbox->url); + $this->assertSame([ + '3000' => 'http://s-abc.sandboxes.test', + '5173' => 'http://s-abc-5173.sandboxes.test', + ], $sandbox->urls); + $this->assertNull($sandbox->error); + + $request = $http->requests[0]; + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('/v1/sandbox', (string) $request->getUri()); + $this->assertJsonStringEqualsJsonString( + '{"pool":"py","ports":[5173],"artifacts":[' + .'{"id":"code","type":"download","in":"https://acme.test/app.tar.gz","out":"app.tar.gz"},' + .'{"id":"unpack","type":"unarchive","depends":"code","in":"app.tar.gz","out":"."}' + .'],"timeoutSeconds":0,"idleTimeoutSeconds":900}', + (string) $request->getBody(), + ); + } + + public function test_create_without_a_pool_sizes_the_pod(): void + { + $http = new Client([new Response(201, body: new Stream('{"id":"sbx-1","status":"ready","url":"http://s-abc.sandboxes.test"}'))]); + + $sandbox = new Sandboxes($http)->create( + image: 'python:3.12-slim', + port: 3000, + cpu: 2.0, + memory: 2048, + runtimeClass: RuntimeClass::Gvisor, + ); + + $this->assertNull($sandbox->poolId); + $this->assertJsonStringEqualsJsonString( + '{"image":"python:3.12-slim","port":3000,"cpu":2,"memory":2048,"runtimeClass":"gvisor"}', + (string) $http->requests[0]->getBody(), + ); + } + + public function test_create_requires_exactly_one_of_pool_or_image(): void + { + $http = new Client; + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('exactly one of pool or image'); + + new Sandboxes($http)->create(pool: 'py', image: 'python:3.12-slim', port: 3000); + } + + public function test_create_from_an_image_requires_a_port(): void + { + $http = new Client; + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('requires a port'); + + new Sandboxes($http)->create(image: 'python:3.12-slim'); + } + + public function test_a_failed_sandbox_is_a_status_not_an_error(): void + { + $http = new Client([new Response(201, body: new Stream( + '{"id":"py-1","poolId":"py","status":"failed","error":"artifact code: 404"}' + ))]); + + $sandbox = new Sandboxes($http)->create(pool: 'py'); + + $this->assertSame(SandboxState::Failed, $sandbox->status); + $this->assertSame('artifact code: 404', $sandbox->error); + $this->assertNull($sandbox->url); + } + + public function test_get_list_and_delete(): void + { + $http = new Client([ + new Response(200, body: new Stream('{"id":"py-1","poolId":"py","status":"creating"}')), + new Response(200, body: new Stream('{"sandboxes":[{"id":"py-1","poolId":"py","status":"ready","url":"http://s-abc.sandboxes.test"}]}')), + new Response(204), + ]); + $sandboxes = new Sandboxes($http); + + $this->assertSame(SandboxState::Creating, $sandboxes->get('py-1')->status); + $this->assertCount(1, $sandboxes->list()->sandboxes); + $sandboxes->delete('py-1'); + + $this->assertSame('/v1/sandbox/py-1', (string) $http->requests[0]->getUri()); + $this->assertSame('/v1/sandbox', (string) $http->requests[1]->getUri()); + $this->assertSame('DELETE', $http->requests[2]->getMethod()); + } + + public function test_pools_are_read_only(): void + { + $http = new Client([ + new Response(200, body: new Stream('{"pools":[{"id":"py","image":"python:3.12-slim","size":4,"warm":4,"claimed":1}]}')), + new Response(200, body: new Stream('{"id":"py","image":"python:3.12-slim","size":4,"warm":3,"claimed":1}')), + ]); + $sandboxes = new Sandboxes($http); + + $pools = $sandboxes->pools(); + $this->assertCount(1, $pools->pools); + $this->assertSame(4, $pools->pools[0]->warm); + + $pool = $sandboxes->pool('py'); + $this->assertSame('python:3.12-slim', $pool->image); + $this->assertSame(3, $pool->warm); + $this->assertSame('/v1/sandbox-pool', (string) $http->requests[0]->getUri()); + $this->assertSame('/v1/sandbox-pool/py', (string) $http->requests[1]->getUri()); + } + + public function test_an_exhausted_rejecting_pool_raises_an_api_exception(): void + { + $http = new Client([new Response(429, body: new Stream('{"error":"pool py has no warm pod"}'))]); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('pool py has no warm pod'); + + new Sandboxes($http)->create(pool: 'py'); + } +}