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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<application-slug>`
- 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

Expand Down Expand Up @@ -58,6 +59,9 @@ Depending on the options you choose, Beacon generates:
- `Dockerfile`
- `charts/<application-slug>/Chart.yaml`
- `charts/<application-slug>/values.yaml`
- `charts/<application-slug>/values.local.yaml`
- `charts/<application-slug>/values.staging.yaml`
- `charts/<application-slug>/values.production.yaml`
- `charts/<application-slug>/templates/_helpers.tpl`
- `charts/<application-slug>/templates/deployment.yaml`
- `charts/<application-slug>/templates/service.yaml`
Expand All @@ -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.
Expand Down
92 changes: 91 additions & 1 deletion src/Commands/DeployCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}
Expand All @@ -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 {
Expand All @@ -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?',
Expand All @@ -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());
Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand Down Expand Up @@ -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) ?? '');
Expand Down
38 changes: 38 additions & 0 deletions src/Deploy/DeploymentEnvironmentProfileRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace DevOption\Beacon\Deploy;

final class DeploymentEnvironmentProfileRepository
{
public function discover(string $chartAbsolutePath): DeploymentEnvironmentProfiles
{
$environmentNames = [];

foreach (glob(rtrim($chartAbsolutePath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'values.*.yaml') ?: [] as $path) {
$filename = basename($path);

if (preg_match('/^values\.([a-z0-9-]+)\.yaml$/', $filename, $matches) === 1) {
$environmentNames[] = $matches[1];
}
}

$environmentNames = array_values(array_unique($environmentNames));

$orderedNames = [];

foreach (DeploymentEnvironmentProfiles::defaults() as $defaultEnvironment) {
if (in_array($defaultEnvironment, $environmentNames, true)) {
$orderedNames[] = $defaultEnvironment;
}
}

$customNames = array_values(array_diff($environmentNames, $orderedNames));
sort($customNames);

return new DeploymentEnvironmentProfiles(
$environmentNames !== [] ? array_merge($orderedNames, $customNames) : DeploymentEnvironmentProfiles::defaults(),
Comment thread
ibourgeois marked this conversation as resolved.
);
}
}
64 changes: 64 additions & 0 deletions src/Deploy/DeploymentEnvironmentProfiles.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace DevOption\Beacon\Deploy;

use RuntimeException;

final readonly class DeploymentEnvironmentProfiles
{
/**
* @param array<int, string> $names
*/
public function __construct(
public array $names,
) {
if ($this->names === []) {
throw new RuntimeException('At least one deployment environment profile must be available.');
}
}

/**
* @return array<int, string>
*/
public static function defaults(): array
{
return ['local', 'staging', 'production'];
}

/**
* @return array<string, string>
*/
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,
));
}
}
}
6 changes: 6 additions & 0 deletions src/Deploy/HelmReleaseDeployer.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,19 @@ public function deploy(
string $chartPath,
string $namespace,
string $context,
string $sharedValuesPath,
string $environmentValuesPath,
): string {
$result = Process::path($basePath)->run([
'helm',
'upgrade',
'--install',
$release,
$chartPath,
'-f',
$sharedValuesPath,
'-f',
$environmentValuesPath,
'--namespace',
$namespace,
'--create-namespace',
Expand Down
3 changes: 3 additions & 0 deletions src/Helm/HelmChartGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions stubs/helm/values.local.yaml.stub
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
replicaCount: 1

image:
tag: local

env:
APP_ENV: local
APP_DEBUG: "true"
8 changes: 8 additions & 0 deletions stubs/helm/values.production.yaml.stub
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
replicaCount: 3

image:
tag: production

env:
APP_ENV: production
APP_DEBUG: "false"
8 changes: 8 additions & 0 deletions stubs/helm/values.staging.yaml.stub
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
replicaCount: 2

image:
tag: staging

env:
APP_ENV: staging
APP_DEBUG: "false"
Loading
Loading