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
12 changes: 10 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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();
```

Expand Down Expand Up @@ -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
Expand Down
20 changes: 14 additions & 6 deletions rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
146 changes: 62 additions & 84 deletions src/Builders/ClusterBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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<int, string> $sans */
public function additionalSans(array $sans): static
{
if ($sans) {
if ($sans !== []) {
$this->flags['--additional-sans'] = implode(',', $sans);
}

Expand Down Expand Up @@ -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' => [
Expand All @@ -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' => [
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand All @@ -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
Expand All @@ -524,54 +550,6 @@ 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 All @@ -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');
}

Expand All @@ -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));
}
Expand All @@ -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;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/GeneratedConfigs.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace ArioLabs\Talos;

final class GeneratedConfigs
final readonly class GeneratedConfigs
{
public function __construct(
private string $controlplane,
Expand Down
6 changes: 3 additions & 3 deletions src/Support/ProcessRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
Expand All @@ -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];
Expand Down
Loading