> * 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(), EnvironmentScope::production->value => (new CdEnvVariable( environment_scope: EnvironmentScope::production, key: 'APP_DEBUG', value: 'false', protected: true, masked: false, ))->isProjectDefault(), ], 'APP_ENV' => [ EnvironmentScope::staging->value => (new CdEnvVariable( environment_scope: EnvironmentScope::staging, key: 'APP_ENV', value: 'staging', protected: true, ))->isProjectDefault(), EnvironmentScope::production->value => (new CdEnvVariable( environment_scope: EnvironmentScope::production, key: 'APP_ENV', value: 'production', protected: true, masked: true, ))->isProjectDefault(), ], 'APP_KEY' => (new CdEnvVariable( environment_scope: EnvironmentScope::all, key: 'APP_KEY', value: $this->generateRandomKey(), protected: true, masked: true, ))->isProjectDefault(), 'APP_NAME' => (new CdEnvVariable( environment_scope: EnvironmentScope::all, key: 'APP_NAME', value: 'ChangeMe', protected: true, ))->isProjectDefault(), 'DOMAIN' => [ EnvironmentScope::staging->value => (new CdEnvVariable( environment_scope: EnvironmentScope::staging, key: 'DOMAIN', value: '$CI_PROJECT_NAME.re7-s-web.fr', protected: true, raw: true, ))->isProjectDefault(), EnvironmentScope::production->value => (new CdEnvVariable( environment_scope: EnvironmentScope::production, key: 'DOMAIN', value: 'changeme.changeme', protected: true, masked: true, ))->isProjectDefault(), ], 'CERT_ISSUER' => [ EnvironmentScope::staging->value => (new CdEnvVariable( environment_scope: EnvironmentScope::staging, key: 'CERT_ISSUER', value: 're7-letsencrypt-production-dns01', protected: true, masked: true, ))->isProjectDefault(), EnvironmentScope::production->value => (new CdEnvVariable( environment_scope: EnvironmentScope::production, key: 'CERT_ISSUER', value: 'letsencrypt-production-http01', protected: true, masked: true, ))->isProjectDefault(), ], 'DB_DATABASE' => [ EnvironmentScope::staging->value => (new CdEnvVariable( environment_scope: EnvironmentScope::staging, key: 'DB_DATABASE', value: 'changeme', protected: true, masked: true, ))->isProjectDefault(), EnvironmentScope::production->value => (new CdEnvVariable( environment_scope: EnvironmentScope::production, key: 'DB_DATABASE', value: 'changeme', protected: true, masked: true, ))->isProjectDefault(), ], 'DB_USERNAME' => [ EnvironmentScope::staging->value => (new CdEnvVariable( environment_scope: EnvironmentScope::staging, key: 'DB_USERNAME', value: 'changeme', protected: true, masked: true, ))->isProjectDefault(), EnvironmentScope::production->value => (new CdEnvVariable( environment_scope: EnvironmentScope::production, key: 'DB_USERNAME', value: 'changeme', protected: true, masked: true, ))->isProjectDefault(), ], 'DB_PASSWORD' => [ EnvironmentScope::staging->value => (new CdEnvVariable( environment_scope: EnvironmentScope::staging, key: 'DB_PASSWORD', value: 'changeme', protected: true, masked: true, ))->isProjectDefault(), EnvironmentScope::production->value => (new CdEnvVariable( environment_scope: EnvironmentScope::production, key: 'DB_PASSWORD', value: 'changeme', protected: true, masked: true, ))->isProjectDefault(), ], 'MAIL_ENCRYPTION' => [ EnvironmentScope::staging->value => (new CdEnvVariable( environment_scope: EnvironmentScope::staging, key: 'MAIL_ENCRYPTION', value: 'null', protected: true, masked: true, ))->isProjectDefault(), EnvironmentScope::production->value => (new CdEnvVariable( environment_scope: EnvironmentScope::production, key: 'MAIL_ENCRYPTION', value: 'tls', ))->isProjectDefault(), ], 'MAIL_HOST' => [ EnvironmentScope::staging->value => (new CdEnvVariable( environment_scope: EnvironmentScope::staging, key: 'MAIL_HOST', value: 'null', protected: true, ))->isProjectDefault(), EnvironmentScope::production->value => (new CdEnvVariable( environment_scope: EnvironmentScope::production, key: 'MAIL_HOST', value: 'smtp-relay.brevo.com', protected: true, masked: true, ))->isProjectDefault(), ], 'MAIL_PORT' => [ EnvironmentScope::staging->value => (new CdEnvVariable( environment_scope: EnvironmentScope::staging, key: 'MAIL_PORT', value: 'null', protected: true, ))->isProjectDefault(), EnvironmentScope::production->value => (new CdEnvVariable( environment_scope: EnvironmentScope::production, key: 'MAIL_PORT', value: '587', protected: true, ))->isProjectDefault(), ], 'MAIL_USERNAME' => [ EnvironmentScope::staging->value => (new CdEnvVariable( environment_scope: EnvironmentScope::staging, key: 'MAIL_USERNAME', value: 'changeme', protected: true, masked: true, ))->isProjectDefault(), EnvironmentScope::production->value => (new CdEnvVariable( environment_scope: EnvironmentScope::production, key: 'MAIL_USERNAME', value: 'changeme', protected: true, masked: true, ))->isProjectDefault(), ], 'MAIL_PASSWORD' => [ EnvironmentScope::staging->value => (new CdEnvVariable( environment_scope: EnvironmentScope::staging, key: 'MAIL_PASSWORD', value: 'changeme', protected: true, masked: true, ))->isProjectDefault(), EnvironmentScope::production->value => (new CdEnvVariable( environment_scope: EnvironmentScope::production, key: 'MAIL_PASSWORD', value: 'changeme', protected: true, masked: true, ))->isProjectDefault(), ], 'MAIL_FROM_ADDRESS' => (new CdEnvVariable( environment_scope: EnvironmentScope::all, key: 'MAIL_FROM_ADDRESS', value: 'no-reply@changeme.changeme', protected: true, masked: true, ))->isProjectDefault(), 'PVC_SIZE' => (new CdEnvVariable( environment_scope: EnvironmentScope::all, key: 'PVC_SIZE', value: '1Gi', protected: true, )), 'LDC_WEB_API_KEY' => (new CdEnvVariable( environment_scope: EnvironmentScope::all, key: 'LDC_WEB_API_KEY', value: 'changeme', protected: true, masked: true, )), 'SENTRY_LARAVEL_DSN' => (new CdEnvVariable( environment_scope: EnvironmentScope::all, key: 'SENTRY_LARAVEL_DSN', value: 'changeme', protected: true, masked: true, )), ]; 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 void * @throws \RuntimeException If an error occurs while comunicating with gitlab. * @throws \Psy\Readline\Hoa\ConsoleException If the user cancels this command. */ public function handle(): void { $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); $this->saveProjectCdVarsToGitlab($projectId, $newVars); $this->info('Done !!!'); $this->info('Please check on ' . $this->getProjetCdVarsUrl($projectId)); } // * PRIVATE GITLAB /** * Save compiled vars on Gitlab project * * @param integer $projectId * @param array> $newVars * @return void */ private function saveProjectCdVarsToGitlab(int $projectId, array $newVars): void { try { $updateProjectVar = function (int $projectId, CdEnvVariable $var) { $this->client->projects()->addVariable( $projectId, $var->getFormatedGitlabKey(), $var->value, $var->protected, $var->environment_scope->value, [ 'masked' => $var->masked, 'raw' => $var->raw ] ); }; foreach ($newVars as $varOrVarList) { if ($varOrVarList instanceof CdEnvVariable) { $updateProjectVar($projectId, $varOrVarList); continue; } foreach ($varOrVarList as $var) { $updateProjectVar($projectId, $var); } } } 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 } /** * 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> */ 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)) ->groupBy(fn (CdEnvVariable $envVar) => $envVar->key, true) ->map(function (Collection $envVars) { if ($envVars->count() === 1) { return $envVars->first(); } return $envVars->values() ->keyBy(fn (CdEnvVariable $envVar) => $envVar->environment_scope->value) ->all(); })->all()); } /** * 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> ...$cdVars * @return array> */ private function mergeVars(array ...$cdVars): array { /** @var array>|null */ $firstArr = Arr::pull($cdVars, 0); if (!$firstArr) { return []; } collect($cdVars)->each(function (array $cdVarArray) use (&$firstArr) { /** @var array>|null $cdVarArray */ foreach ($cdVarArray as $key => $cdVarList) { /** @var \App\Lib\CdEnvVariable|array|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; } Arr::set($firstArr, $key, $cdVarList); continue; } // * 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 $firstArrCdVarList * @param \App\Lib\CdEnvVariable $varWithAllScope * @return array|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 $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; } /** * Generate a random key for the application. * * ! Copy from \Illuminate\Foundation\Console\KeyGenerateCommand * * @return string */ protected function generateRandomKey() { return 'base64:' . base64_encode( Encrypter::generateKey(config('app.cipher')) ); } }