Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -60,10 +61,14 @@ Depending on the options you choose, Beacon generates:
- `charts/<application-slug>/Chart.yaml`
- `charts/<application-slug>/values.yaml`
- `charts/<application-slug>/values.local.yaml`
- `charts/<application-slug>/values.local.secrets.example.yaml`
- `charts/<application-slug>/values.staging.yaml`
- `charts/<application-slug>/values.staging.secrets.example.yaml`
- `charts/<application-slug>/values.production.yaml`
- `charts/<application-slug>/values.production.secrets.example.yaml`
- `charts/<application-slug>/templates/_helpers.tpl`
- `charts/<application-slug>/templates/deployment.yaml`
- `charts/<application-slug>/templates/secret.yaml`
- `charts/<application-slug>/templates/service.yaml`
- `charts/<application-slug>/templates/ingress.yaml`

Expand All @@ -83,7 +88,18 @@ 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.<environment>.secrets.example.yaml` templates to show the expected secret structure
- adds `/charts/*/values.*.secrets.yaml` to the Laravel app `.gitignore`
- automatically includes `values.<environment>.secrets.yaml` during `beacon:deploy` when that ignored file exists

This keeps secret values out of Git-tracked files, but it does not keep them out of Helm release metadata. When `values.<environment>.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

Expand Down
16 changes: 16 additions & 0 deletions src/Commands/DeployCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions src/Commands/InstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/Deploy/DeploymentEnvironmentProfiles.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
16 changes: 14 additions & 2 deletions src/Deploy/HelmReleaseDeployer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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());
Expand Down
70 changes: 70 additions & 0 deletions src/Filesystem/GitignoreUpdater.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace DevOption\Beacon\Filesystem;

use RuntimeException;

final readonly class GitignoreUpdater
{
public function __construct(
private SafeFileWriter $writer,
) {
}

/**
* @param array<int, string> $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/', $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)) {
$missingEntries[] = $entry;
}
}

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);
}
}
6 changes: 6 additions & 0 deletions src/Helm/HelmChartGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
Expand All @@ -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 = [];
Expand Down
38 changes: 37 additions & 1 deletion src/Install/InstallConfiguration.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,21 @@
'docker-and-helm' => 'Dockerfile and Helm chart',
];

/**
* @var array<string, string>
*/
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));
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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,
];
}
}
Loading
Loading