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
3 changes: 2 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`
- ingress provider: disabled, Ingress NGINX, or Traefik
- secret handling: Beacon-managed Helm secret or an existing Kubernetes secret
- whether Beacon should update Composer scripts

Expand Down Expand Up @@ -88,7 +89,7 @@ Example managed scripts:
}
```

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.
Beacon generates `values.yaml` as the shared chart values file and also creates environment-specific overlays for `local`, `staging`, and `production`. The selected ingress strategy is persisted there so the generated chart knows whether ingress is disabled and which class name Beacon should prepare for. 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:

Expand Down
1 change: 1 addition & 0 deletions src/Commands/InstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ protected function displayConfigurationSummary(InstallConfiguration $configurati
$this->components->twoColumnDetail('Scaffolding', $configuration->deploymentTargetLabel());

if ($configuration->usesHelm()) {
$this->components->twoColumnDetail('Ingress', $configuration->ingressProviderLabel());
$this->components->twoColumnDetail('Secrets', $configuration->secretHandlingLabel());

if ($configuration->existingSecretName !== null) {
Expand Down
3 changes: 3 additions & 0 deletions src/Helm/HelmChartGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ public function renderFiles(InstallConfiguration $configuration): array
'{{service_port}}' => (string) $this->servicePort($configuration),
'{{create_managed_secret}}' => $configuration->secretHandling === 'managed-secret' ? 'true' : 'false',
'{{existing_secret_name}}' => $this->escapeYamlDoubleQuotedString($configuration->existingSecretName ?? ''),
'{{ingress_provider}}' => $configuration->ingressProvider,
'{{ingress_enabled}}' => $configuration->ingressEnabled() ? 'true' : 'false',
'{{ingress_class_name}}' => $this->escapeYamlDoubleQuotedString($configuration->ingressClassName()),
];

$files = [];
Expand Down
23 changes: 23 additions & 0 deletions src/Install/IngressProviderRecommendation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace DevOption\Beacon\Install;

use InvalidArgumentException;

final readonly class IngressProviderRecommendation
{
public function __construct(
public string $provider,
public string $clusterContext,
) {
if (! array_key_exists($this->provider, InstallConfiguration::INGRESS_PROVIDER_OPTIONS)) {
throw new InvalidArgumentException(sprintf('Unsupported ingress provider recommendation [%s].', $this->provider));
}

if (trim($this->clusterContext) === '') {
throw new InvalidArgumentException('A cluster context is required for ingress recommendations.');
}
}
}
48 changes: 48 additions & 0 deletions src/Install/IngressProviderRecommendationRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace DevOption\Beacon\Install;

use Illuminate\Support\Facades\Process;

final class IngressProviderRecommendationRepository
{
public function recommend(string $basePath): ?IngressProviderRecommendation
{
$context = $this->currentContext($basePath);

if ($context === null) {
return null;
}

$normalized = strtolower($context);

return match (true) {
str_contains($normalized, 'rancher-desktop'),
str_contains($normalized, 'k3s'),
str_contains($normalized, 'traefik') => new IngressProviderRecommendation('traefik', $context),
str_contains($normalized, 'nginx'),
str_contains($normalized, 'ingress-nginx'),
str_contains($normalized, 'minikube') => new IngressProviderRecommendation('nginx', $context),
default => null,
};
}

private function currentContext(string $basePath): ?string
{
$result = Process::path($basePath)->run([
'kubectl',
'config',
'current-context',
]);

if (! $result->successful()) {
return null;
}

$context = trim($result->output());

return $context !== '' ? $context : null;
}
}
38 changes: 37 additions & 1 deletion src/Install/InstallConfiguration.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,23 @@
'existing-secret' => 'Existing Kubernetes secret',
];

/**
* @var array<string, string>
*/
public const INGRESS_PROVIDER_OPTIONS = [
'none' => 'Disabled',
'nginx' => 'Ingress NGINX',
'traefik' => 'Traefik',
];

public function __construct(
public string $applicationName,
public string $runtime,
public string $deploymentTarget,
public bool $updateComposerScripts,
public string $secretHandling = 'managed-secret',
public ?string $existingSecretName = null,
public string $ingressProvider = 'none',
) {
if (! array_key_exists($this->runtime, self::RUNTIME_OPTIONS)) {
throw new InvalidArgumentException(sprintf('Unsupported runtime [%s].', $this->runtime));
Expand All @@ -60,6 +70,10 @@ public function __construct(
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.');
}

if (! array_key_exists($this->ingressProvider, self::INGRESS_PROVIDER_OPTIONS)) {
throw new InvalidArgumentException(sprintf('Unsupported ingress provider [%s].', $this->ingressProvider));
}
}

public function runtimeLabel(): string
Expand All @@ -82,14 +96,35 @@ public function usesHelm(): bool
return in_array($this->deploymentTarget, ['helm', 'docker-and-helm'], true);
}

public function ingressProviderLabel(): string
{
return self::INGRESS_PROVIDER_OPTIONS[$this->ingressProvider];
}

public function ingressEnabled(): bool
{
return $this->ingressProvider !== 'none';
}

public function ingressClassName(): string
{
return match ($this->ingressProvider) {
'none' => '',
'nginx' => 'nginx',
'traefik' => 'traefik',
default => throw new InvalidArgumentException(sprintf('Unsupported ingress provider [%s].', $this->ingressProvider)),
};
}

/**
* @return array{
* application_name: string,
* runtime: string,
* deployment_target: string,
* update_composer_scripts: bool,
* secret_handling: string,
* existing_secret_name: ?string
* existing_secret_name: ?string,
* ingress_provider: string
* }
*/
public function toArray(): array
Expand All @@ -101,6 +136,7 @@ public function toArray(): array
'update_composer_scripts' => $this->updateComposerScripts,
'secret_handling' => $this->secretHandling,
'existing_secret_name' => $this->existingSecretName,
'ingress_provider' => $this->ingressProvider,
];
}
}
48 changes: 46 additions & 2 deletions src/Install/InstallConfigurationCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,22 @@

