From 7024a3d9792a57e5a4b339d5498aea92c3fc1885 Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Thu, 9 Apr 2026 00:33:44 -0400 Subject: [PATCH 1/2] feat: add kubernetes context selection to deploy command --- README.md | 2 +- src/BeaconServiceProvider.php | 2 + src/Commands/DeployCommand.php | 204 ++++++++++++++++++ src/Composer/ComposerScriptsUpdater.php | 5 +- src/Deploy/HelmReleaseDeployer.php | 44 ++++ src/Deploy/KubernetesContextRepository.php | 87 ++++++++ src/Deploy/KubernetesContexts.php | 34 +++ tests/Feature/DeployCommandTest.php | 157 ++++++++++++++ tests/Feature/InstallCommandTest.php | 4 +- .../Composer/ComposerScriptsUpdaterTest.php | 4 +- 10 files changed, 534 insertions(+), 9 deletions(-) create mode 100644 src/Commands/DeployCommand.php create mode 100644 src/Deploy/HelmReleaseDeployer.php create mode 100644 src/Deploy/KubernetesContextRepository.php create mode 100644 src/Deploy/KubernetesContexts.php create mode 100644 tests/Feature/DeployCommandTest.php diff --git a/README.md b/README.md index 63916b0..bfa86a3 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Example managed scripts: { "scripts": { "beacon:build": "docker build --file Dockerfile --tag my-app:latest .", - "beacon:deploy": "helm upgrade --install my-app ./charts/my-app --namespace default --create-namespace" + "beacon:deploy": "@php artisan beacon:deploy" } } ``` diff --git a/src/BeaconServiceProvider.php b/src/BeaconServiceProvider.php index 4297816..675d360 100644 --- a/src/BeaconServiceProvider.php +++ b/src/BeaconServiceProvider.php @@ -5,6 +5,7 @@ namespace DevOption\Beacon; use DevOption\Beacon\Commands\InstallCommand; +use DevOption\Beacon\Commands\DeployCommand; use Illuminate\Support\ServiceProvider; class BeaconServiceProvider extends ServiceProvider @@ -13,6 +14,7 @@ public function boot(): void { if ($this->app->runningInConsole()) { $this->commands([ + DeployCommand::class, InstallCommand::class, ]); } diff --git a/src/Commands/DeployCommand.php b/src/Commands/DeployCommand.php new file mode 100644 index 0000000..687d274 --- /dev/null +++ b/src/Commands/DeployCommand.php @@ -0,0 +1,204 @@ +laravel->basePath(); + $chartPath = $this->chartPath($basePath); + $release = $this->releaseName($chartPath); + $contexts = $contextRepository->discover($basePath); + $context = $this->deploymentContext($contexts); + $namespace = $this->namespace(); + + $this->displayDeploymentSummary($release, $chartPath, $context, $namespace); + + if ($this->input->isInteractive() && ! confirm( + label: 'Continue with this deployment target?', + default: true, + )) { + $this->components->warn('Beacon deployment cancelled.'); + + return self::INVALID; + } + + $output = $helmReleaseDeployer->deploy( + basePath: $basePath, + release: $release, + chartPath: $chartPath, + namespace: $namespace, + context: $context, + ); + } catch (Throwable $throwable) { + $message = trim($throwable->getMessage()); + + $this->components->error( + $message !== '' + ? sprintf('Beacon deployment failed: %s', $message) + : 'Beacon deployment failed.' + ); + + return self::FAILURE; + } + + if ($output !== '') { + $this->components->info('Helm output'); + $this->line($output); + } + + outro('Beacon deployment completed.'); + + return self::SUCCESS; + } + + private function chartPath(string $basePath): string + { + $configuredPath = $this->option('chart'); + + if (is_string($configuredPath) && trim($configuredPath) !== '') { + return trim($configuredPath); + } + + $chartsDirectory = rtrim($basePath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'charts'; + + if (! is_dir($chartsDirectory)) { + throw new RuntimeException('Unable to locate a Helm chart. Run Beacon install first or pass --chart=.'); + } + + $configuredApplicationName = $this->laravel->config->get('app.name'); + $applicationName = is_string($configuredApplicationName) ? $configuredApplicationName : ''; + $slug = $this->applicationSlug($applicationName); + + if ($slug !== '' && is_dir($chartsDirectory.DIRECTORY_SEPARATOR.$slug)) { + return './charts/'.$slug; + } + + $directories = array_values(array_filter(glob($chartsDirectory.DIRECTORY_SEPARATOR.'*') ?: [], 'is_dir')); + + if (count($directories) === 1) { + return './charts/'.basename($directories[0]); + } + + throw new RuntimeException('Unable to determine which Helm chart to deploy. Pass --chart= to choose one explicitly.'); + } + + private function releaseName(string $chartPath): string + { + $configuredRelease = $this->option('release'); + + if (is_string($configuredRelease) && trim($configuredRelease) !== '') { + return trim($configuredRelease); + } + + $release = trim(basename($chartPath)); + + if ($release === '' || $release === '.' || $release === DIRECTORY_SEPARATOR) { + throw new RuntimeException('Unable to determine the Helm release name. Pass --release= to set one explicitly.'); + } + + return $release; + } + + private function deploymentContext(KubernetesContexts $contexts): string + { + $configuredContext = $this->option('context'); + + if (is_string($configuredContext) && trim($configuredContext) !== '') { + $configuredContext = trim($configuredContext); + + if (! in_array($configuredContext, $contexts->available, true)) { + throw new RuntimeException(sprintf( + 'The selected Kubernetes context [%s] is not available.', + $configuredContext, + )); + } + + return $configuredContext; + } + + if (! $this->input->isInteractive()) { + return $contexts->current; + } + + /** @var string $selectedContext */ + $selectedContext = select( + label: 'Which Kubernetes context should Beacon deploy to?', + options: $contexts->promptOptions(), + default: $contexts->current, + ); + + return $selectedContext; + } + + private function namespace(): string + { + $configuredNamespace = $this->option('namespace'); + + if (is_string($configuredNamespace) && trim($configuredNamespace) !== '') { + return trim($configuredNamespace); + } + + if (! $this->input->isInteractive()) { + return 'default'; + } + + return text( + label: 'Which namespace should Beacon deploy into?', + default: 'default', + validate: static fn (string $value): ?string => trim($value) === '' ? 'A namespace is required.' : null, + ); + } + + private function displayDeploymentSummary( + string $release, + string $chartPath, + string $context, + string $namespace, + ): void { + $this->components->info('Deployment target'); + $this->components->twoColumnDetail('Release', $release); + $this->components->twoColumnDetail('Chart', $chartPath); + $this->components->twoColumnDetail('Context', $context); + $this->components->twoColumnDetail('Namespace', $namespace); + } + + private function applicationSlug(string $applicationName): string + { + $normalized = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $applicationName) ?? ''); + $normalized = trim($normalized, '-'); + $normalized = substr($normalized, 0, 63); + $normalized = trim($normalized, '-'); + + return $normalized !== '' ? $normalized : ''; + } +} diff --git a/src/Composer/ComposerScriptsUpdater.php b/src/Composer/ComposerScriptsUpdater.php index e4ff2ef..f32d392 100644 --- a/src/Composer/ComposerScriptsUpdater.php +++ b/src/Composer/ComposerScriptsUpdater.php @@ -75,10 +75,7 @@ private function desiredScripts(InstallConfiguration $configuration): array ]; if ($configuration->deploymentTarget !== 'docker') { - $scripts['beacon:deploy'] = sprintf( - 'helm upgrade --install %1$s ./charts/%1$s --namespace default --create-namespace', - $slug - ); + $scripts['beacon:deploy'] = '@php artisan beacon:deploy'; } return $scripts; diff --git a/src/Deploy/HelmReleaseDeployer.php b/src/Deploy/HelmReleaseDeployer.php new file mode 100644 index 0000000..0e3ee94 --- /dev/null +++ b/src/Deploy/HelmReleaseDeployer.php @@ -0,0 +1,44 @@ +run([ + 'helm', + 'upgrade', + '--install', + $release, + $chartPath, + '--namespace', + $namespace, + '--create-namespace', + '--kube-context', + $context, + ]); + + if (! $result->successful()) { + $errorOutput = trim($result->errorOutput()); + + throw new RuntimeException( + $errorOutput !== '' + ? sprintf('Beacon deploy failed. %s', $errorOutput) + : 'Beacon deploy failed.', + ); + } + + return trim($result->output()); + } +} diff --git a/src/Deploy/KubernetesContextRepository.php b/src/Deploy/KubernetesContextRepository.php new file mode 100644 index 0000000..33a1fee --- /dev/null +++ b/src/Deploy/KubernetesContextRepository.php @@ -0,0 +1,87 @@ +availableContexts($basePath); + $currentContext = $this->currentContext($basePath); + + if (! in_array($currentContext, $availableContexts, true)) { + array_unshift($availableContexts, $currentContext); + $availableContexts = array_values(array_unique($availableContexts)); + } + + return new KubernetesContexts($availableContexts, $currentContext); + } + + /** + * @return array + */ + private function availableContexts(string $basePath): array + { + $result = Process::path($basePath)->run([ + 'kubectl', + 'config', + 'get-contexts', + '-o', + 'name', + ]); + + if (! $result->successful()) { + throw new RuntimeException($this->failureMessage( + 'Unable to discover Kubernetes contexts.', + $result->errorOutput(), + )); + } + + $contexts = array_values(array_filter(array_map( + static fn (string $context): string => trim($context), + preg_split('/\R+/', $result->output()) ?: [], + ))); + + if ($contexts === []) { + throw new RuntimeException('Unable to discover Kubernetes contexts. kubectl returned no configured contexts.'); + } + + return $contexts; + } + + private function currentContext(string $basePath): string + { + $result = Process::path($basePath)->run([ + 'kubectl', + 'config', + 'current-context', + ]); + + if (! $result->successful()) { + throw new RuntimeException($this->failureMessage( + 'Unable to determine the current Kubernetes context.', + $result->errorOutput(), + )); + } + + $currentContext = trim($result->output()); + + if ($currentContext === '') { + throw new RuntimeException('Unable to determine the current Kubernetes context. kubectl returned an empty value.'); + } + + return $currentContext; + } + + private function failureMessage(string $prefix, string $errorOutput): string + { + $errorOutput = trim($errorOutput); + + return $errorOutput !== '' ? sprintf('%s %s', $prefix, $errorOutput) : $prefix; + } +} diff --git a/src/Deploy/KubernetesContexts.php b/src/Deploy/KubernetesContexts.php new file mode 100644 index 0000000..59182b7 --- /dev/null +++ b/src/Deploy/KubernetesContexts.php @@ -0,0 +1,34 @@ + $available + */ + public function __construct( + public array $available, + public string $current, + ) { + if ($this->available === []) { + throw new InvalidArgumentException('At least one Kubernetes context must be available.'); + } + + if (! in_array($this->current, $this->available, true)) { + throw new InvalidArgumentException('The current Kubernetes context must be included in the available contexts.'); + } + } + + /** + * @return array + */ + public function promptOptions(): array + { + return array_combine($this->available, $this->available) ?: []; + } +} diff --git a/tests/Feature/DeployCommandTest.php b/tests/Feature/DeployCommandTest.php new file mode 100644 index 0000000..758567a --- /dev/null +++ b/tests/Feature/DeployCommandTest.php @@ -0,0 +1,157 @@ +command === ['kubectl', 'config', 'get-contexts', '-o', 'name']) { + return Process::result(implode(PHP_EOL, $availableContexts).PHP_EOL, '', 0); + } + + if ($process->command === ['kubectl', 'config', 'current-context']) { + return Process::result($currentContext.PHP_EOL, '', 0); + } + + if (array_slice($process->command, 0, 3) === ['helm', 'upgrade', '--install']) { + return Process::result('Release deployed.', '', 0); + } + + return Process::result(); + }); +} + +function expectBeaconDeployPrompts( + PendingCommand $command, + string $context = 'staging', + string $namespace = 'preview', +): PendingCommand { + return $command + ->expectsPromptsIntro('Beacon will help you choose a Kubernetes deployment target.') + ->expectsChoice( + 'Which Kubernetes context should Beacon deploy to?', + $context, + [ + 'rancher-desktop' => 'rancher-desktop', + 'staging' => 'staging', + ], + ) + ->expectsQuestion('Which namespace should Beacon deploy into?', $namespace) + ->expectsConfirmation('Continue with this deployment target?', 'yes'); +} + +function supportsDeployPendingPromptExpectations(): bool +{ + return version_compare(Application::VERSION, '12.0.0', '>='); +} + +it('boots the deploy command through the package service provider', function (): void { + expect($this->app->getProvider(BeaconServiceProvider::class))->not->toBeNull() + ->and($this->app->make(\Illuminate\Contracts\Console\Kernel::class)->all())->toHaveKey('beacon:deploy'); +}); + +it('deploys to the current Kubernetes context when running non-interactively', function (): void { + $directory = beaconTestApplicationDirectory(); + $originalBasePath = $this->app->basePath(); + + $this->app->setBasePath($directory); + $this->app['config']->set('app.name', 'Beacon Demo'); + + mkdir($directory.'/charts/beacon-demo', 0755, true); + fakeKubernetesContextDiscovery(); + + try { + $this->artisan('beacon:deploy', ['--no-interaction' => true]) + ->expectsOutputToContain('Deployment target') + ->expectsOutputToContain('beacon-demo') + ->expectsOutputToContain('rancher-desktop') + ->expectsOutputToContain('default') + ->expectsOutputToContain('Beacon deployment completed.') + ->assertSuccessful(); + + Process::assertRan(fn ($process) => $process->path === $directory + && $process->command === [ + 'helm', + 'upgrade', + '--install', + 'beacon-demo', + './charts/beacon-demo', + '--namespace', + 'default', + '--create-namespace', + '--kube-context', + 'rancher-desktop', + ]); + } finally { + $this->app->setBasePath($originalBasePath); + removeBeaconTestDirectory($directory); + } +}); + +it('allows the user to choose a Kubernetes context and namespace interactively', function (): void { + if (! supportsDeployPendingPromptExpectations()) { + $this->markTestSkipped('Pending command prompt expectations are not reliable on Laravel 11.'); + } + + $directory = beaconTestApplicationDirectory(); + $originalBasePath = $this->app->basePath(); + + $this->app->setBasePath($directory); + $this->app['config']->set('app.name', 'Beacon Demo'); + + mkdir($directory.'/charts/beacon-demo', 0755, true); + fakeKubernetesContextDiscovery(); + + try { + expectBeaconDeployPrompts($this->artisan('beacon:deploy')) + ->expectsOutputToContain('Deployment target') + ->expectsOutputToContain('staging') + ->expectsOutputToContain('preview') + ->expectsOutputToContain('Beacon deployment completed.') + ->assertSuccessful(); + + Process::assertRan(fn ($process) => $process->path === $directory + && $process->command === [ + 'helm', + 'upgrade', + '--install', + 'beacon-demo', + './charts/beacon-demo', + '--namespace', + 'preview', + '--create-namespace', + '--kube-context', + 'staging', + ]); + } finally { + $this->app->setBasePath($originalBasePath); + removeBeaconTestDirectory($directory); + } +}); + +it('fails clearly when kubernetes contexts cannot be discovered', function (): void { + Process::fake([ + '*' => Process::result('', 'kubectl config is unavailable.', 1), + ]); + + $directory = beaconTestApplicationDirectory(); + $originalBasePath = $this->app->basePath(); + + $this->app->setBasePath($directory); + mkdir($directory.'/charts/beacon-demo', 0755, true); + $this->app['config']->set('app.name', 'Beacon Demo'); + + try { + $this->artisan('beacon:deploy', ['--no-interaction' => true]) + ->expectsOutputToContain('Beacon deployment failed: Unable to discover Kubernetes contexts. kubectl config is unavailable.') + ->assertExitCode(1); + } finally { + $this->app->setBasePath($originalBasePath); + removeBeaconTestDirectory($directory); + } +}); diff --git a/tests/Feature/InstallCommandTest.php b/tests/Feature/InstallCommandTest.php index 1c3aabe..3b2763c 100644 --- a/tests/Feature/InstallCommandTest.php +++ b/tests/Feature/InstallCommandTest.php @@ -140,7 +140,7 @@ function supportsPendingPromptExpectations(): bool ->and($manifest['scripts'])->toMatchArray([ 'test' => '@php artisan test', 'beacon:build' => 'docker build --file Dockerfile --tag beacon-demo:latest .', - 'beacon:deploy' => 'helm upgrade --install beacon-demo ./charts/beacon-demo --namespace default --create-namespace', + 'beacon:deploy' => '@php artisan beacon:deploy', ]) ->and($manifest['scripts-descriptions'])->toMatchArray([ 'beacon:build' => 'Build the Beacon production Docker image.', @@ -198,7 +198,7 @@ function supportsPendingPromptExpectations(): bool ->and($manifest['scripts'])->toMatchArray([ 'test' => '@php artisan test', 'beacon:build' => 'docker build --file Dockerfile --tag beacon-demo:latest .', - 'beacon:deploy' => 'helm upgrade --install beacon-demo ./charts/beacon-demo --namespace default --create-namespace', + 'beacon:deploy' => '@php artisan beacon:deploy', ]) ->and($manifest['scripts-descriptions'])->toMatchArray([ 'beacon:build' => 'Build the Beacon production Docker image.', diff --git a/tests/Unit/Composer/ComposerScriptsUpdaterTest.php b/tests/Unit/Composer/ComposerScriptsUpdaterTest.php index 36a99cf..05accf5 100644 --- a/tests/Unit/Composer/ComposerScriptsUpdaterTest.php +++ b/tests/Unit/Composer/ComposerScriptsUpdaterTest.php @@ -29,7 +29,7 @@ expect($manifest['scripts'])->toBe([ 'test' => '@php artisan test', 'beacon:build' => 'docker build --file Dockerfile --tag beacon-demo:latest .', - 'beacon:deploy' => 'helm upgrade --install beacon-demo ./charts/beacon-demo --namespace default --create-namespace', + 'beacon:deploy' => '@php artisan beacon:deploy', ]) ->and($manifest['scripts-descriptions'])->toBe([ 'test' => 'Run the application test suite.', @@ -116,7 +116,7 @@ ->and($manifest['scripts'])->toBe([ 'test' => '@php artisan test', 'beacon:build' => 'docker build --file Dockerfile --tag beacon-demo:latest .', - 'beacon:deploy' => 'helm upgrade --install beacon-demo ./charts/beacon-demo --namespace default --create-namespace', + 'beacon:deploy' => '@php artisan beacon:deploy', ]) ->and($manifest['scripts-descriptions'])->toBe([ 'beacon:build' => 'Build the Beacon production Docker image.', From bff80a018e47da7ef5cbbceda63569b16d5913c5 Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Thu, 9 Apr 2026 00:52:32 -0400 Subject: [PATCH 2/2] fix: normalize deploy inputs and errors --- src/Commands/DeployCommand.php | 6 +- src/Deploy/HelmReleaseDeployer.php | 4 +- tests/Feature/DeployCommandTest.php | 105 ++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 6 deletions(-) diff --git a/src/Commands/DeployCommand.php b/src/Commands/DeployCommand.php index 687d274..987f7cc 100644 --- a/src/Commands/DeployCommand.php +++ b/src/Commands/DeployCommand.php @@ -172,11 +172,11 @@ private function namespace(): string return 'default'; } - return text( + return trim(text( label: 'Which namespace should Beacon deploy into?', default: 'default', validate: static fn (string $value): ?string => trim($value) === '' ? 'A namespace is required.' : null, - ); + )); } private function displayDeploymentSummary( @@ -199,6 +199,6 @@ private function applicationSlug(string $applicationName): string $normalized = substr($normalized, 0, 63); $normalized = trim($normalized, '-'); - return $normalized !== '' ? $normalized : ''; + return $normalized !== '' ? $normalized : 'beacon'; } } diff --git a/src/Deploy/HelmReleaseDeployer.php b/src/Deploy/HelmReleaseDeployer.php index 0e3ee94..1f0dcdb 100644 --- a/src/Deploy/HelmReleaseDeployer.php +++ b/src/Deploy/HelmReleaseDeployer.php @@ -33,9 +33,7 @@ public function deploy( $errorOutput = trim($result->errorOutput()); throw new RuntimeException( - $errorOutput !== '' - ? sprintf('Beacon deploy failed. %s', $errorOutput) - : 'Beacon deploy failed.', + $errorOutput !== '' ? $errorOutput : 'Helm command failed.', ); } diff --git a/tests/Feature/DeployCommandTest.php b/tests/Feature/DeployCommandTest.php index 758567a..a6a3c69 100644 --- a/tests/Feature/DeployCommandTest.php +++ b/tests/Feature/DeployCommandTest.php @@ -134,6 +134,76 @@ function supportsDeployPendingPromptExpectations(): bool } }); +it('trims the chosen namespace before invoking helm', function (): void { + if (! supportsDeployPendingPromptExpectations()) { + $this->markTestSkipped('Pending command prompt expectations are not reliable on Laravel 11.'); + } + + $directory = beaconTestApplicationDirectory(); + $originalBasePath = $this->app->basePath(); + + $this->app->setBasePath($directory); + $this->app['config']->set('app.name', 'Beacon Demo'); + + mkdir($directory.'/charts/beacon-demo', 0755, true); + fakeKubernetesContextDiscovery(); + + try { + expectBeaconDeployPrompts($this->artisan('beacon:deploy'), namespace: ' preview ') + ->expectsOutputToContain('preview') + ->assertSuccessful(); + + Process::assertRan(fn ($process) => $process->path === $directory + && $process->command === [ + 'helm', + 'upgrade', + '--install', + 'beacon-demo', + './charts/beacon-demo', + '--namespace', + 'preview', + '--create-namespace', + '--kube-context', + 'staging', + ]); + } finally { + $this->app->setBasePath($originalBasePath); + removeBeaconTestDirectory($directory); + } +}); + +it('falls back to the beacon chart slug when the application name normalizes to empty', function (): void { + $directory = beaconTestApplicationDirectory(); + $originalBasePath = $this->app->basePath(); + + $this->app->setBasePath($directory); + $this->app['config']->set('app.name', '!!!'); + + mkdir($directory.'/charts/beacon', 0755, true); + fakeKubernetesContextDiscovery(); + + try { + $this->artisan('beacon:deploy', ['--no-interaction' => true])->assertSuccessful(); + + Process::assertRan(fn ($process) => $process->path === $directory + && $process->command === [ + 'helm', + 'upgrade', + '--install', + 'beacon', + './charts/beacon', + '--namespace', + 'default', + '--create-namespace', + '--kube-context', + 'rancher-desktop', + ]); + } finally { + $this->app->setBasePath($originalBasePath); + removeBeaconTestDirectory($directory); + } +}); + it('fails clearly when kubernetes contexts cannot be discovered', function (): void { Process::fake([ '*' => Process::result('', 'kubectl config is unavailable.', 1), @@ -155,3 +225,38 @@ function supportsDeployPendingPromptExpectations(): bool removeBeaconTestDirectory($directory); } }); + +it('surfaces helm failures without duplicating the beacon deployment prefix', function (): void { + $directory = beaconTestApplicationDirectory(); + $originalBasePath = $this->app->basePath(); + + $this->app->setBasePath($directory); + $this->app['config']->set('app.name', 'Beacon Demo'); + + mkdir($directory.'/charts/beacon-demo', 0755, true); + + Process::fake(function ($process) { + if ($process->command === ['kubectl', 'config', 'get-contexts', '-o', 'name']) { + return Process::result("rancher-desktop\n", '', 0); + } + + if ($process->command === ['kubectl', 'config', 'current-context']) { + return Process::result("rancher-desktop\n", '', 0); + } + + if (array_slice($process->command, 0, 3) === ['helm', 'upgrade', '--install']) { + return Process::result('', 'release failed', 1); + } + + return Process::result(); + }); + + try { + $this->artisan('beacon:deploy', ['--no-interaction' => true]) + ->expectsOutputToContain('Beacon deployment failed: release failed') + ->assertExitCode(1); + } finally { + $this->app->setBasePath($originalBasePath); + removeBeaconTestDirectory($directory); + } +});