Skip to content
Closed
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
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,43 @@ the package is `0.x` the public API may change in a minor release; see

## [Unreleased]

### Added

- **Secrets by reference.** `ServiceSpec::$secretRefs` — a list of
`SecretReference(variable, secret, key)` — compiles to `valueFrom.secretKeyRef`
on every container of the service (web, processes, runs, the release) at a
Secret the operator created. The value is never in the spec or anything
compiled; a missing Secret or key fails the pod with
`CreateContainerConfigError`, never `optional`. Refused at compile time: names
Kubernetes would refuse, a variable claimed by a reference and by anything
else, and a reference to `<service>-env` or `<service>-registry`.
- **Probes for the web process.** `ServiceSpec::$health` — a
`HealthCheck(readinessPath, livenessPath, tokenVariable)` — compiles a
`startupProbe` and `livenessProbe` (`httpGet` on the liveness path) and a
`readinessProbe`. A readiness token compiles to an `exec` probe that reads the
variable from the container's own environment and sends it through `curl -H @-`,
because an `httpGet` probe can only carry static headers and the token would
otherwise be in the pod spec. Process Deployments, runs and releases carry no
probes. Null compiles none, as before.
- **The release.** `ServiceSpec::$release` (argv) and `ReleaseJobCompiler`
(`Contracts\ReleaseCompiler`): the run Job for the service's release command,
named `<service>-release-<hash of its pod template>` so one release is one Job.
Not part of the service's manifest set, so an applier can run it to completion
before the Deployments roll — see
[the sequence](docs/core-concepts/secrets-probes-releases.md#the-release).
- `CompilesPlatformIntent::compileRelease()`.

### Changed

- `RunJobCompiler` and process Deployments also strip `startupProbe` from the
container they derive from the web Deployment.
- `CompilesPlatformIntent::compileRun()` passes the held target to
`RunJobCompiler`, so a run's labels follow the target's identity like
everything else compiled in the test.

No compiled output changes for a spec that sets none of the new fields; the
existing golden files are byte-for-byte unchanged.

## [0.8.0] — 2026-08-09

### Fixed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ the application's.
- [Labels on compiled objects](docs/core-concepts/labels.md)
- [Kubernetes API versions](docs/core-concepts/api-versions.md)
- [Plan and diff](docs/core-concepts/plan-and-diff.md)
- [Secrets by reference, probes and the release](docs/core-concepts/secrets-probes-releases.md)
- [Public API and what `0.x` means](docs/core-concepts/public-api.md)
- [Compiling without a framework](docs/cookbook/compile-without-a-framework.md)
- [Writing your own mapper](docs/cookbook/writing-a-mapper.md)
Expand Down
1 change: 1 addition & 0 deletions docs/core-concepts/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ description: How the package is put together, why the compiler is pure, and what
`0.x` means for you.

- [Databases and the node that dies](databases.md) — why the engines compile down different paths, and what has to exist before one can have replicas.
- [Secrets by reference, probes and the release](secrets-probes-releases.md) — environment from an operator's Secret, readiness and liveness for the web process, and a command that finishes before anything rolls.
4 changes: 2 additions & 2 deletions docs/core-concepts/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ called out in the [changelog](../../CHANGELOG.md) before then.

| Surface | What is promised |
|---|---|
| `Cbox\Platform\Contracts\*` | the interfaces: `Compiler`, `DatabaseCompiler`, `BackupCompiler`, `GatewayCompiler`, `RunCompiler`, `Planner`, `SnapshotRuntime` |
| The spec value objects | `ServiceSpec`, `ProcessSpec`, `VolumeSpec`, `RuntimeSettings`, `RegistrySpec`, `DatabaseSpec`, `BackupSpec`, `BackupStorage`, `RestoreSpec`, `RunSpec`, `EnvironmentGatewaySpec`, `BindingSpec`, `ConnectionSource`, `ResourceRequirements` — construct them |
| `Cbox\Platform\Contracts\*` | the interfaces: `Compiler`, `DatabaseCompiler`, `BackupCompiler`, `GatewayCompiler`, `RunCompiler`, `ReleaseCompiler`, `Planner`, `SnapshotRuntime` |
| The spec value objects | `ServiceSpec`, `ProcessSpec`, `VolumeSpec`, `RuntimeSettings`, `RegistrySpec`, `DatabaseSpec`, `BackupSpec`, `BackupStorage`, `RestoreSpec`, `RunSpec`, `EnvironmentGatewaySpec`, `BindingSpec`, `ConnectionSource`, `ResourceRequirements`, `SecretReference`, `HealthCheck` — construct them |
| `Cbox\Platform\Capability\*` | `PlatformTarget`, `HttpAutoscaler`, `BackupCatalog`, `CustomerAccess`, `Certificates`, `CertificateSource`, `Placement`, `GatewayImplementation`, `GatewayOwnership`, `CustomResourcePolicy` |
| `Cbox\Platform\Manifest\*` | `Manifest`, `ManifestSet` — including `hash()`, `hashes()`, `key()`, `find()`, `toYaml()`, `toArray()`, `fromArray()` |
| `Cbox\Platform\Plan\*` | `Plan`, `PlanEntry`, `PlanAction`, `HashPlanner` (including `planAgainst()`), `ManifestDiff` (including `ManifestDiff::REDACTED`), `FieldChange`, `FieldChangeKind` |
Expand Down
167 changes: 167 additions & 0 deletions docs/core-concepts/secrets-probes-releases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
---
title: Secrets by reference, probes and the release
weight: 27
description: Environment from a Secret an operator created, readiness and liveness for the web process, and a command that has to finish before the workloads roll.
---

# Secrets by reference, probes and the release

Three things a production service needs that a spec could not say before
`0.12`: a credential that never passes through whoever builds the spec, a
readiness probe that holds a rollout until the new pod can serve, and a
migration that runs once, before any of that.

## Secrets by reference

`ServiceSpec::$envSecret` carries **values**: the compiler writes them into
`<service>-env` and references that. Whoever builds the spec holds the plaintext.

`ServiceSpec::$secretRefs` carries **names**:

```php
use Cbox\Platform\Service\SecretReference;

new ServiceSpec(
// …
secretRefs: [
new SecretReference(variable: 'APP_KEY', secret: 'cbox-id-app', key: 'APP_KEY'),
new SecretReference(variable: 'MAIL_PASSWORD', secret: 'cbox-id-mail', key: 'MAIL_PASSWORD'),
],
);
```

Each one compiles to `valueFrom.secretKeyRef` on **every container of the
service** — the web Deployment, every process Deployment, every run Job and the
release Job — because they all take the web container and change its command.
The Secret is the operator's: nothing here creates, updates or prunes it, and its
value is never in the spec, the compiled set, a plan, a diff or a golden file.

**A missing Secret, or a missing key in it, fails the pod — on purpose.** The
reference is never `optional`, so Kubernetes holds the container in
`CreateContainerConfigError` with an event naming the Secret or the key, and the
pod does not start. That is the right failure: a process that booted without its
`APP_KEY` would be serving, and wrong. Create the Secret in the service's
namespace before the first deploy.

Refused at compile time, with a sentence rather than an API-server error:

| Refused | Why |
|---|---|
| a variable that is not `[A-Za-z_][A-Za-z0-9_]*` | the only names every shell and runtime can read |
| a Secret name that is not an RFC 1123 subdomain (≤ 253) | Kubernetes would refuse it |
| a key outside `[-._a-zA-Z0-9]`, over 253, or `.`/`..` | Kubernetes would refuse it |
| a variable also in `env`, `envSecret`, a binding, or another reference | a container takes the last entry for a name, so one source would be silently ignored |
| `<service>-env` or `<service>-registry` | those are Secrets this compiler writes; the next deploy would overwrite the operator's |

## Probes

`ServiceSpec::$health` takes a `HealthCheck`. Null compiles no probes, which is
what every service compiled before this.

```php
use Cbox\Platform\Service\HealthCheck;

health: new HealthCheck(
readinessPath: '/health/ready',
livenessPath: '/up',
tokenVariable: 'HEALTH_TOKEN', // optional
),
```

Web Deployment only. A worker listens on nothing, so a process Deployment, a run
and the release carry no probes at all.

| Probe | From | Shape |
|---|---|---|
| `startupProbe` | `livenessPath` | `httpGet`, every 2 s, 150 failures — five minutes to boot |
| `livenessProbe` | `livenessPath` | `httpGet`, every 10 s, timeout 3 s, restart after 3 misses |
| `readinessProbe` | `readinessPath` | `httpGet` — or `exec` with a token, below — every 10 s, timeout 5 s, out of rotation after 3 misses |

**Startup gates liveness.** Until it passes, neither liveness nor readiness
runs, so a slow first boot is not killed for being slow. No liveness path, no
startup probe: it exists only to hold liveness back.

**Liveness is shallow.** Point it at something that answers whenever the process
is up — Laravel's `/up`. A liveness probe on a deep check restarts every
healthy pod the moment the database is slow.

### Readiness that needs a token

An application that refuses to describe its dependencies to an anonymous caller
is right to. But a Kubernetes `httpGet` probe sends **static headers only** —
there is no `secretKeyRef` for a probe header — so the token would have to be
written into the pod spec, readable by anyone who can `kubectl get deploy -o
yaml`. That is not offered.

Instead a readiness check with a `tokenVariable` compiles to an `exec` probe that
asks the container itself, with the token from its own environment:

```sh
printf 'Authorization: Bearer %s\n' "${HEALTH_TOKEN}" \
| curl -fsS -o /dev/null --max-time 4 -H @- 'http://127.0.0.1:80/health/ready'
```

- The value never leaves the Secret → env path: the probe names the variable,
the container's shell reads it.
- The header goes through stdin (`-H @-`), not argv, so the token is not in the
process list either.
- It needs `sh` and `curl` in the image. Every Cbox base image has both; an image
without curl fails readiness visibly (`curl: not found` in the pod's events)
rather than serving unprobed.
- The variable must be set by the spec — normally a `SecretReference` — or the
compile is refused: an empty token is a probe that is refused forever and a
rollout that hangs.
- Refused on the checkpointing (CRIU) scale-to-zero tier, where an idle
container is a checkpoint and has nothing to run a command in. Use a readiness
path that needs no token there.

The path is restricted to `[A-Za-z0-9._~%/+:@=&?,-]`, because it is part of a
shell line: a quote, a dollar sign or a space is refused rather than escaped.

The alternative — letting the readiness path answer anyone on the pod network
without a token — was rejected: it exposes which dependency is unhappy to every
pod in the cluster, which is exactly what the token is there to prevent.

## The release

`ServiceSpec::$release` is an argv list, `['php', 'artisan', 'migrate', '--force']`.
`ServiceCompiler` ignores it. `ReleaseJobCompiler` compiles it:

```php
use Cbox\Platform\Compile\ReleaseJobCompiler;

$release = new ReleaseJobCompiler($services, $target)->compile($spec); // ManifestSet: 0 or 1 Job
```

The Job is a [run](../getting-started/installation.md): the web container with
the command on it — same image, env, Secret references, bindings and pull
credential — no probes, `restartPolicy: Never`, `backoffLimit: 0`, an hour's
deadline, kept a day after it finishes so its logs can be read.

**Its name is the release.** `<service>-release-<10 hex>`, where the hash is of
the Job's pod template. The same spec compiles to the same Job, so an applier
deploying it twice finds it already `Complete` and does not migrate twice; a new
image, variable or command is a new Job. (A Job's template is immutable, so a
fixed name could only ever run the first release.) Empty while the service is
suspended.

**It is deliberately not in the service's manifest set.** A set is applied at
once and has no order; the release must finish before the Deployments change.
The compiler keeps the two apart so the applier can sequence them:

1. Apply the service's set **without** its `Deployment`s and the scalers that
act on them (`ScaledObject`, `HTTPScaledObject`) — the namespace, Secrets,
Services and databases the release needs. Wait for the databases to accept
connections.
2. If a Job of the release's name exists and has `Failed`, delete it (the same
release, retried). Apply the release Job and wait for its `Complete` or
`Failed` condition. Watch its pod: `CreateContainerConfigError` (a missing
Secret) or `ImagePullBackOff` will never finish on their own, so treat them as
a failure rather than waiting out the deadline.
3. On `Failed`, **stop**: report the Job's logs and apply nothing else. The
running release — the old Deployments — is untouched and still serving.
4. On `Complete`, apply the full set. The workloads roll behind their readiness
probes.

Migrations therefore run while the previous release still serves, so they must
be safe for the old code: add, never drop or rename what it still reads.
4 changes: 3 additions & 1 deletion docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ use Cbox\Platform\Compile\BackupCompiler;
use Cbox\Platform\Compile\CnpgDatabaseCompiler;
use Cbox\Platform\Compile\EngineDatabaseCompiler;
use Cbox\Platform\Compile\EnvironmentGatewayCompiler;
use Cbox\Platform\Compile\ReleaseJobCompiler;
use Cbox\Platform\Compile\RunJobCompiler;
use Cbox\Platform\Compile\ServiceCompiler;
use Cbox\Platform\Compile\StatefulDatabaseCompiler;
Expand All @@ -37,7 +38,8 @@ $databases = new EngineDatabaseCompiler(
new StatefulDatabaseCompiler($target, $backups),
);
$gateways = new EnvironmentGatewayCompiler();
$runs = new RunJobCompiler($services);
$runs = new RunJobCompiler($services, $target);
$releases = new ReleaseJobCompiler($services, $target); // run BEFORE the service's Deployments
```

## Wiring in a Laravel container
Expand Down
1 change: 1 addition & 0 deletions docs/getting-started/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class DeployTest extends TestCase
| `compileBackup(BackupSpec)` | |
| `compileGateway(EnvironmentGatewaySpec)` | |
| `compileRun(RunSpec)` | |
| `compileRelease(ServiceSpec)` | the release Job; an empty set when there is none |

## Fakes, for testing the layer above the compiler

Expand Down
101 changes: 101 additions & 0 deletions src/Compile/ReleaseJobCompiler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

declare(strict_types=1);

namespace Cbox\Platform\Compile;

use Cbox\Platform\Capability\PlatformTarget;
use Cbox\Platform\Contracts\Compiler;
use Cbox\Platform\Contracts\ReleaseCompiler;
use Cbox\Platform\Manifest\Manifest;
use Cbox\Platform\Manifest\ManifestSet;
use Cbox\Platform\Run\RunSpec;
use Cbox\Platform\Service\ServiceSpec;

/**
* A service's release command — `php artisan migrate --force` — as the Job an
* applier runs to completion BEFORE the workloads roll.
*
* A RUN, WITH A NAME THAT IS THE RELEASE. It is built by {@see RunJobCompiler},
* so it is the web Deployment's own container with the command on it: the same
* image, env, Secret references, bindings and pull credential, and no way to
* drift from what the new pods will see. What this adds is the name.
*
* NAMED FOR WHAT IT RUNS. `<service>-release-<hash>`, where the hash is of the
* Job's pod template — image, command, environment, everything. So:
*
* - the same release deployed twice is the SAME Job, and an applier finds it
* already complete instead of migrating twice;
* - a new image, a changed variable or a different command is a NEW Job.
* A Job's pod template is immutable once created, so re-applying one name
* with a new image is refused by the API server — a fixed name could only
* ever run the first release;
* - it is a pure function of the spec, like everything else here: no clock,
* no counter, no release number the compiler would have to be told.
*
* NOT PART OF THE SERVICE'S MANIFEST SET, and that is the point rather than an
* omission. A set is applied at once and has no order; this has to finish, and
* succeed, before the Deployments in that set are allowed to change. Keeping it
* apart is what lets an applier sequence it:
*
* 1. apply the service's set WITHOUT its Deployments and scalers
* (the namespace, Secrets, Services, databases — what the release needs)
* 2. apply this Job and wait for `Complete` — or `Failed`, and stop there,
* with the running release untouched
* 3. apply the full set, and the workloads roll
*
* NOTHING WHILE SUSPENDED. A service that is put away runs nothing, and a
* migration nobody is going to serve is a migration nobody is watching.
*/
class ReleaseJobCompiler implements ReleaseCompiler
{
/** The longest a Job name can be and still label its pods (`job-name`). */
private const MAX_NAME = 63;

public function __construct(
private readonly Compiler $services,
private readonly PlatformTarget $target = new PlatformTarget,
) {}

public function compile(ServiceSpec $spec): ManifestSet
{
if ($spec->release === [] || $spec->suspended) {
return new ManifestSet([]);
}

$job = new RunJobCompiler($this->services, $this->target)->compile(new RunSpec(
runId: 'release',
jobName: $spec->name.'-release',
command: $spec->release,
service: $spec,
))->manifests[0];

// The template's canonical hash — the same canonicalisation plan/diff
// uses, so two specs that compile to the same pod are the same release.
/** @var array<string, mixed> $template */
$template = $job->map('spec', 'template');
$name = self::jobName($spec->name, new Manifest('batch/v1', 'Job', '', '', $template)->hash());

$body = $job->body;
$body['metadata'] = ['name' => $name] + $job->map('metadata');

return new ManifestSet([new Manifest(
apiVersion: $job->apiVersion,
kind: $job->kind,
name: $name,
namespace: $job->namespace,
body: $body,
)]);
}

/**
* `<service>-release-<10 hex>`, shortened from the service's end so the
* whole thing stays a valid pod label.
*/
public static function jobName(string $service, string $hash): string
{
$suffix = '-release-'.substr($hash, 0, 10);

return rtrim(substr($service, 0, self::MAX_NAME - strlen($suffix)), '-').$suffix;
}
}
6 changes: 4 additions & 2 deletions src/Compile/RunJobCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ public function compile(RunSpec $spec): ManifestSet
$container['command'] = $spec->command;

// A run does not serve. Ports on a Job are inert at best, and a
// readiness probe on a container that exits is a contradiction.
unset($container['ports'], $container['readinessProbe'], $container['livenessProbe']);
// readiness probe on a container that exits is a contradiction — a
// liveness or startup probe on one would kill a long migration for not
// answering HTTP.
unset($container['ports'], $container['readinessProbe'], $container['livenessProbe'], $container['startupProbe']);

$podSpec['containers'] = [$container];
$podSpec['restartPolicy'] = 'Never';
Expand Down
Loading
Loading