From 2cd10c3628dc41ca5328ea2acc7bde9694fcf700 Mon Sep 17 00:00:00 2001 From: Nick Arellano Date: Sat, 30 Aug 2025 21:15:50 -0500 Subject: [PATCH 1/2] feat(cluster): add in-memory config generation Add methods to generate Talos configurations in memory without disk persistence. Update ClusterBuilder to support optional outDir and introduce generateTo for explicit directory output. Enhance TalosCluster with genTalosconfig for in-memory talosconfig generation. Update documentation to reflect these changes. --- CHANGELOG.md | 28 ++++++++-- README.md | 49 +++++++++++++++- src/Builders/ClusterBuilder.php | 99 ++++++++++++++++++++++++++++++--- src/TalosCluster.php | 30 ++++++++++ 4 files changed, 190 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08d6cb0..b4a7bc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,30 @@ All notable changes to this project will be documented in this file. +## [1.1.0] - 2025-08-31 + +### Fixed +- `ClusterBuilder::generateInMemory()` no longer delegates to disk generation; now builds YAML purely in memory. + +### Added +- `TalosCluster::genTalosconfig(string $cluster, string $endpoint, array $flags = [])` to generate and return a `talosconfig` string using a temporary `talosctl gen config` run (no persistence). +- `ClusterBuilder::talosconfigInMemory(array $flags = [])` convenience wrapper for fetching an in‑memory `talosconfig` for the builder’s cluster/endpoint. +- `ClusterBuilder::generateTo(string $dir)` to explicitly generate configs to a target directory. + +### Changed +- `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 Initial stable release. -- Fluent builder API for Talos machine configuration -- Secrets generation + injection (TalosSecrets; TalosCluster::genSecrets, ClusterBuilder::secrets) -- YAML sequence normalization to avoid `{}` where lists are expected -- Integration validation with `talosctl validate` (required), CI matrix for Talos current + prior -- 100 0.000000e+00st coverage, PHPStan clean, Pint clean +### Added +- Fluent builder API for Talos machine configuration (networking, control plane, etcd, install helpers). +- Secrets workflow: `TalosCluster::genSecrets()`, `TalosSecrets` (serialize/deserialize), `ClusterBuilder::secrets(...)` injection. +- YAML patching helpers: `set()`, `patchBoth()`, `patchFile()` with documented precedence. +- Integration validation with `talosctl validate`; CI matrix for Talos current + prior minor. +- Laravel integration: `TalosFactory`, service provider, facade. + +### Quality +- 100% test coverage, PHPStan clean, Pint formatted. diff --git a/README.md b/README.md index 5620c9e..c61168e 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ $builder = new ClusterBuilder( talos: $talos, cluster: 'demo', endpoint: 'https://10.255.0.50:6443', - outDir: sys_get_temp_dir().'/talos-demo-'.uniqid(), + // outDir is optional; prefer in-memory + writeTo() or use generateTo($dir) ); $dir = $builder @@ -67,7 +67,7 @@ $dir = $builder ->cni(Cni::Flannel) ->additionalSans(['demo.local', '10.255.0.50']) ->manifests(['https://example.com/addons.yaml']) - ->generate(); + ->generateTo(sys_get_temp_dir().'/talos-demo-'.uniqid()); // Apply to a node (example) $talos->nodes(['10.255.0.50'])->applyConfig($dir.'/controlplane.yaml', insecure: true); @@ -94,7 +94,7 @@ $payload = $secrets->toBase64Json(); $secrets = TalosSecrets::fromBase64Json($payload); // 3) Regenerate configs with secrets -$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'); $dir = $builder ->secrets($secrets) ->network(dns: 'cluster.local', pod: ['10.42.0.0/16'], svc: ['10.43.0.0/16']) @@ -123,6 +123,45 @@ $workerYaml = $configs->worker(); // Optionally persist later $out = sys_get_temp_dir().'/talos-out-'.uniqid(); $configs->writeTo($out); + +// Also get an in-memory talosconfig (not persisted) +$talosconfig = $builder->talosconfigInMemory(); +``` + +### Laravel Workflow (Secrets → In‑Memory Configs → talosconfig) + +Below is an example of the intended application flow when using this package inside a Laravel app. Secrets are generated once, encrypted and stored by your app, and later used to regenerate configs and a talosconfig entirely in memory. + +```php +use ArioLabs\Talos\TalosFactory; +use ArioLabs\Talos\TalosSecrets; +use ArioLabs\Talos\Builders\ClusterBuilder; + +// 1) Generate secrets and store (encrypt before saving) +$talos = (new TalosFactory(['log' => false]))->for(); +$secrets = $talos->genSecrets(); +$payload = $secrets->toBase64Json(); // encrypt this string with app key, store in DB + +// ... later in a request/job: load + decrypt +$secrets = TalosSecrets::fromBase64Json($payload); + +// 2) Regenerate configs in memory +$builder = new ClusterBuilder($talos, 'demo', 'https://10.255.0.50:6443'); +$configs = $builder + ->secrets($secrets) + ->network(dns: 'cluster.local', pod: ['10.42.0.0/16'], svc: ['10.43.0.0/16']) + ->generateInMemory(); + +$controlplaneYaml = $configs->controlplane(); +$workerYaml = $configs->worker(); + +// 3) Obtain a talosconfig (in memory) for subsequent operations +$talosconfig = $builder->talosconfigInMemory(); + +// Optionally persist if needed +$dir = storage_path('talos/'.uniqid()); +$configs->writeTo($dir); +file_put_contents($dir.'/talosconfig', $talosconfig); ``` ## Fluent Builder Reference @@ -152,6 +191,10 @@ $configs->writeTo($out); - `patchFile(string $relativeYaml, array $patch)` → per-file overrides - Secrets - `secrets(TalosSecrets $secrets)` → injects cluster/machine secrets + - `generateInMemory()` → return `GeneratedConfigs` without touching disk + - `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 ## Patching Precedence diff --git a/src/Builders/ClusterBuilder.php b/src/Builders/ClusterBuilder.php index 3b7dc10..b3bd124 100644 --- a/src/Builders/ClusterBuilder.php +++ b/src/Builders/ClusterBuilder.php @@ -7,6 +7,7 @@ use ArioLabs\Talos\Enums\Cni; use ArioLabs\Talos\TalosCluster; use InvalidArgumentException; +use Symfony\Component\Yaml\Yaml; final class ClusterBuilder { @@ -31,7 +32,7 @@ public function __construct( private TalosCluster $talos, private string $cluster, private string $endpoint, - private string $outDir, + private ?string $outDir = null, ) {} /** @param array $sans */ @@ -459,11 +460,19 @@ public function etcdExtraArgs(array $args): static } public function generate(): string + { + $dir = $this->outDir ?? rtrim(sys_get_temp_dir(), '/').'/talos-gen-'.uniqid(); + + return $this->generateTo($dir); + } + + /** Generate configs to a specific directory (explicit disk output). */ + public function generateTo(string $dir): string { if ($this->secrets !== null) { - $dir = $this->talos->genConfigWithSecrets($this->cluster, $this->endpoint, $this->outDir, $this->secrets, $this->flags); + $dir = $this->talos->genConfigWithSecrets($this->cluster, $this->endpoint, $dir, $this->secrets, $this->flags); } else { - $dir = $this->talos->genConfig($this->cluster, $this->endpoint, $this->outDir, $this->flags); + $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) { @@ -479,15 +488,89 @@ public function generate(): string public function generateInMemory(): \ArioLabs\Talos\GeneratedConfigs { - $dir = $this->generate(); - $cp = $dir.'/controlplane.yaml'; - $wk = $dir.'/worker.yaml'; - $cpYaml = is_file($cp) ? (string) file_get_contents($cp) : ''; - $wkYaml = is_file($wk) ? (string) file_get_contents($wk) : ''; + // Build configs purely in memory from accumulated patches. + // Start from empty base and merge machine-level patches into both files. + $cp = []; + $wk = []; + + if ($this->machinePatches) { + $cp = $this->deepMerge($cp, $this->machinePatches); + $wk = $this->deepMerge($wk, $this->machinePatches); + } + + // 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); + } + } + + // Normalize and dump as YAML strings. + $cpYaml = Yaml::dump($this->normalizeSequences($cp), 8, 2); + $wkYaml = Yaml::dump($this->normalizeSequences($wk), 8, 2); return new \ArioLabs\Talos\GeneratedConfigs($cpYaml, $wkYaml); } + /** Convenience: generate a talosconfig string for this builder's + * cluster/endpoint using a temporary gen-config run (no persistence). + * @param array $flags + */ + 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 { diff --git a/src/TalosCluster.php b/src/TalosCluster.php index b53bf78..86071c8 100644 --- a/src/TalosCluster.php +++ b/src/TalosCluster.php @@ -209,6 +209,36 @@ public function genSecrets(): TalosSecrets return TalosSecrets::fromArray($data); } + /** Generate a talosconfig and return its contents as a string. + * Uses a temporary directory; no persistent files are left behind. + * @param array $flags + */ + public function genTalosconfig(string $cluster, string $endpoint, array $flags = []): string + { + $tmp = rtrim(sys_get_temp_dir(), '/').'/talos-tmp-'.uniqid(); + @mkdir($tmp, 0775, true); + + // Reuse genConfig to invoke talosctl with any flags provided. + $dir = $this->genConfig($cluster, $endpoint, $tmp, $flags); + $path = $dir.'/talosconfig'; + + $content = is_file($path) ? (string) file_get_contents($path) : ''; + + // Best-effort cleanup + if (is_file($path)) { + @unlink($path); + } + // Remove other files if present + foreach (['controlplane.yaml', 'worker.yaml'] as $f) { + if (is_file($dir.'/'.$f)) { + @unlink($dir.'/'.$f); + } + } + @rmdir($dir); + + return $content; + } + /** @param array $flags */ public function genConfigWithSecrets(string $cluster, string $endpoint, string $outputDir, TalosSecrets $secrets, array $flags = []): string { From 27c9bb077abaf0f0ff9fe6bfd5a21d1f2182ecdd Mon Sep 17 00:00:00 2001 From: Nick Arellano Date: Sat, 30 Aug 2025 21:21:21 -0500 Subject: [PATCH 2/2] feat(cluster): enhance temp dir handling and add tests Improve temporary directory handling by using mb_rtrim for consistency. Add comprehensive tests for in-memory talosconfig generation and patch file application to ensure reliability and correctness. --- src/Builders/ClusterBuilder.php | 5 ++- src/TalosCluster.php | 10 +++-- tests/Fakes/ProcessRunnerWritingFake.php | 35 +++++++++++++++ .../Feature/InMemoryPatchFileFeatureTest.php | 45 +++++++++++++++++++ .../TalosconfigInMemoryFromBuilderTest.php | 17 +++++++ tests/TalosconfigGenerationTest.php | 24 ++++++++++ 6 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 tests/Fakes/ProcessRunnerWritingFake.php create mode 100644 tests/Feature/InMemoryPatchFileFeatureTest.php create mode 100644 tests/Feature/TalosconfigInMemoryFromBuilderTest.php create mode 100644 tests/TalosconfigGenerationTest.php diff --git a/src/Builders/ClusterBuilder.php b/src/Builders/ClusterBuilder.php index b3bd124..0f226f5 100644 --- a/src/Builders/ClusterBuilder.php +++ b/src/Builders/ClusterBuilder.php @@ -461,7 +461,7 @@ public function etcdExtraArgs(array $args): static public function generate(): string { - $dir = $this->outDir ?? rtrim(sys_get_temp_dir(), '/').'/talos-gen-'.uniqid(); + $dir = $this->outDir ?? mb_rtrim(sys_get_temp_dir(), '/').'/talos-gen-'.uniqid(); return $this->generateTo($dir); } @@ -516,7 +516,8 @@ public function generateInMemory(): \ArioLabs\Talos\GeneratedConfigs /** Convenience: generate a talosconfig string for this builder's * cluster/endpoint using a temporary gen-config run (no persistence). - * @param array $flags + * + * @param array $flags */ public function talosconfigInMemory(array $flags = []): string { diff --git a/src/TalosCluster.php b/src/TalosCluster.php index 86071c8..babeddd 100644 --- a/src/TalosCluster.php +++ b/src/TalosCluster.php @@ -210,12 +210,14 @@ public function genSecrets(): TalosSecrets } /** Generate a talosconfig and return its contents as a string. - * Uses a temporary directory; no persistent files are left behind. - * @param array $flags + * Uses a temporary directory by default; pass $outDir to control the path. + * No persistent files are left behind. + * + * @param array $flags */ - public function genTalosconfig(string $cluster, string $endpoint, array $flags = []): string + public function genTalosconfig(string $cluster, string $endpoint, array $flags = [], ?string $outDir = null): string { - $tmp = rtrim(sys_get_temp_dir(), '/').'/talos-tmp-'.uniqid(); + $tmp = $outDir ?: (mb_rtrim(sys_get_temp_dir(), '/').'/talos-tmp-'.uniqid()); @mkdir($tmp, 0775, true); // Reuse genConfig to invoke talosctl with any flags provided. diff --git a/tests/Fakes/ProcessRunnerWritingFake.php b/tests/Fakes/ProcessRunnerWritingFake.php new file mode 100644 index 0000000..bd13369 --- /dev/null +++ b/tests/Fakes/ProcessRunnerWritingFake.php @@ -0,0 +1,35 @@ +calls[] = $args; + // Find output directory in args (after --output) + $outIndex = array_search('--output', $args, true); + if ($outIndex !== false && isset($args[$outIndex + 1])) { + $dir = (string) $args[$outIndex + 1]; + @mkdir($dir, 0775, true); + file_put_contents($dir.'/talosconfig', $this->talosconfigContent); + file_put_contents($dir.'/controlplane.yaml', $this->cpContent); + file_put_contents($dir.'/worker.yaml', $this->wkContent); + } + + return [0, '', '']; + } +} diff --git a/tests/Feature/InMemoryPatchFileFeatureTest.php b/tests/Feature/InMemoryPatchFileFeatureTest.php new file mode 100644 index 0000000..a88972a --- /dev/null +++ b/tests/Feature/InMemoryPatchFileFeatureTest.php @@ -0,0 +1,45 @@ +set('cluster.apiServer.certSANs', []); + + // Per-file patches with nested structures to hit recursive deepMerge + $b->patchFile('controlplane.yaml', [ + 'cluster' => [ + 'network' => [ + 'dnsDomain' => 'cluster.local', + ], + ], + ]); + $b->patchFile('worker.yaml', [ + 'machine' => [ + 'network' => [ + 'nameservers' => ['1.1.1.1'], + ], + ], + ]); + + $configs = $b->generateInMemory(); + + $cp = Yaml::parse($configs->controlplane()) ?: []; + $wk = Yaml::parse($configs->worker()) ?: []; + + expect($cp['cluster']['network']['dnsDomain'] ?? null)->toBe('cluster.local'); + expect($wk['machine']['network']['nameservers'] ?? [])->toBe(['1.1.1.1']); + + // certSANs was an empty sequence; it should be omitted after normalization + expect(isset($cp['cluster']['apiServer']['certSANs']))->toBeFalse(); +}); diff --git a/tests/Feature/TalosconfigInMemoryFromBuilderTest.php b/tests/Feature/TalosconfigInMemoryFromBuilderTest.php new file mode 100644 index 0000000..1596b5a --- /dev/null +++ b/tests/Feature/TalosconfigInMemoryFromBuilderTest.php @@ -0,0 +1,17 @@ +talosconfigInMemory(); + + expect($cfg)->toContain('kind: talosconfig'); +}); diff --git a/tests/TalosconfigGenerationTest.php b/tests/TalosconfigGenerationTest.php new file mode 100644 index 0000000..3761e5b --- /dev/null +++ b/tests/TalosconfigGenerationTest.php @@ -0,0 +1,24 @@ +genTalosconfig('demo', 'https://10.0.0.1:6443', outDir: $dir); + + expect($content)->toContain('kind: talosconfig'); + // Files should be removed by genTalosconfig + expect(is_file($dir.'/talosconfig'))->toBeFalse(); + expect(is_file($dir.'/controlplane.yaml'))->toBeFalse(); + expect(is_file($dir.'/worker.yaml'))->toBeFalse(); + // Best-effort rmdir — directory may or may not be removed depending on FS; assert no files +});