Files
ABSDEV/app/Console/Commands/ReleaseAuditCommand.php
2026-07-24 14:10:21 +01:00

212 lines
6.8 KiB
PHP

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use JsonException;
use Throwable;
class ReleaseAuditCommand extends Command
{
protected $signature = 'release:audit
{--production : Apply production-readiness checks}
{--fail-on-warning : Return a failure exit code when warnings exist}';
protected $description = 'Audit Content Manager for v1.0 release readiness';
/** @var array<int, string> */
private array $failures = [];
/** @var array<int, string> */
private array $warnings = [];
public function handle(): int
{
$this->components->info('Content Manager '.config('release.version').' release audit');
$this->checkApplication();
$this->checkDatabase();
$this->checkModules();
$this->checkRoutes();
$this->checkProductionConfiguration();
$this->newLine();
foreach ($this->warnings as $warning) {
$this->components->warn($warning);
}
foreach ($this->failures as $failure) {
$this->components->error($failure);
}
if ($this->failures !== []) {
$this->components->error(sprintf(
'Release audit failed with %d error(s) and %d warning(s).',
count($this->failures),
count($this->warnings),
));
return self::FAILURE;
}
if ($this->option('fail-on-warning') && $this->warnings !== []) {
$this->components->error('Release audit contains warnings and --fail-on-warning was supplied.');
return self::FAILURE;
}
$this->components->success(sprintf(
'Release audit passed with %d warning(s).',
count($this->warnings),
));
return self::SUCCESS;
}
private function checkApplication(): void
{
$this->check((bool) config('app.key'), 'Application key is configured.');
$this->check(is_writable(storage_path()), 'Storage directory is writable.');
$this->check(is_writable(base_path('bootstrap/cache')), 'Bootstrap cache directory is writable.');
try {
Artisan::call('about', ['--only' => 'environment']);
$this->recordPass('Laravel boots successfully.');
} catch (Throwable $exception) {
$this->recordFailure('Laravel failed to boot: '.$exception->getMessage());
}
}
private function checkDatabase(): void
{
try {
DB::connection()->getPdo();
$this->recordPass('Database connection is available.');
} catch (Throwable $exception) {
$this->recordFailure('Database connection failed: '.$exception->getMessage());
return;
}
if (! Schema::hasTable('migrations')) {
$this->recordFailure('The migrations table does not exist.');
return;
}
try {
Artisan::call('migrate:status', ['--no-interaction' => true]);
$output = Artisan::output();
if (str_contains($output, 'Pending')) {
$this->recordFailure('One or more database migrations are pending.');
} else {
$this->recordPass('No pending database migrations were detected.');
}
} catch (Throwable $exception) {
$this->recordFailure('Migration status check failed: '.$exception->getMessage());
}
}
private function checkModules(): void
{
$modulePath = base_path('Modules');
if (! is_dir($modulePath)) {
$this->recordFailure('Modules directory is missing.');
return;
}
$available = [];
foreach (glob($modulePath.'/*/module.json') ?: [] as $manifest) {
try {
$data = json_decode((string) file_get_contents($manifest), true, 512, JSON_THROW_ON_ERROR);
$name = $data['name'] ?? basename(dirname($manifest));
$available[] = $name;
$this->recordPass("Module manifest is valid: {$name}.");
} catch (JsonException $exception) {
$this->recordFailure(sprintf('Invalid module manifest %s: %s', $manifest, $exception->getMessage()));
}
}
foreach (config('release.required_modules', []) as $required) {
if (! in_array($required, $available, true)) {
$this->recordWarning("Expected module is not present: {$required}.");
}
}
}
private function checkRoutes(): void
{
try {
$routes = Route::getRoutes();
$names = [];
foreach ($routes as $route) {
$name = $route->getName();
if ($name === null || trim($name) === '' || str_ends_with($name, '.')) {
continue;
}
if (isset($names[$name])) {
$this->recordFailure("Duplicate named route detected: {$name}.");
}
$names[$name] = true;
}
$this->recordPass(sprintf('Route registry loaded with %d route(s).', count($routes)));
} catch (Throwable $exception) {
$this->recordFailure('Route audit failed: '.$exception->getMessage());
}
}
private function checkProductionConfiguration(): void
{
if (! $this->option('production')) {
return;
}
$this->check(app()->environment('production'), 'APP_ENV is production.');
$this->check(config('app.debug') === false, 'APP_DEBUG is disabled.');
$this->check(config('app.url') !== null, 'APP_URL is configured.');
$this->check(config('session.secure') === true, 'Secure session cookies are enabled.', warning: true);
$this->check(config('marketplace.allow_remote_install', false) === false, 'Remote marketplace installation is disabled.', warning: true);
$this->check(config('cloud-devops.allow_execution', false) === false, 'Cloud deployment execution is disabled until explicitly configured.', warning: true);
}
private function check(bool $condition, string $message, bool $warning = false): void
{
if ($condition) {
$this->recordPass($message);
return;
}
$warning ? $this->recordWarning($message) : $this->recordFailure($message);
}
private function recordPass(string $message): void
{
$this->components->twoColumnDetail($message, '<fg=green>PASS</>');
}
private function recordWarning(string $message): void
{
$this->warnings[] = $message;
}
private function recordFailure(string $message): void
{
$this->failures[] = $message;
}
}