From e5b57383ec762c286b039b3a3247983c86f5a473 Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Thu, 9 Apr 2026 01:53:08 -0400 Subject: [PATCH 1/2] feat: add ingress provider selection --- README.md | 3 +- src/Commands/InstallCommand.php | 1 + src/Helm/HelmChartGenerator.php | 3 ++ src/Install/IngressProviderRecommendation.php | 23 ++++++++ ...ngressProviderRecommendationRepository.php | 48 +++++++++++++++++ src/Install/InstallConfiguration.php | 38 +++++++++++++- src/Install/InstallConfigurationCollector.php | 47 ++++++++++++++++- stubs/helm/values.yaml.stub | 5 +- tests/Feature/InstallCommandTest.php | 19 ++++++- tests/Unit/Helm/HelmChartGeneratorTest.php | 18 +++++++ ...ssProviderRecommendationRepositoryTest.php | 52 +++++++++++++++++++ .../InstallConfigurationCollectorTest.php | 27 +++++++++- 12 files changed, 276 insertions(+), 8 deletions(-) create mode 100644 src/Install/IngressProviderRecommendation.php create mode 100644 src/Install/IngressProviderRecommendationRepository.php create mode 100644 tests/Unit/Install/IngressProviderRecommendationRepositoryTest.php diff --git a/README.md b/README.md index 3db25ef..d907695 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Beacon currently prompts for: - application name - runtime: `php-fpm` or `octane` - deployment scaffolding: `docker`, `helm`, or `docker-and-helm` +- ingress provider: disabled, Ingress NGINX, or Traefik - secret handling: Beacon-managed Helm secret or an existing Kubernetes secret - whether Beacon should update Composer scripts @@ -88,7 +89,7 @@ Example managed scripts: } ``` -Beacon generates `values.yaml` as the shared chart values file and also creates environment-specific overlays for `local`, `staging`, and `production`. The deploy command always applies `values.yaml` first, then layers the selected environment values file on top when it runs Helm. +Beacon generates `values.yaml` as the shared chart values file and also creates environment-specific overlays for `local`, `staging`, and `production`. The selected ingress strategy is persisted there so the generated chart knows whether ingress is disabled and which class name Beacon should prepare for. The deploy command always applies `values.yaml` first, then layers the selected environment values file on top when it runs Helm. Sensitive application values are kept out of the committed environment overlays. Beacon now: diff --git a/src/Commands/InstallCommand.php b/src/Commands/InstallCommand.php index bfd7d98..57b39b9 100644 --- a/src/Commands/InstallCommand.php +++ b/src/Commands/InstallCommand.php @@ -68,6 +68,7 @@ protected function displayConfigurationSummary(InstallConfiguration $configurati $this->components->twoColumnDetail('Scaffolding', $configuration->deploymentTargetLabel()); if ($configuration->usesHelm()) { + $this->components->twoColumnDetail('Ingress', $configuration->ingressProviderLabel()); $this->components->twoColumnDetail('Secrets', $configuration->secretHandlingLabel()); if ($configuration->existingSecretName !== null) { diff --git a/src/Helm/HelmChartGenerator.php b/src/Helm/HelmChartGenerator.php index 78f0e2c..fe4e670 100644 --- a/src/Helm/HelmChartGenerator.php +++ b/src/Helm/HelmChartGenerator.php @@ -47,6 +47,9 @@ public function renderFiles(InstallConfiguration $configuration): array '{{service_port}}' => (string) $this->servicePort($configuration), '{{create_managed_secret}}' => $configuration->secretHandling === 'managed-secret' ? 'true' : 'false', '{{existing_secret_name}}' => $this->escapeYamlDoubleQuotedString($configuration->existingSecretName ?? ''), + '{{ingress_provider}}' => $configuration->ingressProvider, + '{{ingress_enabled}}' => $configuration->ingressEnabled() ? 'true' : 'false', + '{{ingress_class_name}}' => $this->escapeYamlDoubleQuotedString($configuration->ingressClassName()), ]; $files = []; diff --git a/src/Install/IngressProviderRecommendation.php b/src/Install/IngressProviderRecommendation.php new file mode 100644 index 0000000..037f235 --- /dev/null +++ b/src/Install/IngressProviderRecommendation.php @@ -0,0 +1,23 @@ +provider, InstallConfiguration::INGRESS_PROVIDER_OPTIONS)) { + throw new InvalidArgumentException(sprintf('Unsupported ingress provider recommendation [%s].', $this->provider)); + } + + if (trim($this->clusterContext) === '') { + throw new InvalidArgumentException('A cluster context is required for ingress recommendations.'); + } + } +} diff --git a/src/Install/IngressProviderRecommendationRepository.php b/src/Install/IngressProviderRecommendationRepository.php new file mode 100644 index 0000000..4d4223c --- /dev/null +++ b/src/Install/IngressProviderRecommendationRepository.php @@ -0,0 +1,48 @@ +currentContext($basePath); + + if ($context === null) { + return null; + } + + $normalized = strtolower($context); + + return match (true) { + str_contains($normalized, 'rancher-desktop'), + str_contains($normalized, 'k3s'), + str_contains($normalized, 'traefik') => new IngressProviderRecommendation('traefik', $context), + str_contains($normalized, 'nginx'), + str_contains($normalized, 'ingress-nginx'), + str_contains($normalized, 'minikube') => new IngressProviderRecommendation('nginx', $context), + default => null, + }; + } + + private function currentContext(string $basePath): ?string + { + $result = Process::path($basePath)->run([ + 'kubectl', + 'config', + 'current-context', + ]); + + if (! $result->successful()) { + return null; + } + + $context = trim($result->output()); + + return $context !== '' ? $context : null; + } +} diff --git a/src/Install/InstallConfiguration.php b/src/Install/InstallConfiguration.php index 3ec460d..cf7578d 100644 --- a/src/Install/InstallConfiguration.php +++ b/src/Install/InstallConfiguration.php @@ -33,6 +33,15 @@ 'existing-secret' => 'Existing Kubernetes secret', ]; + /** + * @var array + */ + public const INGRESS_PROVIDER_OPTIONS = [ + 'none' => 'Disabled', + 'nginx' => 'Ingress NGINX', + 'traefik' => 'Traefik', + ]; + public function __construct( public string $applicationName, public string $runtime, @@ -40,6 +49,7 @@ public function __construct( public bool $updateComposerScripts, public string $secretHandling = 'managed-secret', public ?string $existingSecretName = null, + public string $ingressProvider = 'none', ) { if (! array_key_exists($this->runtime, self::RUNTIME_OPTIONS)) { throw new InvalidArgumentException(sprintf('Unsupported runtime [%s].', $this->runtime)); @@ -60,6 +70,10 @@ public function __construct( if ($this->secretHandling !== 'existing-secret' && $this->existingSecretName !== null) { throw new InvalidArgumentException('An existing secret name may only be provided when using the existing Kubernetes secret mode.'); } + + if (! array_key_exists($this->ingressProvider, self::INGRESS_PROVIDER_OPTIONS)) { + throw new InvalidArgumentException(sprintf('Unsupported ingress provider [%s].', $this->ingressProvider)); + } } public function runtimeLabel(): string @@ -82,6 +96,26 @@ public function usesHelm(): bool return in_array($this->deploymentTarget, ['helm', 'docker-and-helm'], true); } + public function ingressProviderLabel(): string + { + return self::INGRESS_PROVIDER_OPTIONS[$this->ingressProvider]; + } + + public function ingressEnabled(): bool + { + return $this->ingressProvider !== 'none'; + } + + public function ingressClassName(): string + { + return match ($this->ingressProvider) { + 'none' => '', + 'nginx' => 'nginx', + 'traefik' => 'traefik', + default => throw new InvalidArgumentException(sprintf('Unsupported ingress provider [%s].', $this->ingressProvider)), + }; + } + /** * @return array{ * application_name: string, @@ -89,7 +123,8 @@ public function usesHelm(): bool * deployment_target: string, * update_composer_scripts: bool, * secret_handling: string, - * existing_secret_name: ?string + * existing_secret_name: ?string, + * ingress_provider: string * } */ public function toArray(): array @@ -101,6 +136,7 @@ public function toArray(): array 'update_composer_scripts' => $this->updateComposerScripts, 'secret_handling' => $this->secretHandling, 'existing_secret_name' => $this->existingSecretName, + 'ingress_provider' => $this->ingressProvider, ]; } } diff --git a/src/Install/InstallConfigurationCollector.php b/src/Install/InstallConfigurationCollector.php index e150c24..5e88c82 100644 --- a/src/Install/InstallConfigurationCollector.php +++ b/src/Install/InstallConfigurationCollector.php @@ -10,12 +10,21 @@ class InstallConfigurationCollector { + public function __construct( + private readonly ?IngressProviderRecommendationRepository $ingressProviderRecommendationRepository = null, + ) { + } + public function collect( string $basePath, ?string $applicationName = null, bool $interactive = true, ): InstallConfiguration { - $defaults = $this->defaultConfiguration($basePath, $applicationName); + $defaults = $this->defaultConfiguration( + $basePath, + $applicationName, + $this->ingressProviderRecommendationRepository?->recommend($basePath), + ); if (! $interactive) { return $defaults; @@ -24,6 +33,9 @@ public function collect( $applicationName = $this->askApplicationName($defaults->applicationName); $runtime = $this->askRuntime($defaults->runtime); $deploymentTarget = $this->askDeploymentTarget($defaults->deploymentTarget); + $ingressProvider = $this->usesHelm($deploymentTarget) + ? $this->askIngressProvider($defaults->ingressProvider, $this->ingressProviderRecommendationRepository?->recommend($basePath)) + : 'none'; $updateComposerScripts = $this->askUpdateComposerScripts($defaults->updateComposerScripts); $secretHandling = $this->usesHelm($deploymentTarget) ? $this->askSecretHandling($defaults->secretHandling) @@ -39,10 +51,15 @@ public function collect( updateComposerScripts: $updateComposerScripts, secretHandling: $secretHandling, existingSecretName: $existingSecretName, + ingressProvider: $ingressProvider, ); } - public function defaultConfiguration(string $basePath, ?string $applicationName = null): InstallConfiguration + public function defaultConfiguration( + string $basePath, + ?string $applicationName = null, + ?IngressProviderRecommendation $ingressProviderRecommendation = null, + ): InstallConfiguration { $normalizedApplicationName = $this->normalizeApplicationName($applicationName ?? ''); @@ -55,6 +72,7 @@ public function defaultConfiguration(string $basePath, ?string $applicationName updateComposerScripts: true, secretHandling: 'managed-secret', existingSecretName: null, + ingressProvider: $ingressProviderRecommendation?->provider ?? 'none', ); } @@ -102,6 +120,31 @@ protected function askUpdateComposerScripts(bool $default): bool ); } + protected function askIngressProvider( + string $default, + ?IngressProviderRecommendation $recommendation = null, + ): string { + $options = InstallConfiguration::INGRESS_PROVIDER_OPTIONS; + + if ($recommendation !== null && array_key_exists($recommendation->provider, $options)) { + $options[$recommendation->provider] = sprintf( + '%s (Recommended for %s)', + $options[$recommendation->provider], + $recommendation->clusterContext, + ); + $default = $recommendation->provider; + } + + /** @var string $ingressProvider */ + $ingressProvider = select( + label: 'Which ingress provider should Beacon prepare for?', + options: $options, + default: $default + ); + + return $ingressProvider; + } + protected function askSecretHandling(string $default): string { /** @var string $secretHandling */ diff --git a/stubs/helm/values.yaml.stub b/stubs/helm/values.yaml.stub index 21027a6..ed38d7e 100644 --- a/stubs/helm/values.yaml.stub +++ b/stubs/helm/values.yaml.stub @@ -12,8 +12,9 @@ service: port: {{service_port}} ingress: - enabled: false - className: "" + provider: {{ingress_provider}} + enabled: {{ingress_enabled}} + className: "{{ingress_class_name}}" annotations: {} hosts: - host: {{chart_name}}.example.com diff --git a/tests/Feature/InstallCommandTest.php b/tests/Feature/InstallCommandTest.php index 43c74c7..f40a059 100644 --- a/tests/Feature/InstallCommandTest.php +++ b/tests/Feature/InstallCommandTest.php @@ -41,6 +41,7 @@ function expectBeaconInstallPrompts( string $applicationName = 'Beacon Demo', string $runtime = 'octane', string $deploymentTarget = 'docker-and-helm', + string $ingressProvider = 'none', bool $updateComposerScripts = true, string $secretHandling = 'managed-secret', ): PendingCommand { @@ -64,6 +65,15 @@ function expectBeaconInstallPrompts( 'docker-and-helm' => 'Dockerfile and Helm chart', ] ) + ->expectsChoice( + 'Which ingress provider should Beacon prepare for?', + $ingressProvider, + [ + 'none' => 'Disabled', + 'nginx' => 'Ingress NGINX', + 'traefik' => 'Traefik', + ] + ) ->expectsConfirmation( 'Should Beacon plan to update Composer scripts during installation?', $updateComposerScripts ? 'yes' : 'no' @@ -118,11 +128,12 @@ function supportsPendingPromptExpectations(): bool fakeOctaneComposerRequire($directory); try { - expectBeaconInstallPrompts($this->artisan('beacon:install'), ' Beacon Demo ') + expectBeaconInstallPrompts($this->artisan('beacon:install'), ' Beacon Demo ', ingressProvider: 'traefik') ->expectsOutputToContain('Install skeleton summary') ->expectsOutputToContain('Beacon Demo') ->expectsOutputToContain('Laravel Octane') ->expectsOutputToContain('Dockerfile and Helm chart') + ->expectsOutputToContain('Traefik') ->expectsOutputToContain('Beacon-managed Helm secret') ->expectsOutputToContain('Plan to update') ->expectsOutputToContain('Octane integration') @@ -146,6 +157,7 @@ function supportsPendingPromptExpectations(): bool ->and(file_get_contents($directory.'/Dockerfile'))->toContain('LABEL io.devoption.beacon.runtime="octane"') ->and($directory.'/charts/beacon-demo/Chart.yaml')->toBeFile() ->and(file_get_contents($directory.'/charts/beacon-demo/values.yaml'))->toContain('runtime: octane') + ->and(file_get_contents($directory.'/charts/beacon-demo/values.yaml'))->toContain('provider: traefik') ->and(file_get_contents($directory.'/charts/beacon-demo/values.yaml'))->toContain('create: true') ->and($directory.'/charts/beacon-demo/values.local.yaml')->toBeFile() ->and($directory.'/charts/beacon-demo/values.local.secrets.example.yaml')->toBeFile() @@ -181,6 +193,7 @@ function supportsPendingPromptExpectations(): bool updateComposerScripts: true, secretHandling: 'managed-secret', existingSecretName: null, + ingressProvider: 'nginx', )); fakeOctaneComposerRequire($directory); @@ -190,6 +203,7 @@ function supportsPendingPromptExpectations(): bool ->expectsOutputToContain('Beacon Demo') ->expectsOutputToContain('Laravel Octane') ->expectsOutputToContain('Dockerfile and Helm chart') + ->expectsOutputToContain('Ingress NGINX') ->expectsOutputToContain('Beacon-managed Helm secret') ->expectsOutputToContain('Plan to update') ->expectsOutputToContain('Octane integration') @@ -213,6 +227,7 @@ function supportsPendingPromptExpectations(): bool ->and(file_get_contents($directory.'/Dockerfile'))->toContain('LABEL io.devoption.beacon.runtime="octane"') ->and($directory.'/charts/beacon-demo/Chart.yaml')->toBeFile() ->and(file_get_contents($directory.'/charts/beacon-demo/values.yaml'))->toContain('runtime: octane') + ->and(file_get_contents($directory.'/charts/beacon-demo/values.yaml'))->toContain('provider: nginx') ->and(file_get_contents($directory.'/charts/beacon-demo/values.yaml'))->toContain('create: true') ->and($directory.'/charts/beacon-demo/values.local.yaml')->toBeFile() ->and($directory.'/charts/beacon-demo/values.local.secrets.example.yaml')->toBeFile() @@ -248,6 +263,7 @@ function supportsPendingPromptExpectations(): bool updateComposerScripts: true, secretHandling: 'managed-secret', existingSecretName: null, + ingressProvider: 'none', )); fakeOctaneComposerRequire($directory); @@ -297,6 +313,7 @@ function supportsPendingPromptExpectations(): bool updateComposerScripts: false, secretHandling: 'managed-secret', existingSecretName: null, + ingressProvider: 'none', )); $this->artisan('beacon:install', ['--no-interaction' => true]) diff --git a/tests/Unit/Helm/HelmChartGeneratorTest.php b/tests/Unit/Helm/HelmChartGeneratorTest.php index 7f817fc..e40b46f 100644 --- a/tests/Unit/Helm/HelmChartGeneratorTest.php +++ b/tests/Unit/Helm/HelmChartGeneratorTest.php @@ -38,6 +38,7 @@ ->and($files['Chart.yaml'])->toContain('Beacon Demo') ->and($files['values.yaml'])->toContain('runtime: php-fpm') ->and($files['values.yaml'])->toContain('port: 9000') + ->and($files['values.yaml'])->toContain('provider: none') ->and($files['values.yaml'])->toContain('create: true') ->and($files['values.local.yaml'])->toContain('APP_ENV: local') ->and($files['values.local.secrets.example.yaml'])->toContain('# APP_KEY: base64:replace-me') @@ -62,6 +63,23 @@ ->and($files['values.yaml'])->toContain('port: 8000'); }); +it('renders ingress provider values from the selected install strategy', function (): void { + $generator = new HelmChartGenerator(new SafeFileWriter); + $configuration = new InstallConfiguration( + applicationName: 'Beacon Demo', + runtime: 'octane', + deploymentTarget: 'docker-and-helm', + updateComposerScripts: false, + ingressProvider: 'traefik', + ); + + $files = $generator->renderFiles($configuration); + + expect($files['values.yaml'])->toContain('provider: traefik') + ->and($files['values.yaml'])->toContain('enabled: true') + ->and($files['values.yaml'])->toContain('className: "traefik"'); +}); + it('renders existing secret references when the user chooses an external kubernetes secret', function (): void { $generator = new HelmChartGenerator(new SafeFileWriter); $configuration = new InstallConfiguration( diff --git a/tests/Unit/Install/IngressProviderRecommendationRepositoryTest.php b/tests/Unit/Install/IngressProviderRecommendationRepositoryTest.php new file mode 100644 index 0000000..f6c578b --- /dev/null +++ b/tests/Unit/Install/IngressProviderRecommendationRepositoryTest.php @@ -0,0 +1,52 @@ + Process::result("rancher-desktop\n", '', 0), + ]); + + $repository = new IngressProviderRecommendationRepository; + + $recommendation = $repository->recommend('/tmp/app'); + + expect($recommendation)->not->toBeNull() + ->and($recommendation?->provider)->toBe('traefik') + ->and($recommendation?->clusterContext)->toBe('rancher-desktop'); +}); + +it('recommends ingress nginx for nginx-oriented contexts', function (): void { + Process::fake([ + '*' => Process::result("kind-nginx\n", '', 0), + ]); + + $repository = new IngressProviderRecommendationRepository; + + $recommendation = $repository->recommend('/tmp/app'); + + expect($recommendation)->not->toBeNull() + ->and($recommendation?->provider)->toBe('nginx'); +}); + +it('returns no recommendation when kubectl is unavailable or the context is unknown', function (): void { + Process::fake([ + '*' => Process::result('', 'kubectl not available', 1), + ]); + + $repository = new IngressProviderRecommendationRepository; + + expect($repository->recommend('/tmp/app'))->toBeNull(); + + Process::fake([ + '*' => Process::result("production-cluster\n", '', 0), + ]); + + expect($repository->recommend('/tmp/app'))->toBeNull(); +}); diff --git a/tests/Unit/Install/InstallConfigurationCollectorTest.php b/tests/Unit/Install/InstallConfigurationCollectorTest.php index 66ad4d2..a08b0bb 100644 --- a/tests/Unit/Install/InstallConfigurationCollectorTest.php +++ b/tests/Unit/Install/InstallConfigurationCollectorTest.php @@ -4,6 +4,7 @@ use DevOption\Beacon\Install\InstallConfiguration; use DevOption\Beacon\Install\InstallConfigurationCollector; +use DevOption\Beacon\Install\IngressProviderRecommendation; it('builds a default install configuration from the application context', function (): void { $collector = new InstallConfigurationCollector; @@ -19,7 +20,8 @@ ->and($configuration->deploymentTarget)->toBe('docker-and-helm') ->and($configuration->updateComposerScripts)->toBeTrue() ->and($configuration->secretHandling)->toBe('managed-secret') - ->and($configuration->existingSecretName)->toBeNull(); + ->and($configuration->existingSecretName)->toBeNull() + ->and($configuration->ingressProvider)->toBe('none'); }); it('falls back to the base path name when no application name is provided', function (): void { @@ -32,6 +34,18 @@ expect($configuration->applicationName)->toBe('acme-platform'); }); +it('uses the recommended ingress provider when one is available from the cluster context', function (): void { + $collector = new InstallConfigurationCollector; + + $configuration = $collector->defaultConfiguration( + basePath: '/srv/apps/acme-platform', + applicationName: 'Acme Platform', + ingressProviderRecommendation: new IngressProviderRecommendation('traefik', 'rancher-desktop'), + ); + + expect($configuration->ingressProvider)->toBe('traefik'); +}); + it('falls back to the base path name when the configured application name is blank', function (): void { $collector = new InstallConfigurationCollector; @@ -74,6 +88,16 @@ protected function askUpdateComposerScripts(bool $default): bool return false; } + protected function askIngressProvider( + string $default, + ?IngressProviderRecommendation $recommendation = null, + ): string { + expect($default)->toBe('none') + ->and($recommendation)->toBeNull(); + + return 'traefik'; + } + protected function askSecretHandling(string $default): string { expect($default)->toBe('managed-secret'); @@ -102,5 +126,6 @@ protected function askExistingSecretName(string $default): string 'update_composer_scripts' => false, 'secret_handling' => 'existing-secret', 'existing_secret_name' => 'shared-platform-env', + 'ingress_provider' => 'traefik', ]); }); From 6aba9d4e0e02862f4bd9c4537e7ac04863e1947d Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Thu, 9 Apr 2026 02:03:55 -0400 Subject: [PATCH 2/2] fix: reuse ingress recommendation during install --- src/Install/InstallConfigurationCollector.php | 5 +++-- tests/Unit/Install/InstallConfigurationCollectorTest.php | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Install/InstallConfigurationCollector.php b/src/Install/InstallConfigurationCollector.php index 5e88c82..cfa2057 100644 --- a/src/Install/InstallConfigurationCollector.php +++ b/src/Install/InstallConfigurationCollector.php @@ -20,10 +20,11 @@ public function collect( ?string $applicationName = null, bool $interactive = true, ): InstallConfiguration { + $ingressProviderRecommendation = $this->ingressProviderRecommendationRepository?->recommend($basePath); $defaults = $this->defaultConfiguration( $basePath, $applicationName, - $this->ingressProviderRecommendationRepository?->recommend($basePath), + $ingressProviderRecommendation, ); if (! $interactive) { @@ -34,7 +35,7 @@ public function collect( $runtime = $this->askRuntime($defaults->runtime); $deploymentTarget = $this->askDeploymentTarget($defaults->deploymentTarget); $ingressProvider = $this->usesHelm($deploymentTarget) - ? $this->askIngressProvider($defaults->ingressProvider, $this->ingressProviderRecommendationRepository?->recommend($basePath)) + ? $this->askIngressProvider($defaults->ingressProvider, $ingressProviderRecommendation) : 'none'; $updateComposerScripts = $this->askUpdateComposerScripts($defaults->updateComposerScripts); $secretHandling = $this->usesHelm($deploymentTarget) diff --git a/tests/Unit/Install/InstallConfigurationCollectorTest.php b/tests/Unit/Install/InstallConfigurationCollectorTest.php index a08b0bb..df2a74f 100644 --- a/tests/Unit/Install/InstallConfigurationCollectorTest.php +++ b/tests/Unit/Install/InstallConfigurationCollectorTest.php @@ -5,6 +5,7 @@ use DevOption\Beacon\Install\InstallConfiguration; use DevOption\Beacon\Install\InstallConfigurationCollector; use DevOption\Beacon\Install\IngressProviderRecommendation; +use DevOption\Beacon\Install\IngressProviderRecommendationRepository; it('builds a default install configuration from the application context', function (): void { $collector = new InstallConfigurationCollector;