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
20 changes: 20 additions & 0 deletions docs/guide/usage/patching.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,26 @@ The library automatically sets the correct Content-Type headers:
- JSON Patch: `application/json-patch+json`
- JSON Merge Patch: `application/merge-patch+json`

## Empty Maps vs Empty Lists

PHP represents both empty JSON objects (`{}`) and empty JSON arrays (`[]`) as `[]`, but the Kubernetes API distinguishes them: map fields such as `emptyDir: {}` are rejected with a 422 error when sent as `[]`. This matters for patches built from fetched resources, where every `{}` in the API response decodes to a PHP `[]`.

Patch payloads receive the same structural coercion as `create()`/`update()` payloads:

- Empty arrays nested inside JSON Patch operation values and JSON Merge Patch documents encode as `{}`.
- An operation value that is itself an empty array stays `[]`, so clearing list fields works: `['op' => 'replace', 'path' => '/metadata/finalizers', 'value' => []]`.
- Known list fields (`finalizers`, `conditions`, `accessModes`, `mountOptions`, `allowedTopologies`) stay `[]` at any depth.

```php
// Fetched template volumes decode emptyDir: {} to [] in PHP.
$volumes = $statefulSet->getAttribute('spec.template.spec.volumes');

// Re-encodes emptyDir as {} instead of [], so the API accepts it.
$statefulSet->jsonPatch([
['op' => 'replace', 'path' => '/spec/template/spec/volumes', 'value' => $volumes],
]);
```

## Practical Examples

### Rolling Update with Version Check
Expand Down
45 changes: 44 additions & 1 deletion src/Kinds/K8sResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
use RenokiCo\PhpK8s\Exceptions\KubernetesWatchException;
use RenokiCo\PhpK8s\K8s;
use RenokiCo\PhpK8s\KubernetesCluster;
use RenokiCo\PhpK8s\Patches\JsonMergePatch;
use RenokiCo\PhpK8s\Patches\JsonPatch;
use RenokiCo\PhpK8s\Traits\Resource\HasAnnotations;
use RenokiCo\PhpK8s\Traits\Resource\HasAttributes;
use RenokiCo\PhpK8s\Traits\Resource\HasEvents;
Expand Down Expand Up @@ -156,6 +158,47 @@ public function toJsonPayload(?string $kind = null): string|false
return json_encode($coerced, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}

/**
* Convert a JSON Patch (RFC 6902) to the JSON payload sent to the cluster,
* coercing empty map fields inside operation values from [] to {} like
* toJsonPayload does for full payloads. A value that is itself an empty
* array stays a list, so clearing list fields (e.g. finalizers) works.
*
* @throws \JsonException
*/
public function toJsonPatchPayload(JsonPatch|array $patch): string
{
$operations = $patch instanceof JsonPatch ? $patch->toArray() : $patch;

foreach ($operations as $index => $operation) {
$value = $operation['value'] ?? null;

if (is_array($value) && $value !== []) {
$operations[$index]['value'] = $this->coerceEmptyArraysToObjects($value);
}
Comment on lines +176 to +178

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Empty map values remain arrays

When a JSON Patch operation clears a map-valued field such as /metadata/labels using value => [], this guard skips coercion and serializes the value as [], causing Kubernetes to reject the patch because the field requires {}.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Kinds/K8sResource.php
Line: 176-178

Comment:
**Empty map values remain arrays**

When a JSON Patch operation clears a map-valued field such as `/metadata/labels` using `value => []`, this guard skips coercion and serializes the value as `[]`, causing Kubernetes to reject the patch because the field requires `{}`.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

}

return json_encode($operations, JSON_THROW_ON_ERROR);
}

/**
* Convert a JSON Merge Patch (RFC 7396) to the JSON payload sent to the
* cluster, coercing empty map fields from [] to {} like toJsonPayload
* does for full payloads. An empty patch encodes as {} because a merge
* patch document is always a JSON object.
*
* @throws \JsonException
*/
public function toJsonMergePatchPayload(JsonMergePatch|array $patch): string
{
$document = $patch instanceof JsonMergePatch ? $patch->toArray() : $patch;

return json_encode(
$document === [] ? (object) [] : $this->coerceEmptyArraysToObjects($document),
JSON_THROW_ON_ERROR
);
}

