diff --git a/CHANGELOG.md b/CHANGELOG.md index b4a7bc5..a65fb57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,15 @@ All notable changes to this project will be documented in this file. -## [1.1.0] - 2025-08-31 +## [1.1.1] - 2025-08-30 + +### Fixed +- `ClusterBuilder::generateInMemory()` now generates real Talos machine configs by invoking `talosctl gen config` in a temporary directory, applying patches, reading YAML back, and cleaning up. This fixes prior behavior introduced in 1.1.0 that produced patch-shaped YAML not guaranteed to be valid Talos configs. + +### Notes +- Exceptions: errors from `talosctl` surface as `ArioLabs\\Talos\\Exceptions\\CommandFailed`; a `RuntimeException` is thrown if `controlplane.yaml`/`worker.yaml` are not produced. + +## [1.1.0] - 2025-08-30 ### Fixed - `ClusterBuilder::generateInMemory()` no longer delegates to disk generation; now builds YAML purely in memory. @@ -16,7 +24,7 @@ All notable changes to this project will be documented in this file. - `ClusterBuilder` constructor `outDir` parameter is now optional (`?string`); when omitted, `generate()` uses a temporary directory under the system temp path. - README updated to recommend in-memory generation + `GeneratedConfigs::writeTo()` and to document `generateTo()`. -## [1.0.0] - YYYY-MM-DD +## [1.0.0] - 2025-08-30 Initial stable release. diff --git a/README.md b/README.md index c61168e..77a0b00 100644 --- a/README.md +++ b/README.md @@ -103,13 +103,13 @@ $dir = $builder ### In‑Memory Generation -Work with the generated YAML purely in memory, and only write to disk if/when you need to persist or apply them later. +Obtain the generated YAML as strings, and only write to disk if/when you need to persist or apply them later. Under the hood, this uses a temporary directory to invoke `talosctl gen config`, applies your patches, then cleans up. Requires `talosctl` in PATH; errors from `talosctl` bubble as `ArioLabs\\Talos\\Exceptions\\CommandFailed`, and a `RuntimeException` is thrown if expected files are not produced. ```php use ArioLabs\Talos\Builders\ClusterBuilder; use ArioLabs\Talos\GeneratedConfigs; -$builder = new ClusterBuilder($talos, 'demo', 'https://10.255.0.50:6443', sys_get_temp_dir().'/demo-'.uniqid()); +$builder = new ClusterBuilder($talos, 'demo', 'https://10.255.0.50:6443'); $configs = $builder ->secrets($secrets) @@ -124,7 +124,7 @@ $workerYaml = $configs->worker(); $out = sys_get_temp_dir().'/talos-out-'.uniqid(); $configs->writeTo($out); -// Also get an in-memory talosconfig (not persisted) +// Also get an in-memory talosconfig (not persisted; uses a temp dir under the hood) $talosconfig = $builder->talosconfigInMemory(); ``` @@ -191,7 +191,7 @@ file_put_contents($dir.'/talosconfig', $talosconfig); - `patchFile(string $relativeYaml, array $patch)` → per-file overrides - Secrets - `secrets(TalosSecrets $secrets)` → injects cluster/machine secrets - - `generateInMemory()` → return `GeneratedConfigs` without touching disk + - `generateInMemory()` → returns `GeneratedConfigs` (uses a temp dir internally; throws on failure) - `talosconfigInMemory(array $flags = [])` → return talosconfig content as a string - `generateTo(string $dir)` → generate configs to a specific directory - Constructor `outDir` is optional; if omitted, `generate()` uses a temp directory diff --git a/rector.php b/rector.php index 4c9ba9f..b91d2bf 100644 --- a/rector.php +++ b/rector.php @@ -3,14 +3,22 @@ declare(strict_types=1); use Rector\Config\RectorConfig; +use Rector\Php83\Rector\ClassMethod\AddOverrideAttributeToOverriddenMethodsRector; return RectorConfig::configure() ->withPaths([ __DIR__.'/config', - __DIR__.'/tests', + __DIR__.'/src', ]) - // uncomment to reach your current PHP version - // ->withPhpSets() - ->withTypeCoverageLevel(0) - ->withDeadCodeLevel(0) - ->withCodeQualityLevel(0); + ->withSkip([ + AddOverrideAttributeToOverriddenMethodsRector::class, + ]) + ->withPreparedSets( + deadCode: true, + codeQuality: true, + typeDeclarations: true, + privatization: true, + earlyReturn: true, + strictBooleans: true, + ) + ->withPhpSets(); diff --git a/src/Builders/ClusterBuilder.php b/src/Builders/ClusterBuilder.php index 0f226f5..303f6e0 100644 --- a/src/Builders/ClusterBuilder.php +++ b/src/Builders/ClusterBuilder.php @@ -7,7 +7,7 @@ use ArioLabs\Talos\Enums\Cni; use ArioLabs\Talos\TalosCluster; use InvalidArgumentException; -use Symfony\Component\Yaml\Yaml; +use RuntimeException; final class ClusterBuilder { @@ -29,16 +29,16 @@ final class ClusterBuilder private ?\ArioLabs\Talos\TalosSecrets $secrets = null; public function __construct( - private TalosCluster $talos, - private string $cluster, - private string $endpoint, - private ?string $outDir = null, + private readonly TalosCluster $talos, + private readonly string $cluster, + private readonly string $endpoint, + private readonly ?string $outDir = null, ) {} /** @param array $sans */ public function additionalSans(array $sans): static { - if ($sans) { + if ($sans !== []) { $this->flags['--additional-sans'] = implode(',', $sans); } @@ -82,7 +82,7 @@ public function secrets(\ArioLabs\Talos\TalosSecrets $secrets): static public function podSubnets(array $cidrs): static { $this->validateCidrs($cidrs, 'podSubnets'); - if ($cidrs) { + if ($cidrs !== []) { $this->mergeMachinePatch([ 'cluster' => [ 'network' => [ @@ -99,7 +99,7 @@ public function podSubnets(array $cidrs): static public function serviceSubnets(array $cidrs): static { $this->validateCidrs($cidrs, 'serviceSubnets'); - if ($cidrs) { + if ($cidrs !== []) { $this->mergeMachinePatch([ 'cluster' => [ 'network' => [ @@ -226,7 +226,7 @@ public function staticInterface(string $name, array $addresses, array $routes = 'dhcp' => $dhcp, 'addresses' => array_values($addresses), ]; - if ($routes) { + if ($routes !== []) { $entry['routes'] = array_values($routes); } $this->interfaces[] = $entry; @@ -283,13 +283,13 @@ public function network( if ($dns !== null) { $this->dnsDomain($dns); } - if ($pod) { + if ($pod !== []) { $this->podSubnets($pod); } - if ($svc) { + if ($svc !== []) { $this->serviceSubnets($svc); } - if ($nameservers) { + if ($nameservers !== []) { $this->nameservers($nameservers); } foreach ($interfaces as $i => $cfg) { @@ -469,13 +469,13 @@ public function generate(): string /** Generate configs to a specific directory (explicit disk output). */ public function generateTo(string $dir): string { - if ($this->secrets !== null) { + if ($this->secrets instanceof \ArioLabs\Talos\TalosSecrets) { $dir = $this->talos->genConfigWithSecrets($this->cluster, $this->endpoint, $dir, $this->secrets, $this->flags); } else { $dir = $this->talos->genConfig($this->cluster, $this->endpoint, $dir, $this->flags); } // Apply accumulated machine-level patches (e.g., network settings) to both configs first - if ($this->machinePatches) { + if ($this->machinePatches !== []) { $this->talos->patchYaml($dir.'/controlplane.yaml', $this->machinePatches); $this->talos->patchYaml($dir.'/worker.yaml', $this->machinePatches); } @@ -488,30 +488,56 @@ public function generateTo(string $dir): string public function generateInMemory(): \ArioLabs\Talos\GeneratedConfigs { - // Build configs purely in memory from accumulated patches. - // Start from empty base and merge machine-level patches into both files. - $cp = []; - $wk = []; + // Strict mode: always generate via talosctl to ensure real Talos configs. + $tmp = mb_rtrim(sys_get_temp_dir(), '/').'/talos-inmem-'.uniqid(); + @mkdir($tmp, 0775, true); - if ($this->machinePatches) { - $cp = $this->deepMerge($cp, $this->machinePatches); - $wk = $this->deepMerge($wk, $this->machinePatches); - } + $dir = $tmp; + try { + if ($this->secrets instanceof \ArioLabs\Talos\TalosSecrets) { + $dir = $this->talos->genConfigWithSecrets($this->cluster, $this->endpoint, $tmp, $this->secrets, $this->flags); + } else { + $dir = $this->talos->genConfig($this->cluster, $this->endpoint, $tmp, $this->flags); + } - // Apply any per-file overrides next. - foreach ($this->patches as $file => $patch) { - if ($file === 'controlplane.yaml') { - $cp = $this->deepMerge($cp, $patch); - } elseif ($file === 'worker.yaml') { - $wk = $this->deepMerge($wk, $patch); + $cpPath = $dir.'/controlplane.yaml'; + $wkPath = $dir.'/worker.yaml'; + + if (! is_file($cpPath) || ! is_file($wkPath)) { + throw new RuntimeException('talosctl gen config did not produce controlplane.yaml/worker.yaml'); } - } - // Normalize and dump as YAML strings. - $cpYaml = Yaml::dump($this->normalizeSequences($cp), 8, 2); - $wkYaml = Yaml::dump($this->normalizeSequences($wk), 8, 2); + if ($this->machinePatches !== []) { + $this->talos->patchYaml($cpPath, $this->machinePatches); + $this->talos->patchYaml($wkPath, $this->machinePatches); + } + foreach ($this->patches as $file => $patch) { + $target = $dir.'/'.$file; + if (is_file($target)) { + $this->talos->patchYaml($target, $patch); + } + } - return new \ArioLabs\Talos\GeneratedConfigs($cpYaml, $wkYaml); + $cpYaml = (string) file_get_contents($cpPath); + $wkYaml = (string) file_get_contents($wkPath); + + return new \ArioLabs\Talos\GeneratedConfigs($cpYaml, $wkYaml); + } finally { + // Best-effort cleanup of temp artifacts + $cp = $dir.'/controlplane.yaml'; + $wk = $dir.'/worker.yaml'; + if (is_file($cp)) { + @unlink($cp); + } + if (is_file($wk)) { + @unlink($wk); + } + $tc = $dir.'/talosconfig'; + if (is_file($tc)) { + @unlink($tc); + } + @rmdir($dir); + } } /** Convenience: generate a talosconfig string for this builder's @@ -524,54 +550,6 @@ public function talosconfigInMemory(array $flags = []): string return $this->talos->genTalosconfig($this->cluster, $this->endpoint, $flags); } - /** @param array $a - * @param array $b - * @return array */ - private function deepMerge(array $a, array $b): array - { - foreach ($b as $k => $v) { - if (is_array($v) && isset($a[$k]) && is_array($a[$k])) { - $a[$k] = $this->deepMerge($a[$k], $v); - } else { - $a[$k] = $v; - } - } - - return $a; - } - - /** Normalize known sequence-typed keys: if empty, omit them so the YAML dumper doesn't emit '{}'. - * Keeps empty map-typed keys (e.g., registries: {}) untouched. - * - * @param array $data - * @return array */ - private function normalizeSequences(array $data): array - { - $sequenceKeys = [ - 'certSANs', - 'extraManifests', - 'inlineManifests', - 'podSubnets', - 'serviceSubnets', - 'advertisedSubnets', - 'nameservers', - 'interfaces', - ]; - - foreach ($data as $k => $v) { - if (is_array($v)) { - if ($v === [] && in_array((string) $k, $sequenceKeys, true)) { - unset($data[$k]); - - continue; - } - $data[$k] = $this->normalizeSequences($v); - } - } - - return $data; - } - /** @param array $patch */ private function mergeMachinePatch(array $patch): void { @@ -584,7 +562,7 @@ private function mergeMachinePatch(array $patch): void private function buildNestedPatchFromDot(string $path, string|int|float|bool|array|null $value): array { $keys = array_filter(explode('.', $path), static fn (string $k): bool => $k !== ''); - if (! $keys) { + if ($keys === []) { throw new InvalidArgumentException('set() path must be non-empty'); } @@ -605,7 +583,7 @@ private function validateCidrs(array $cidrs, string $field): void } // Basic CIDR validation for IPv4 or IPv6 $isIpv4 = (bool) preg_match('/^((25[0-5]|2[0-4]\\d|[01]?\\d?\\d)(\\.)){3}(25[0-5]|2[0-4]\\d|[01]?\\d?\\d)\/(3[0-2]|[12]?\\d)$/', $cidr); - $isIpv6 = (bool) preg_match('/^([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}\/([0-9]|[1-9]\\d|1[01]\\d|12[0-8])$/', $cidr); + $isIpv6 = (bool) preg_match('/^([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}\/(\d|[1-9]\d|1[01]\d|12[0-8])$/', $cidr); if (! $isIpv4 && ! $isIpv6) { throw new InvalidArgumentException(sprintf('Invalid CIDR "%s" for %s', $cidr, $field)); } @@ -628,7 +606,7 @@ private function isValidDnsName(string $name): bool if ($label === '' || mb_strlen($label) > 63) { return false; } - if (! preg_match('/^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/', $label)) { + if (in_array(preg_match('/^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/', $label), [0, false], true)) { return false; } } diff --git a/src/GeneratedConfigs.php b/src/GeneratedConfigs.php index c3cbb68..47938b8 100644 --- a/src/GeneratedConfigs.php +++ b/src/GeneratedConfigs.php @@ -4,7 +4,7 @@ namespace ArioLabs\Talos; -final class GeneratedConfigs +final readonly class GeneratedConfigs { public function __construct( private string $controlplane, diff --git a/src/Support/ProcessRunner.php b/src/Support/ProcessRunner.php index cd50eb0..a6fd152 100644 --- a/src/Support/ProcessRunner.php +++ b/src/Support/ProcessRunner.php @@ -7,7 +7,7 @@ use Closure; use Symfony\Component\Process\Process; -final class ProcessRunner implements Runner +final readonly class ProcessRunner implements Runner { public function __construct( private string $bin, @@ -25,7 +25,7 @@ public function run(array $args, ?Closure $onChunk = null): array $cmd = array_merge([$this->bin], $args); $proc = new Process($cmd, $this->cwd, $this->env, null, $this->timeout); - if ($onChunk) { + if ($onChunk instanceof Closure) { $proc->run(fn (string $type, string $buffer) => $onChunk($type, $buffer)); } else { $proc->run(); @@ -36,7 +36,7 @@ public function run(array $args, ?Closure $onChunk = null): array $err = $proc->getErrorOutput(); if ($this->log && function_exists('logger')) { - logger()->debug('[talosctl]'.implode(' ', $cmd), compact('exit', 'out', 'err')); + logger()->debug('[talosctl]'.implode(' ', $cmd), ['exit' => $exit, 'out' => $out, 'err' => $err]); } return [$exit, $out, $err]; diff --git a/src/TalosCluster.php b/src/TalosCluster.php index babeddd..816ca55 100644 --- a/src/TalosCluster.php +++ b/src/TalosCluster.php @@ -12,7 +12,7 @@ final class TalosCluster { public function __construct( - private Runner $runner, + private readonly Runner $runner, private ?string $talosconfig = null, private ?string $clusterName = null, /** @var array */ @@ -123,7 +123,7 @@ public function kubeconfig(?string $outPath = null, bool $force = true): string if ($force) { $args[] = '--force'; } - if ($outPath) { + if ($outPath !== null && $outPath !== '' && $outPath !== '0') { $args[] = '-f'; $args[] = $outPath; } @@ -132,7 +132,7 @@ public function kubeconfig(?string $outPath = null, bool $force = true): string throw new CommandFailed($args, $c, $e); } - return $outPath ?: mb_trim($o); + return $outPath !== null && $outPath !== '' && $outPath !== '0' ? $outPath : mb_trim($o); } public function upgrade(string $image, bool $reboot = true): void @@ -217,7 +217,7 @@ public function genSecrets(): TalosSecrets */ public function genTalosconfig(string $cluster, string $endpoint, array $flags = [], ?string $outDir = null): string { - $tmp = $outDir ?: (mb_rtrim(sys_get_temp_dir(), '/').'/talos-tmp-'.uniqid()); + $tmp = $outDir !== null && $outDir !== '' && $outDir !== '0' ? $outDir : (mb_rtrim(sys_get_temp_dir(), '/').'/talos-tmp-'.uniqid()); @mkdir($tmp, 0775, true); // Reuse genConfig to invoke talosctl with any flags provided. @@ -246,7 +246,7 @@ public function genConfigWithSecrets(string $cluster, string $endpoint, string $ { $dir = $this->genConfig($cluster, $endpoint, $outputDir, $flags); $patch = $secrets->toPatch(); - if ($patch) { + if ($patch !== []) { $this->patchYaml($dir.'/controlplane.yaml', $patch); $this->patchYaml($dir.'/worker.yaml', $patch); } @@ -267,7 +267,7 @@ private function run(array $args, ?Closure $onChunk = null): array private function talosFlags(): array { $flags = []; - if ($this->talosconfig) { + if ($this->talosconfig !== null && $this->talosconfig !== '' && $this->talosconfig !== '0') { $flags[] = '--talosconfig='.$this->talosconfig; } @@ -277,13 +277,13 @@ private function talosFlags(): array /** @return array */ private function nodeFlags(): array { - return $this->nodes ? ['--nodes', implode(',', $this->nodes)] : []; + return $this->nodes !== [] ? ['--nodes', implode(',', $this->nodes)] : []; } /** @return array */ private function endpointFlags(): array { - return $this->endpoints ? ['--endpoints', implode(',', $this->endpoints)] : []; + return $this->endpoints !== [] ? ['--endpoints', implode(',', $this->endpoints)] : []; } /** @param array $a @@ -292,11 +292,7 @@ private function endpointFlags(): array private function deepMerge(array $a, array $b): array { foreach ($b as $k => $v) { - if (is_array($v) && isset($a[$k]) && is_array($a[$k])) { - $a[$k] = $this->deepMerge($a[$k], $v); - } else { - $a[$k] = $v; - } + $a[$k] = is_array($v) && isset($a[$k]) && is_array($a[$k]) ? $this->deepMerge($a[$k], $v) : $v; } return $a; diff --git a/src/TalosSecrets.php b/src/TalosSecrets.php index fbb82a5..aced51c 100644 --- a/src/TalosSecrets.php +++ b/src/TalosSecrets.php @@ -6,7 +6,7 @@ use Symfony\Component\Yaml\Yaml; -final class TalosSecrets +final readonly class TalosSecrets { /** @param array $data */ public function __construct(private array $data) {} @@ -79,10 +79,10 @@ public function toPatch(): array } $patch = []; - if ($clusterPatch) { + if ($clusterPatch !== []) { $patch['cluster'] = $clusterPatch; } - if ($machinePatch) { + if ($machinePatch !== []) { $patch['machine'] = $machinePatch; } diff --git a/tests/Feature/InMemoryPatchFileFeatureTest.php b/tests/Feature/InMemoryPatchFileFeatureTest.php index a88972a..60946f6 100644 --- a/tests/Feature/InMemoryPatchFileFeatureTest.php +++ b/tests/Feature/InMemoryPatchFileFeatureTest.php @@ -5,10 +5,11 @@ use ArioLabs\Talos\Builders\ClusterBuilder; use ArioLabs\Talos\TalosCluster; use Symfony\Component\Yaml\Yaml; -use Tests\Fakes\ProcessRunnerFake; +use Tests\Fakes\ProcessRunnerWritingFake; it('applies per-file patches in generateInMemory() and normalizes sequences', function (): void { - $runner = new ProcessRunnerFake(); + // Use writer fake so talosctl gen config writes real files to temp dir + $runner = new ProcessRunnerWritingFake(); $talos = new TalosCluster($runner); $b = new ClusterBuilder($talos, 'demo', '10.0.0.1'); diff --git a/tests/Feature/InMemoryStrictGeneratesWithSecretsTest.php b/tests/Feature/InMemoryStrictGeneratesWithSecretsTest.php new file mode 100644 index 0000000..9d99b5a --- /dev/null +++ b/tests/Feature/InMemoryStrictGeneratesWithSecretsTest.php @@ -0,0 +1,34 @@ + [ + 'id' => 'abc123', + ], + ]); + + $configs = $b + ->secrets($secrets) + ->network(dns: 'cluster.local', pod: ['10.42.0.0/16'], svc: ['10.43.0.0/16']) + ->generateInMemory(); + + $cp = Yaml::parse($configs->controlplane()) ?: []; + $wk = Yaml::parse($configs->worker()) ?: []; + + expect($cp['cluster']['id'] ?? null)->toBe('abc123'); + expect($wk['cluster']['id'] ?? null)->toBe('abc123'); +}); diff --git a/tests/Feature/InMemoryStrictThrowsWhenNoFilesTest.php b/tests/Feature/InMemoryStrictThrowsWhenNoFilesTest.php new file mode 100644 index 0000000..c3bad63 --- /dev/null +++ b/tests/Feature/InMemoryStrictThrowsWhenNoFilesTest.php @@ -0,0 +1,16 @@ +generateInMemory(); +})->throws(RuntimeException::class); diff --git a/tests/GeneratedConfigsTest.php b/tests/GeneratedConfigsTest.php index 1fb1268..48e1052 100644 --- a/tests/GeneratedConfigsTest.php +++ b/tests/GeneratedConfigsTest.php @@ -6,10 +6,10 @@ use ArioLabs\Talos\GeneratedConfigs; use ArioLabs\Talos\TalosCluster; use Symfony\Component\Yaml\Yaml; -use Tests\Fakes\ProcessRunnerFake; +use Tests\Fakes\ProcessRunnerWritingFake; it('generates in-memory configs from builder patches', function (): void { - $runner = new ProcessRunnerFake(); + $runner = new ProcessRunnerWritingFake(); $talos = new TalosCluster($runner); $out = sys_get_temp_dir().'/talos-generated-inmem-'.uniqid();