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
28 changes: 23 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
49 changes: 46 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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'])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
100 changes: 92 additions & 8 deletions src/Builders/ClusterBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use ArioLabs\Talos\Enums\Cni;
use ArioLabs\Talos\TalosCluster;
use InvalidArgumentException;
use Symfony\Component\Yaml\Yaml;

final class ClusterBuilder
{
Expand All @@ -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<int, string> $sans */
Expand Down Expand Up @@ -459,11 +460,19 @@ public function etcdExtraArgs(array $args): static
}

public function generate(): string
{
$dir = $this->outDir ?? mb_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) {
Expand All @@ -479,15 +488,90 @@ 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<int|string, string|bool> $flags
*/
public function talosconfigInMemory(array $flags = []): string
{
return $this->talos->genTalosconfig($this->cluster, $this->endpoint, $flags);
}

/** @param array<int|string, mixed> $a
* @param array<int|string, mixed> $b
* @return array<int|string, mixed> */
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<int|string, mixed> $data
* @return array<int|string, mixed> */
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<int|string, mixed> $patch */
private function mergeMachinePatch(array $patch): void
{
Expand Down
32 changes: 32 additions & 0 deletions src/TalosCluster.php
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,38 @@ public function genSecrets(): TalosSecrets
return TalosSecrets::fromArray($data);
}

/** Generate a talosconfig and return its contents as a string.
* Uses a temporary directory by default; pass $outDir to control the path.
* No persistent files are left behind.
*
* @param array<int|string, string|bool> $flags
*/
public function genTalosconfig(string $cluster, string $endpoint, array $flags = [], ?string $outDir = null): string
{
$tmp = $outDir ?: (mb_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<int|string, string|bool> $flags */
public function genConfigWithSecrets(string $cluster, string $endpoint, string $outputDir, TalosSecrets $secrets, array $flags = []): string
{
Expand Down
35 changes: 35 additions & 0 deletions tests/Fakes/ProcessRunnerWritingFake.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace Tests\Fakes;

use ArioLabs\Talos\Support\Runner;
use Closure;

final class ProcessRunnerWritingFake implements Runner
{
public array $calls = [];

public function __construct(
private string $talosconfigContent = "kind: talosconfig\n",
private string $cpContent = "controlplane: true\n",
private string $wkContent = "worker: true\n",
) {}

public function run(array $args, ?Closure $onChunk = null): array
{
$this->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, '', ''];
}
}
45 changes: 45 additions & 0 deletions tests/Feature/InMemoryPatchFileFeatureTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

use ArioLabs\Talos\Builders\ClusterBuilder;
use ArioLabs\Talos\TalosCluster;
use Symfony\Component\Yaml\Yaml;
use Tests\Fakes\ProcessRunnerFake;

it('applies per-file patches in generateInMemory() and normalizes sequences', function (): void {
$runner = new ProcessRunnerFake();
$talos = new TalosCluster($runner);

$b = new ClusterBuilder($talos, 'demo', '10.0.0.1');

// Add an empty sequence to exercise normalization + nested merge on both CP/WK
$b->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();
});
17 changes: 17 additions & 0 deletions tests/Feature/TalosconfigInMemoryFromBuilderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

use ArioLabs\Talos\Builders\ClusterBuilder;
use ArioLabs\Talos\TalosCluster;
use Tests\Fakes\ProcessRunnerWritingFake;

it('fetches talosconfig via builder talosconfigInMemory()', function (): void {
$runner = new ProcessRunnerWritingFake("kind: talosconfig\n");
$talos = new TalosCluster($runner);

$b = new ClusterBuilder($talos, 'demo', 'https://10.0.0.1:6443');
$cfg = $b->talosconfigInMemory();

expect($cfg)->toContain('kind: talosconfig');
});
Loading