/**
* Kubernetes map fields are encoded by PHP's json_encode as `[]` when empty,
* but the API expects `{}` for objects. Walk the decoded structure and convert
Expand All @@ -171,7 +214,7 @@ public function toJsonPayload(?string $kind = null): string|false
*/
protected function coerceEmptyArraysToObjects(array $attributes): array
{
$emptyArrayLists = ['allowedTopologies', 'mountOptions', 'accessModes'];
$emptyArrayLists = ['allowedTopologies', 'mountOptions', 'accessModes', 'finalizers', 'conditions'];

foreach ($attributes as $key => $value) {
if (! is_array($value)) {
Expand Down
28 changes: 4 additions & 24 deletions src/Traits/RunsClusterOperations.php
Original file line number Diff line number Diff line change
Expand Up @@ -306,18 +306,12 @@ public function apply(string $fieldManager, bool $force = false, array $query =
*/
public function jsonPatch(JsonPatch|array $patch, array $query = ['pretty' => 1]): static
{
if (is_array($patch)) {
$payload = json_encode($patch);
} else {
$payload = $patch->toJson();
}

$instance = $this->cluster
->setResourceClass(get_class($this))
->runOperation(
Operation::JSON_PATCH,
$this->resourcePath(),
$payload,
$this->toJsonPatchPayload($patch),
$query
);

Expand All @@ -335,18 +329,12 @@ public function jsonPatch(JsonPatch|array $patch, array $query = ['pretty' => 1]
*/
public function jsonMergePatch(JsonMergePatch|array $patch, array $query = ['pretty' => 1]): static
{
if (is_array($patch)) {
$payload = json_encode($patch);
} else {
$payload = $patch->toJson();
}

$instance = $this->cluster
->setResourceClass(get_class($this))
->runOperation(
Operation::JSON_MERGE_PATCH,
$this->resourcePath(),
$payload,
$this->toJsonMergePatchPayload($patch),
$query
);

Expand Down Expand Up @@ -617,16 +605,12 @@ public function updateStatus(array $query = ['pretty' => 1]): static
*/
public function jsonPatchStatus(JsonPatch|array $patch, array $query = ['pretty' => 1]): static
{
if (is_array($patch)) {
$patch = new JsonPatch($patch);
}

$instance = $this->cluster
->setResourceClass(get_class($this))
->runOperation(
Operation::JSON_PATCH,
$this->resourceStatusPath(),
$patch->toJson(),
$this->toJsonPatchPayload($patch),
$query
);

Expand All @@ -640,16 +624,12 @@ public function jsonPatchStatus(JsonPatch|array $patch, array $query = ['pretty'
*/
public function jsonMergePatchStatus(JsonMergePatch|array $patch, array $query = ['pretty' => 1]): static
{
if (is_array($patch)) {
$patch = new JsonMergePatch($patch);
}

$instance = $this->cluster
->setResourceClass(get_class($this))
->runOperation(
Operation::JSON_MERGE_PATCH,
$this->resourceStatusPath(),
$patch->toJson(),
$this->toJsonMergePatchPayload($patch),
$query
);

Expand Down
48 changes: 48 additions & 0 deletions tests/PatchIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -407,4 +407,52 @@ public function test_patch_complex_nested_changes_on_live_pod()
$this->cleanupTestPod($pod);
}
}

public function test_json_patch_value_built_from_fetched_resource_with_empty_dir()
{
$deployment = $this->cluster->deployment()
->setName('patch-empty-dir-test')
->setSelectors(['matchLabels' => ['app' => 'patch-empty-dir-test']])
->setReplicas(1)
->setTemplate([
'metadata' => ['labels' => ['app' => 'patch-empty-dir-test']],
'spec' => [
'containers' => [
[
'name' => 'mariadb',
'image' => 'public.ecr.aws/docker/library/mariadb:11.8',
'env' => [['name' => 'MARIADB_ROOT_PASSWORD', 'value' => 'test']],
'volumeMounts' => [['name' => 'scratch', 'mountPath' => '/scratch']],
],
],
'volumes' => [
['name' => 'scratch', 'emptyDir' => []],
],
],
])
->createOrUpdate();

try {
$fetched = $this->cluster->getDeploymentByName('patch-empty-dir-test');

$template = $fetched->getAttribute('spec.template');

$this->assertSame(
[],
$template['spec']['volumes'][0]['emptyDir'],
'The apiserver returns emptyDir: {} which decodes to an empty PHP array.'
);

$template['metadata']['labels']['patched'] = 'true';

$patched = $fetched->jsonPatch([
['op' => 'replace', 'path' => '/spec/template', 'value' => $template],
]);

$this->assertSame('true', $patched->getAttribute('spec.template.metadata.labels.patched'));
$this->assertSame([], $patched->getAttribute('spec.template.spec.volumes')[0]['emptyDir']);
} finally {
$deployment->delete();
}
}
}
176 changes: 176 additions & 0 deletions tests/PatchPayloadTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
<?php

namespace RenokiCo\PhpK8s\Test;

use RenokiCo\PhpK8s\Patches\JsonMergePatch;
use RenokiCo\PhpK8s\Patches\JsonPatch;

class PatchPayloadTest extends TestCase
{
public function test_json_patch_array_values_coerce_nested_empty_maps()
{
$payload = $this->cluster->statefulSet()->toJsonPatchPayload([
[
'op' => 'replace',
'path' => '/spec/template/spec/volumes',
'value' => [
['name' => 'wal', 'emptyDir' => []],
],
],
]);

$this->assertStringContainsString('"emptyDir":{}', $payload);
$this->assertStringNotContainsString('"emptyDir":[]', $payload);
}

public function test_json_patch_object_values_coerce_nested_empty_maps()
{
$patch = (new JsonPatch)->replace('/spec/template/spec/volumes', [
['name' => 'wal', 'emptyDir' => []],
]);

$payload = $this->cluster->statefulSet()->toJsonPatchPayload($patch);

$this->assertStringContainsString('"emptyDir":{}', $payload);
$this->assertStringNotContainsString('"emptyDir":[]', $payload);
}

public function test_json_patch_top_level_empty_list_value_stays_list()
{
$payload = $this->cluster->configmap()->toJsonPatchPayload([
['op' => 'replace', 'path' => '/metadata/finalizers', 'value' => []],
]);

$this->assertStringContainsString('"value":[]', $payload);
}

public function test_json_patch_values_respect_exempted_list_fields()
{
$payload = $this->cluster->persistentVolume()->toJsonPatchPayload([
[
'op' => 'replace',
'path' => '/spec',
'value' => [
'accessModes' => [],
'mountOptions' => [],
'nodeAffinity' => [],
],
],
]);

$this->assertStringContainsString('"accessModes":[]', $payload);
$this->assertStringContainsString('"mountOptions":[]', $payload);
$this->assertStringContainsString('"nodeAffinity":{}', $payload);
}

public function test_json_patch_nested_finalizers_stay_lists_while_maps_coerce()
{
$payload = $this->cluster->configmap()->toJsonPatchPayload([
[
'op' => 'replace',
'path' => '/metadata',
'value' => [
'finalizers' => [],
'labels' => [],
],
],
]);

$this->assertStringContainsString('"finalizers":[]', $payload);
$this->assertStringContainsString('"labels":{}', $payload);
}

public function test_json_patch_string_values_containing_empty_array_literal_are_preserved()
{
$script = 'foreach (glob("/x/*") ?: [] as $file) { echo $file; }';

$payload = $this->cluster->pod()->toJsonPatchPayload([
[
'op' => 'replace',
'path' => '/spec/containers/0/command',
'value' => ['php', '-r', $script],
],
]);

$this->assertStringNotContainsString('?: {}', $payload);

$decoded = json_decode($payload, true);

$this->assertSame($script, $decoded[0]['value'][2]);
}

public function test_json_patch_scalar_and_valueless_operations_are_untouched()
{
$payload = $this->cluster->deployment()->toJsonPatchPayload([
['op' => 'replace', 'path' => '/spec/replicas', 'value' => 3],
['op' => 'remove', 'path' => '/metadata/labels/deprecated'],
['op' => 'add', 'path' => '/metadata/labels/app', 'value' => null],
]);

$this->assertSame(
'[{"op":"replace","path":"\/spec\/replicas","value":3},'
.'{"op":"remove","path":"\/metadata\/labels\/deprecated"},'
.'{"op":"add","path":"\/metadata\/labels\/app","value":null}]',
$payload
);
}

public function test_empty_json_patch_encodes_as_list()
{
$this->assertSame('[]', $this->cluster->configmap()->toJsonPatchPayload([]));
$this->assertSame('[]', $this->cluster->configmap()->toJsonPatchPayload(new JsonPatch));
}

public function test_json_merge_patch_document_coerces_nested_empty_maps()
{
$payload = $this->cluster->statefulSet()->toJsonMergePatchPayload([
'spec' => [
'template' => [
'spec' => [
'volumes' => [
['name' => 'wal', 'emptyDir' => []],
],
],
],
],
]);

$this->assertStringContainsString('"emptyDir":{}', $payload);
$this->assertStringNotContainsString('"emptyDir":[]', $payload);
}

public function test_json_merge_patch_object_document_coerces_nested_empty_maps()
{
$patch = new JsonMergePatch([
'spec' => ['selector' => []],
]);

$payload = $this->cluster->service()->toJsonMergePatchPayload($patch);

$this->assertStringContainsString('"selector":{}', $payload);
}

public function test_json_merge_patch_clearing_finalizers_stays_list()
{
$payload = $this->cluster->configmap()->toJsonMergePatchPayload([
'metadata' => ['finalizers' => []],
]);

$this->assertSame('{"metadata":{"finalizers":[]}}', $payload);
}

public function test_json_merge_patch_clearing_conditions_stays_list()
{
$payload = $this->cluster->deployment()->toJsonMergePatchPayload([
'status' => ['conditions' => []],
]);

$this->assertSame('{"status":{"conditions":[]}}', $payload);
}

public function test_empty_json_merge_patch_encodes_as_object()
{
$this->assertSame('{}', $this->cluster->configmap()->toJsonMergePatchPayload([]));
$this->assertSame('{}', $this->cluster->configmap()->toJsonMergePatchPayload(new JsonMergePatch));
}
}