diff --git a/src/Kinds/K8sResource.php b/src/Kinds/K8sResource.php index 864fa84..91210db 100644 --- a/src/Kinds/K8sResource.php +++ b/src/Kinds/K8sResource.php @@ -145,21 +145,49 @@ public function toJson($options = 0, ?string $kind = null): string|false } /** - * Convert the object to its JSON representation, but - * escaping [] for {}. Optionally, you can specify + * Convert the object to its JSON representation, coercing empty + * map fields from [] to {}. Optionally, you can specify * the Kind attribute to replace. - * - * @return string */ public function toJsonPayload(?string $kind = null): string|false { - $attributes = $this->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES, $kind); + $coerced = $this->coerceEmptyArraysToObjects($this->toArray($kind)); + + return json_encode($coerced, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + /** + * 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 + * every empty array into an empty object so the byte sequence is only ever + * changed at structural positions — never inside string scalars (which would + * corrupt embedded scripts such as `glob(...) ?: []`). + * + * A handful of genuine list fields must stay as `[]` even when empty; those + * keys are exempted by name at any nesting depth. + * + * @param array $attributes + * @return array + */ + protected function coerceEmptyArraysToObjects(array $attributes): array + { + $emptyArrayLists = ['allowedTopologies', 'mountOptions', 'accessModes']; + + foreach ($attributes as $key => $value) { + if (! is_array($value)) { + continue; + } - $attributes = str_replace(': []', ': {}', $attributes); + if ($value === []) { + if (! in_array($key, $emptyArrayLists, true)) { + $attributes[$key] = (object) []; + } - $attributes = str_replace('"allowedTopologies": {}', '"allowedTopologies": []', $attributes); - $attributes = str_replace('"mountOptions": {}', '"mountOptions": []', $attributes); - $attributes = str_replace('"accessModes": {}', '"accessModes": []', $attributes); + continue; + } + + $attributes[$key] = $this->coerceEmptyArraysToObjects($value); + } return $attributes; } diff --git a/tests/JsonPayloadTest.php b/tests/JsonPayloadTest.php new file mode 100644 index 0000000..4e31dc7 --- /dev/null +++ b/tests/JsonPayloadTest.php @@ -0,0 +1,113 @@ +setName('backup') + ->setImage('public.ecr.aws/docker/library/php', '8.4') + ->setCommand(['php', '-r', 'foreach (glob("/x/*") ?: [] as $file) { echo $file; }']); + + $pod = $this->cluster->pod() + ->setName('backup') + ->setContainers([$container]); + + $job = $this->cluster->job() + ->setName('backup') + ->setTemplate($pod); + + $payload = $job->toJsonPayload(); + $decoded = json_decode($payload, true); + + $command = $decoded['spec']['template']['spec']['containers'][0]['command'][2]; + + $this->assertSame('foreach (glob("/x/*") ?: [] as $file) { echo $file; }', $command); + $this->assertStringNotContainsString('?: {}', $payload); + } + + public function test_generic_colon_space_bracket_string_is_preserved() + { + $container = K8s::container() + ->setName('echo') + ->setImage('public.ecr.aws/docker/library/busybox') + ->setCommand(['/bin/sh', '-c', 'echo arr: [] done']); + + $pod = $this->cluster->pod() + ->setName('echo') + ->setContainers([$container]); + + $decoded = json_decode($pod->toJsonPayload(), true); + + $this->assertSame('echo arr: [] done', $decoded['spec']['containers'][0]['command'][2]); + } + + public function test_non_empty_list_arrays_stay_lists() + { + $container = K8s::container() + ->setName('echo') + ->setImage('public.ecr.aws/docker/library/busybox') + ->setCommand(['/bin/sh', '-c', 'true']); + + $pod = $this->cluster->pod() + ->setName('echo') + ->setContainers([$container]); + + $decoded = json_decode($pod->toJsonPayload(), true); + + $this->assertTrue(array_is_list($decoded['spec']['containers'])); + $this->assertCount(1, $decoded['spec']['containers']); + } + + public function test_empty_map_fields_are_coerced_to_objects() + { + $configMap = $this->cluster->configmap() + ->setName('settings') + ->setLabels([]) + ->setData([]); + + $payload = $configMap->toJsonPayload(); + + $this->assertStringContainsString('"data": {}', $payload); + $this->assertStringContainsString('"labels": {}', $payload); + } + + public function test_exempted_list_fields_stay_empty_arrays() + { + $storageClass = $this->cluster->storageClass() + ->setName('standard') + ->setProvisioner('csi.example.com'); + + $storageClass->setAttribute('mountOptions', []); + $storageClass->setAttribute('allowedTopologies', []); + $storageClass->setAttribute('accessModes', []); + + $payload = $storageClass->toJsonPayload(); + + $this->assertStringContainsString('"mountOptions": []', $payload); + $this->assertStringContainsString('"allowedTopologies": []', $payload); + $this->assertStringContainsString('"accessModes": []', $payload); + } + + public function test_nested_empty_map_deep_in_spec_is_coerced() + { + $container = K8s::container() + ->setName('echo') + ->setImage('public.ecr.aws/docker/library/busybox') + ->setCommand(['/bin/sh', '-c', 'true']); + + $pod = $this->cluster->pod() + ->setName('echo') + ->setContainers([$container]); + + $pod->setAttribute('spec.nodeSelector', []); + + $payload = $pod->toJsonPayload(); + + $this->assertStringContainsString('"nodeSelector": {}', $payload); + } +}