diff --git a/README.md b/README.md index bfa86a3..a3ab13d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Current MVP features: - optional Laravel Octane installation when the Octane runtime is selected - Dockerfile generation for `php-fpm` or `octane` - Helm chart scaffolding under `charts/` +- named Helm environment overlays for `local`, `staging`, and `production` - managed Composer scripts for `beacon:build` and `beacon:deploy` - Pest coverage, Laravel compatibility CI, and automated semantic releases for the package itself @@ -58,6 +59,9 @@ Depending on the options you choose, Beacon generates: - `Dockerfile` - `charts//Chart.yaml` - `charts//values.yaml` +- `charts//values.local.yaml` +- `charts//values.staging.yaml` +- `charts//values.production.yaml` - `charts//templates/_helpers.tpl` - `charts//templates/deployment.yaml` - `charts//templates/service.yaml` @@ -79,6 +83,8 @@ Example managed scripts: } ``` +Beacon generates `values.yaml` as the default 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. + ## Rerunning the installer Beacon is designed to be rerunnable. diff --git a/src/Commands/DeployCommand.php b/src/Commands/DeployCommand.php index 987f7cc..1f30865 100644 --- a/src/Commands/DeployCommand.php +++ b/src/Commands/DeployCommand.php @@ -5,6 +5,8 @@ namespace DevOption\Beacon\Commands; use DevOption\Beacon\Deploy\HelmReleaseDeployer; +use DevOption\Beacon\Deploy\DeploymentEnvironmentProfileRepository; +use DevOption\Beacon\Deploy\DeploymentEnvironmentProfiles; use DevOption\Beacon\Deploy\KubernetesContextRepository; use DevOption\Beacon\Deploy\KubernetesContexts; use Illuminate\Console\Command; @@ -20,6 +22,7 @@ class DeployCommand extends Command { protected $signature = 'beacon:deploy + {--environment= : Deployment environment profile to use} {--context= : Kubernetes context to deploy to} {--namespace= : Kubernetes namespace to deploy into} {--release= : Helm release name override} @@ -28,6 +31,7 @@ class DeployCommand extends Command protected $description = 'Deploy the Beacon Helm release'; public function handle( + DeploymentEnvironmentProfileRepository $environmentProfileRepository, KubernetesContextRepository $contextRepository, HelmReleaseDeployer $helmReleaseDeployer, ): int { @@ -36,12 +40,15 @@ public function handle( try { $basePath = $this->laravel->basePath(); $chartPath = $this->chartPath($basePath); + $chartAbsolutePath = $this->chartAbsolutePath($basePath, $chartPath); $release = $this->releaseName($chartPath); + $environmentProfiles = $environmentProfileRepository->discover($chartAbsolutePath); + $environment = $this->environment($environmentProfiles); $contexts = $contextRepository->discover($basePath); $context = $this->deploymentContext($contexts); $namespace = $this->namespace(); - $this->displayDeploymentSummary($release, $chartPath, $context, $namespace); + $this->displayDeploymentSummary($release, $chartPath, $environment, $context, $namespace); if ($this->input->isInteractive() && ! confirm( label: 'Continue with this deployment target?', @@ -58,6 +65,8 @@ public function handle( chartPath: $chartPath, namespace: $namespace, context: $context, + sharedValuesPath: $this->sharedValuesPath($chartPath), + environmentValuesPath: $this->environmentValuesPath($chartPath, $environmentProfiles, $environment), ); } catch (Throwable $throwable) { $message = trim($throwable->getMessage()); @@ -112,6 +121,19 @@ private function chartPath(string $basePath): string throw new RuntimeException('Unable to determine which Helm chart to deploy. Pass --chart= to choose one explicitly.'); } + private function chartAbsolutePath(string $basePath, string $chartPath): string + { + if ($this->isAbsolutePath($chartPath)) { + return $chartPath; + } + + if (preg_match('/^\.[\/\\\\]/', $chartPath) === 1) { + return rtrim($basePath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.ltrim(substr($chartPath, 2), '/\\'); + } + + return rtrim($basePath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$chartPath; + } + private function releaseName(string $chartPath): string { $configuredRelease = $this->option('release'); @@ -129,6 +151,37 @@ private function releaseName(string $chartPath): string return $release; } + private function environment(DeploymentEnvironmentProfiles $profiles): string + { + $configuredEnvironment = $this->option('environment'); + + if (is_string($configuredEnvironment) && trim($configuredEnvironment) !== '') { + $configuredEnvironment = trim($configuredEnvironment); + + if (! in_array($configuredEnvironment, $profiles->names, true)) { + throw new RuntimeException(sprintf( + 'The selected deployment environment [%s] is not available.', + $configuredEnvironment, + )); + } + + return $configuredEnvironment; + } + + if (! $this->input->isInteractive()) { + return $profiles->default(); + } + + /** @var string $selectedEnvironment */ + $selectedEnvironment = select( + label: 'Which deployment environment should Beacon use?', + options: $profiles->promptOptions(), + default: $profiles->default(), + ); + + return $selectedEnvironment; + } + private function deploymentContext(KubernetesContexts $contexts): string { $configuredContext = $this->option('context'); @@ -182,16 +235,53 @@ private function namespace(): string private function displayDeploymentSummary( string $release, string $chartPath, + string $environment, string $context, string $namespace, ): void { $this->components->info('Deployment target'); $this->components->twoColumnDetail('Release', $release); $this->components->twoColumnDetail('Chart', $chartPath); + $this->components->twoColumnDetail('Environment', $environment); $this->components->twoColumnDetail('Context', $context); $this->components->twoColumnDetail('Namespace', $namespace); } + private function environmentValuesPath( + string $chartPath, + DeploymentEnvironmentProfiles $profiles, + string $environment, + ): string { + $path = $this->joinHelmPath($chartPath, $profiles->overlayRelativePath($environment)); + + if (! is_file($this->chartAbsolutePath($this->laravel->basePath(), $path))) { + throw new RuntimeException(sprintf( + 'Unable to locate the [%s] deployment environment overlay. Re-run Beacon install to generate environment profile values files or pass a chart with overlays.', + $environment, + )); + } + + return $path; + } + + private function sharedValuesPath(string $chartPath): string + { + return $this->joinHelmPath($chartPath, 'values.yaml'); + } + + private function joinHelmPath(string $basePath, string $relativePath): string + { + return rtrim(str_replace('\\', '/', $basePath), '/').'/'.$relativePath; + } + + private function isAbsolutePath(string $path): bool + { + return str_starts_with($path, '/') + || str_starts_with($path, '\\') + || preg_match('/^[A-Za-z]:[\/\\\\]/', $path) === 1 + || preg_match('/^\\\\\\\\[^\\\\]+\\\\[^\\\\]+/', $path) === 1; + } + private function applicationSlug(string $applicationName): string { $normalized = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $applicationName) ?? ''); diff --git a/src/Deploy/DeploymentEnvironmentProfileRepository.php b/src/Deploy/DeploymentEnvironmentProfileRepository.php new file mode 100644 index 0000000..13d2adb --- /dev/null +++ b/src/Deploy/DeploymentEnvironmentProfileRepository.php @@ -0,0 +1,38 @@ + $names + */ + public function __construct( + public array $names, + ) { + if ($this->names === []) { + throw new RuntimeException('At least one deployment environment profile must be available.'); + } + } + + /** + * @return array + */ + public static function defaults(): array + { + return ['local', 'staging', 'production']; + } + + /** + * @return array + */ + public function promptOptions(): array + { + return array_combine($this->names, array_map('ucfirst', $this->names)) ?: []; + } + + public function default(): string + { + return in_array('local', $this->names, true) ? 'local' : $this->names[0]; + } + + public function overlayRelativePath(string $environment): string + { + $this->guardEnvironment($environment); + + return sprintf('values.%s.yaml', $environment); + } + + public function overlayAbsolutePath(string $chartAbsolutePath, string $environment): string + { + return rtrim($chartAbsolutePath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$this->overlayRelativePath($environment); + } + + private function guardEnvironment(string $environment): void + { + if (! in_array($environment, $this->names, true)) { + throw new RuntimeException(sprintf( + 'The selected deployment environment [%s] is not available.', + $environment, + )); + } + } +} diff --git a/src/Deploy/HelmReleaseDeployer.php b/src/Deploy/HelmReleaseDeployer.php index 1f0dcdb..0542622 100644 --- a/src/Deploy/HelmReleaseDeployer.php +++ b/src/Deploy/HelmReleaseDeployer.php @@ -15,6 +15,8 @@ public function deploy( string $chartPath, string $namespace, string $context, + string $sharedValuesPath, + string $environmentValuesPath, ): string { $result = Process::path($basePath)->run([ 'helm', @@ -22,6 +24,10 @@ public function deploy( '--install', $release, $chartPath, + '-f', + $sharedValuesPath, + '-f', + $environmentValuesPath, '--namespace', $namespace, '--create-namespace', diff --git a/src/Helm/HelmChartGenerator.php b/src/Helm/HelmChartGenerator.php index 0fa982b..90c6fe7 100644 --- a/src/Helm/HelmChartGenerator.php +++ b/src/Helm/HelmChartGenerator.php @@ -17,6 +17,9 @@ private const STUBS = [ 'Chart.yaml' => 'Chart.yaml.stub', 'values.yaml' => 'values.yaml.stub', + 'values.local.yaml' => 'values.local.yaml.stub', + 'values.staging.yaml' => 'values.staging.yaml.stub', + 'values.production.yaml' => 'values.production.yaml.stub', 'templates/_helpers.tpl' => 'templates/_helpers.tpl.stub', 'templates/deployment.yaml' => 'templates/deployment.yaml.stub', 'templates/service.yaml' => 'templates/service.yaml.stub', diff --git a/stubs/helm/values.local.yaml.stub b/stubs/helm/values.local.yaml.stub new file mode 100644 index 0000000..7359a30 --- /dev/null +++ b/stubs/helm/values.local.yaml.stub @@ -0,0 +1,8 @@ +replicaCount: 1 + +image: + tag: local + +env: + APP_ENV: local + APP_DEBUG: "true" diff --git a/stubs/helm/values.production.yaml.stub b/stubs/helm/values.production.yaml.stub new file mode 100644 index 0000000..695673e --- /dev/null +++ b/stubs/helm/values.production.yaml.stub @@ -0,0 +1,8 @@ +replicaCount: 3 + +image: + tag: production + +env: + APP_ENV: production + APP_DEBUG: "false" diff --git a/stubs/helm/values.staging.yaml.stub b/stubs/helm/values.staging.yaml.stub new file mode 100644 index 0000000..1854fff --- /dev/null +++ b/stubs/helm/values.staging.yaml.stub @@ -0,0 +1,8 @@ +replicaCount: 2 + +image: + tag: staging + +env: + APP_ENV: staging + APP_DEBUG: "false" diff --git a/tests/Feature/DeployCommandTest.php b/tests/Feature/DeployCommandTest.php index a6a3c69..d6c4c59 100644 --- a/tests/Feature/DeployCommandTest.php +++ b/tests/Feature/DeployCommandTest.php @@ -28,11 +28,21 @@ function fakeKubernetesContextDiscovery(string $currentContext = 'rancher-deskto function expectBeaconDeployPrompts( PendingCommand $command, + string $environment = 'staging', string $context = 'staging', string $namespace = 'preview', ): PendingCommand { return $command ->expectsPromptsIntro('Beacon will help you choose a Kubernetes deployment target.') + ->expectsChoice( + 'Which deployment environment should Beacon use?', + $environment, + [ + 'local' => 'Local', + 'staging' => 'Staging', + 'production' => 'Production', + ], + ) ->expectsChoice( 'Which Kubernetes context should Beacon deploy to?', $context, @@ -63,12 +73,17 @@ function supportsDeployPendingPromptExpectations(): bool $this->app['config']->set('app.name', 'Beacon Demo'); mkdir($directory.'/charts/beacon-demo', 0755, true); + touch($directory.'/charts/beacon-demo/values.yaml'); + touch($directory.'/charts/beacon-demo/values.local.yaml'); + touch($directory.'/charts/beacon-demo/values.staging.yaml'); + touch($directory.'/charts/beacon-demo/values.production.yaml'); fakeKubernetesContextDiscovery(); try { $this->artisan('beacon:deploy', ['--no-interaction' => true]) ->expectsOutputToContain('Deployment target') ->expectsOutputToContain('beacon-demo') + ->expectsOutputToContain('local') ->expectsOutputToContain('rancher-desktop') ->expectsOutputToContain('default') ->expectsOutputToContain('Beacon deployment completed.') @@ -81,6 +96,10 @@ function supportsDeployPendingPromptExpectations(): bool '--install', 'beacon-demo', './charts/beacon-demo', + '-f', + './charts/beacon-demo/values.yaml', + '-f', + './charts/beacon-demo/values.local.yaml', '--namespace', 'default', '--create-namespace', @@ -105,12 +124,15 @@ function supportsDeployPendingPromptExpectations(): bool $this->app['config']->set('app.name', 'Beacon Demo'); mkdir($directory.'/charts/beacon-demo', 0755, true); + touch($directory.'/charts/beacon-demo/values.yaml'); + touch($directory.'/charts/beacon-demo/values.local.yaml'); + touch($directory.'/charts/beacon-demo/values.staging.yaml'); + touch($directory.'/charts/beacon-demo/values.production.yaml'); fakeKubernetesContextDiscovery(); try { expectBeaconDeployPrompts($this->artisan('beacon:deploy')) ->expectsOutputToContain('Deployment target') - ->expectsOutputToContain('staging') ->expectsOutputToContain('preview') ->expectsOutputToContain('Beacon deployment completed.') ->assertSuccessful(); @@ -122,6 +144,10 @@ function supportsDeployPendingPromptExpectations(): bool '--install', 'beacon-demo', './charts/beacon-demo', + '-f', + './charts/beacon-demo/values.yaml', + '-f', + './charts/beacon-demo/values.staging.yaml', '--namespace', 'preview', '--create-namespace', @@ -146,6 +172,10 @@ function supportsDeployPendingPromptExpectations(): bool $this->app['config']->set('app.name', 'Beacon Demo'); mkdir($directory.'/charts/beacon-demo', 0755, true); + touch($directory.'/charts/beacon-demo/values.yaml'); + touch($directory.'/charts/beacon-demo/values.local.yaml'); + touch($directory.'/charts/beacon-demo/values.staging.yaml'); + touch($directory.'/charts/beacon-demo/values.production.yaml'); fakeKubernetesContextDiscovery(); try { @@ -160,6 +190,10 @@ function supportsDeployPendingPromptExpectations(): bool '--install', 'beacon-demo', './charts/beacon-demo', + '-f', + './charts/beacon-demo/values.yaml', + '-f', + './charts/beacon-demo/values.staging.yaml', '--namespace', 'preview', '--create-namespace', @@ -180,6 +214,10 @@ function supportsDeployPendingPromptExpectations(): bool $this->app['config']->set('app.name', '!!!'); mkdir($directory.'/charts/beacon', 0755, true); + touch($directory.'/charts/beacon/values.yaml'); + touch($directory.'/charts/beacon/values.local.yaml'); + touch($directory.'/charts/beacon/values.staging.yaml'); + touch($directory.'/charts/beacon/values.production.yaml'); fakeKubernetesContextDiscovery(); try { @@ -192,6 +230,10 @@ function supportsDeployPendingPromptExpectations(): bool '--install', 'beacon', './charts/beacon', + '-f', + './charts/beacon/values.yaml', + '-f', + './charts/beacon/values.local.yaml', '--namespace', 'default', '--create-namespace', @@ -214,6 +256,10 @@ function supportsDeployPendingPromptExpectations(): bool $this->app->setBasePath($directory); mkdir($directory.'/charts/beacon-demo', 0755, true); + touch($directory.'/charts/beacon-demo/values.yaml'); + touch($directory.'/charts/beacon-demo/values.local.yaml'); + touch($directory.'/charts/beacon-demo/values.staging.yaml'); + touch($directory.'/charts/beacon-demo/values.production.yaml'); $this->app['config']->set('app.name', 'Beacon Demo'); try { @@ -234,6 +280,10 @@ function supportsDeployPendingPromptExpectations(): bool $this->app['config']->set('app.name', 'Beacon Demo'); mkdir($directory.'/charts/beacon-demo', 0755, true); + touch($directory.'/charts/beacon-demo/values.yaml'); + touch($directory.'/charts/beacon-demo/values.local.yaml'); + touch($directory.'/charts/beacon-demo/values.staging.yaml'); + touch($directory.'/charts/beacon-demo/values.production.yaml'); Process::fake(function ($process) { if ($process->command === ['kubectl', 'config', 'get-contexts', '-o', 'name']) { @@ -260,3 +310,66 @@ function supportsDeployPendingPromptExpectations(): bool removeBeaconTestDirectory($directory); } }); + +it('allows an explicit environment to be selected 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); + touch($directory.'/charts/beacon-demo/values.yaml'); + touch($directory.'/charts/beacon-demo/values.local.yaml'); + touch($directory.'/charts/beacon-demo/values.staging.yaml'); + touch($directory.'/charts/beacon-demo/values.production.yaml'); + fakeKubernetesContextDiscovery(); + + try { + $this->artisan('beacon:deploy', ['--environment' => 'production', '--no-interaction' => true]) + ->expectsOutputToContain('production') + ->assertSuccessful(); + + Process::assertRan(fn ($process) => $process->path === $directory + && $process->command === [ + 'helm', + 'upgrade', + '--install', + 'beacon-demo', + './charts/beacon-demo', + '-f', + './charts/beacon-demo/values.yaml', + '-f', + './charts/beacon-demo/values.production.yaml', + '--namespace', + 'default', + '--create-namespace', + '--kube-context', + 'rancher-desktop', + ]); + } finally { + $this->app->setBasePath($originalBasePath); + removeBeaconTestDirectory($directory); + } +}); + +it('fails clearly when the selected environment overlay does not exist', 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); + touch($directory.'/charts/beacon-demo/values.yaml'); + fakeKubernetesContextDiscovery(); + + try { + $this->artisan('beacon:deploy', ['--environment' => 'staging', '--no-interaction' => true]) + ->expectsOutputToContain('Beacon deployment failed: Unable to locate the [staging] deployment environment overlay.') + ->assertExitCode(1); + } finally { + $this->app->setBasePath($originalBasePath); + removeBeaconTestDirectory($directory); + } +}); diff --git a/tests/Feature/InstallCommandTest.php b/tests/Feature/InstallCommandTest.php index 3b2763c..2eba9cb 100644 --- a/tests/Feature/InstallCommandTest.php +++ b/tests/Feature/InstallCommandTest.php @@ -136,6 +136,9 @@ 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($directory.'/charts/beacon-demo/values.local.yaml')->toBeFile() + ->and($directory.'/charts/beacon-demo/values.staging.yaml')->toBeFile() + ->and($directory.'/charts/beacon-demo/values.production.yaml')->toBeFile() ->and($manifest['require'])->toHaveKey('laravel/octane') ->and($manifest['scripts'])->toMatchArray([ 'test' => '@php artisan test', @@ -194,6 +197,9 @@ 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($directory.'/charts/beacon-demo/values.local.yaml')->toBeFile() + ->and($directory.'/charts/beacon-demo/values.staging.yaml')->toBeFile() + ->and($directory.'/charts/beacon-demo/values.production.yaml')->toBeFile() ->and($manifest['require'])->toHaveKey('laravel/octane') ->and($manifest['scripts'])->toMatchArray([ 'test' => '@php artisan test', diff --git a/tests/Unit/Deploy/DeploymentEnvironmentProfileRepositoryTest.php b/tests/Unit/Deploy/DeploymentEnvironmentProfileRepositoryTest.php new file mode 100644 index 0000000..6df8dd3 --- /dev/null +++ b/tests/Unit/Deploy/DeploymentEnvironmentProfileRepositoryTest.php @@ -0,0 +1,41 @@ +discover($chartPath); + + expect($profiles->names)->toBe(['local', 'staging', 'production']) + ->and($profiles->default())->toBe('local') + ->and($profiles->overlayRelativePath('staging'))->toBe('values.staging.yaml'); + } finally { + removeBeaconTestDirectory($directory); + } +}); + +it('falls back to the default deployment environment profile set when no overlays exist', function (): void { + $directory = beaconTestTempDirectory(); + $chartPath = $directory.'/charts/beacon-demo'; + + mkdir($chartPath, 0755, true); + + try { + $profiles = (new DeploymentEnvironmentProfileRepository())->discover($chartPath); + + expect($profiles->names)->toBe(['local', 'staging', 'production']) + ->and($profiles->default())->toBe('local'); + } finally { + removeBeaconTestDirectory($directory); + } +}); diff --git a/tests/Unit/Helm/HelmChartGeneratorTest.php b/tests/Unit/Helm/HelmChartGeneratorTest.php index 90504dc..4ded30e 100644 --- a/tests/Unit/Helm/HelmChartGeneratorTest.php +++ b/tests/Unit/Helm/HelmChartGeneratorTest.php @@ -22,6 +22,9 @@ expect(array_keys($files))->toBe([ 'Chart.yaml', 'values.yaml', + 'values.local.yaml', + 'values.staging.yaml', + 'values.production.yaml', 'templates/_helpers.tpl', 'templates/deployment.yaml', 'templates/service.yaml', @@ -31,6 +34,9 @@ ->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.local.yaml'])->toContain('APP_ENV: local') + ->and($files['values.staging.yaml'])->toContain('APP_ENV: staging') + ->and($files['values.production.yaml'])->toContain('APP_ENV: production') ->and($files['templates/deployment.yaml'])->toContain('containerPort: {{ .Values.service.port }}'); }); @@ -97,7 +103,10 @@ expect($result->chartPath)->toBe($directory.'/charts/beacon-demo') ->and($result->files['Chart.yaml']->status)->toBe(FileWriteStatus::Created) ->and($directory.'/charts/beacon-demo/Chart.yaml')->toBeFile() - ->and(file_get_contents($directory.'/charts/beacon-demo/values.yaml'))->toContain('port: 9000'); + ->and(file_get_contents($directory.'/charts/beacon-demo/values.yaml'))->toContain('port: 9000') + ->and($directory.'/charts/beacon-demo/values.local.yaml')->toBeFile() + ->and($directory.'/charts/beacon-demo/values.staging.yaml')->toBeFile() + ->and($directory.'/charts/beacon-demo/values.production.yaml')->toBeFile(); } finally { removeBeaconTestDirectory($directory); }