ldc-projects-administration/app/Console/Commands/SetGitlabCdVariables.php
2024-09-05 13:52:44 +02:00

817 lines
32 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Enums\EnvironmentScope;
use App\Lib\CdEnvVariable;
use App\Lib\Common;
use App\Lib\Gitlab;
use GrahamCampbell\GitLab\GitLabManager;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Psy\Readline\Hoa\ConsoleException;
use SebastianBergmann\Diff\Differ;
use SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder;
class SetGitlabCdVariables extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'uwil:gitlab:set_variables {--P|projectId=}';
/**
* The console command description.
*
* @var string
*/
protected $description = "Set default variables for an Uwil Project";
/**
* The gitlab client
*
* @var \Gitlab\Client
*/
private \Gitlab\Client $client;
/**
* The vars that should be present by default
*
* * These are type env_var.
*
* @var array<string,\App\Lib\CdEnvVariable|array<string,\App\Lib\CdEnvVariable>>
* Keyed with `\App\Enums\EnvironmentScope`.
*/
private static array $defaultEnvVars = [];
/**
* Create a new console command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
// * Init default vars reference
if (empty(self::$defaultEnvVars)) {
self::$defaultEnvVars = [
'APP_DEBUG' => [
EnvironmentScope::staging->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::staging,
key: 'APP_DEBUG',
value: 'false',
protected: true,
))->isProjectDefault()->wasPrefixed(),
EnvironmentScope::production->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::production,
key: 'APP_DEBUG',
value: 'false',
protected: true,
masked: false,
))->isProjectDefault()->wasPrefixed(),
],
'APP_ENV' => [
EnvironmentScope::staging->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::staging,
key: 'APP_ENV',
value: 'staging',
protected: true,
))->isProjectDefault()->wasPrefixed(),
EnvironmentScope::production->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::production,
key: 'APP_ENV',
value: 'production',
protected: true,
masked: true,
))->isProjectDefault()->wasPrefixed(),
],
'APP_KEY' => (new CdEnvVariable(
environment_scope: EnvironmentScope::all,
key: 'APP_KEY',
value: Common::generateRandomKey(),
protected: true,
masked: true,
))->isProjectDefault()->wasPrefixed(),
'APP_NAME' => (new CdEnvVariable(
environment_scope: EnvironmentScope::all,
key: 'APP_NAME',
value: 'ChangeMe',
protected: true,
))->isProjectDefault()->wasPrefixed(),
'DOMAIN' => [
EnvironmentScope::staging->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::staging,
key: 'DOMAIN',
value: '$CI_PROJECT_NAME.re7-s-web.fr',
protected: true,
raw: false,
))->isProjectDefault()->wasPrefixed(),
EnvironmentScope::production->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::production,
key: 'DOMAIN',
value: 'changeme.changeme',
protected: true,
masked: true,
))->isProjectDefault()->wasPrefixed(),
],
'CERT_ISSUER' => [
EnvironmentScope::staging->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::staging,
key: 'CERT_ISSUER',
value: 're7-letsencrypt-production-dns01',
protected: true,
masked: true,
))->isProjectDefault()->wasPrefixed(),
EnvironmentScope::production->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::production,
key: 'CERT_ISSUER',
value: 'letsencrypt-production-http01',
protected: true,
masked: true,
))->isProjectDefault()->wasPrefixed(),
],
'DB_DATABASE' => [
EnvironmentScope::staging->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::staging,
key: 'DB_DATABASE',
value: 'changeme',
protected: true,
masked: true,
))->isProjectDefault()->wasPrefixed(),
EnvironmentScope::production->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::production,
key: 'DB_DATABASE',
value: 'changeme',
protected: true,
masked: true,
))->isProjectDefault()->wasPrefixed(),
],
'DB_USERNAME' => [
EnvironmentScope::staging->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::staging,
key: 'DB_USERNAME',
value: 'changeme',
protected: true,
masked: true,
))->isProjectDefault()->wasPrefixed(),
EnvironmentScope::production->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::production,
key: 'DB_USERNAME',
value: 'changeme',
protected: true,
masked: true,
))->isProjectDefault()->wasPrefixed(),
],
'DB_PASSWORD' => [
EnvironmentScope::staging->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::staging,
key: 'DB_PASSWORD',
value: 'changeme',
protected: true,
masked: true,
))->isProjectDefault()->wasPrefixed(),
EnvironmentScope::production->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::production,
key: 'DB_PASSWORD',
value: 'changeme',
protected: true,
masked: true,
))->isProjectDefault()->wasPrefixed(),
],
'MAIL_ENCRYPTION' => [
EnvironmentScope::staging->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::staging,
key: 'MAIL_ENCRYPTION',
value: 'null',
protected: true,
))->isProjectDefault()->wasPrefixed(),
EnvironmentScope::production->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::production,
key: 'MAIL_ENCRYPTION',
value: 'tls',
protected: true,
))->isProjectDefault()->wasPrefixed(),
],
'MAIL_HOST' => [
EnvironmentScope::staging->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::staging,
key: 'MAIL_HOST',
value: 'null',
protected: true,
))->isProjectDefault()->wasPrefixed(),
EnvironmentScope::production->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::production,
key: 'MAIL_HOST',
value: 'smtp-relay.brevo.com',
protected: true,
masked: true,
))->isProjectDefault()->wasPrefixed(),
],
'MAIL_PORT' => [
EnvironmentScope::staging->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::staging,
key: 'MAIL_PORT',
value: 'null',
protected: true,
))->isProjectDefault()->wasPrefixed(),
EnvironmentScope::production->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::production,
key: 'MAIL_PORT',
value: '587',
protected: true,
))->isProjectDefault()->wasPrefixed(),
],
'MAIL_USERNAME' => [
EnvironmentScope::staging->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::staging,
key: 'MAIL_USERNAME',
value: 'null',
protected: true,
))->isProjectDefault()->wasPrefixed(),
EnvironmentScope::production->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::production,
key: 'MAIL_USERNAME',
value: 'changeme',
protected: true,
masked: true,
))->isProjectDefault()->wasPrefixed(),
],
'MAIL_PASSWORD' => [
EnvironmentScope::staging->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::staging,
key: 'MAIL_PASSWORD',
value: 'null',
protected: true,
))->isProjectDefault()->wasPrefixed(),
EnvironmentScope::production->value => (new CdEnvVariable(
environment_scope: EnvironmentScope::production,
key: 'MAIL_PASSWORD',
value: 'changeme',
protected: true,
masked: true,
))->isProjectDefault()->wasPrefixed(),
],
'MAIL_FROM_ADDRESS' => (new CdEnvVariable(
environment_scope: EnvironmentScope::all,
key: 'MAIL_FROM_ADDRESS',
value: 'no-reply@changeme.changeme',
protected: true,
masked: true,
))->isProjectDefault()->wasPrefixed(),
'PVC_SIZE' => (new CdEnvVariable(
environment_scope: EnvironmentScope::all,
key: 'PVC_SIZE',
value: '1Gi',
protected: true,
))->wasPrefixed(),
'LDC_WEB_API_KEY' => (new CdEnvVariable(
environment_scope: EnvironmentScope::all,
key: 'LDC_WEB_API_KEY',
value: 'changeme',
protected: true,
masked: true,
))->wasPrefixed(),
'SENTRY_LARAVEL_DSN' => (new CdEnvVariable(
environment_scope: EnvironmentScope::all,
key: 'SENTRY_LARAVEL_DSN',
value: 'changeme',
protected: true,
masked: true,
))->wasPrefixed(),
];
self::$defaultEnvVars = Arr::sortRecursive(self::$defaultEnvVars);
} //end if
$this->client = app(GitLabManager::class);
}
/**
* Execute the console command.
*
* ! This command may throw exceptions if there is a unhandled difference between actual vars and template.
*
* @return integer
* @throws \RuntimeException If an error occurs while comunicating with gitlab.
* @throws \Psy\Readline\Hoa\ConsoleException If the user cancels this command.
*/
public function handle(): int
{
$this->warn(
'! This command may throw exceptions if there is a unhandled difference between actual vars and template.'
);
$this->warn('!! Sensitive data is masked !!');
$projects = $this->client->projects();
$projectId = $this->option('projectId');
/** @var int|null $projectId */
$projectId = \is_numeric($projectId) ? intval($projectId) : null;
/** @var string|null */
$projectNameWithNamespace = null;
if (is_null($projectId)) {
$this->getInput(
'Quel projet veut-on paramètrer ?',
[
'required',
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed
function (
string $parameter,
string|null $value,
callable $fail
) use (
$projects,
&$projectId,
&$projectNameWithNamespace
) {
$results = collect($projects->all(['search' => $value]));
if (!$results->count()) {
$fail('Project not found');
return;
}
$projectNameWithNamespace = $results->count() > 1 ? $this->getChoice(
'Veuillez choisir parmis les résultats',
$results->pluck('name_with_namespace')->all()
) : strval(Arr::get(Arr::get($results, 0), 'name_with_namespace'));
if (empty($projectNameWithNamespace)) {
$fail('Project not found');
return;
}
$this->info("Selected project {$projectNameWithNamespace}");
$projectId = Arr::get($results->where(
'name_with_namespace',
$projectNameWithNamespace
)->firstOrFail(), 'id');
}
]
);
} else {
$projectNameWithNamespace = Arr::get($this->client->projects()->show($projectId), 'name_with_namespace');
if (empty($projectNameWithNamespace)) {
throw new \RuntimeException(
"Could not get 'name_with_namespace' for project project id `{$projectId}`"
);
}
} //end if
/** @var int $projectId */
$this->info("Using project id `{$projectId}` name `{$projectNameWithNamespace}`");
\sleep(6);
$actualProjectVars = Arr::sort($this->getProjectCdVars($projectId));
$newVars = Arr::sort($this->mergeVars(self::$defaultEnvVars, $actualProjectVars));
$this->showAndConfirmDiff($actualProjectVars, $newVars);
$this->warn('The following vars will be injected and replace previous CD vars');
$this->line($this->stringifyVars($newVars));
if (!$this->confirm('Save these to git lab ?')) {
throw new ConsoleException('Operation aborted');
}
if (
$this->getChoice(
'Again: Previous vars will be lost and these will be saved, are you sure ?',
['yes I am sure', 'no cancel please']
) === 'no cancel please'
) {
throw new ConsoleException('Operation aborted');
}
// * Remove existing vars before inserting all
$this->removeExistingProjectCdVars($projectId);
if (!Gitlab::saveProjectCdVarsToGitlab($projectId, $newVars)) {
$this->warn(
'Sorry an error just happened, please check actual env vars to prevent project crash ok k8s'
);
return 1;
}
$this->info('Done !!!');
$this->info('Please check on ' . $this->getProjetCdVarsUrl($projectId));
return 0;
}
// * PRIVATE GITLAB
/**
* Remove all existing project CD vars
*
* @param integer $projectId
* @return void
*/
private function removeExistingProjectCdVars(int $projectId): void
{
collect($this->queryProjectCdVars($projectId))->each(function (array $gitlabVar) use ($projectId) {
try {
$this->client->projects()->removeVariable(
$projectId,
Arr::get($gitlabVar, 'key'),
[
'filter' => [
'environment_scope' => Arr::get($gitlabVar, 'environment_scope')
]
]
);
} catch (\Exception $e) {
$this->warn(
'Sorry an error just happened, please check actual env vars to prevent project crash ok k8s'
);
dump($e);
exit;
} //end try
});
}
/**
* Get Project vars
*
* @param integer $projectId The project id for which we want cd variables.
* @return array<string,\App\Lib\CdEnvVariable|array<string,\App\Lib\CdEnvVariable>>
*/
private function getProjectCdVars(int $projectId): array
{
return Arr::sortRecursive(collect($this->queryProjectCdVars($projectId))
->map(function (array $envVar) {
$envVar = collect($envVar);
$env_scope = EnvironmentScope::make($envVar->get('environment_scope', ''));
if (!$env_scope) {
throw new \Error('Unhandled scope \'' . $envVar->get('environment_scope', '') . '\'');
}
$envVar->put('environment_scope', $env_scope);
return $envVar->all();
})->filter(fn (array $envVar) => Arr::get($envVar, 'variable_type') === 'env_var')
->map(fn (array $envVar) => CdEnvVariable::make(
$envVar,
$this->wasGlCdVarPrefixed($envVar),
$this->wasGlCdVarDefault($envVar)
))
->groupBy(fn (CdEnvVariable $envVar) => $envVar->key, true)
->map(function (Collection $envVars) {
if (
$envVars->count() === 1 and
$envVars->first()->environment_scope->equals(EnvironmentScope::all)
) {
return $envVars->first();
}
return $envVars->values()
->keyBy(fn (CdEnvVariable $envVar) => $envVar->environment_scope->value)
->all();
})->all());
}
/**
* Is the GL var default ?
*
* @param array $envVar
* @return boolean
*/
private function wasGlCdVarDefault(array $envVar): bool
{
$virtual_var = CdEnvVariable::make($envVar);
/** @var \App\Lib\CdEnvVariable|array<\App\Lib\CdEnvVariable>|null $var */
$var = Arr::get(self::$defaultEnvVars, $virtual_var->key);
if (!$var) {
return false;
}
if (is_array($var)) {
/** @var \App\Lib\CdEnvVariable|null $var */
$var = Arr::get($var, $virtual_var->environment_scope->value);
if (!$var) {
return false;
}
}
return $var->isDefault;
}
/**
* Is the GL var prefixed ?
*
* @param array $envVar
* @return boolean
*/
private function wasGlCdVarPrefixed(array $envVar): bool
{
$virtual_var = CdEnvVariable::make($envVar);
/** @var \App\Lib\CdEnvVariable|array<\App\Lib\CdEnvVariable>|null $var */
$var = Arr::get(self::$defaultEnvVars, $virtual_var->key);
if (!$var) {
return false;
}
if (is_array($var)) {
/** @var \App\Lib\CdEnvVariable|null $var */
$var = Arr::get($var, $virtual_var->environment_scope->value);
if (!$var) {
return false;
}
}
return $var->wasPrefixed;
}
/**
* Query Gitlab project vars
*
* @param integer $projectId
* @return array
*/
private function queryProjectCdVars(int $projectId): array
{
// ! per_page max is 100, if this limit is reached need to implement pagination scrapping.
return $this->client->projects()->variables($projectId, ['per_page' => 100]);
}
/**
* Get project CD vars url
*
* @param integer $projectId
* @return string
*/
private function getProjetCdVarsUrl(int $projectId): string
{
return Str::of(Arr::get($this->client->projects()->show($projectId), 'web_url'))
->finish('/')->append('-/settings/ci_cd');
}
// * PRIVATE MISC
/**
* Show diff and request user consent
*
* @param array $actualProjectVars
* @param array $newVars
* @return void
* @throws \Psy\Readline\Hoa\ConsoleException If user wants to abort.
* @throws \RuntimeException If diff is empty.
*/
private function showAndConfirmDiff(array $actualProjectVars, array $newVars): void
{
// * Calc diff
$builder = new UnifiedDiffOutputBuilder(
// * custom header
"--- Original\n+++ New\n",
// * do not add line numbers to the diff
false
);
$differ = new Differ($builder);
$diff = Str::of($differ->diff(
$this->stringifyVars(...$actualProjectVars),
$this->stringifyVars(...$newVars),
))->explode("\n");
$diff->each(function (string $line) {
if (Str::of($line)->startsWith('-')) {
$this->error($line);
return;
}
if (Str::of($line)->startsWith('+')) {
$this->info($line);
return;
}
$this->line($line);
});
if ($diff->count() <= 3) {
throw new \RuntimeException(
'There is no diff between skeletton Cd vars and actual vars, no need to update, Operation aborted'
);
}
if (!$this->confirm('The following diff will be set, continue ?')) {
throw new ConsoleException('Operation aborted');
}
}
/**
* Stringify a var list
*
* @param \App\Lib\CdEnvVariable|array ...$vars
* @return string
*/
private function stringifyVars(CdEnvVariable|array ...$vars): string
{
$out = Str::of('');
foreach ($vars as $var) {
if (is_array($var)) {
$out = $out->append($this->stringifyVars(...$var));
continue;
}
$out = $out->append($var)->newLine();
}
return $out;
}
/**
* Join variables
*
* @param array<string,\App\Lib\CdEnvVariable|array<string,\App\Lib\CdEnvVariable>> ...$cdVars
* @return array<string,\App\Lib\CdEnvVariable|array<string,\App\Lib\CdEnvVariable>>
*/
private function mergeVars(array ...$cdVars): array
{
/** @var array<string,\App\Lib\CdEnvVariable|array<string,\App\Lib\CdEnvVariable>>|null */
$firstArr = Arr::pull($cdVars, 0);
if (!$firstArr) {
return [];
}
collect($cdVars)->each(function (array $cdVarArray) use (&$firstArr) {
/** @var array<string,\App\Lib\CdEnvVariable|array<string,\App\Lib\CdEnvVariable>>|null $cdVarArray */
foreach ($cdVarArray as $key => $cdVarList) {
/** @var \App\Lib\CdEnvVariable|array<string,\App\Lib\CdEnvVariable>|null The default config for actual key */
$firstArrCdVarList = Arr::get($firstArr, $key);
// ? A default config exists for this key ?
if (!$firstArrCdVarList) {
// * Assign and continue
Arr::set($firstArr, $key, $cdVarList);
continue;
}
// * Need to merge props form here.
// * This should indicate that environment_scope is all
if ($firstArrCdVarList instanceof \App\Lib\CdEnvVariable) {
$this->assertEnvironmentScopeIs(EnvironmentScope::all, ...Arr::wrap($cdVarList));
// ? Are we going to set all scopes var to an existing array of var with different scopes ?
if (is_array($cdVarList)) {
if ($firstArrCdVarList->environment_scope->equals(EnvironmentScope::all)) {
Arr::set($firstArr, $key, $this->askWhichShouldBeKeptOnMergeScopes(
$cdVarList,
$firstArrCdVarList
));
continue;
}
Arr::set($firstArr, $key, $cdVarList);
continue;
}
// * Replace firArrVar.
Arr::set($firstArr, $key, $cdVarList);
continue;
} //end if
if ($cdVarList instanceof \App\Lib\CdEnvVariable) {
if ($cdVarList->environment_scope->equals(EnvironmentScope::all)) {
Arr::set($firstArr, $key, $this->askWhichShouldBeKeptOnMergeScopes(
$firstArrCdVarList,
$cdVarList
));
continue;
}
// * Single var with env other than all
$cdVarList = Arr::wrap($cdVarList);
}
// * This should indicate that there is no environment_scope in array.
$newCdVarList = $firstArrCdVarList;
$this->assertEnvironmentScopeIsNot(EnvironmentScope::all, ...$cdVarList);
foreach ($cdVarList as $environment_scope_value => $cdVar) {
// ! The can merge all scope with other scopes
$newCdVarList[$environment_scope_value] = $cdVar;
}
Arr::set($firstArr, $key, $newCdVarList);
} //end foreach
});
return $firstArr;
}
/**
*
* @param array<string,\App\Lib\CdEnvVariable> $firstArrCdVarList
* @param \App\Lib\CdEnvVariable $varWithAllScope
* @return array<string,\App\Lib\CdEnvVariable>|CdEnvVariable
*/
private function askWhichShouldBeKeptOnMergeScopes(
array $firstArrCdVarList,
CdEnvVariable $varWithAllScope
): array|CdEnvVariable {
$this->info(' --- VAR ARRAY --->');
collect($firstArrCdVarList)->each(fn (CdEnvVariable $var) => $this->info($var));
$this->info(' --- VAR `All` scope --->');
$this->info($varWithAllScope);
$message =
'How do you want to proceed in order to merge `All` scope var into a multiple scopes var array ?';
$replaceMessage = 'Replace the var array with the `All` scope var';
$keepMessage = 'Replace the `All` scope var with var array';
switch ($this->getChoice($message, [$replaceMessage, $keepMessage], $replaceMessage)) {
case $replaceMessage:
return $varWithAllScope;
case $keepMessage:
default:
return $firstArrCdVarList;
}
}
/**
* Asset that a variable `environment_scope` has a certain value.
*
* @param \App\Enums\EnvironmentScope $environment_scope
* @param \App\Lib\CdEnvVariable ...$var
* @return void
* @throws \RuntimeException If the variable `environment_scope` is different that expected.
*/
private function assertEnvironmentScopeIs(
EnvironmentScope $environment_scope,
CdEnvVariable ...$var
): void {
foreach ($var as $cdVar) {
if (!$cdVar->environment_scope->equals($environment_scope)) {
$scopeLabel = $environment_scope->label();
$this->error(' --- CONTEXT --->');
collect($var)->each(fn (CdEnvVariable $var) => $this->info($var));
$scopeLabel = $environment_scope->label();
throw new \RuntimeException(
"environment_scope should have been `{$scopeLabel}` into var {$cdVar}"
);
}
}
}
/**
* Asset that a variable `environment_scope` dow not have a certain value.
*
* @param \App\Enums\EnvironmentScope $environment_scope
* @param \App\Lib\CdEnvVariable ...$var
* @return void
* @throws \RuntimeException If the variable `environment_scope` is different that expected.
*/
private function assertEnvironmentScopeIsNot(
EnvironmentScope $environment_scope,
CdEnvVariable ...$var
): void {
foreach ($var as $cdVar) {
if ($cdVar->environment_scope->equals($environment_scope)) {
$scopeLabel = $environment_scope->label();
throw new \RuntimeException(
"environment_scope shouldn't be `{$scopeLabel}` on var {$cdVar}"
);
}
}
}
/**
* Request user to make a choice
*
* @param string $askMessage
* @param array<string> $choices
* @param string|null $default
* @return string
*/
private function getChoice(
string $askMessage,
array $choices,
string $default = null
): string {
return $this->choice($askMessage, $choices, $default);
}
/**
* Get user input
*
* @param string $askMessage Ask something to the user.
* @param string|array $validate As you would pass to laravel validator.
* @param string $errorMessage Message to show in case of wrong input.
* @return string
*/
private function getInput(
string $askMessage,
string|array $validate = 'min:3|max:255',
string $errorMessage = 'Invalid value try again'
): string {
$value = null;
while (is_null($value)) {
try {
/** @var string|null $tmp */
$tmp = $this->ask($askMessage);
$validator = Validator::make(['input' => $tmp], [
'input' => \is_string($validate) ? "required|{$validate}" :
\array_merge(['required'], $validate)
]);
$validator->validate();
/** @var string $value */
$value = $tmp;
} catch (ValidationException $e) {
$this->error(sprintf(
"{$errorMessage} %s",
\implode(',', collect($e->errors())->flatten()->all())
));
continue;
}
}; //end while
return $value;
}
}