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
160 changes: 159 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
146 changes: 146 additions & 0 deletions src/DeploymentPools.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?php

declare(strict_types=1);

namespace OpenRuntimes\Orchestrator;

use OpenRuntimes\Orchestrator\Exception\ClientException;
use OpenRuntimes\Orchestrator\Model\Activation;
use OpenRuntimes\Orchestrator\Model\ActivationList;
use OpenRuntimes\Orchestrator\Model\Artifact\Artifact;
use OpenRuntimes\Orchestrator\Model\Callback;
use OpenRuntimes\Orchestrator\Model\Pool;
use OpenRuntimes\Orchestrator\Model\PoolList;
use Psr\Http\Client\ClientInterface;
use Utopia\Client\Adapter\Curl\Client as CurlAdapter;
use Utopia\Client as HttpClient;
use Utopia\Psr7\Method;

/**
* Deployment pools: standing warm capacity, and the activations that claim a
* warm pod and late-bind a payload onto it. Pools themselves are operator
* configuration, so the API over them is read plus activate.
*/
final readonly class DeploymentPools
{
private Transport $transport;

public function __construct(
ClientInterface $client = new HttpClient(new CurlAdapter),
) {
$this->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<string, string> $environment
* @param list<Artifact> $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);
}
}
Loading
Loading