class InstallConfigurationCollector
{
public function __construct(
private readonly ?IngressProviderRecommendationRepository $ingressProviderRecommendationRepository = null,
) {
}

public function collect(
string $basePath,
?string $applicationName = null,
bool $interactive = true,
): InstallConfiguration {
$defaults = $this->defaultConfiguration($basePath, $applicationName);
$ingressProviderRecommendation = $this->ingressProviderRecommendationRepository?->recommend($basePath);
$defaults = $this->defaultConfiguration(
$basePath,
$applicationName,
$ingressProviderRecommendation,
);

if (! $interactive) {
return $defaults;
Expand All @@ -24,6 +34,9 @@ public function collect(
$applicationName = $this->askApplicationName($defaults->applicationName);
$runtime = $this->askRuntime($defaults->runtime);
$deploymentTarget = $this->askDeploymentTarget($defaults->deploymentTarget);
$ingressProvider = $this->usesHelm($deploymentTarget)
? $this->askIngressProvider($defaults->ingressProvider, $ingressProviderRecommendation)
: 'none';
Comment thread
ibourgeois marked this conversation as resolved.
$updateComposerScripts = $this->askUpdateComposerScripts($defaults->updateComposerScripts);
$secretHandling = $this->usesHelm($deploymentTarget)
? $this->askSecretHandling($defaults->secretHandling)
Expand All @@ -39,10 +52,15 @@ public function collect(
updateComposerScripts: $updateComposerScripts,
secretHandling: $secretHandling,
existingSecretName: $existingSecretName,
ingressProvider: $ingressProvider,
);
}

public function defaultConfiguration(string $basePath, ?string $applicationName = null): InstallConfiguration
public function defaultConfiguration(
string $basePath,
?string $applicationName = null,
?IngressProviderRecommendation $ingressProviderRecommendation = null,
): InstallConfiguration
{
$normalizedApplicationName = $this->normalizeApplicationName($applicationName ?? '');

Expand All @@ -55,6 +73,7 @@ public function defaultConfiguration(string $basePath, ?string $applicationName
updateComposerScripts: true,
secretHandling: 'managed-secret',
existingSecretName: null,
ingressProvider: $ingressProviderRecommendation?->provider ?? 'none',
);
}

Expand Down Expand Up @@ -102,6 +121,31 @@ protected function askUpdateComposerScripts(bool $default): bool
);
}

protected function askIngressProvider(
string $default,
?IngressProviderRecommendation $recommendation = null,
): string {
$options = InstallConfiguration::INGRESS_PROVIDER_OPTIONS;

if ($recommendation !== null && array_key_exists($recommendation->provider, $options)) {
$options[$recommendation->provider] = sprintf(
'%s (Recommended for %s)',
$options[$recommendation->provider],
$recommendation->clusterContext,
);
$default = $recommendation->provider;
}

/** @var string $ingressProvider */
$ingressProvider = select(
label: 'Which ingress provider should Beacon prepare for?',
options: $options,
default: $default
);

return $ingressProvider;
}

protected function askSecretHandling(string $default): string
{
/** @var string $secretHandling */
Expand Down
5 changes: 3 additions & 2 deletions stubs/helm/values.yaml.stub
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ service:
port: {{service_port}}

ingress:
enabled: false
className: ""
provider: {{ingress_provider}}
enabled: {{ingress_enabled}}
className: "{{ingress_class_name}}"
annotations: {}
hosts:
- host: {{chart_name}}.example.com
Expand Down
19 changes: 18 additions & 1 deletion tests/Feature/InstallCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ function expectBeaconInstallPrompts(
string $applicationName = 'Beacon Demo',
string $runtime = 'octane',
string $deploymentTarget = 'docker-and-helm',
string $ingressProvider = 'none',
bool $updateComposerScripts = true,
string $secretHandling = 'managed-secret',
): PendingCommand {
Expand All @@ -64,6 +65,15 @@ function expectBeaconInstallPrompts(
'docker-and-helm' => 'Dockerfile and Helm chart',
]
)
->expectsChoice(
'Which ingress provider should Beacon prepare for?',
$ingressProvider,
[
'none' => 'Disabled',
'nginx' => 'Ingress NGINX',
'traefik' => 'Traefik',
]
)
->expectsConfirmation(
'Should Beacon plan to update Composer scripts during installation?',
$updateComposerScripts ? 'yes' : 'no'
Expand Down Expand Up @@ -118,11 +128,12 @@ function supportsPendingPromptExpectations(): bool
fakeOctaneComposerRequire($directory);

try {
expectBeaconInstallPrompts($this->artisan('beacon:install'), ' Beacon Demo ')
expectBeaconInstallPrompts($this->artisan('beacon:install'), ' Beacon Demo ', ingressProvider: 'traefik')
->expectsOutputToContain('Install skeleton summary')
->expectsOutputToContain('Beacon Demo')
->expectsOutputToContain('Laravel Octane')
->expectsOutputToContain('Dockerfile and Helm chart')
->expectsOutputToContain('Traefik')
->expectsOutputToContain('Beacon-managed Helm secret')
->expectsOutputToContain('Plan to update')
->expectsOutputToContain('Octane integration')
Expand All @@ -146,6 +157,7 @@ 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('provider: traefik')
->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()
Expand Down Expand Up @@ -181,6 +193,7 @@ function supportsPendingPromptExpectations(): bool
updateComposerScripts: true,
secretHandling: 'managed-secret',
existingSecretName: null,
ingressProvider: 'nginx',
));
fakeOctaneComposerRequire($directory);

Expand All @@ -190,6 +203,7 @@ function supportsPendingPromptExpectations(): bool
->expectsOutputToContain('Beacon Demo')
->expectsOutputToContain('Laravel Octane')
->expectsOutputToContain('Dockerfile and Helm chart')
->expectsOutputToContain('Ingress NGINX')
->expectsOutputToContain('Beacon-managed Helm secret')
->expectsOutputToContain('Plan to update')
->expectsOutputToContain('Octane integration')
Expand All @@ -213,6 +227,7 @@ 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('provider: nginx')
->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()
Expand Down Expand Up @@ -248,6 +263,7 @@ function supportsPendingPromptExpectations(): bool
updateComposerScripts: true,
secretHandling: 'managed-secret',
existingSecretName: null,
ingressProvider: 'none',
));
fakeOctaneComposerRequire($directory);

Expand Down Expand Up @@ -297,6 +313,7 @@ function supportsPendingPromptExpectations(): bool
updateComposerScripts: false,
secretHandling: 'managed-secret',
existingSecretName: null,
ingressProvider: 'none',
));

$this->artisan('beacon:install', ['--no-interaction' => true])
Expand Down
Loading
Loading