move into sonardesk

This commit is contained in:
Mark Gregory
2026-10-12 14:50:13 +01:00
parent 5f0d2ceb72
commit 38b4a7406f
879 changed files with 24021 additions and 1 deletions
+9
View File
@@ -0,0 +1,9 @@
<?php
return [
'enabled' => env('CLOUD_DEVOPS_ENABLED', true),
'allow_execution' => env('CLOUD_DEVOPS_ALLOW_EXECUTION', false),
'default_timeout' => (int) env('CLOUD_DEVOPS_TIMEOUT', 900),
'retention_days' => (int) env('CLOUD_DEVOPS_RETENTION_DAYS', 90),
'allowed_drivers' => ['local', 'ssh', 'docker', 'kubernetes'],
];
@@ -0,0 +1,22 @@
<?php
namespace Modules\CloudDevOps\Console\Commands;
use Illuminate\Console\Command;
use Modules\CloudDevOps\Models\DeploymentHealthCheck;
use Modules\CloudDevOps\Services\HealthCheckRunner;
final class CloudDevOpsHealthCommand extends Command
{
protected $signature = 'cloud-devops:health';
protected $description = 'Run all active deployment health checks';
public function handle(HealthCheckRunner $runner): int
{
DeploymentHealthCheck::query()->each(fn ($check) => $runner->run($check));
$this->info('Health checks completed.');
return self::SUCCESS;
}
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\CloudDevOps\Console\Commands;
use Illuminate\Console\Command;
use Modules\CloudDevOps\Models\DeploymentRelease;
final class CloudDevOpsPruneCommand extends Command
{
protected $signature = 'cloud-devops:prune {--days=}';
protected $description = 'Prune old deployment release history';
public function handle(): int
{
$days = (int) ($this->option('days') ?: config('clouddevops.retention_days', 90));
$count = DeploymentRelease::where('created_at', '<', now()->subDays($days))->whereIn('status', ['succeeded', 'failed', 'cancelled'])->delete();
$this->info("Pruned {$count} releases.");
return self::SUCCESS;
}
}
@@ -0,0 +1,122 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('deployment_environments', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('type')->default('production');
$table->string('status')->default('active');
$table->boolean('is_protected')->default(false);
$table->json('variables')->nullable();
$table->timestamps();
});
Schema::create('deployment_targets', function (Blueprint $table): void {
$table->id();
$table->foreignId('environment_id')->constrained('deployment_environments')->cascadeOnDelete();
$table->string('name');
$table->string('driver')->default('ssh');
$table->string('host')->nullable();
$table->unsignedInteger('port')->nullable();
$table->string('path')->nullable();
$table->json('configuration')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::create('deployment_pipelines', function (Blueprint $table): void {
$table->id();
$table->foreignId('environment_id')->constrained('deployment_environments')->cascadeOnDelete();
$table->string('name');
$table->string('trigger')->default('manual');
$table->json('steps');
$table->boolean('requires_approval')->default(false);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::create('deployment_releases', function (Blueprint $table): void {
$table->id();
$table->foreignId('environment_id')->constrained('deployment_environments')->cascadeOnDelete();
$table->foreignId('pipeline_id')->nullable()->constrained('deployment_pipelines')->nullOnDelete();
$table->string('version');
$table->string('commit_hash')->nullable();
$table->string('status')->default('pending');
$table->foreignId('requested_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('started_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->text('failure_message')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->index(['environment_id', 'status']);
});
Schema::create('deployment_steps', function (Blueprint $table): void {
$table->id();
$table->foreignId('release_id')->constrained('deployment_releases')->cascadeOnDelete();
$table->string('name');
$table->unsignedInteger('position')->default(0);
$table->string('status')->default('pending');
$table->text('output')->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
});
Schema::create('deployment_health_checks', function (Blueprint $table): void {
$table->id();
$table->foreignId('environment_id')->constrained('deployment_environments')->cascadeOnDelete();
$table->string('name');
$table->string('type')->default('http');
$table->string('endpoint')->nullable();
$table->unsignedInteger('expected_status')->default(200);
$table->string('status')->default('unknown');
$table->unsignedInteger('response_time_ms')->nullable();
$table->timestamp('checked_at')->nullable();
$table->text('message')->nullable();
$table->timestamps();
});
Schema::create('deployment_rollbacks', function (Blueprint $table): void {
$table->id();
$table->foreignId('environment_id')->constrained('deployment_environments')->cascadeOnDelete();
$table->foreignId('from_release_id')->constrained('deployment_releases')->cascadeOnDelete();
$table->foreignId('to_release_id')->nullable()->constrained('deployment_releases')->nullOnDelete();
$table->string('status')->default('pending');
$table->foreignId('requested_by')->nullable()->constrained('users')->nullOnDelete();
$table->text('reason')->nullable();
$table->timestamps();
});
Schema::create('deployment_secrets', function (Blueprint $table): void {
$table->id();
$table->foreignId('environment_id')->constrained('deployment_environments')->cascadeOnDelete();
$table->string('key');
$table->string('provider')->default('database');
$table->string('reference')->nullable();
$table->timestamp('rotated_at')->nullable();
$table->timestamps();
$table->unique(['environment_id', 'key']);
});
}
public function down(): void
{
Schema::dropIfExists('deployment_secrets');
Schema::dropIfExists('deployment_rollbacks');
Schema::dropIfExists('deployment_health_checks');
Schema::dropIfExists('deployment_steps');
Schema::dropIfExists('deployment_releases');
Schema::dropIfExists('deployment_pipelines');
Schema::dropIfExists('deployment_targets');
Schema::dropIfExists('deployment_environments');
}
};
@@ -0,0 +1,21 @@
<?php
namespace Modules\CloudDevOps\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Modules\CloudDevOps\Models\DeploymentEnvironment;
use Modules\CloudDevOps\Models\DeploymentRelease;
final class CloudDevOpsApiController extends Controller
{
public function environments(): JsonResponse
{
return response()->json(['data' => DeploymentEnvironment::withCount(['targets', 'pipelines', 'releases'])->get()]);
}
public function releases(): JsonResponse
{
return response()->json(['data' => DeploymentRelease::with(['environment', 'steps'])->latest()->paginate()]);
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\CloudDevOps\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Modules\CloudDevOps\Models\DeploymentEnvironment;
use Modules\CloudDevOps\Models\DeploymentHealthCheck;
use Modules\CloudDevOps\Models\DeploymentPipeline;
use Modules\CloudDevOps\Models\DeploymentRelease;
use Modules\CloudDevOps\Services\DeploymentManager;
use Modules\CloudDevOps\Services\HealthCheckRunner;
final class CloudDevOpsController extends Controller
{
public function index(): View
{
return view('clouddevops::index', [
'environments' => DeploymentEnvironment::withCount(['targets', 'pipelines', 'releases'])->latest()->get(),
'releases' => DeploymentRelease::with('environment')->latest()->limit(10)->get(),
'checks' => DeploymentHealthCheck::with('environment')->latest('checked_at')->limit(10)->get(),
]);
}
public function storeEnvironment(Request $request): RedirectResponse
{
$data = $request->validate(['name' => 'required|string|max:120', 'slug' => 'required|alpha_dash|max:120|unique:deployment_environments,slug', 'type' => 'required|in:development,staging,production']);
DeploymentEnvironment::create($data);
return back()->with('status', 'Environment created.');
}
public function deploy(Request $request, DeploymentEnvironment $environment, DeploymentManager $manager): RedirectResponse
{
$data = $request->validate(['pipeline_id' => 'required|integer', 'version' => 'nullable|string|max:120', 'commit_hash' => 'nullable|string|max:120']);
$pipeline = DeploymentPipeline::where('environment_id', $environment->id)->findOrFail($data['pipeline_id']);
$manager->createRelease($environment, $pipeline, $data + ['requested_by' => $request->user()?->id]);
return back()->with('status', 'Release created.');
}
public function runCheck(DeploymentHealthCheck $check, HealthCheckRunner $runner): RedirectResponse
{
$runner->run($check);
return back()->with('status', 'Health check completed.');
}
}
@@ -0,0 +1,36 @@
<?php
namespace Modules\CloudDevOps\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class DeploymentEnvironment extends Model
{
protected $guarded = [];
protected function casts(): array
{
return ['variables' => 'encrypted:array', 'is_protected' => 'boolean'];
}
public function targets(): HasMany
{
return $this->hasMany(DeploymentTarget::class, 'environment_id');
}
public function pipelines(): HasMany
{
return $this->hasMany(DeploymentPipeline::class, 'environment_id');
}
public function releases(): HasMany
{
return $this->hasMany(DeploymentRelease::class, 'environment_id');
}
public function healthChecks(): HasMany
{
return $this->hasMany(DeploymentHealthCheck::class, 'environment_id');
}
}
@@ -0,0 +1,15 @@
<?php
namespace Modules\CloudDevOps\Models;
use Illuminate\Database\Eloquent\Model;
final class DeploymentHealthCheck extends Model
{
protected $guarded = [];
protected function casts(): array
{
return ['checked_at' => 'datetime'];
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Modules\CloudDevOps\Models;
use Illuminate\Database\Eloquent\Model;
final class DeploymentPipeline extends Model
{
protected $guarded = [];
protected function casts(): array
{
return ['steps' => 'array', 'requires_approval' => 'boolean', 'is_active' => 'boolean'];
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Modules\CloudDevOps\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class DeploymentRelease extends Model
{
protected $guarded = [];
protected function casts(): array
{
return ['metadata' => 'array', 'started_at' => 'datetime', 'completed_at' => 'datetime'];
}
public function environment(): BelongsTo
{
return $this->belongsTo(DeploymentEnvironment::class, 'environment_id');
}
public function steps(): HasMany
{
return $this->hasMany(DeploymentStep::class, 'release_id')->orderBy('position');
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Modules\CloudDevOps\Models;
use Illuminate\Database\Eloquent\Model;
final class DeploymentRollback extends Model
{
protected $guarded = [];
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Modules\CloudDevOps\Models;
use Illuminate\Database\Eloquent\Model;
final class DeploymentSecret extends Model
{
protected $guarded = [];
protected function casts(): array
{
return ['rotated_at' => 'datetime'];
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Modules\CloudDevOps\Models;
use Illuminate\Database\Eloquent\Model;
final class DeploymentStep extends Model
{
protected $guarded = [];
protected function casts(): array
{
return ['started_at' => 'datetime', 'completed_at' => 'datetime'];
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Modules\CloudDevOps\Models;
use Illuminate\Database\Eloquent\Model;
final class DeploymentTarget extends Model
{
protected $guarded = [];
protected function casts(): array
{
return ['configuration' => 'encrypted:array', 'is_active' => 'boolean'];
}
}
@@ -0,0 +1,25 @@
<?php
namespace Modules\CloudDevOps\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\CloudDevOps\Console\Commands\CloudDevOpsHealthCommand;
use Modules\CloudDevOps\Console\Commands\CloudDevOpsPruneCommand;
final class CloudDevOpsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(module_path('CloudDevOps', 'Config/config.php'), 'clouddevops');
$this->app->register(RouteServiceProvider::class);
}
public function boot(): void
{
$this->loadViewsFrom(module_path('CloudDevOps', 'Resources/views'), 'clouddevops');
$this->loadMigrationsFrom(module_path('CloudDevOps', 'Database/Migrations'));
if ($this->app->runningInConsole()) {
$this->commands([CloudDevOpsHealthCommand::class, CloudDevOpsPruneCommand::class]);
}
}
}
@@ -0,0 +1,15 @@
<?php
namespace Modules\CloudDevOps\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
Route::middleware('web')->group(module_path('CloudDevOps', 'Routes/web.php'));
Route::middleware('api')->prefix('api')->group(module_path('CloudDevOps', 'Routes/api.php'));
}
}
@@ -0,0 +1,44 @@
<x-layouts.app :title="__('Cloud & DevOps')">
<div class="flex h-full w-full flex-1 flex-col gap-6 rounded-xl">
<div>
<flux:heading size="xl">Cloud & DevOps</flux:heading>
<flux:subheading>Manage environments, deployment releases, pipelines and service health.</flux:subheading>
</div>
@if (session('status'))
<flux:callout variant="success">{{ session('status') }}</flux:callout>
@endif
<div class="grid gap-4 md:grid-cols-3">
<div class="rounded-xl border p-5"><p class="text-sm text-zinc-500">Environments</p><p class="mt-2 text-3xl font-semibold">{{ $environments->count() }}</p></div>
<div class="rounded-xl border p-5"><p class="text-sm text-zinc-500">Recent releases</p><p class="mt-2 text-3xl font-semibold">{{ $releases->count() }}</p></div>
<div class="rounded-xl border p-5"><p class="text-sm text-zinc-500">Healthy checks</p><p class="mt-2 text-3xl font-semibold">{{ $checks->where('status', 'healthy')->count() }}</p></div>
</div>
@can('cloud-devops.manage')
<form method="POST" action="{{ route('administration.cloud-devops.environments.store') }}" class="grid gap-3 rounded-xl border p-5 md:grid-cols-4">
@csrf
<flux:input name="name" label="Environment name" required />
<flux:input name="slug" label="Slug" required />
<flux:select name="type" label="Type"><option value="development">Development</option><option value="staging">Staging</option><option value="production">Production</option></flux:select>
<div class="flex items-end"><flux:button type="submit" variant="primary">Create environment</flux:button></div>
</form>
@endcan
<section class="space-y-3">
<flux:heading size="lg">Environments</flux:heading>
<div class="grid gap-4 lg:grid-cols-2">
@forelse ($environments as $environment)
<article class="rounded-xl border p-5">
<div class="flex items-center justify-between gap-3"><div><h2 class="font-semibold">{{ $environment->name }}</h2><p class="text-sm text-zinc-500">{{ ucfirst($environment->type) }} · {{ ucfirst($environment->status) }}</p></div>@if ($environment->is_protected)<flux:badge color="amber">Protected</flux:badge>@endif</div>
<div class="mt-4 grid grid-cols-3 gap-3 text-sm"><div><span class="text-zinc-500">Targets</span><p>{{ $environment->targets_count }}</p></div><div><span class="text-zinc-500">Pipelines</span><p>{{ $environment->pipelines_count }}</p></div><div><span class="text-zinc-500">Releases</span><p>{{ $environment->releases_count }}</p></div></div>
</article>
@empty
<div class="col-span-full rounded-xl border p-8 text-center text-zinc-500">No deployment environments configured.</div>
@endforelse
</div>
</section>
<section class="space-y-3"><flux:heading size="lg">Recent releases</flux:heading><div class="overflow-hidden rounded-xl border"><table class="w-full text-sm"><thead class="bg-zinc-50 dark:bg-zinc-900"><tr><th class="p-3 text-left">Version</th><th class="p-3 text-left">Environment</th><th class="p-3 text-left">Status</th><th class="p-3 text-left">Created</th></tr></thead><tbody>@forelse ($releases as $release)<tr class="border-t"><td class="p-3">{{ $release->version }}</td><td class="p-3">{{ $release->environment?->name }}</td><td class="p-3">{{ ucfirst(str_replace('_', ' ', $release->status)) }}</td><td class="p-3">{{ $release->created_at?->diffForHumans() }}</td></tr>@empty<tr><td colspan="4" class="p-6 text-center text-zinc-500">No releases yet.</td></tr>@endforelse</tbody></table></div></section>
</div>
</x-layouts.app>
+9
View File
@@ -0,0 +1,9 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\CloudDevOps\Http\Controllers\CloudDevOpsApiController;
Route::middleware(['auth:sanctum', 'permission:cloud-devops.view'])->prefix('v1/cloud-devops')->group(function (): void {
Route::get('/environments', [CloudDevOpsApiController::class, 'environments']);
Route::get('/releases', [CloudDevOpsApiController::class, 'releases']);
});
+14
View File
@@ -0,0 +1,14 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\CloudDevOps\Http\Controllers\CloudDevOpsController;
Route::middleware(['web', 'auth', 'verified', 'permission:cloud-devops.view'])
->prefix('administration/cloud-devops')
->name('administration.cloud-devops.')
->group(function (): void {
Route::get('/', [CloudDevOpsController::class, 'index'])->name('index');
Route::post('/environments', [CloudDevOpsController::class, 'storeEnvironment'])->middleware('permission:cloud-devops.manage')->name('environments.store');
Route::post('/environments/{environment}/deploy', [CloudDevOpsController::class, 'deploy'])->middleware('permission:cloud-devops.deploy')->name('deploy');
Route::post('/health-checks/{check}/run', [CloudDevOpsController::class, 'runCheck'])->middleware('permission:cloud-devops.manage')->name('checks.run');
});
@@ -0,0 +1,40 @@
<?php
namespace Modules\CloudDevOps\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Modules\CloudDevOps\Models\DeploymentEnvironment;
use Modules\CloudDevOps\Models\DeploymentPipeline;
use Modules\CloudDevOps\Models\DeploymentRelease;
final class DeploymentManager
{
public function createRelease(DeploymentEnvironment $environment, DeploymentPipeline $pipeline, array $attributes = []): DeploymentRelease
{
return DB::transaction(function () use ($environment, $pipeline, $attributes): DeploymentRelease {
$release = $environment->releases()->create([
'pipeline_id' => $pipeline->getKey(),
'version' => $attributes['version'] ?? now()->format('YmdHis').'-'.Str::lower(Str::random(6)),
'commit_hash' => $attributes['commit_hash'] ?? null,
'status' => $pipeline->requires_approval ? 'awaiting_approval' : 'pending',
'requested_by' => $attributes['requested_by'] ?? null,
'metadata' => $attributes['metadata'] ?? [],
]);
foreach ($pipeline->steps as $position => $step) {
$release->steps()->create(['name' => $step['name'] ?? 'Step '.($position + 1), 'position' => $position]);
}
return $release->load('steps');
});
}
public function execute(DeploymentRelease $release): DeploymentRelease
{
abort_unless(config('clouddevops.allow_execution'), 403, 'Deployment execution is disabled.');
$release->update(['status' => 'running', 'started_at' => now()]);
return $release->refresh();
}
}
@@ -0,0 +1,24 @@
<?php
namespace Modules\CloudDevOps\Services;
use Illuminate\Support\Facades\Http;
use Modules\CloudDevOps\Models\DeploymentHealthCheck;
final class HealthCheckRunner
{
public function run(DeploymentHealthCheck $check): DeploymentHealthCheck
{
$started = microtime(true);
try {
$response = Http::timeout(10)->get((string) $check->endpoint);
$status = $response->status() === $check->expected_status ? 'healthy' : 'unhealthy';
$message = 'HTTP '.$response->status();
} catch (\Throwable $e) {
$status = 'unhealthy';
$message = $e->getMessage();
} $check->update(['status' => $status, 'response_time_ms' => (int) ((microtime(true) - $started) * 1000), 'checked_at' => now(), 'message' => $message]);
return $check->refresh();
}
}
@@ -0,0 +1,18 @@
<?php
namespace Modules\CloudDevOps\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\CloudDevOps\Models\DeploymentEnvironment;
use Tests\TestCase;
final class CloudDevOpsModuleTest extends TestCase
{
use RefreshDatabase;
public function test_environment_can_be_created(): void
{
$environment = DeploymentEnvironment::create(['name' => 'Production', 'slug' => 'production', 'type' => 'production']);
$this->assertDatabaseHas('deployment_environments', ['id' => $environment->id, 'slug' => 'production']);
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "CloudDevOps",
"alias": "clouddevops",
"description": "Cloud deployment and DevOps operations for Content Manager.",
"keywords": ["cloud", "devops", "deployments", "pipelines"],
"priority": 18,
"providers": ["Modules\\CloudDevOps\\Providers\\CloudDevOpsServiceProvider"],
"files": []
}