From e2198806b31d01431be6abf806590e538d124e4b Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Thu, 9 Apr 2026 01:22:55 -0400 Subject: [PATCH 1/2] feat: add helm secret handling flow --- README.md | 16 ++++- src/Commands/DeployCommand.php | 16 +++++ src/Commands/InstallCommand.php | 16 +++++ src/Deploy/DeploymentEnvironmentProfiles.php | 19 ++++++ src/Deploy/HelmReleaseDeployer.php | 16 ++++- src/Filesystem/GitignoreUpdater.php | 57 ++++++++++++++++ src/Helm/HelmChartGenerator.php | 6 ++ src/Install/InstallConfiguration.php | 38 ++++++++++- src/Install/InstallConfigurationCollector.php | 68 +++++++++++++++++-- src/Install/InstallResult.php | 1 + src/Install/InstallWorkflow.php | 11 ++- stubs/helm/templates/_helpers.tpl.stub | 4 ++ stubs/helm/templates/deployment.yaml.stub | 7 ++ stubs/helm/templates/secret.yaml.stub | 13 ++++ .../values.local.secrets.example.yaml.stub | 4 ++ ...alues.production.secrets.example.yaml.stub | 4 ++ .../values.staging.secrets.example.yaml.stub | 4 ++ stubs/helm/values.yaml.stub | 6 +- tests/Feature/DeployCommandTest.php | 43 ++++++++++++ tests/Feature/InstallCommandTest.php | 23 +++++++ .../Unit/Filesystem/GitignoreUpdaterTest.php | 40 +++++++++++ tests/Unit/Helm/HelmChartGeneratorTest.php | 28 +++++++- .../InstallConfigurationCollectorTest.php | 20 +++++- 23 files changed, 448 insertions(+), 12 deletions(-) create mode 100644 src/Filesystem/GitignoreUpdater.php create mode 100644 stubs/helm/templates/secret.yaml.stub create mode 100644 stubs/helm/values.local.secrets.example.yaml.stub create mode 100644 stubs/helm/values.production.secrets.example.yaml.stub create mode 100644 stubs/helm/values.staging.secrets.example.yaml.stub create mode 100644 tests/Unit/Filesystem/GitignoreUpdaterTest.php diff --git a/README.md b/README.md index a3ab13d..7f03c11 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` +- secret handling: Beacon-managed Helm secret or an existing Kubernetes secret - whether Beacon should update Composer scripts When the Octane runtime is selected, Beacon checks the target application's `composer.json` and installs `laravel/octane` if it is not already present. @@ -60,10 +61,14 @@ Depending on the options you choose, Beacon generates: - `charts//Chart.yaml` - `charts//values.yaml` - `charts//values.local.yaml` +- `charts//values.local.secrets.example.yaml` - `charts//values.staging.yaml` +- `charts//values.staging.secrets.example.yaml` - `charts//values.production.yaml` +- `charts//values.production.secrets.example.yaml` - `charts//templates/_helpers.tpl` - `charts//templates/deployment.yaml` +- `charts//templates/secret.yaml` - `charts//templates/service.yaml` - `charts//templates/ingress.yaml` @@ -83,7 +88,16 @@ 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. +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. + +Sensitive application values are kept out of the committed environment overlays. Beacon now: + +- keeps non-sensitive settings in the regular `values*.yaml` files +- generates `values..secrets.example.yaml` templates to show the expected secret structure +- adds `/charts/*/values.*.secrets.yaml` to the Laravel app `.gitignore` +- automatically includes `values..secrets.yaml` during `beacon:deploy` when that ignored file exists + +If you choose the existing Kubernetes secret mode during installation, Beacon configures the chart to reference that external secret instead of creating its own Secret manifest. ## Rerunning the installer diff --git a/src/Commands/DeployCommand.php b/src/Commands/DeployCommand.php index 1f30865..180fc8c 100644 --- a/src/Commands/DeployCommand.php +++ b/src/Commands/DeployCommand.php @@ -67,6 +67,7 @@ public function handle( context: $context, sharedValuesPath: $this->sharedValuesPath($chartPath), environmentValuesPath: $this->environmentValuesPath($chartPath, $environmentProfiles, $environment), + secretValuesPath: $this->secretValuesPath($chartAbsolutePath, $chartPath, $environmentProfiles, $environment), ); } catch (Throwable $throwable) { $message = trim($throwable->getMessage()); @@ -269,6 +270,21 @@ private function sharedValuesPath(string $chartPath): string return $this->joinHelmPath($chartPath, 'values.yaml'); } + private function secretValuesPath( + string $chartAbsolutePath, + string $chartPath, + DeploymentEnvironmentProfiles $profiles, + string $environment, + ): ?string { + $absolutePath = $profiles->secretOverlayAbsolutePath($chartAbsolutePath, $environment); + + if (! is_file($absolutePath)) { + return null; + } + + return $this->joinHelmPath($chartPath, $profiles->secretOverlayRelativePath($environment)); + } + private function joinHelmPath(string $basePath, string $relativePath): string { return rtrim(str_replace('\\', '/', $basePath), '/').'/'.$relativePath; diff --git a/src/Commands/InstallCommand.php b/src/Commands/InstallCommand.php index 502da74..bfd7d98 100644 --- a/src/Commands/InstallCommand.php +++ b/src/Commands/InstallCommand.php @@ -66,6 +66,15 @@ protected function displayConfigurationSummary(InstallConfiguration $configurati $this->components->twoColumnDetail('Application', $configuration->applicationName); $this->components->twoColumnDetail('Runtime', $configuration->runtimeLabel()); $this->components->twoColumnDetail('Scaffolding', $configuration->deploymentTargetLabel()); + + if ($configuration->usesHelm()) { + $this->components->twoColumnDetail('Secrets', $configuration->secretHandlingLabel()); + + if ($configuration->existingSecretName !== null) { + $this->components->twoColumnDetail('Secret name', $configuration->existingSecretName); + } + } + $this->components->twoColumnDetail( 'Composer scripts', $configuration->updateComposerScripts ? 'Plan to update' : 'Leave unchanged' @@ -100,6 +109,13 @@ protected function displayArtifactSummary(InstallResult $result): void ? $this->formatFileWriteResult($result->composerManifest) : 'Left unchanged' ); + + $this->components->twoColumnDetail( + '.gitignore', + $result->gitignore !== null + ? $this->formatFileWriteResult($result->gitignore) + : 'Left unchanged' + ); } protected function formatFileWriteResult(FileWriteResult $result): string diff --git a/src/Deploy/DeploymentEnvironmentProfiles.php b/src/Deploy/DeploymentEnvironmentProfiles.php index d4ddc61..e1977e5 100644 --- a/src/Deploy/DeploymentEnvironmentProfiles.php +++ b/src/Deploy/DeploymentEnvironmentProfiles.php @@ -52,6 +52,25 @@ public function overlayAbsolutePath(string $chartAbsolutePath, string $environme return rtrim($chartAbsolutePath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$this->overlayRelativePath($environment); } + public function secretOverlayRelativePath(string $environment): string + { + $this->guardEnvironment($environment); + + return sprintf('values.%s.secrets.yaml', $environment); + } + + public function secretExampleRelativePath(string $environment): string + { + $this->guardEnvironment($environment); + + return sprintf('values.%s.secrets.example.yaml', $environment); + } + + public function secretOverlayAbsolutePath(string $chartAbsolutePath, string $environment): string + { + return rtrim($chartAbsolutePath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$this->secretOverlayRelativePath($environment); + } + private function guardEnvironment(string $environment): void { if (! in_array($environment, $this->names, true)) { diff --git a/src/Deploy/HelmReleaseDeployer.php b/src/Deploy/HelmReleaseDeployer.php index 0542622..84ff02d 100644 --- a/src/Deploy/HelmReleaseDeployer.php +++ b/src/Deploy/HelmReleaseDeployer.php @@ -17,8 +17,9 @@ public function deploy( string $context, string $sharedValuesPath, string $environmentValuesPath, + ?string $secretValuesPath = null, ): string { - $result = Process::path($basePath)->run([ + $command = [ 'helm', 'upgrade', '--install', @@ -28,12 +29,23 @@ public function deploy( $sharedValuesPath, '-f', $environmentValuesPath, + ]; + + if ($secretValuesPath !== null) { + $command[] = '-f'; + $command[] = $secretValuesPath; + } + + $command = [ + ...$command, '--namespace', $namespace, '--create-namespace', '--kube-context', $context, - ]); + ]; + + $result = Process::path($basePath)->run($command); if (! $result->successful()) { $errorOutput = trim($result->errorOutput()); diff --git a/src/Filesystem/GitignoreUpdater.php b/src/Filesystem/GitignoreUpdater.php new file mode 100644 index 0000000..388928c --- /dev/null +++ b/src/Filesystem/GitignoreUpdater.php @@ -0,0 +1,57 @@ + $entries + */ + public function ensureEntries(string $path, array $entries): FileWriteResult + { + $entries = array_values(array_filter(array_map( + static fn (string $entry): string => trim($entry), + $entries, + ), static fn (string $entry): bool => $entry !== '')); + + $entries = array_values(array_unique($entries)); + + if ($entries === []) { + throw new RuntimeException('At least one .gitignore entry is required.'); + } + + $contents = ''; + + if (is_file($path)) { + $contents = file_get_contents($path); + + if ($contents === false) { + throw new RuntimeException(sprintf('Unable to read .gitignore [%s].', $path)); + } + } + + $lines = $contents === '' + ? [] + : (preg_split('/\R/', rtrim($contents, "\r\n")) ?: []); + $existingEntries = array_map('trim', $lines); + + foreach ($entries as $entry) { + if (! in_array($entry, $existingEntries, true)) { + $lines[] = $entry; + } + } + + $updatedContents = implode(PHP_EOL, $lines).PHP_EOL; + + return $this->writer->write($path, $updatedContents, ExistingFileBehavior::Overwrite); + } +} diff --git a/src/Helm/HelmChartGenerator.php b/src/Helm/HelmChartGenerator.php index 90c6fe7..78f0e2c 100644 --- a/src/Helm/HelmChartGenerator.php +++ b/src/Helm/HelmChartGenerator.php @@ -18,10 +18,14 @@ 'Chart.yaml' => 'Chart.yaml.stub', 'values.yaml' => 'values.yaml.stub', 'values.local.yaml' => 'values.local.yaml.stub', + 'values.local.secrets.example.yaml' => 'values.local.secrets.example.yaml.stub', 'values.staging.yaml' => 'values.staging.yaml.stub', + 'values.staging.secrets.example.yaml' => 'values.staging.secrets.example.yaml.stub', 'values.production.yaml' => 'values.production.yaml.stub', + 'values.production.secrets.example.yaml' => 'values.production.secrets.example.yaml.stub', 'templates/_helpers.tpl' => 'templates/_helpers.tpl.stub', 'templates/deployment.yaml' => 'templates/deployment.yaml.stub', + 'templates/secret.yaml' => 'templates/secret.yaml.stub', 'templates/service.yaml' => 'templates/service.yaml.stub', 'templates/ingress.yaml' => 'templates/ingress.yaml.stub', ]; @@ -41,6 +45,8 @@ public function renderFiles(InstallConfiguration $configuration): array '{{chart_name}}' => $this->chartName($configuration), '{{runtime}}' => $configuration->runtime, '{{service_port}}' => (string) $this->servicePort($configuration), + '{{create_managed_secret}}' => $configuration->secretHandling === 'managed-secret' ? 'true' : 'false', + '{{existing_secret_name}}' => $this->escapeYamlDoubleQuotedString($configuration->existingSecretName ?? ''), ]; $files = []; diff --git a/src/Install/InstallConfiguration.php b/src/Install/InstallConfiguration.php index 1c79a22..3ec460d 100644 --- a/src/Install/InstallConfiguration.php +++ b/src/Install/InstallConfiguration.php @@ -25,11 +25,21 @@ 'docker-and-helm' => 'Dockerfile and Helm chart', ]; + /** + * @var array + */ + public const SECRET_HANDLING_OPTIONS = [ + 'managed-secret' => 'Beacon-managed Helm secret', + 'existing-secret' => 'Existing Kubernetes secret', + ]; + public function __construct( public string $applicationName, public string $runtime, public string $deploymentTarget, public bool $updateComposerScripts, + public string $secretHandling = 'managed-secret', + public ?string $existingSecretName = null, ) { if (! array_key_exists($this->runtime, self::RUNTIME_OPTIONS)) { throw new InvalidArgumentException(sprintf('Unsupported runtime [%s].', $this->runtime)); @@ -38,6 +48,18 @@ public function __construct( if (! array_key_exists($this->deploymentTarget, self::DEPLOYMENT_TARGET_OPTIONS)) { throw new InvalidArgumentException(sprintf('Unsupported deployment target [%s].', $this->deploymentTarget)); } + + if (! array_key_exists($this->secretHandling, self::SECRET_HANDLING_OPTIONS)) { + throw new InvalidArgumentException(sprintf('Unsupported secret handling mode [%s].', $this->secretHandling)); + } + + if ($this->secretHandling === 'existing-secret' && ($this->existingSecretName === null || trim($this->existingSecretName) === '')) { + throw new InvalidArgumentException('An existing secret name is required when using the existing Kubernetes secret mode.'); + } + + 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.'); + } } public function runtimeLabel(): string @@ -50,12 +72,24 @@ public function deploymentTargetLabel(): string return self::DEPLOYMENT_TARGET_OPTIONS[$this->deploymentTarget]; } + public function secretHandlingLabel(): string + { + return self::SECRET_HANDLING_OPTIONS[$this->secretHandling]; + } + + public function usesHelm(): bool + { + return in_array($this->deploymentTarget, ['helm', 'docker-and-helm'], true); + } + /** * @return array{ * application_name: string, * runtime: string, * deployment_target: string, - * update_composer_scripts: bool + * update_composer_scripts: bool, + * secret_handling: string, + * existing_secret_name: ?string * } */ public function toArray(): array @@ -65,6 +99,8 @@ public function toArray(): array 'runtime' => $this->runtime, 'deployment_target' => $this->deploymentTarget, 'update_composer_scripts' => $this->updateComposerScripts, + 'secret_handling' => $this->secretHandling, + 'existing_secret_name' => $this->existingSecretName, ]; } } diff --git a/src/Install/InstallConfigurationCollector.php b/src/Install/InstallConfigurationCollector.php index 7b9c7f8..e150c24 100644 --- a/src/Install/InstallConfigurationCollector.php +++ b/src/Install/InstallConfigurationCollector.php @@ -21,11 +21,24 @@ public function collect( return $defaults; } + $applicationName = $this->askApplicationName($defaults->applicationName); + $runtime = $this->askRuntime($defaults->runtime); + $deploymentTarget = $this->askDeploymentTarget($defaults->deploymentTarget); + $updateComposerScripts = $this->askUpdateComposerScripts($defaults->updateComposerScripts); + $secretHandling = $this->usesHelm($deploymentTarget) + ? $this->askSecretHandling($defaults->secretHandling) + : $defaults->secretHandling; + $existingSecretName = $this->usesHelm($deploymentTarget) && $secretHandling === 'existing-secret' + ? $this->askExistingSecretName($defaults->existingSecretName ?? $this->defaultExistingSecretName($applicationName)) + : null; + return new InstallConfiguration( - applicationName: $this->askApplicationName($defaults->applicationName), - runtime: $this->askRuntime($defaults->runtime), - deploymentTarget: $this->askDeploymentTarget($defaults->deploymentTarget), - updateComposerScripts: $this->askUpdateComposerScripts($defaults->updateComposerScripts), + applicationName: $applicationName, + runtime: $runtime, + deploymentTarget: $deploymentTarget, + updateComposerScripts: $updateComposerScripts, + secretHandling: $secretHandling, + existingSecretName: $existingSecretName, ); } @@ -40,6 +53,8 @@ public function defaultConfiguration(string $basePath, ?string $applicationName runtime: 'php-fpm', deploymentTarget: 'docker-and-helm', updateComposerScripts: true, + secretHandling: 'managed-secret', + existingSecretName: null, ); } @@ -87,8 +102,53 @@ protected function askUpdateComposerScripts(bool $default): bool ); } + protected function askSecretHandling(string $default): string + { + /** @var string $secretHandling */ + $secretHandling = select( + label: 'How should Beacon handle sensitive application environment values?', + options: InstallConfiguration::SECRET_HANDLING_OPTIONS, + default: $default + ); + + return $secretHandling; + } + + protected function askExistingSecretName(string $default): string + { + return trim(text( + label: 'What is the existing Kubernetes secret name?', + default: $default, + required: 'A Kubernetes secret name is required.', + validate: fn (string $value): ?string => $this->isValidSecretName(trim($value)) + ? null + : 'Use a valid Kubernetes secret name (lowercase letters, numbers, and dashes).' + )); + } + protected function normalizeApplicationName(string $applicationName): string { return trim($applicationName); } + + protected function defaultExistingSecretName(string $applicationName): string + { + $slug = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $applicationName) ?? ''); + $slug = trim($slug, '-'); + $slug = $slug !== '' ? $slug : 'beacon'; + + return substr($slug.'-env', 0, 253); + } + + protected function usesHelm(string $deploymentTarget): bool + { + return in_array($deploymentTarget, ['helm', 'docker-and-helm'], true); + } + + private function isValidSecretName(string $value): bool + { + return $value !== '' + && strlen($value) <= 253 + && preg_match('/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/', $value) === 1; + } } diff --git a/src/Install/InstallResult.php b/src/Install/InstallResult.php index 0ed4789..01e778d 100644 --- a/src/Install/InstallResult.php +++ b/src/Install/InstallResult.php @@ -15,6 +15,7 @@ public function __construct( public ?FileWriteResult $dockerfile, public ?HelmChartWriteResult $helmChart, public ?FileWriteResult $composerManifest, + public ?FileWriteResult $gitignore, ) { } } diff --git a/src/Install/InstallWorkflow.php b/src/Install/InstallWorkflow.php index 23b9093..ce4e811 100644 --- a/src/Install/InstallWorkflow.php +++ b/src/Install/InstallWorkflow.php @@ -7,6 +7,7 @@ use DevOption\Beacon\Composer\ComposerScriptsUpdater; use DevOption\Beacon\Docker\DockerfileGenerator; use DevOption\Beacon\Filesystem\ExistingFileBehavior; +use DevOption\Beacon\Filesystem\GitignoreUpdater; use DevOption\Beacon\Helm\HelmChartGenerator; use DevOption\Beacon\Octane\OctaneInstaller; @@ -17,6 +18,7 @@ public function __construct( private DockerfileGenerator $dockerfileGenerator, private HelmChartGenerator $helmChartGenerator, private ComposerScriptsUpdater $composerScriptsUpdater, + private GitignoreUpdater $gitignoreUpdater, ) { } @@ -41,6 +43,13 @@ public function run(string $basePath, InstallConfiguration $configuration): Inst ) : null; - return new InstallResult($octane, $dockerfile, $helmChart, $composerManifest); + $gitignore = $configuration->usesHelm() + ? $this->gitignoreUpdater->ensureEntries( + rtrim($basePath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'.gitignore', + ['/charts/*/values.*.secrets.yaml'], + ) + : null; + + return new InstallResult($octane, $dockerfile, $helmChart, $composerManifest, $gitignore); } } diff --git a/stubs/helm/templates/_helpers.tpl.stub b/stubs/helm/templates/_helpers.tpl.stub index f3c4826..4db5c43 100644 --- a/stubs/helm/templates/_helpers.tpl.stub +++ b/stubs/helm/templates/_helpers.tpl.stub @@ -15,6 +15,10 @@ {{- end -}} {{- end -}} +{{- define "{{chart_name}}.secretName" -}} +{{- printf "%s-env" (include "{{chart_name}}.fullname" .) | trunc 63 | trimSuffix "-" -}} +{{- end -}} + {{- define "{{chart_name}}.labels" -}} helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} app.kubernetes.io/name: {{ include "{{chart_name}}.name" . }} diff --git a/stubs/helm/templates/deployment.yaml.stub b/stubs/helm/templates/deployment.yaml.stub index a5cb55a..0cd0690 100644 --- a/stubs/helm/templates/deployment.yaml.stub +++ b/stubs/helm/templates/deployment.yaml.stub @@ -24,11 +24,18 @@ spec: - name: app containerPort: {{ .Values.service.port }} protocol: TCP + {{- if .Values.env }} env: {{- range $name, $value := .Values.env }} - name: {{ $name }} value: {{ $value | quote }} {{- end }} + {{- end }} + {{- if or .Values.secret.existingSecretName (and .Values.secret.create .Values.secret.env) }} + envFrom: + - secretRef: + name: {{ default (include "{{chart_name}}.secretName" .) .Values.secret.existingSecretName }} + {{- end }} livenessProbe: tcpSocket: port: app diff --git a/stubs/helm/templates/secret.yaml.stub b/stubs/helm/templates/secret.yaml.stub new file mode 100644 index 0000000..9361d3c --- /dev/null +++ b/stubs/helm/templates/secret.yaml.stub @@ -0,0 +1,13 @@ +{{- if and .Values.secret.create (not .Values.secret.existingSecretName) .Values.secret.env }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "{{chart_name}}.secretName" . }} + labels: + {{- include "{{chart_name}}.labels" . | nindent 4 }} +type: Opaque +stringData: + {{- range $name, $value := .Values.secret.env }} + {{ $name }}: {{ $value | quote }} + {{- end }} +{{- end }} diff --git a/stubs/helm/values.local.secrets.example.yaml.stub b/stubs/helm/values.local.secrets.example.yaml.stub new file mode 100644 index 0000000..b6afbe6 --- /dev/null +++ b/stubs/helm/values.local.secrets.example.yaml.stub @@ -0,0 +1,4 @@ +secret: + env: + # APP_KEY: base64:replace-me + # DB_PASSWORD: replace-me diff --git a/stubs/helm/values.production.secrets.example.yaml.stub b/stubs/helm/values.production.secrets.example.yaml.stub new file mode 100644 index 0000000..b6afbe6 --- /dev/null +++ b/stubs/helm/values.production.secrets.example.yaml.stub @@ -0,0 +1,4 @@ +secret: + env: + # APP_KEY: base64:replace-me + # DB_PASSWORD: replace-me diff --git a/stubs/helm/values.staging.secrets.example.yaml.stub b/stubs/helm/values.staging.secrets.example.yaml.stub new file mode 100644 index 0000000..b6afbe6 --- /dev/null +++ b/stubs/helm/values.staging.secrets.example.yaml.stub @@ -0,0 +1,4 @@ +secret: + env: + # APP_KEY: base64:replace-me + # DB_PASSWORD: replace-me diff --git a/stubs/helm/values.yaml.stub b/stubs/helm/values.yaml.stub index b640119..21027a6 100644 --- a/stubs/helm/values.yaml.stub +++ b/stubs/helm/values.yaml.stub @@ -31,9 +31,13 @@ resources: memory: 1Gi env: - APP_ENV: production LOG_CHANNEL: stderr +secret: + create: {{create_managed_secret}} + existingSecretName: "{{existing_secret_name}}" + env: {} + podAnnotations: {} nodeSelector: {} diff --git a/tests/Feature/DeployCommandTest.php b/tests/Feature/DeployCommandTest.php index d6c4c59..d4214fd 100644 --- a/tests/Feature/DeployCommandTest.php +++ b/tests/Feature/DeployCommandTest.php @@ -112,6 +112,49 @@ function supportsDeployPendingPromptExpectations(): bool } }); +it('includes a secret overlay when one exists for the selected environment', 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.local.secrets.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])->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.local.yaml', + '-f', + './charts/beacon-demo/values.local.secrets.yaml', + '--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.'); diff --git a/tests/Feature/InstallCommandTest.php b/tests/Feature/InstallCommandTest.php index 2eba9cb..43c74c7 100644 --- a/tests/Feature/InstallCommandTest.php +++ b/tests/Feature/InstallCommandTest.php @@ -42,6 +42,7 @@ function expectBeaconInstallPrompts( string $runtime = 'octane', string $deploymentTarget = 'docker-and-helm', bool $updateComposerScripts = true, + string $secretHandling = 'managed-secret', ): PendingCommand { return $command ->expectsPromptsIntro('Beacon will guide you through the initial production install setup.') @@ -66,6 +67,14 @@ function expectBeaconInstallPrompts( ->expectsConfirmation( 'Should Beacon plan to update Composer scripts during installation?', $updateComposerScripts ? 'yes' : 'no' + ) + ->expectsChoice( + 'How should Beacon handle sensitive application environment values?', + $secretHandling, + [ + 'managed-secret' => 'Beacon-managed Helm secret', + 'existing-secret' => 'Existing Kubernetes secret', + ] ); } @@ -114,6 +123,7 @@ function supportsPendingPromptExpectations(): bool ->expectsOutputToContain('Beacon Demo') ->expectsOutputToContain('Laravel Octane') ->expectsOutputToContain('Dockerfile and Helm chart') + ->expectsOutputToContain('Beacon-managed Helm secret') ->expectsOutputToContain('Plan to update') ->expectsOutputToContain('Octane integration') ->expectsOutputToContain('Installed now') @@ -136,9 +146,12 @@ 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('create: true') ->and($directory.'/charts/beacon-demo/values.local.yaml')->toBeFile() + ->and($directory.'/charts/beacon-demo/values.local.secrets.example.yaml')->toBeFile() ->and($directory.'/charts/beacon-demo/values.staging.yaml')->toBeFile() ->and($directory.'/charts/beacon-demo/values.production.yaml')->toBeFile() + ->and(file_get_contents($directory.'/.gitignore'))->toContain('/charts/*/values.*.secrets.yaml') ->and($manifest['require'])->toHaveKey('laravel/octane') ->and($manifest['scripts'])->toMatchArray([ 'test' => '@php artisan test', @@ -166,6 +179,8 @@ function supportsPendingPromptExpectations(): bool runtime: 'octane', deploymentTarget: 'docker-and-helm', updateComposerScripts: true, + secretHandling: 'managed-secret', + existingSecretName: null, )); fakeOctaneComposerRequire($directory); @@ -175,6 +190,7 @@ function supportsPendingPromptExpectations(): bool ->expectsOutputToContain('Beacon Demo') ->expectsOutputToContain('Laravel Octane') ->expectsOutputToContain('Dockerfile and Helm chart') + ->expectsOutputToContain('Beacon-managed Helm secret') ->expectsOutputToContain('Plan to update') ->expectsOutputToContain('Octane integration') ->expectsOutputToContain('Installed now') @@ -197,9 +213,12 @@ 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('create: true') ->and($directory.'/charts/beacon-demo/values.local.yaml')->toBeFile() + ->and($directory.'/charts/beacon-demo/values.local.secrets.example.yaml')->toBeFile() ->and($directory.'/charts/beacon-demo/values.staging.yaml')->toBeFile() ->and($directory.'/charts/beacon-demo/values.production.yaml')->toBeFile() + ->and(file_get_contents($directory.'/.gitignore'))->toContain('/charts/*/values.*.secrets.yaml') ->and($manifest['require'])->toHaveKey('laravel/octane') ->and($manifest['scripts'])->toMatchArray([ 'test' => '@php artisan test', @@ -227,6 +246,8 @@ function supportsPendingPromptExpectations(): bool runtime: 'octane', deploymentTarget: 'docker-and-helm', updateComposerScripts: true, + secretHandling: 'managed-secret', + existingSecretName: null, )); fakeOctaneComposerRequire($directory); @@ -274,6 +295,8 @@ function supportsPendingPromptExpectations(): bool runtime: 'octane', deploymentTarget: 'docker', updateComposerScripts: false, + secretHandling: 'managed-secret', + existingSecretName: null, )); $this->artisan('beacon:install', ['--no-interaction' => true]) diff --git a/tests/Unit/Filesystem/GitignoreUpdaterTest.php b/tests/Unit/Filesystem/GitignoreUpdaterTest.php new file mode 100644 index 0000000..9ec98ec --- /dev/null +++ b/tests/Unit/Filesystem/GitignoreUpdaterTest.php @@ -0,0 +1,40 @@ +ensureEntries($path, ['/charts/*/values.*.secrets.yaml']); + + expect($result->status)->toBe(FileWriteStatus::Overwritten) + ->and(file_get_contents($path))->toBe("/vendor\n/build\n/charts/*/values.*.secrets.yaml\n"); + } finally { + removeBeaconTestDirectory($directory); + } +}); + +it('returns unchanged when all gitignore entries are already present', function (): void { + $directory = beaconTestTempDirectory(); + $path = $directory.'/.gitignore'; + file_put_contents($path, "/vendor\n/charts/*/values.*.secrets.yaml\n"); + + $updater = new GitignoreUpdater(new SafeFileWriter); + + try { + $result = $updater->ensureEntries($path, ['/charts/*/values.*.secrets.yaml']); + + expect($result->status)->toBe(FileWriteStatus::Unchanged); + } finally { + removeBeaconTestDirectory($directory); + } +}); diff --git a/tests/Unit/Helm/HelmChartGeneratorTest.php b/tests/Unit/Helm/HelmChartGeneratorTest.php index 4ded30e..7f817fc 100644 --- a/tests/Unit/Helm/HelmChartGeneratorTest.php +++ b/tests/Unit/Helm/HelmChartGeneratorTest.php @@ -23,10 +23,14 @@ 'Chart.yaml', 'values.yaml', 'values.local.yaml', + 'values.local.secrets.example.yaml', 'values.staging.yaml', + 'values.staging.secrets.example.yaml', 'values.production.yaml', + 'values.production.secrets.example.yaml', 'templates/_helpers.tpl', 'templates/deployment.yaml', + 'templates/secret.yaml', 'templates/service.yaml', 'templates/ingress.yaml', ]) @@ -34,10 +38,13 @@ ->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('create: true') ->and($files['values.local.yaml'])->toContain('APP_ENV: local') + ->and($files['values.local.secrets.example.yaml'])->toContain('# APP_KEY: base64:replace-me') ->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 }}'); + ->and($files['templates/deployment.yaml'])->toContain('containerPort: {{ .Values.service.port }}') + ->and($files['templates/secret.yaml'])->toContain('kind: Secret'); }); it('renders octane-specific helm values', function (): void { @@ -55,6 +62,23 @@ ->and($files['values.yaml'])->toContain('port: 8000'); }); +it('renders existing secret references when the user chooses an external kubernetes secret', function (): void { + $generator = new HelmChartGenerator(new SafeFileWriter); + $configuration = new InstallConfiguration( + applicationName: 'Beacon Demo', + runtime: 'php-fpm', + deploymentTarget: 'helm', + updateComposerScripts: true, + secretHandling: 'existing-secret', + existingSecretName: 'shared-platform-env', + ); + + $files = $generator->renderFiles($configuration); + + expect($files['values.yaml'])->toContain('create: false') + ->and($files['values.yaml'])->toContain('existingSecretName: "shared-platform-env"'); +}); + it('truncates long chart names to helm-friendly lengths', function (): void { $generator = new HelmChartGenerator(new SafeFileWriter); $configuration = new InstallConfiguration( @@ -105,7 +129,9 @@ ->and($directory.'/charts/beacon-demo/Chart.yaml')->toBeFile() ->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.local.secrets.example.yaml')->toBeFile() ->and($directory.'/charts/beacon-demo/values.staging.yaml')->toBeFile() + ->and($directory.'/charts/beacon-demo/values.staging.secrets.example.yaml')->toBeFile() ->and($directory.'/charts/beacon-demo/values.production.yaml')->toBeFile(); } finally { removeBeaconTestDirectory($directory); diff --git a/tests/Unit/Install/InstallConfigurationCollectorTest.php b/tests/Unit/Install/InstallConfigurationCollectorTest.php index f6449db..66ad4d2 100644 --- a/tests/Unit/Install/InstallConfigurationCollectorTest.php +++ b/tests/Unit/Install/InstallConfigurationCollectorTest.php @@ -17,7 +17,9 @@ ->and($configuration->applicationName)->toBe('Beacon Demo') ->and($configuration->runtime)->toBe('php-fpm') ->and($configuration->deploymentTarget)->toBe('docker-and-helm') - ->and($configuration->updateComposerScripts)->toBeTrue(); + ->and($configuration->updateComposerScripts)->toBeTrue() + ->and($configuration->secretHandling)->toBe('managed-secret') + ->and($configuration->existingSecretName)->toBeNull(); }); it('falls back to the base path name when no application name is provided', function (): void { @@ -71,6 +73,20 @@ protected function askUpdateComposerScripts(bool $default): bool return false; } + + protected function askSecretHandling(string $default): string + { + expect($default)->toBe('managed-secret'); + + return 'existing-secret'; + } + + protected function askExistingSecretName(string $default): string + { + expect($default)->toBe('beacon-app-env'); + + return 'shared-platform-env'; + } }; $configuration = $collector->collect( @@ -84,5 +100,7 @@ protected function askUpdateComposerScripts(bool $default): bool 'runtime' => 'octane', 'deployment_target' => 'helm', 'update_composer_scripts' => false, + 'secret_handling' => 'existing-secret', + 'existing_secret_name' => 'shared-platform-env', ]); }); From 5ddf3aa2abf3584e377fa94c4a9fbbfa1518ce7a Mon Sep 17 00:00:00 2001 From: Derek Bourgeois Date: Thu, 9 Apr 2026 01:41:18 -0400 Subject: [PATCH 2/2] fix: preserve gitignore formatting for secret entries --- README.md | 4 +++- src/Filesystem/GitignoreUpdater.php | 21 +++++++++++++++---- .../Unit/Filesystem/GitignoreUpdaterTest.php | 17 +++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7f03c11..3db25ef 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,9 @@ Sensitive application values are kept out of the committed environment overlays. - adds `/charts/*/values.*.secrets.yaml` to the Laravel app `.gitignore` - automatically includes `values..secrets.yaml` during `beacon:deploy` when that ignored file exists -If you choose the existing Kubernetes secret mode during installation, Beacon configures the chart to reference that external secret instead of creating its own Secret manifest. +This keeps secret values out of Git-tracked files, but it does not keep them out of Helm release metadata. When `values..secrets.yaml` is passed to Helm, those values are stored in the Helm release Secret or ConfigMap and can remain in release history. + +If you choose the existing Kubernetes secret mode during installation, Beacon configures the chart to reference that external secret instead of creating its own Secret manifest. Use that mode, or another workflow that injects secrets outside Helm values files, if you need to avoid persisting secret values in Helm release metadata or history. ## Rerunning the installer diff --git a/src/Filesystem/GitignoreUpdater.php b/src/Filesystem/GitignoreUpdater.php index 388928c..41bd207 100644 --- a/src/Filesystem/GitignoreUpdater.php +++ b/src/Filesystem/GitignoreUpdater.php @@ -41,16 +41,29 @@ public function ensureEntries(string $path, array $entries): FileWriteResult $lines = $contents === '' ? [] - : (preg_split('/\R/', rtrim($contents, "\r\n")) ?: []); - $existingEntries = array_map('trim', $lines); + : (preg_split('/\R/', $contents) ?: []); + $existingEntries = array_values(array_filter(array_map( + static fn (string $line): string => trim($line), + $lines, + ), static fn (string $line): bool => $line !== '')); + $missingEntries = []; foreach ($entries as $entry) { if (! in_array($entry, $existingEntries, true)) { - $lines[] = $entry; + $missingEntries[] = $entry; } } - $updatedContents = implode(PHP_EOL, $lines).PHP_EOL; + if ($missingEntries === []) { + return $this->writer->write($path, $contents, ExistingFileBehavior::Overwrite); + } + + $lineEnding = str_contains($contents, "\r\n") ? "\r\n" : "\n"; + $separator = $contents === '' || preg_match('/\R\z/', $contents) === 1 ? '' : $lineEnding; + $updatedContents = $contents + .$separator + .implode($lineEnding, $missingEntries) + .$lineEnding; return $this->writer->write($path, $updatedContents, ExistingFileBehavior::Overwrite); } diff --git a/tests/Unit/Filesystem/GitignoreUpdaterTest.php b/tests/Unit/Filesystem/GitignoreUpdaterTest.php index 9ec98ec..2d8e920 100644 --- a/tests/Unit/Filesystem/GitignoreUpdaterTest.php +++ b/tests/Unit/Filesystem/GitignoreUpdaterTest.php @@ -38,3 +38,20 @@ removeBeaconTestDirectory($directory); } }); + +it('preserves existing line endings and trailing blank lines when appending entries', function (): void { + $directory = beaconTestTempDirectory(); + $path = $directory.'/.gitignore'; + file_put_contents($path, "/vendor\r\n/build\r\n\r\n"); + + $updater = new GitignoreUpdater(new SafeFileWriter); + + try { + $result = $updater->ensureEntries($path, ['/charts/*/values.*.secrets.yaml']); + + expect($result->status)->toBe(FileWriteStatus::Overwritten) + ->and(file_get_contents($path))->toBe("/vendor\r\n/build\r\n\r\n/charts/*/values.*.secrets.yaml\r\n"); + } finally { + removeBeaconTestDirectory($directory); + } +});